Compare commits

...

13 Commits

Author SHA1 Message Date
Mohamed Boudra
71ce3b434e Normalize plan file ids across platforms 2026-05-31 10:05:24 +07:00
Mohamed Boudra
bcd1f28f9a Surface plans as first-class timeline items 2026-05-31 10:05:24 +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
48 changed files with 2406 additions and 273 deletions

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

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:

View File

@@ -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`).

View File

@@ -1 +1 @@
sha256-PYbY3Lk9+y2byl1mN9dVO4YGXLEZrmnuPYxDw59LE5g=
sha256-ngMXsN0UDo4ldTMQgDQ4r6XfAvwnwh4hzglIcmGYu9E=

676
package-lock.json generated
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",

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"
}
}

View File

@@ -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);
});
});
});

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",

View File

@@ -41,6 +41,7 @@ import { PlanCard } from "@/components/plan-card";
import type { StreamItem } from "@/types/stream";
import type { PendingPermission } from "@/types/shared";
import type {
AgentPlanAction,
AgentPermissionAction,
AgentPermissionResponse,
} from "@getpaseo/protocol/agent-types";
@@ -113,6 +114,86 @@ function renderPendingPermissionsNode(input: {
);
}
function PlanTimelineCard({
item,
agentId,
client,
}: {
item: Extract<StreamItem, { kind: "plan" }>;
agentId: string;
client: DaemonClient | null;
}) {
const [respondingActionId, setRespondingActionId] = useState<string | null>(null);
const respondToPlan = useMutation({
mutationFn: async (action: AgentPlanAction) => {
if (!client) {
throw new Error("No daemon connection");
}
setRespondingActionId(action.id);
const result = await client.respondToPlan(agentId, item.planId, { actionId: action.id });
if (!result.ok) {
throw new Error(result.error ?? "Failed to respond to plan");
}
},
onSettled: () => setRespondingActionId(null),
});
const actions = item.actions ?? [];
return (
<View>
<PlanCard title="Plan" text={item.text} testID="timeline-plan-card" />
{actions.length > 0 ? (
<View style={permissionStyles.optionsContainer}>
{actions.map((action) => (
<PlanActionButton
key={action.id}
action={action}
respondingActionId={respondingActionId}
isResponding={respondToPlan.isPending}
onRespond={respondToPlan.mutate}
/>
))}
</View>
) : null}
</View>
);
}
function PlanActionButton({
action,
respondingActionId,
isResponding,
onRespond,
}: {
action: AgentPlanAction;
respondingActionId: string | null;
isResponding: boolean;
onRespond: (action: AgentPlanAction) => void;
}) {
const Icon = action.variant === "danger" ? ThemedXIcon : ThemedCheckIcon;
const permissionAction = useMemo<AgentPermissionAction>(
() => ({
...action,
behavior: action.variant === "danger" ? "deny" : "allow",
}),
[action],
);
const handlePress = useCallback(() => onRespond(action), [action, onRespond]);
return (
<PermissionActionButton
action={permissionAction}
isRespondingAction={respondingActionId === action.id}
isResponding={isResponding}
isPrimary={action.variant === "primary"}
Icon={Icon}
testID={`plan-action-${action.id}`}
onPress={handlePress}
/>
);
}
function renderStreamItemWithTurnFooter(input: {
content: ReactNode;
layoutItem: StreamLayoutItem;
@@ -549,6 +630,9 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
case "todo_list":
return <TodoListCard items={item.items} />;
case "plan":
return <PlanTimelineCard item={item} agentId={agentId} client={client} />;
case "compaction":
return (
<CompactionMarker
@@ -562,7 +646,14 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
return null;
}
},
[renderUserMessageItem, renderAssistantMessageItem, renderThoughtItem, renderToolCallItem],
[
agentId,
client,
renderUserMessageItem,
renderAssistantMessageItem,
renderThoughtItem,
renderToolCallItem,
],
);
const bottomTurnFooterHost = streamLayout.auxiliaryTurnFooter;

View File

@@ -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" }]);
});
});

View File

@@ -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;
},
};

View File

@@ -99,6 +99,19 @@ function todoTimeline(items: { text: string; completed: boolean }[]): AgentStrea
};
}
function planTimeline(): AgentStreamEventPayload {
return {
type: "timeline",
provider: "codex",
item: {
type: "plan",
planId: "plan-1",
text: "# Plan\n\n- Implement it",
actions: [{ id: "implement", label: "Implement", variant: "primary" }],
},
};
}
function findToolByCallId(state: StreamItem[], callId: string): AgentToolCallItem | undefined {
return state.find(
(item): item is AgentToolCallItem =>
@@ -693,6 +706,26 @@ describe("stream reducer canonical tool calls", () => {
assert.strictEqual(todos.items[1]?.completed, true);
});
it("converts plan timeline updates to plan items", () => {
const state = hydrateStreamState([
{
event: planTimeline(),
timestamp: new Date("2025-01-01T10:55:00Z"),
},
]);
const plan = state.find(
(item): item is Extract<StreamItem, { kind: "plan" }> => item.kind === "plan",
);
assert.ok(plan);
assert.strictEqual(plan.planId, "plan-1");
assert.strictEqual(plan.text, "# Plan\n\n- Implement it");
assert.deepStrictEqual(plan.actions, [
{ id: "implement", label: "Implement", variant: "primary" },
]);
});
it("renders Claude TodoWrite as todo_list and suppresses tool call badge", () => {
const state = hydrateStreamState([
{

View File

@@ -1,4 +1,8 @@
import type { AgentProvider, ToolCallDetail } from "@getpaseo/protocol/agent-types";
import type {
AgentPlanAction,
AgentProvider,
ToolCallDetail,
} from "@getpaseo/protocol/agent-types";
import type { AgentAttachment, AgentStreamEventPayload } from "@getpaseo/protocol/messages";
import type { AttachmentMetadata } from "@/attachments/types";
import { extractTaskEntriesFromToolCall } from "../utils/tool-call-parsers";
@@ -48,6 +52,7 @@ export type StreamItem =
| AssistantMessageItem
| ThoughtItem
| ToolCallItem
| PlanItem
| TodoListItem
| ActivityLogItem
| CompactionItem;
@@ -168,6 +173,16 @@ export interface TodoListItem {
items: TodoEntry[];
}
export interface PlanItem {
kind: "plan";
id: string;
timestamp: Date;
provider: AgentProvider;
planId: string;
text: string;
actions?: AgentPlanAction[];
}
export type StreamUpdateSource = "live" | "canonical";
function isRecord(value: unknown): value is Record<string, unknown> {
@@ -653,6 +668,34 @@ function appendTodoList(
return [...state, entry];
}
function appendPlan(
state: StreamItem[],
provider: AgentProvider,
plan: { planId: string; text: string; actions?: AgentPlanAction[] },
timestamp: Date,
): StreamItem[] {
const existingIndex = state.findIndex(
(item) => item.kind === "plan" && item.provider === provider && item.planId === plan.planId,
);
const entry: PlanItem = {
kind: "plan",
id: `plan_${plan.planId}`,
timestamp,
provider,
planId: plan.planId,
text: plan.text,
actions: plan.actions,
};
if (existingIndex >= 0) {
const next = [...state];
next[existingIndex] = entry;
return next;
}
return [...state, entry];
}
function reduceTimelineToolCall(
state: StreamItem[],
event: Extract<AgentStreamEventPayload, { type: "timeline" }>,
@@ -774,6 +817,8 @@ function reduceTimelineEvent(
}));
return finalizeActiveThoughts(appendTodoList(state, event.provider, items, timestamp));
}
case "plan":
return finalizeActiveThoughts(appendPlan(state, event.provider, item, timestamp));
case "error": {
const activity: ActivityLogItem = {
kind: "activity_log",

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",

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": {

View File

@@ -255,6 +255,7 @@ test("advertises client capabilities in hello", async () => {
protocolVersion: 1,
capabilities: {
custom_mode_icons: true,
first_class_plans: true,
reasoning_merge_enum: true,
},
});

View File

@@ -84,6 +84,7 @@ import type {
import type {
AgentPermissionRequest,
AgentPermissionResponse,
AgentPlanResponse,
AgentPersistenceHandle,
AgentProvider,
AgentSessionConfig,
@@ -363,6 +364,10 @@ type DictationFinishAcceptedPayload = Extract<
{ type: "dictation_stream_finish_accepted" }
>["payload"];
type AgentPermissionResolvedPayload = AgentPermissionResolvedMessage["payload"];
type AgentPlanRespondPayload = Extract<
SessionOutboundMessage,
{ type: "agent.plan.respond.response" }
>["payload"];
type ListTerminalsPayload = ListTerminalsResponse["payload"];
type CreateTerminalPayload = CreateTerminalResponse["payload"];
export type RenameTerminalResult = z.infer<typeof RenameTerminalResponseSchema>["payload"];
@@ -3613,6 +3618,38 @@ export class DaemonClient {
});
}
async respondToPlan(
agentId: string,
planId: string,
response: AgentPlanResponse,
requestId = `plan-response-${Date.now()}-${Math.random().toString(36).slice(2)}`,
timeout = 15000,
): Promise<AgentPlanRespondPayload> {
const message = SessionInboundMessageSchema.parse({
type: "agent.plan.respond.request",
agentId,
planId,
actionId: response.actionId,
...(response.feedback !== undefined ? { feedback: response.feedback } : {}),
requestId,
});
return this.sendRequest({
requestId,
message,
timeout,
options: { skipQueue: true },
select: (msg) => {
if (msg.type !== "agent.plan.respond.response") {
return null;
}
if (msg.payload.requestId !== requestId) {
return null;
}
return msg.payload;
},
});
}
// ============================================================================
// Waiting / Streaming Helpers
// ============================================================================
@@ -4278,6 +4315,7 @@ export class DaemonClient {
protocolVersion: 1,
capabilities: {
[CLIENT_CAPS.customModeIcons]: true,
[CLIENT_CAPS.firstClassPlans]: true,
[CLIENT_CAPS.reasoningMergeEnum]: true,
},
...(this.config.appVersion ? { appVersion: this.config.appVersion } : {}),

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",

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",

View File

@@ -1,6 +1,6 @@
{
"name": "@getpaseo/highlight",
"version": "0.1.86",
"version": "0.1.87",
"files": [
"dist",
"!dist/**/*.map"

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",

View File

@@ -313,12 +313,32 @@ export interface CompactionTimelineItem {
preTokens?: number;
}
export interface AgentPlanAction {
id: string;
label: string;
variant?: "primary" | "secondary" | "danger";
}
export interface PlanTimelineItem {
[key: string]: unknown;
type: "plan";
planId: string;
text: string;
actions?: AgentPlanAction[];
}
export interface AgentPlanResponse {
actionId: string;
feedback?: string;
}
export type AgentTimelineItem =
| { type: "user_message"; text: string; messageId?: string }
| { type: "assistant_message"; text: string; messageId?: string }
| { type: "reasoning"; text: string }
| ToolCallTimelineItem
| { type: "todo"; items: { text: string; completed: boolean }[] }
| PlanTimelineItem
| { type: "error"; message: string }
| CompactionTimelineItem;

View File

@@ -1,4 +1,5 @@
export const CLIENT_CAPS = {
firstClassPlans: "first_class_plans",
reasoningMergeEnum: "reasoning_merge_enum",
// COMPAT(customModeIcons): added in v0.1.84. Old clients pin AgentModeIcon to
// a closed enum and crash rendering unknown values; daemon downgrades icons

View File

@@ -179,11 +179,13 @@ describe("checkout PR schemas", () => {
features: {
providersSnapshot: true,
checkoutGithubSetAutoMerge: true,
firstClassPlans: true,
},
}).features,
).toEqual({
providersSnapshot: true,
checkoutGithubSetAutoMerge: true,
firstClassPlans: true,
});
});
});

View File

@@ -153,6 +153,7 @@ import type {
AgentPermissionRequest,
AgentPermissionResponse,
AgentPersistenceHandle,
AgentPlanAction,
ProviderStatus,
AgentRuntimeInfo,
AgentTimelineItem,
@@ -334,6 +335,12 @@ export const AgentPermissionResponseSchema: z.ZodType<AgentPermissionResponse> =
}),
]);
const AgentPlanActionSchema: z.ZodType<AgentPlanAction> = z.object({
id: z.string(),
label: z.string(),
variant: z.enum(["primary", "secondary", "danger"]).optional(),
});
export const AgentPermissionRequestPayloadSchema: z.ZodType<
AgentPermissionRequest,
z.ZodTypeDef,
@@ -549,6 +556,12 @@ export const AgentTimelineItemPayloadSchema: z.ZodType<AgentTimelineItem, z.ZodT
}),
),
}),
z.object({
type: z.literal("plan"),
planId: z.string(),
text: z.string(),
actions: z.array(AgentPlanActionSchema).optional(),
}),
z.object({
type: z.literal("error"),
message: z.string(),
@@ -1324,6 +1337,15 @@ export const AgentPermissionResponseMessageSchema = z.object({
response: AgentPermissionResponseSchema,
});
export const AgentPlanRespondRequestMessageSchema = z.object({
type: z.literal("agent.plan.respond.request"),
agentId: z.string(),
planId: z.string(),
actionId: z.string(),
feedback: z.string().optional(),
requestId: z.string(),
});
const CheckoutErrorCodeSchema = z.enum([
"NOT_GIT_REPO",
"NOT_ALLOWED",
@@ -1901,6 +1923,7 @@ export const SessionInboundMessageSchema = z.discriminatedUnion("type", [
SetAgentFeatureRequestMessageSchema,
AgentRewindRequestMessageSchema,
AgentPermissionResponseMessageSchema,
AgentPlanRespondRequestMessageSchema,
CheckoutStatusRequestSchema,
SubscribeCheckoutDiffRequestSchema,
UnsubscribeCheckoutDiffRequestSchema,
@@ -2138,6 +2161,8 @@ export const ServerInfoStatusPayloadSchema = z
.object({
providersSnapshot: z.boolean().optional(),
checkoutGithubSetAutoMerge: z.boolean().optional(),
// COMPAT(firstClassPlans): added in v0.1.82, remove gate after 2026-11-28.
firstClassPlans: z.boolean().optional(),
// COMPAT(daemonStatusRpc): added in v0.1.76, remove gate after 2026-11-18.
daemonStatusRpc: z.boolean().optional(),
// COMPAT(terminalRestoreModes): added in v0.1.81, remove gate after 2026-11-23.
@@ -2704,6 +2729,17 @@ export const SendAgentMessageResponseMessageSchema = z.object({
}),
});
export const AgentPlanRespondResponseMessageSchema = z.object({
type: z.literal("agent.plan.respond.response"),
payload: z.object({
requestId: z.string(),
agentId: z.string(),
planId: z.string(),
ok: z.boolean(),
error: z.string().nullable().optional(),
}),
});
export const WaitForFinishResponseMessageSchema = z.object({
type: z.literal("wait_for_finish_response"),
payload: z.object({
@@ -3693,6 +3729,7 @@ export const SessionOutboundMessageSchema = z.discriminatedUnion("type", [
CancelAgentResponseMessageSchema,
ClearAgentAttentionResponseMessageSchema,
SendAgentMessageResponseMessageSchema,
AgentPlanRespondResponseMessageSchema,
SetVoiceModeResponseMessageSchema,
DaemonGetStatusResponseSchema,
DaemonGetPairingOfferResponseSchema,
@@ -4106,6 +4143,7 @@ export const WSHelloMessageSchema = z.object({
.object({
voice: z.boolean().optional(),
pushNotifications: z.boolean().optional(),
[CLIENT_CAPS.firstClassPlans]: z.boolean().optional(),
[CLIENT_CAPS.reasoningMergeEnum]: z.boolean().optional(),
[CLIENT_CAPS.customModeIcons]: z.boolean().optional(),
})

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",

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",

View File

@@ -19,6 +19,8 @@ import {
type AgentLaunchContext,
type AgentSlashCommand,
type AgentMode,
type AgentPlanResponse,
type AgentPlanResult,
type AgentPermissionRequest,
type AgentPermissionResponse,
type AgentPermissionResult,
@@ -1836,6 +1838,31 @@ export class AgentManager {
}
}
async respondToPlan(
agentId: string,
planId: string,
response: AgentPlanResponse,
): Promise<AgentPlanResult | void> {
const agent = this.requireAgent(agentId);
if (!agent.session.respondToPlan) {
throw new Error(`Agent provider '${agent.provider}' does not support plan responses`);
}
const result = await agent.session.respondToPlan(planId, response);
try {
await this.refreshSessionState(agent);
} catch {
// Ignore refresh errors - state sync after plan response is best effort.
}
this.touchUpdatedAt(agent);
await this.persistSnapshot(agent);
this.emitState(agent);
return result;
}
async cancelAgentRun(agentId: string): Promise<boolean> {
const agent = this.requireSessionAgent(agentId);
const pendingRun = this.foregroundRuns.getPendingRun(agentId);

View File

@@ -342,12 +342,27 @@ export interface CompactionTimelineItem {
preTokens?: number;
}
export interface AgentPlanAction {
id: string;
label: string;
variant?: "primary" | "secondary" | "danger";
}
export interface PlanTimelineItem {
[key: string]: unknown;
type: "plan";
planId: string;
text: string;
actions?: AgentPlanAction[];
}
export type AgentTimelineItem =
| { type: "user_message"; text: string; messageId?: string }
| { type: "assistant_message"; text: string; messageId?: string }
| { type: "reasoning"; text: string }
| ToolCallTimelineItem
| { type: "todo"; items: { text: string; completed: boolean }[] }
| PlanTimelineItem
| { type: "error"; message: string }
| CompactionTimelineItem;
@@ -551,6 +566,15 @@ export interface AgentPermissionResult {
followUpPrompt?: AgentPromptInput;
}
export interface AgentPlanResponse {
actionId: string;
feedback?: string;
}
export interface AgentPlanResult {
followUpPrompt?: AgentPromptInput;
}
export interface AgentSession {
readonly provider: AgentProvider;
readonly id: string | null;
@@ -569,6 +593,7 @@ export interface AgentSession {
requestId: string,
response: AgentPermissionResponse,
): Promise<AgentPermissionResult | void>;
respondToPlan?(planId: string, response: AgentPlanResponse): Promise<AgentPlanResult | void>;
describePersistence(): AgentPersistenceHandle | null;
interrupt(): Promise<void>;
close(): Promise<void>;

View File

@@ -0,0 +1,41 @@
import { describe, expect, test } from "vitest";
import { isPlanFilePath, planItemFromToolCall } from "./plan-files.js";
describe("plan file detection", () => {
test("accepts only narrow Paseo and OpenCode plan markdown paths", () => {
expect(isPlanFilePath(".paseo/plans/feature.md")).toBe(true);
expect(isPlanFilePath("/Users/me/project/.paseo/plans/feature.markdown")).toBe(true);
expect(isPlanFilePath(".opencode/plans/refactor.md")).toBe(true);
expect(isPlanFilePath("/Users/me/.opencode/plans/refactor.markdown")).toBe(true);
expect(isPlanFilePath("PLAN.md")).toBe(false);
expect(isPlanFilePath("docs/plan.md")).toBe(false);
expect(isPlanFilePath(".paseo/notes/feature.md")).toBe(false);
expect(isPlanFilePath(".paseo/plans/feature.txt")).toBe(false);
});
test("turns successful plan writes into non-actionable plan items", async () => {
const item = await planItemFromToolCall({
cwd: "/workspace",
homeDir: "/Users/me",
item: {
type: "tool_call",
callId: "write-plan",
name: "write",
status: "completed",
error: null,
detail: {
type: "write",
filePath: ".paseo/plans/feature.md",
content: "# Plan\n\n- Implement it",
},
},
});
expect(item).toEqual({
type: "plan",
planId: "plan-file:.paseo/plans/feature.md",
text: "# Plan\n\n- Implement it",
});
});
});

View File

@@ -0,0 +1,70 @@
import path from "node:path";
import fs from "node:fs/promises";
import type { AgentTimelineItem, ToolCallTimelineItem } from "./agent-sdk-types.js";
const PLAN_FILE_EXTENSIONS = new Set([".md", ".markdown"]);
const PLAN_DIRECTORIES = new Set(["/.paseo/plans/", "/.opencode/plans/"]);
export function isPlanFilePath(filePath: string): boolean {
const normalized = normalizePlanPath(filePath);
const ext = path.posix.extname(normalized).toLowerCase();
if (!PLAN_FILE_EXTENSIONS.has(ext)) {
return false;
}
const searchable = normalized.startsWith("/") ? normalized : `/${normalized}`;
return Array.from(PLAN_DIRECTORIES).some((dir) => searchable.includes(dir));
}
export async function planItemFromToolCall(params: {
item: ToolCallTimelineItem;
cwd: string;
homeDir: string;
}): Promise<AgentTimelineItem | null> {
const { item, cwd, homeDir } = params;
if (item.status !== "completed") {
return null;
}
const detail = item.detail;
if (detail.type !== "write" && detail.type !== "edit") {
return null;
}
if (!isPlanFilePath(detail.filePath)) {
return null;
}
const explicitContent = detail.type === "write" ? detail.content : undefined;
const text = explicitContent ?? (await readPlanFile(detail.filePath, cwd, homeDir));
if (!text?.trim()) {
return null;
}
return {
type: "plan",
planId: `plan-file:${normalizePlanPath(detail.filePath)}`,
text: text.trim(),
};
}
function normalizePlanPath(filePath: string): string {
return path.posix.normalize(filePath.replace(/\\/g, "/"));
}
async function readPlanFile(
filePath: string,
cwd: string,
homeDir: string,
): Promise<string | null> {
const candidates = path.isAbsolute(filePath)
? [filePath]
: [path.resolve(cwd, filePath), path.resolve(homeDir, filePath)];
for (const candidate of candidates) {
try {
return await fs.readFile(candidate, "utf8");
} catch {
// Try the next candidate.
}
}
return null;
}

View File

@@ -363,6 +363,7 @@ export function wrapSessionProvider(provider: AgentProvider, inner: AgentSession
setMode: (modeId) => inner.setMode(modeId),
getPendingPermissions: () => inner.getPendingPermissions(),
respondToPermission: (requestId, response) => inner.respondToPermission(requestId, response),
respondToPlan: inner.respondToPlan?.bind(inner),
describePersistence: () => mapPersistenceHandle(provider, inner.describePersistence()),
interrupt: () => inner.interrupt(),
close: () => inner.close(),

View File

@@ -997,7 +997,7 @@ test("preserves bypass capability across query restarts triggered by thinking ch
}
});
test("plan approval exposes a resume-bypass action and can return to bypassPermissions", async () => {
test("plan item exposes a resume-bypass action and can return to bypassPermissions", async () => {
const queryMock = createBaseQueryMock(vi.fn(async () => ({ done: true, value: undefined })));
sdkQueryFactory.mockImplementation(() => queryMock);
@@ -1023,44 +1023,36 @@ test("plan approval exposes a resume-bypass action and can return to bypassPermi
{},
);
const requestEvent = events.find(
(event): event is Extract<AgentStreamEvent, { type: "permission_requested" }> =>
event.type === "permission_requested" && event.request.kind === "plan",
const planEvent = events.find(
(event): event is Extract<AgentStreamEvent, { type: "timeline" }> =>
event.type === "timeline" && event.item.type === "plan",
);
expect(requestEvent).toBeDefined();
expect(requestEvent?.request.actions).toEqual([
expect(planEvent).toBeDefined();
expect(planEvent?.item.type === "plan" ? planEvent.item.actions : undefined).toEqual([
{
id: "reject",
label: "Reject",
behavior: "deny",
variant: "danger",
intent: "dismiss",
},
{
id: "implement",
label: "Implement",
behavior: "allow",
variant: "primary",
intent: "implement",
},
{
id: "implement_resume",
label: "Implement with Bypass",
behavior: "allow",
variant: "secondary",
intent: "implement_resume",
},
]);
if (!requestEvent) {
throw new Error("Expected plan permission request");
if (!planEvent || planEvent.item.type !== "plan") {
throw new Error("Expected plan item");
}
expect(session.getPendingPermissions()).toEqual([]);
await session.respondToPermission(requestEvent.request.id, {
behavior: "allow",
selectedActionId: "implement_resume",
});
await session.respondToPlan?.(planEvent.item.planId, { actionId: "implement_resume" });
await expect(pendingResolution).resolves.toMatchObject({
behavior: "allow",

View File

@@ -57,6 +57,8 @@ import {
type AgentMetadata,
type AgentMode,
type AgentModelDefinition,
type AgentPlanAction,
type AgentPlanResponse,
type AgentPermissionRequest,
type AgentPermissionRequestKind,
type AgentPermissionResponse,
@@ -897,6 +899,14 @@ function buildClaudePlanPermissionActions(
return actions;
}
function buildClaudePlanActions(resumeMode: PermissionMode | null): AgentPlanAction[] {
return buildClaudePlanPermissionActions(resumeMode).map(({ id, label, variant }) => ({
id,
label,
variant,
}));
}
interface TimelineFragment {
kind: "assistant" | "reasoning";
text: string;
@@ -1584,6 +1594,7 @@ class ClaudeAgentSession implements AgentSession {
private toolUseIndexToId = new Map<number, string>();
private toolUseInputBuffers = new Map<string, string>();
private pendingPermissions = new Map<string, PendingPermission>();
private pendingPlans = new Map<string, string>();
private activeForegroundTurnId: string | null = null;
private autonomousTurn: AutonomousTurnState | null = null;
private readonly subscribers = new Set<(event: AgentStreamEvent) => void>();
@@ -1919,82 +1930,140 @@ class ClaudeAgentSession implements AgentSession {
}
getPendingPermissions(): AgentPermissionRequest[] {
return Array.from(this.pendingPermissions.values()).map((entry) => entry.request);
const hiddenPlanRequestIds = new Set(this.pendingPlans.values());
return Array.from(this.pendingPermissions.values())
.filter((entry) => !hiddenPlanRequestIds.has(entry.request.id))
.map((entry) => entry.request);
}
async respondToPermission(requestId: string, response: AgentPermissionResponse): Promise<void> {
private clearPendingPlanForPermission(requestId: string): void {
for (const [planId, pendingRequestId] of this.pendingPlans) {
if (pendingRequestId === requestId) {
this.pendingPlans.delete(planId);
}
}
}
async respondToPermission(
requestId: string,
response: AgentPermissionResponse,
emitResolution = true,
): Promise<void> {
const pending = this.pendingPermissions.get(requestId);
if (!pending) {
throw new Error(`No pending permission request with id '${requestId}'`);
}
this.pendingPermissions.delete(requestId);
this.clearPendingPlanForPermission(requestId);
pending.cleanup?.();
if (response.behavior === "allow") {
if (pending.request.kind === "plan") {
const selectedActionId = response.selectedActionId;
const shouldResumePriorMode =
selectedActionId === "implement_resume" && this.planResumeMode === "bypassPermissions";
const targetMode: PermissionMode = shouldResumePriorMode
? "bypassPermissions"
: "acceptEdits";
await this.setMode(targetMode);
this.pushToolCall(
mapClaudeCompletedToolCall({
name: "plan_approval",
callId: pending.request.id,
input: pending.request.input ?? null,
output: {
approved: true,
actionId: selectedActionId ?? "implement",
},
}),
);
}
const updatedInput =
pending.request.kind === "question"
? normalizeClaudeAskUserQuestionUpdatedInput(
response.updatedInput,
pending.request.input ?? undefined,
)
: (response.updatedInput ?? pending.request.input ?? {});
const result: PermissionResult = {
behavior: "allow",
updatedInput,
updatedPermissions: this.normalizePermissionUpdates(response.updatedPermissions),
};
pending.resolve(result);
await this.resolveAllowedPermission(pending, response);
} else {
if (pending.request.kind === "tool") {
this.pushToolCall(
mapClaudeFailedToolCall({
name: pending.request.name,
callId:
(typeof pending.request.metadata?.toolUseId === "string"
? pending.request.metadata.toolUseId
: null) ?? pending.request.id,
input: pending.request.input ?? null,
output: null,
error: { message: response.message ?? "Permission denied" },
}),
);
}
const result: PermissionResult = {
behavior: "deny",
message: response.message ?? "Permission request denied",
interrupt: response.interrupt,
};
pending.resolve(result);
this.resolveDeniedPermission(pending, response);
}
this.pushEvent({
type: "permission_resolved",
provider: "claude",
requestId,
resolution: response,
if (emitResolution) {
this.pushEvent({
type: "permission_resolved",
provider: "claude",
requestId,
resolution: response,
});
}
}
private async resolveAllowedPermission(
pending: PendingPermission,
response: Extract<AgentPermissionResponse, { behavior: "allow" }>,
): Promise<void> {
if (pending.request.kind === "plan") {
const selectedActionId = response.selectedActionId;
const shouldResumePriorMode =
selectedActionId === "implement_resume" && this.planResumeMode === "bypassPermissions";
const targetMode: PermissionMode = shouldResumePriorMode
? "bypassPermissions"
: "acceptEdits";
await this.setMode(targetMode);
this.pushToolCall(
mapClaudeCompletedToolCall({
name: "plan_approval",
callId: pending.request.id,
input: pending.request.input ?? null,
output: {
approved: true,
actionId: selectedActionId ?? "implement",
},
}),
);
}
const updatedInput =
pending.request.kind === "question"
? normalizeClaudeAskUserQuestionUpdatedInput(
response.updatedInput,
pending.request.input ?? undefined,
)
: (response.updatedInput ?? pending.request.input ?? {});
pending.resolve({
behavior: "allow",
updatedInput,
updatedPermissions: this.normalizePermissionUpdates(response.updatedPermissions),
});
}
private resolveDeniedPermission(
pending: PendingPermission,
response: Extract<AgentPermissionResponse, { behavior: "deny" }>,
): void {
if (pending.request.kind === "tool") {
this.pushToolCall(
mapClaudeFailedToolCall({
name: pending.request.name,
callId:
(typeof pending.request.metadata?.toolUseId === "string"
? pending.request.metadata.toolUseId
: null) ?? pending.request.id,
input: pending.request.input ?? null,
output: null,
error: { message: response.message ?? "Permission denied" },
}),
);
}
pending.resolve({
behavior: "deny",
message: response.message ?? "Permission request denied",
interrupt: response.interrupt,
});
}
async respondToPlan(planId: string, response: AgentPlanResponse): Promise<void> {
const requestId = this.pendingPlans.get(planId);
if (!requestId) {
throw new Error(`No pending Claude plan with id '${planId}'`);
}
this.pendingPlans.delete(planId);
if (response.actionId === "implement" || response.actionId === "implement_resume") {
await this.respondToPermission(
requestId,
{ behavior: "allow", selectedActionId: response.actionId },
false,
);
return;
}
if (response.actionId === "reject") {
await this.respondToPermission(
requestId,
{ behavior: "deny", selectedActionId: response.actionId, message: response.feedback },
false,
);
return;
}
throw new Error(`Unknown Claude plan action '${response.actionId}'`);
}
describePersistence(): AgentPersistenceHandle | null {
if (this.persistence) {
return this.persistence;
@@ -3774,11 +3843,26 @@ class ClaudeAgentSession implements AgentSession {
metadata: Object.keys(metadata).length ? metadata : undefined,
};
this.pushEvent({
type: "permission_requested",
provider: "claude",
request,
});
if (kind === "plan" && typeof input.plan === "string") {
const planId = `plan-${randomUUID()}`;
this.pendingPlans.set(planId, requestId);
this.pushEvent({
type: "timeline",
provider: "claude",
item: {
type: "plan",
planId,
text: input.plan,
actions: buildClaudePlanActions(this.planResumeMode),
},
});
} else {
this.pushEvent({
type: "permission_requested",
provider: "claude",
request,
});
}
return await new Promise<PermissionResult>((resolve, reject) => {
const cleanupFns: Array<() => void> = [];
@@ -3795,6 +3879,11 @@ class ClaudeAgentSession implements AgentSession {
const abortHandler = () => {
this.pendingPermissions.delete(requestId);
for (const [planId, pendingRequestId] of this.pendingPlans) {
if (pendingRequestId === requestId) {
this.pendingPlans.delete(planId);
}
}
cleanup();
reject(new Error("Permission request aborted"));
};

View File

@@ -1989,7 +1989,7 @@ describe("Codex app-server provider", () => {
});
});
test("emits a synthetic plan approval permission after a successful Codex plan turn", () => {
test("emits an actionable plan item after a successful Codex plan turn", () => {
const session = createSession({
featureValues: { plan_mode: true, fast_mode: true },
});
@@ -2018,27 +2018,20 @@ describe("Codex app-server provider", () => {
),
).toBe(false);
expect(events.at(-2)).toEqual({
type: "permission_requested",
type: "timeline",
provider: "codex",
turnId: "test-turn",
request: expect.objectContaining({
provider: "codex",
name: "CodexPlanApproval",
kind: "plan",
title: "Plan",
input: {
plan: "- Inspect the existing auth flow\n- Implement the button behavior",
},
item: expect.objectContaining({
type: "plan",
text: "- Inspect the existing auth flow\n- Implement the button behavior",
actions: [
expect.objectContaining({
id: "reject",
label: "Reject",
behavior: "deny",
}),
expect.objectContaining({
id: "implement",
label: "Implement",
behavior: "allow",
}),
],
}),
@@ -2082,16 +2075,12 @@ describe("Codex app-server provider", () => {
}),
);
expect(events.at(-2)).toEqual({
type: "permission_requested",
type: "timeline",
provider: "codex",
turnId: "test-turn",
request: expect.objectContaining({
provider: "codex",
name: "CodexPlanApproval",
kind: "plan",
input: {
plan: "- Inspect README\n- Add a short note",
},
item: expect.objectContaining({
type: "plan",
text: "- Inspect README\n- Add a short note",
}),
});
});
@@ -2469,7 +2458,7 @@ describe("Codex app-server provider", () => {
]);
});
test("approving a synthetic Codex plan permission disables plan mode, preserves fast mode, and returns follow-up prompt", async () => {
test("responding to a Codex plan item disables plan mode, preserves fast mode, and returns follow-up prompt", async () => {
const session = createSession({
featureValues: { plan_mode: true, fast_mode: true },
});
@@ -2486,19 +2475,16 @@ describe("Codex app-server provider", () => {
turn: { status: "completed", error: null },
});
const request = events.find(
(event): event is Extract<AgentStreamEvent, { type: "permission_requested" }> =>
event.type === "permission_requested" && event.request.kind === "plan",
const plan = events.find(
(event): event is Extract<AgentStreamEvent, { type: "timeline" }> =>
event.type === "timeline" && event.item.type === "plan",
);
expect(request).toBeDefined();
if (!request) {
throw new Error("Expected synthetic plan approval permission");
expect(plan).toBeDefined();
if (!plan || plan.item.type !== "plan") {
throw new Error("Expected plan item");
}
const result = await session.respondToPermission(request.request.id, {
behavior: "allow",
selectedActionId: "implement",
});
const result = await session.respondToPlan?.(plan.item.planId, { actionId: "implement" });
expect(asInternals(session).serviceTier).toBe("fast");
expect(asInternals(session).planModeEnabled).toBe(false);
@@ -2512,18 +2498,10 @@ describe("Codex app-server provider", () => {
expect(result!.followUpPrompt).toEqual(
expect.stringContaining("The user approved the plan. Implement it now."),
);
expect(events.at(-1)).toEqual({
type: "permission_resolved",
provider: "codex",
requestId: request.request.id,
resolution: {
behavior: "allow",
selectedActionId: "implement",
},
});
expect(events).not.toContainEqual(expect.objectContaining({ type: "permission_resolved" }));
});
test("approving a synthetic Codex plan permission keeps fast mode disabled when it started disabled", async () => {
test("responding to a Codex plan item keeps fast mode disabled when it started disabled", async () => {
const session = createSession({
featureValues: { plan_mode: true, fast_mode: false },
});
@@ -2540,19 +2518,16 @@ describe("Codex app-server provider", () => {
turn: { status: "completed", error: null },
});
const request = events.find(
(event): event is Extract<AgentStreamEvent, { type: "permission_requested" }> =>
event.type === "permission_requested" && event.request.kind === "plan",
const plan = events.find(
(event): event is Extract<AgentStreamEvent, { type: "timeline" }> =>
event.type === "timeline" && event.item.type === "plan",
);
expect(request).toBeDefined();
if (!request) {
throw new Error("Expected synthetic plan approval permission");
expect(plan).toBeDefined();
if (!plan || plan.item.type !== "plan") {
throw new Error("Expected plan item");
}
const result = await session.respondToPermission(request.request.id, {
behavior: "allow",
selectedActionId: "implement",
});
const result = await session.respondToPlan?.(plan.item.planId, { actionId: "implement" });
expect(asInternals(session).serviceTier).toBeNull();
expect(asInternals(session).planModeEnabled).toBe(false);
@@ -2608,19 +2583,16 @@ describe("Codex app-server provider", () => {
turn: { status: "completed", error: null },
});
const permissionRequest = events.find(
(event): event is Extract<AgentStreamEvent, { type: "permission_requested" }> =>
event.type === "permission_requested" && event.request.kind === "plan",
const plan = events.find(
(event): event is Extract<AgentStreamEvent, { type: "timeline" }> =>
event.type === "timeline" && event.item.type === "plan",
);
expect(permissionRequest).toBeDefined();
if (!permissionRequest) {
throw new Error("Expected synthetic plan approval permission");
expect(plan).toBeDefined();
if (!plan || plan.item.type !== "plan") {
throw new Error("Expected plan item");
}
const result = await session.respondToPermission(permissionRequest.request.id, {
behavior: "allow",
selectedActionId: "implement",
});
const result = await session.respondToPlan?.(plan.item.planId, { actionId: "implement" });
expect(result?.followUpPrompt).toEqual(expect.any(String));
await session.startTurn(result!.followUpPrompt!);

View File

@@ -8,6 +8,9 @@ import {
type AgentLaunchContext,
type AgentMode,
type AgentModelDefinition,
type AgentPlanAction,
type AgentPlanResponse,
type AgentPlanResult,
type McpServerConfig,
type AgentPersistenceHandle,
type AgentPermissionRequest,
@@ -41,6 +44,7 @@ import os from "node:os";
import path from "node:path";
import { z } from "zod";
import { renderPromptAttachmentAsText } from "../prompt-attachments.js";
import { planItemFromToolCall } from "../plan-files.js";
import { composeSystemPromptParts } from "../system-prompt.js";
import { curateAgentActivity } from "../activity-curator.js";
import {
@@ -941,6 +945,10 @@ function buildPlanPermissionActions(options?: {
return actions;
}
function buildPlanActions(): AgentPlanAction[] {
return buildPlanPermissionActions().map(({ id, label, variant }) => ({ id, label, variant }));
}
function buildCodexPlanImplementationPrompt(planText: string): string {
const normalizedPlan = normalizePlanMarkdown(planText);
if (!normalizedPlan) {
@@ -2925,6 +2933,7 @@ export class CodexAppServerAgentSession implements AgentSession {
private latestPlanResult: { callId: string; text: string; turnId: string | null } | null = null;
private readonly userMessageTurnIndexes = new Map<string, number>();
private readonly userMessageTurnIds: string[] = [];
private pendingPlans = new Map<string, { text: string }>();
private pendingManualCompactionStarts = 0;
private compactionTriggerByItemId = new Map<string, "auto" | "manual">();
// Codex can report one completed compaction through both channels:
@@ -3187,30 +3196,36 @@ export class CodexAppServerAgentSession implements AgentSession {
};
}
private emitSyntheticPlanApprovalRequest(planText: string): void {
const requestId = `permission-${randomUUID()}`;
const request: AgentPermissionRequest = {
id: requestId,
provider: CODEX_PROVIDER,
name: "CodexPlanApproval",
kind: "plan",
title: "Plan",
description: "Review the proposed plan before implementation starts.",
input: { plan: planText },
actions: buildPlanPermissionActions(),
metadata: {
planText,
source: "codex_plan_approval",
},
};
private emitPlanFileItemFromToolCall(item: ToolCallTimelineItem): void {
void planItemFromToolCall({ item, cwd: this.config.cwd, homeDir: homedir() })
.then((planItem) => {
if (planItem) {
this.emitEvent({ type: "timeline", provider: CODEX_PROVIDER, item: planItem });
}
return undefined;
})
.catch((error) => {
this.logger.debug({ error, callId: item.callId }, "Failed to emit plan file item");
});
}
this.pendingPermissions.set(requestId, request);
this.pendingPermissionHandlers.set(requestId, {
resolve: () => undefined,
kind: "plan",
planText,
private emitPlanApprovalItem(planText: string): void {
const text = normalizePlanMarkdown(planText);
if (!text) {
return;
}
const planId = `plan-${randomUUID()}`;
this.pendingPlans.set(planId, { text });
this.emitEvent({
type: "timeline",
provider: CODEX_PROVIDER,
item: {
type: "plan",
planId,
text,
actions: buildPlanActions(),
},
});
this.emitEvent({ type: "permission_requested", provider: CODEX_PROVIDER, request });
}
/**
@@ -3770,6 +3785,29 @@ export class CodexAppServerAgentSession implements AgentSession {
pending.resolve({ answers: {} });
}
async respondToPlan(
planId: string,
response: AgentPlanResponse,
): Promise<AgentPlanResult | void> {
const pending = this.pendingPlans.get(planId);
if (!pending) {
throw new Error(`No pending Codex app-server plan with id '${planId}'`);
}
this.pendingPlans.delete(planId);
if (response.actionId === "implement" || response.actionId === "implement_resume") {
return {
followUpPrompt: this.preparePlanImplementation({ planText: pending.text }),
};
}
if (response.actionId === "reject") {
return;
}
throw new Error(`Unknown Codex plan action '${response.actionId}'`);
}
private handlePlanPermissionResponse(params: {
requestId: string;
response: AgentPermissionResponse;
@@ -4569,7 +4607,7 @@ export class CodexAppServerAgentSession implements AgentSession {
this.emitEvent({ type: "turn_canceled", provider: CODEX_PROVIDER, reason: "interrupted" });
} else {
if (this.planModeEnabled && this.latestPlanResult?.text) {
this.emitSyntheticPlanApprovalRequest(this.latestPlanResult.text);
this.emitPlanApprovalItem(this.latestPlanResult.text);
}
this.emitEvent({
type: "turn_completed",
@@ -4883,6 +4921,9 @@ export class CodexAppServerAgentSession implements AgentSession {
this.warnOnIncompleteEditToolCall(timelineItem, "item_completed", parsed.item);
}
this.emitEvent({ type: "timeline", provider: CODEX_PROVIDER, item: timelineItem });
if (timelineItem.type === "tool_call") {
this.emitPlanFileItemFromToolCall(timelineItem);
}
if (timelineItem.type === "assistant_message") {
this.pendingAssistantMessageBoundary = true;
}

View File

@@ -24,7 +24,7 @@ describe("Codex app-server provider (real) plan mode", () => {
}
});
test("maps gpt-5.4 markdown plans to a plan tool call instead of todo items", async () => {
test("maps gpt-5.4 markdown plans to a normalized plan item instead of todo items", async () => {
const cwd = tmpCwd();
const client = new CodexAppServerAgentClient(createTestLogger());
@@ -50,18 +50,16 @@ describe("Codex app-server provider (real) plan mode", () => {
}),
);
const planCall = result.timeline.find(
(item) => item.type === "tool_call" && item.detail.type === "plan",
);
const planItem = result.timeline.find((item) => item.type === "plan");
expect(planCall).toBeDefined();
if (!planCall || planCall.type !== "tool_call" || planCall.detail.type !== "plan") {
throw new Error("Expected a plan tool call");
expect(planItem).toBeDefined();
if (!planItem || planItem.type !== "plan") {
throw new Error("Expected a normalized plan item");
}
expect(planCall.detail.text).toContain("Login");
expect(planCall.detail.text).toContain("- ");
expect(result.finalText).toBe(planCall.detail.text);
expect(planItem.text).toContain("Login");
expect(planItem.text).toContain("- ");
expect(planItem.actions?.some((action) => action.id === "implement")).toBe(true);
} finally {
await session.close();
}

View File

@@ -57,6 +57,7 @@ import {
resolveProviderLaunch,
type ProviderRuntimeSettings,
} from "../provider-launch-config.js";
import { isPlanFilePath, planItemFromToolCall } from "../plan-files.js";
import { withTimeout } from "../../../utils/promise-timeout.js";
import { execCommand } from "../../../utils/spawn.js";
import { buildToolCallDisplayModel } from "@getpaseo/protocol/tool-call-display";
@@ -1954,6 +1955,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,
@@ -2145,6 +2155,22 @@ function appendOpenCodeToolCallTimelineItem(
provider: "opencode",
item: timelineItem,
});
if (
timelineItem.status === "completed" &&
timelineItem.detail.type === "write" &&
timelineItem.detail.content?.trim() &&
isPlanFilePath(timelineItem.detail.filePath)
) {
events.push({
type: "timeline",
provider: "opencode",
item: {
type: "plan",
planId: `plan-file:${timelineItem.detail.filePath}`,
text: timelineItem.detail.content.trim(),
},
});
}
if (timelineItem.detail.type === "sub_agent" && timelineItem.detail.childSessionId) {
flushOpenCodeSubAgentChildToolParts(timelineItem.detail.childSessionId, state, events);
}
@@ -2438,7 +2464,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);
@@ -3204,9 +3230,28 @@ class OpenCodeAgentSession implements AgentSession {
return;
}
this.notifySubscribers(e, turnId);
if (e.type === "timeline" && e.item.type === "tool_call") {
this.emitPlanFileItemFromToolCall(e.item, turnId);
}
}
}
private emitPlanFileItemFromToolCall(item: ToolCallTimelineItem, turnId: string): void {
void planItemFromToolCall({ item, cwd: this.config.cwd, homeDir: homedir() })
.then((planItem) => {
if (planItem) {
this.notifySubscribers(
{ type: "timeline", provider: "opencode", item: planItem },
turnId,
);
}
return undefined;
})
.catch((error) => {
this.logger.debug({ error, callId: item.callId }, "Failed to emit plan file item");
});
}
private finishForegroundTurn(
event: Extract<AgentStreamEvent, { type: "turn_completed" | "turn_failed" | "turn_canceled" }>,
turnId: string,

View File

@@ -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;

View File

@@ -0,0 +1,273 @@
import { afterEach, beforeEach, describe, expect, test } from "vitest";
import { mkdtempSync, rmSync } from "node:fs";
import { tmpdir } from "node:os";
import path from "node:path";
import pino from "pino";
import { createDaemonTestContext, type DaemonTestContext } from "../test-utils/index.js";
import { createMessageCollector, type MessageCollector } from "../test-utils/message-collector.js";
import { CodexAppServerAgentClient } from "../agent/providers/codex-app-server-agent.js";
import { ClaudeAgentClient } from "../agent/providers/claude/agent.js";
import { createTestPaseoDaemon } from "../test-utils/paseo-daemon.js";
import { DaemonClient } from "../test-utils/daemon-client.js";
import { isProviderAvailable } from "./agent-configs.js";
import type { PlanTimelineItem } from "../agent/agent-sdk-types.js";
function tmpCwd(): string {
return mkdtempSync(path.join(tmpdir(), "daemon-plans-"));
}
function waitForPlanMessage(
collector: MessageCollector,
agentId: string,
timeoutMs: number,
): Promise<PlanTimelineItem> {
return new Promise((resolve, reject) => {
const deadline = Date.now() + timeoutMs;
const timer = setInterval(() => {
const message = collector.messages.find((candidate) => {
if (candidate.type !== "agent_stream") return false;
if (candidate.payload.agentId !== agentId) return false;
return (
candidate.payload.event.type === "timeline" &&
candidate.payload.event.item.type === "plan"
);
});
if (message?.type === "agent_stream") {
const event = message.payload.event;
if (event.type === "timeline" && event.item.type === "plan") {
clearInterval(timer);
resolve(event.item);
}
}
if (Date.now() > deadline) {
clearInterval(timer);
reject(new Error(`Timed out waiting for plan item after ${timeoutMs}ms`));
}
}, 100);
});
}
describe("daemon E2E - first-class plans", () => {
let ctx: DaemonTestContext;
let collector: MessageCollector;
beforeEach(async () => {
ctx = await createDaemonTestContext();
collector = createMessageCollector(ctx.client);
});
afterEach(async () => {
collector.unsubscribe();
await ctx.cleanup();
}, 60_000);
test("surfaces an actionable plan and routes the response through the daemon", async () => {
const cwd = tmpCwd();
try {
const agent = await ctx.client.createAgent({
provider: "codex",
cwd,
title: "Plan E2E",
modeId: "full-access",
});
collector.clear();
await ctx.client.sendMessage(agent.id, "Emit an actionable plan.");
const finalState = await ctx.client.waitForFinish(agent.id, 5_000);
expect(finalState.status).toBe("idle");
const planMessage = collector.messages.find((message) => {
if (message.type !== "agent_stream") return false;
if (message.payload.agentId !== agent.id) return false;
return (
message.payload.event.type === "timeline" && message.payload.event.item.type === "plan"
);
});
expect(planMessage?.type).toBe("agent_stream");
if (planMessage?.type !== "agent_stream") {
throw new Error("Expected plan stream message");
}
const event = planMessage.payload.event;
if (event.type !== "timeline" || event.item.type !== "plan") {
throw new Error("Expected normalized plan item");
}
expect(event.item.actions).toEqual([
{ id: "implement", label: "Implement", variant: "primary" },
]);
const timeline = await ctx.client.fetchAgentTimeline(agent.id, {
direction: "tail",
limit: 0,
projection: "canonical",
});
expect(
timeline.entries.some(
(entry) => entry.item.type === "plan" && entry.item.planId === event.item.planId,
),
).toBe(true);
const response = await ctx.client.respondToPlan(agent.id, event.item.planId, {
actionId: "implement",
});
expect(response).toMatchObject({
agentId: agent.id,
planId: event.item.planId,
ok: true,
error: null,
});
} finally {
rmSync(cwd, { recursive: true, force: true });
}
}, 30_000);
test("surfaces a plan file as a non-actionable plan", async () => {
const cwd = tmpCwd();
try {
const agent = await ctx.client.createAgent({
provider: "opencode",
cwd,
title: "Plan File E2E",
modeId: "full-access",
});
collector.clear();
await ctx.client.sendMessage(agent.id, "Emit a plan file.");
await ctx.client.waitForFinish(agent.id, 5_000);
const timeline = await ctx.client.fetchAgentTimeline(agent.id, {
direction: "tail",
limit: 0,
projection: "canonical",
});
const plan = timeline.entries.find(
(entry) =>
entry.item.type === "plan" && entry.item.planId === "plan-file:.paseo/plans/fake.md",
);
expect(plan?.item).toEqual({
type: "plan",
planId: "plan-file:.paseo/plans/fake.md",
text: "# File plan\n\n- From disk",
});
const response = await ctx.client.respondToPlan(agent.id, "plan-file:.paseo/plans/fake.md", {
actionId: "implement",
});
expect(response.ok).toBe(false);
expect(response.error).toContain("No pending fake plan");
} finally {
rmSync(cwd, { recursive: true, force: true });
}
}, 30_000);
});
describe("daemon E2E - first-class plans with real providers", () => {
test("real Codex plan mode surfaces a normalized actionable plan", async (context) => {
if (!(await isProviderAvailable("codex"))) {
context.skip();
}
const cwd = tmpCwd();
const logger = pino({ level: "silent" });
const daemon = await createTestPaseoDaemon({
agentClients: { codex: new CodexAppServerAgentClient(logger) },
logger,
});
const client = new DaemonClient({ url: `ws://127.0.0.1:${daemon.port}/ws` });
try {
await client.connect();
await client.fetchAgents({ subscribe: { subscriptionId: "real-codex-plan" } });
const agent = await client.createAgent({
provider: "codex",
cwd,
title: "Real Codex Plan E2E",
modeId: "auto",
model: "gpt-5.4",
thinkingOptionId: "medium",
featureValues: { plan_mode: true },
});
await client.sendMessage(
agent.id,
"You are in plan mode. Produce a markdown plan with a short heading and exactly 3 bullets for implementing a login screen. Do not ask questions.",
);
await client.waitForFinish(agent.id, 240_000);
const timeline = await client.fetchAgentTimeline(agent.id, {
direction: "tail",
limit: 0,
projection: "canonical",
});
const plan = timeline.entries.find((entry) => entry.item.type === "plan");
expect(plan?.item.type).toBe("plan");
if (!plan || plan.item.type !== "plan") {
throw new Error("Expected normalized plan item");
}
expect(plan.item.text).toContain("Login");
expect(plan.item.actions?.some((action) => action.id === "implement")).toBe(true);
} finally {
await client.close().catch(() => undefined);
await daemon.close();
rmSync(cwd, { recursive: true, force: true });
}
}, 300_000);
test("real Claude plan mode surfaces a normalized actionable plan", async (context) => {
if (!(await isProviderAvailable("claude"))) {
context.skip();
}
const cwd = tmpCwd();
const logger = pino({ level: "silent" });
const daemon = await createTestPaseoDaemon({
agentClients: { claude: new ClaudeAgentClient({ logger }) },
logger,
});
const client = new DaemonClient({ url: `ws://127.0.0.1:${daemon.port}/ws` });
const collector = createMessageCollector(client);
try {
await client.connect();
await client.fetchAgents({ subscribe: { subscriptionId: "real-claude-plan" } });
const agent = await client.createAgent({
provider: "claude",
cwd,
title: "Real Claude Plan E2E",
modeId: "plan",
model: "haiku",
});
collector.clear();
await client.sendMessage(
agent.id,
[
"Create a short implementation plan for a login screen.",
"Use plan mode and call ExitPlanMode with a markdown plan.",
"Do not edit files.",
].join(" "),
);
const plan = await waitForPlanMessage(collector, agent.id, 120_000);
expect(plan.text).toContain("login");
expect(plan.actions?.some((action) => action.id === "implement")).toBe(true);
const snapshot = await client.fetchAgent(agent.id);
expect(snapshot.agent?.pendingPermissions ?? []).toEqual([]);
const response = await client.respondToPlan(agent.id, plan.planId, { actionId: "reject" });
expect(response).toMatchObject({
agentId: agent.id,
planId: plan.planId,
ok: true,
error: null,
});
} finally {
collector.unsubscribe();
await client.close().catch(() => undefined);
await daemon.close();
rmSync(cwd, { recursive: true, force: true });
}
}, 180_000);
});

View File

@@ -63,6 +63,49 @@ describe("serializeAgentStreamEvent", () => {
expect(serialized.item.messageId).toBe("m1");
});
test("accepts normalized plan timeline items", () => {
const event: AgentStreamEvent = {
type: "timeline",
provider: "codex",
item: {
type: "plan",
planId: "plan-1",
text: "# Plan\n\n- Ship it",
actions: [{ id: "implement", label: "Implement", variant: "primary" }],
},
};
const serialized = serializeAgentStreamEvent(event);
expect(serialized).toMatchObject({
type: "timeline",
item: {
type: "plan",
planId: "plan-1",
text: "# Plan\n\n- Ship it",
actions: [{ id: "implement", label: "Implement", variant: "primary" }],
},
});
});
test("accepts plan response requests", () => {
const parsed = SessionInboundMessageSchema.parse({
type: "agent.plan.respond.request",
agentId: "agent-1",
planId: "plan-1",
actionId: "implement",
requestId: "req-plan-1",
});
expect(parsed).toMatchObject({
type: "agent.plan.respond.request",
agentId: "agent-1",
planId: "plan-1",
actionId: "implement",
requestId: "req-plan-1",
});
});
test("passes canonical tool_call payloads through unchanged", () => {
const event: AgentStreamEvent = {
type: "timeline",

View File

@@ -68,6 +68,7 @@ import { ensureAgentLoaded } from "./agent/agent-loading.js";
import {
formatSystemNotificationPrompt,
sendPromptToAgent,
startAgentRun,
waitForAgentRunStartWithTimeout,
unarchiveAgentState,
} from "./agent/agent-prompt.js";
@@ -142,6 +143,8 @@ import {
type AgentPromptInput,
type AgentRunOptions,
type AgentSessionConfig,
type AgentStreamEvent,
type AgentTimelineItem,
type ProviderSnapshotEntry,
} from "./agent/agent-sdk-types.js";
import type { StoredAgentRecord } from "./agent/agent-storage.js";
@@ -716,6 +719,45 @@ function parseClientCapabilities(
return new Set(result);
}
function projectTimelineItemForClient(
item: AgentTimelineItem,
capabilities: ReadonlySet<ClientCapability>,
): AgentTimelineItem {
if (item.type !== "plan" || capabilities.has(CLIENT_CAPS.firstClassPlans)) {
return item;
}
// COMPAT(firstClassPlans): added in v0.1.82, remove shim after 2026-11-28.
return {
type: "tool_call",
callId: item.planId,
name: "Plan",
status: "completed",
error: null,
detail: {
type: "plan",
text: item.text,
},
};
}
function projectAgentStreamEventForClient(
event: AgentStreamEvent,
capabilities: ReadonlySet<ClientCapability>,
): AgentStreamEvent {
if (event.type !== "timeline") {
return event;
}
const item = projectTimelineItemForClient(event.item, capabilities);
if (item === event.item) {
return event;
}
return {
...event,
item,
};
}
/**
* Session represents a single connected client session.
* It owns all state management, orchestration logic, and message processing.
@@ -1335,7 +1377,11 @@ export class Session {
});
}
const serializedEvent = serializeAgentStreamEvent(event.event);
const projectedEvent = projectAgentStreamEventForClient(
event.event,
this.clientCapabilities,
);
const serializedEvent = serializeAgentStreamEvent(projectedEvent);
if (!serializedEvent) {
return;
}
@@ -1740,6 +1786,7 @@ export class Session {
const promise =
this.dispatchVoiceAndControlMessage(msg) ??
this.dispatchAgentRewindMessage(msg) ??
this.dispatchAgentPlanMessage(msg) ??
this.dispatchAgentLifecycleMessage(msg) ??
this.dispatchAgentConfigMessage(msg) ??
this.dispatchCheckoutMessage(msg) ??
@@ -1751,6 +1798,13 @@ export class Session {
if (promise) await promise;
}
private dispatchAgentPlanMessage(msg: SessionInboundMessage): Promise<void> | undefined {
if (msg.type === "agent.plan.respond.request") {
return this.handleAgentPlanRespondRequest(msg);
}
return undefined;
}
private dispatchVoiceAndControlMessage(msg: SessionInboundMessage): Promise<void> | undefined {
switch (msg.type) {
case "voice_audio_chunk":
@@ -4561,6 +4615,47 @@ export class Session {
}
}
private async handleAgentPlanRespondRequest(
msg: Extract<SessionInboundMessage, { type: "agent.plan.respond.request" }>,
): Promise<void> {
const { agentId, planId, actionId, feedback, requestId } = msg;
try {
const result = await this.agentManager.respondToPlan(agentId, planId, { actionId, feedback });
this.emit({
type: "agent.plan.respond.response",
payload: {
requestId,
agentId,
planId,
ok: true,
error: null,
},
});
if (result?.followUpPrompt) {
startAgentRun(this.agentManager, agentId, result.followUpPrompt, this.sessionLogger, {
replaceRunning: true,
});
}
} catch (error) {
const message = getErrorMessage(error);
this.sessionLogger.error(
{ err: error, agentId, planId, actionId },
"Failed to respond to plan",
);
this.emit({
type: "agent.plan.respond.response",
payload: {
requestId,
agentId,
planId,
ok: false,
error: message,
},
});
}
}
private async handleCheckoutStatusRequest(
msg: Extract<SessionInboundMessage, { type: "checkout_status_request" }>,
): Promise<void> {
@@ -7565,7 +7660,7 @@ export class Session {
hasNewer,
entries: entries.map((entry) => ({
provider: snapshot.provider,
item: entry.item,
item: projectTimelineItemForClient(entry.item, this.clientCapabilities),
timestamp: entry.timestamp,
seqStart: entry.seqStart,
seqEnd: entry.seqEnd,

View File

@@ -10,6 +10,8 @@ import type {
AgentLaunchContext,
AgentMode,
AgentModelDefinition,
AgentPlanResponse,
AgentPlanResult,
AgentPersistenceHandle,
AgentPromptInput,
AgentRunOptions,
@@ -304,6 +306,7 @@ class FakeAgentSession implements AgentSession {
private memoryMarker: string | null = null;
private pendingPermissions: AgentPermissionRequest[] = [];
private permissionGate: Deferred<AgentPermissionResponse> | null = null;
private pendingPlans = new Map<string, { text: string }>();
private readonly historyPath: string;
private readonly subscribers = new Set<(event: AgentStreamEvent) => void>();
private nextTurnOrdinal = 0;
@@ -522,6 +525,55 @@ class FakeAgentSession implements AgentSession {
this.notifySubscribers(completed);
}
private async emitActionablePlanTurn(text: string): Promise<void> {
const planId = `fake-plan-${randomUUID()}`;
const planText = text.includes("custom plan body") ? "custom plan body" : "# Plan\n\n- Test it";
this.pendingPlans.set(planId, { text: planText });
const planEvent: AgentStreamEvent = {
type: "timeline",
provider: this.providerName,
item: {
type: "plan",
planId,
text: planText,
actions: [{ id: "implement", label: "Implement", variant: "primary" }],
},
};
await this.appendHistoryEvent(planEvent);
this.notifySubscribers(planEvent);
const completed: AgentStreamEvent = {
type: "turn_completed",
provider: this.providerName,
usage: { inputTokens: 1, outputTokens: 1 },
};
await this.appendHistoryEvent(completed);
this.notifySubscribers(completed);
}
private async emitPlanFileTurn(): Promise<void> {
const planEvent: AgentStreamEvent = {
type: "timeline",
provider: this.providerName,
item: {
type: "plan",
planId: "plan-file:.paseo/plans/fake.md",
text: "# File plan\n\n- From disk",
},
};
await this.appendHistoryEvent(planEvent);
this.notifySubscribers(planEvent);
const completed: AgentStreamEvent = {
type: "turn_completed",
provider: this.providerName,
usage: { inputTokens: 1, outputTokens: 1 },
};
await this.appendHistoryEvent(completed);
this.notifySubscribers(completed);
}
private async resolveToolPermission(tool: {
name: string;
input?: Record<string, unknown>;
@@ -729,6 +781,16 @@ class FakeAgentSession implements AgentSession {
return;
}
if (textPrompt.toLowerCase().includes("emit an actionable plan")) {
await this.emitActionablePlanTurn(textPrompt);
return;
}
if (textPrompt.toLowerCase().includes("emit a plan file")) {
await this.emitPlanFileTurn();
return;
}
const tool = buildToolCallForPrompt(this.providerName, textPrompt);
if (tool) {
const returnedEarly = await this.emitToolCallTurn(tool, textPrompt);
@@ -834,6 +896,20 @@ class FakeAgentSession implements AgentSession {
this.permissionGate = null;
}
async respondToPlan(
planId: string,
response: AgentPlanResponse,
): Promise<AgentPlanResult | void> {
const pending = this.pendingPlans.get(planId);
if (!pending) {
throw new Error(`No pending fake plan with id '${planId}'`);
}
this.pendingPlans.delete(planId);
if (response.actionId === "implement") {
return { followUpPrompt: `Implement fake plan:\n${pending.text}` };
}
}
describePersistence(): AgentPersistenceHandle | null {
return buildPersistence(
this.providerName,

View File

@@ -1043,6 +1043,8 @@ export class VoiceAssistantWebSocketServer {
providersSnapshot: true,
// COMPAT(checkoutGithubSetAutoMerge): added in v0.1.75, remove gate after 2026-11-13.
checkoutGithubSetAutoMerge: true,
// COMPAT(firstClassPlans): added in v0.1.82, remove gate after 2026-11-28.
firstClassPlans: true,
// COMPAT(daemonStatusRpc): added in v0.1.76, remove gate after 2026-11-18.
daemonStatusRpc: true,
// COMPAT(terminalRestoreModes): added in v0.1.81, remove gate after 2026-11-23.

View File

@@ -73,6 +73,18 @@ const LegacyAgentSnapshotPayloadSchema = AgentSnapshotPayloadSchema.extend({
capabilities: LegacyAgentCapabilityFlagsSchema,
});
const LegacyPlanToolCallSchema = z.object({
type: z.literal("tool_call"),
callId: z.string(),
name: z.string(),
status: z.enum(["running", "completed", "failed", "canceled"]),
error: z.unknown().nullable(),
detail: z.object({
type: z.literal("plan"),
text: z.string(),
}),
});
interface SessionInternals {
handleFetchAgentTimelineRequest: (
message: Extract<
@@ -232,6 +244,16 @@ function createSessionForWireCompatTest(options?: {
timestamp: "2026-05-02T00:00:00.200Z",
item: { type: "assistant_message", text: "done" },
},
{
seq: 4,
timestamp: "2026-05-02T00:00:00.300Z",
item: {
type: "plan",
planId: "plan-1",
text: "# Plan\n\n- Do the thing",
actions: [{ id: "implement", label: "Implement", variant: "primary" }],
},
},
];
const session = new Session({
@@ -367,6 +389,38 @@ describe("wire compatibility", () => {
expect(currentParsed.payload.entries[0]?.collapsed).toContain("reasoning_merge");
});
test("downgrades plan timeline items for clients that do not declare the capability", async () => {
const response = await emitTimelineResponse();
const entry = response.payload.entries.find((item) => item.seqStart === 4);
expect(entry?.item).toEqual({
type: "tool_call",
callId: "plan-1",
name: "Plan",
status: "completed",
error: null,
detail: {
type: "plan",
text: "# Plan\n\n- Do the thing",
},
});
expect(() => LegacyPlanToolCallSchema.parse(entry?.item)).not.toThrow();
});
test("preserves plan timeline items for clients that declare the capability", async () => {
const response = await emitTimelineResponse({
[CLIENT_CAPS.firstClassPlans]: true,
});
const entry = response.payload.entries.find((item) => item.seqStart === 4);
expect(entry?.item).toEqual({
type: "plan",
planId: "plan-1",
text: "# Plan\n\n- Do the thing",
actions: [{ id: "implement", label: "Implement", variant: "primary" }],
});
});
test("sub_agent tool-call payload still parses against the v0.1.65-beta.3 schema", () => {
const parsed = LegacySubAgentToolCallSchema.parse({
type: "tool_call",

View File

@@ -1,6 +1,6 @@
{
"name": "@getpaseo/website",
"version": "0.1.86",
"version": "0.1.87",
"private": true,
"type": "module",
"scripts": {

View File

@@ -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"

View File

@@ -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"