From 9b09ba7d481f598f835a4784fdc46992540a2400 Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Wed, 9 Sep 2026 22:36:07 +0200 Subject: [PATCH] fix(subscriptions): isolate observers and silence unrequested feeds Filtered plugin observations shared a replaceable directory subscription with the app. Give each observation independent source ownership and route all application feeds through explicit demand, preserving combined listing and subscription. --- docs/ad-hoc-daemon-testing.md | 4 +- docs/architecture.md | 17 +- docs/plugins.md | 10 + docs/protocol-compatibility.md | 11 + .../app/e2e/browser/worktree-restore.spec.ts | 2 +- .../chat-outline/use-chat-outline.test.tsx | 135 +- .../chat-outline/use-chat-outline.ts | 26 +- packages/app/src/components/terminal-pane.tsx | 32 +- packages/app/src/contexts/session-context.tsx | 210 +-- packages/app/src/data/push-router.test.ts | 130 +- packages/app/src/data/push-router.ts | 297 ++- .../src/desktop/browser/automation/handler.ts | 27 +- packages/app/src/plugins/catalog-sync.tsx | 41 +- packages/app/src/plugins/client-runtime.ts | 2 +- .../plugins/client-slash-commands/index.ts | 63 +- .../command-center/contributions.test.ts | 7 +- .../plugins/command-center/contributions.ts | 8 +- .../plugins/command-center/registration.tsx | 4 +- packages/app/src/plugins/evaluate.ts | 22 +- .../src/plugins/registry-lifecycle.test.ts | 73 + packages/app/src/plugins/registry.test.ts | 2 +- packages/app/src/plugins/registry.ts | 32 +- packages/app/src/plugins/runtime-boundary.tsx | 9 +- packages/app/src/plugins/settings/index.tsx | 6 +- .../app/src/plugins/sidebar-groups.test.ts | 1 + .../src/plugins/surface-contribution.test.ts | 1 + .../app/src/plugins/surface-runtime.test.tsx | 19 +- packages/app/src/plugins/surface-runtime.ts | 34 +- packages/app/src/plugins/surface-screen.tsx | 13 +- packages/app/src/plugins/theme.test.ts | 1 + .../app/src/plugins/timeline/model.test.ts | 1 + .../app/src/plugins/timeline/view.test.tsx | 167 +- packages/app/src/plugins/timeline/view.tsx | 9 +- packages/app/src/plugins/types.ts | 1 + .../src/plugins/workspace-panels/panel.tsx | 9 +- .../workspace-panels/resolution.test.ts | 1 + .../src/runtime/directory-sync/index.test.ts | 66 +- .../app/src/runtime/directory-sync/index.ts | 236 ++- packages/app/src/runtime/host-runtime.test.ts | 179 +- packages/app/src/runtime/host-runtime.ts | 94 +- .../app/src/runtime/subscription-fixture.ts | 43 + .../app/src/screens/open-project-screen.tsx | 20 + .../screens/settings/plugins-page.test.tsx | 7 +- .../app/src/screens/settings/plugins-page.tsx | 18 +- packages/app/src/stores/session-store.ts | 6 +- .../terminal-stream-controller.test.ts | 93 +- .../runtime/terminal-stream-controller.ts | 146 +- packages/app/src/timeline/replica.test.ts | 3 +- .../src/timeline/viewed-timeline-sync.test.ts | 220 ++- .../app/src/timeline/viewed-timeline-sync.ts | 123 +- packages/app/src/workspace-labels/index.ts | 43 +- .../legacy-daemon-workspaces.test.ts | 105 +- .../src/workspace/legacy-daemon-workspaces.ts | 263 +-- packages/cli/src/commands/agent/attach.ts | 5 + packages/cli/src/commands/agent/logs.ts | 12 + .../client/examples/events-and-timeline.ts | 23 +- packages/client/src/connection.test.ts | 628 +++++-- packages/client/src/connection/index.ts | 104 +- packages/client/src/connection/owned.test.ts | 129 ++ packages/client/src/connection/owned.ts | 235 +++ .../src/daemon-client-transport.test.ts | 12 +- packages/client/src/daemon-client.test.ts | 316 ++-- packages/client/src/daemon-client.ts | 715 ++++---- packages/client/src/index.test.ts | 135 +- packages/client/src/index.ts | 425 +++-- .../client/src/provider-snapshots/index.ts | 49 +- .../client/src/terminal-stream-router.test.ts | 22 +- packages/client/src/terminal-stream-router.ts | 145 +- packages/client/src/terminals/index.test.ts | 2 + .../client/src/timeline-subscription/index.ts | 134 ++ .../src/browser-automation/rpc-schemas.ts | 1 + packages/protocol/src/client-capabilities.ts | 2 + packages/protocol/src/messages.ts | 98 +- .../benchmark-startup-git-real-home.ts | 5 +- packages/server/scripts/test-mcp-inject.ts | 2 +- .../server/scripts/voice-main-daemon-e2e.ts | 6 +- .../server/scripts/voice-roundtrip-debug.ts | 6 +- .../server/src/server/agent/agent-prompt.ts | 5 +- .../authorization/operation-permissions.ts | 6 + .../server/src/server/browser-tools/broker.ts | 9 +- .../src/server/checkout-diff-manager.ts | 6 + .../cli-run-workspace-precedence.e2e.test.ts | 2 +- .../src/server/daemon-client.e2e.test.ts | 8 +- .../agent-archive.native.real.e2e.test.ts | 2 +- .../daemon-e2e/agent-operations.e2e.test.ts | 4 +- ...nt-refresh-rehydrates-timeline.e2e.test.ts | 2 +- .../agent-reload.native.real.e2e.test.ts | 2 +- ...ve-unrelated-github-merge.real.e2e.test.ts | 2 +- .../checkout-diff-subscription.e2e.test.ts | 30 +- ...de-autonomous-wake-simple.real.e2e.test.ts | 2 +- .../claude-autonomous-wake.real.e2e.test.ts | 18 +- .../daemon-e2e/claude-live-usage.e2e.test.ts | 2 +- .../daemon-e2e/claude-rewind.real.e2e.test.ts | 2 +- .../claude-thinking-memory.real.e2e.test.ts | 2 +- .../codex-auto-review.local.e2e.test.ts | 2 +- .../codex-goal-mid-turn.real.e2e.test.ts | 6 +- .../daemon-e2e/codex-rewind.real.e2e.test.ts | 2 +- .../empty-project-persists.e2e.test.ts | 2 +- .../daemon-e2e/live-preferences.e2e.test.ts | 2 +- .../mode-switch-propagation.e2e.test.ts | 2 +- .../model-resolution-on-init.real.e2e.test.ts | 2 +- ...-runtime-reconcile-claude.real.e2e.test.ts | 2 +- .../server/daemon-e2e/omp.real.e2e.test.ts | 3 +- ...open-project-missing-directory.e2e.test.ts | 8 +- ...ject-worktree-reclassification.e2e.test.ts | 2 +- .../opencode-custom-agents.real.e2e.test.ts | 2 +- .../opencode-draft-features.real.e2e.test.ts | 3 +- ...encode-import-persistence.real.e2e.test.ts | 2 +- ...ncode-initial-prompt-wait.real.e2e.test.ts | 2 +- .../opencode-invalid-mode.real.e2e.test.ts | 2 +- .../opencode-invalid-model.real.e2e.test.ts | 2 +- .../daemon-e2e/opencode-omo-real-runtime.ts | 2 +- ...encode-plan-and-questions.real.e2e.test.ts | 2 +- .../opencode-rewind.real.e2e.test.ts | 2 +- .../opencode-send-interrupt.real.e2e.test.ts | 2 +- ...ode-slow-plugin-readiness.real.e2e.test.ts | 2 +- .../daemon-e2e/permissions-codex.e2e.test.ts | 53 +- .../server/daemon-e2e/persistence.e2e.test.ts | 1 + .../daemon-e2e/pi-rewind.real.e2e.test.ts | 2 +- .../src/server/daemon-e2e/pi.real.e2e.test.ts | 3 +- .../project-becomes-git.e2e.test.ts | 2 +- ...ser-message-dedupe-claude.real.e2e.test.ts | 2 +- ...d-during-tool-call-claude.real.e2e.test.ts | 6 +- ...nd-during-tool-call-codex.real.e2e.test.ts | 10 +- ...hile-running-stuck-claude.real.e2e.test.ts | 6 +- .../send-while-running-stuck.real.e2e.test.ts | 6 +- .../server/daemon-e2e/streaming.e2e.test.ts | 2 + .../terminal-byte-headless-parity.e2e.test.ts | 9 +- .../server/daemon-e2e/terminal.e2e.test.ts | 7 +- .../timeline-reconnect-contract.e2e.test.ts | 4 +- .../ui-action-stress.real.e2e.test.ts | 4 +- .../user-message-contract.real.e2e.test.ts | 2 +- .../daemon-e2e/wait-for-idle.e2e.test.ts | 4 +- .../dictation-stream-manager.test.ts | 51 + .../dictation/dictation-stream-manager.ts | 170 +- .../src/server/file-upload/index.test.ts | 33 +- .../server/src/server/file-upload/index.ts | 104 +- .../server/hub/execution-controller.test.ts | 31 +- .../src/server/hub/execution-controller.ts | 18 +- .../hub/test-utils/relationship-harness.ts | 2 +- .../owned-subscriptions-client.e2e.test.ts | 167 ++ .../server/owned-subscriptions.e2e.test.ts | 1587 +++++++++++++++++ .../plugins/agent-configuration.e2e.test.ts | 2 +- .../plugins/lifecycle-archive.e2e.test.ts | 2 +- .../src/server/plugins/lifecycle.e2e.test.ts | 6 +- .../server/plugins/lifecycle.real.e2e.test.ts | 2 +- .../src/server/plugins/plugin-process.ts | 7 + .../src/server/plugins/settings.e2e.test.ts | 1 + .../server/schedule-run-lifecycle.e2e.test.ts | 6 +- .../selective-timeline-delivery.e2e.test.ts | 76 +- packages/server/src/server/session.test.ts | 139 +- packages/server/src/server/session.ts | 1313 +++++++++----- .../session.workspace-git-watch.test.ts | 51 +- .../src/server/session.workspaces.test.ts | 378 ++-- .../agent-updates/agent-updates-service.ts | 246 +-- .../session/checkout/checkout-session.test.ts | 42 +- .../session/checkout/checkout-session.ts | 88 +- ...mon-self-update-session-controller.test.ts | 46 + .../server/session/daemon/daemon-session.ts | 4 + .../src/server/session/daemon/diagnostics.ts | 11 + .../files/workspace-files-session.test.ts | 24 +- .../session/files/workspace-files-session.ts | 85 +- .../session/owned-subscriptions/index.ts | 433 +++++ .../session/owned-subscriptions/replies.ts | 69 + .../provider/provider-catalog-session.test.ts | 10 +- .../provider/provider-catalog-session.ts | 19 +- .../src/server/session/voice/index.test.ts | 58 + .../server/src/server/session/voice/index.ts | 146 ++ .../src/server/session/voice/voice-session.ts | 73 +- .../workspace-git-observer-service.test.ts | 66 - .../workspace-git-observer-service.ts | 69 +- .../workspace-scripts-service.ts | 2 + .../server/test-utils/daemon-test-context.ts | 2 +- .../server/test-utils/fake-agent-client.ts | 10 + .../src/server/test-utils/session-stubs.ts | 5 +- .../src/server/voice-local-agent.e2e.test.ts | 6 +- .../websocket-server.browser-tools.test.ts | 10 +- .../websocket-server.notifications.test.ts | 9 +- .../websocket-server.relay-reconnect.test.ts | 23 +- ...cket-server.terminal-notifications.test.ts | 5 + .../server/src/server/websocket-server.ts | 285 ++- .../src/server/websocket/runtime-metrics.ts | 4 + .../server/src/server/wire-compat.test.ts | 52 +- ...orkspace-archive-record-scoped.e2e.test.ts | 6 +- ...kspace-create-git-fanout.local.e2e.test.ts | 35 +- .../src/server/workspace-directory.test.ts | 49 + .../server/src/server/workspace-directory.ts | 33 +- .../workspace-labels/internal/service.ts | 6 + .../workspace-same-cwd-isolation.e2e.test.ts | 1 + ...terminal-session-controller.resize.test.ts | 36 +- .../terminal-session-controller.test.ts | 457 ++++- .../terminal/terminal-session-controller.ts | 406 +++-- .../src/terminal/terminal-size-ownership.ts | 6 +- plugin-examples/local-plugin/client/main.tsx | 29 +- public-docs/plugins/v0.8/reference.md | 4 + public-docs/sdk/agents.md | 7 +- public-docs/sdk/events.md | 89 +- public-docs/sdk/reference.md | 34 +- 198 files changed, 9341 insertions(+), 4491 deletions(-) create mode 100644 packages/app/src/plugins/registry-lifecycle.test.ts create mode 100644 packages/app/src/runtime/subscription-fixture.ts create mode 100644 packages/client/src/connection/owned.test.ts create mode 100644 packages/client/src/connection/owned.ts create mode 100644 packages/client/src/timeline-subscription/index.ts create mode 100644 packages/server/src/server/owned-subscriptions-client.e2e.test.ts create mode 100644 packages/server/src/server/owned-subscriptions.e2e.test.ts create mode 100644 packages/server/src/server/session/owned-subscriptions/index.ts create mode 100644 packages/server/src/server/session/owned-subscriptions/replies.ts create mode 100644 packages/server/src/server/session/voice/index.test.ts create mode 100644 packages/server/src/server/session/voice/index.ts diff --git a/docs/ad-hoc-daemon-testing.md b/docs/ad-hoc-daemon-testing.md index 87176d24b4..c12f003df9 100644 --- a/docs/ad-hoc-daemon-testing.md +++ b/docs/ad-hoc-daemon-testing.md @@ -51,7 +51,7 @@ const client = new DaemonClient({ appVersion: "0.1.70", // see gotcha #1 }); await client.connect(); -await client.fetchAgents({ subscribe: { subscriptionId: "test" } }); +await client.fetchAgents({ subscribe: {} }); // ... do your testing ... @@ -81,7 +81,7 @@ const client = new DaemonClient({ appVersion: "0.1.70", }); await client.connect(); -await client.fetchAgents({ subscribe: { subscriptionId: "test" } }); +await client.fetchAgents({ subscribe: {} }); // ... test ... diff --git a/docs/architecture.md b/docs/architecture.md index 32fef93532..f305407903 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -239,11 +239,22 @@ Server → Client: status message with payload { status: "server_info", serverId, hostname, version, capabilities?, features } ``` -There is no dedicated welcome message; the server emits a `status` session message after accepting the hello, then begins streaming. The session stores client capabilities from the hello and rehydrates them on reconnect, so the wire boundary can ask one question: `session.supports(...)`. +There is no dedicated welcome message; the server emits one initial `status.server_info` after +accepting hello. Modern clients negotiate `owned_subscriptions` against +`features.ownedSubscriptions`. Connecting or attaching a local listener creates no application-data +demand. A list is a snapshot; adding `subscribe: {}` creates an independent server ID. The client +correlates the initial reply by request ID, then routes updates by subscription ID. + +A logical Session can contain several physical sockets. Each socket owns its capabilities, +subscriptions and ongoing operations; the final JSON/binary send boundary requires that source's +request or subscription provenance. Domain modules own filtering, bootstrap and resource teardown. +The shared ownership boundary only tracks source and lifetime. Releasing a subscription acknowledges +its teardown; reconnect creates new server IDs for surviving client handles. See +[protocol compatibility](protocol-compatibility.md) for the legacy boundary. **Top-level WS envelopes** are `hello`, `recording_state`, `ping`/`pong`, and `session` (which wraps the rich union of session messages). -Client liveness checks use the top-level JSON `ping`/`pong` envelope, not a session RPC or RFC6455 control ping. Current clients ping every 10 seconds, beginning one interval after connecting. The first ping claims an application-ownership lease for that physical socket, all later inbound activity renews it, and the daemon forcibly terminates the socket if the lease expires. A legacy or raw socket that never sends an application ping never enters this lease and is not closed for omitting one. Session RPC timeouts are operation failures and must not be treated as proof that the socket is dead. +Client liveness checks use the top-level JSON `ping`/`pong` envelope, not a session RPC or RFC6455 control ping. Current clients ping every 10 seconds, beginning one interval after connecting. The first ping claims an application-ownership lease for that physical socket, all later inbound activity renews it, and the daemon forcibly terminates the socket if the lease expires. A legacy or raw socket that never sends an application ping never enters this lease and is not closed for omitting one. Session RPC timeouts are operation failures, not proof that the socket is dead. A subscription bootstrap timeout closes its source to clear any unknown server-owned registration; the failed handle is released before other surviving handles reconnect. Every physical send path enforces an 8 MiB outbound high-water mark, including JSON broadcasts, binary terminal frames, and the encrypted relay adapter's asynchronous queue. This sits above the terminal stream's 4 MiB soft backpressure threshold, leaving room for snapshot catch-up before the hard cutoff. JSON is serialized once per broadcast after sockets already at the limit are removed, then its exact byte length is checked for every remaining socket. A frame that would cross the limit is not sent; that physical socket is forcibly terminated without disturbing other sockets attached to the same logical session. Multiple tabs and simultaneous direct and relay paths may legitimately share a client id. @@ -297,7 +308,7 @@ messages or transfers. - WebSocket schemas are append-only. Add fields, do not remove fields, and never make optional fields required. - New wire enum values must be gated at serialization with `session.supports(CLIENT_CAPS.someCapability)`. -- `Session` stores client capabilities from the `hello` handshake and rehydrates them on reconnect, so the wire boundary can ask one question: `session.supports(...)`. +- Capabilities belong to the physical source socket. `session.supports(...)` reads the requesting or observing source at the domain projection boundary; one socket must not borrow a sibling's capabilities. Example: adding a new enum value diff --git a/docs/plugins.md b/docs/plugins.md index 229679ddf7..dfd4d6dde3 100644 --- a/docs/plugins.md +++ b/docs/plugins.md @@ -355,6 +355,16 @@ Native sheets teleport their children. Button surfaces rebuild the installation' query, and toast providers inside the surface content, including overflow pages from different plugins. Providers only around the trigger do not reach those bodies. +Request observation with `client.paseo.agents.list({ subscribe: {} })` and consume the returned +`subscription` handle. Plain `list()` and local `.subscribe(handler)` listeners create no daemon +demand. Each list-and-subscribe call has its own server ID, even for the same query. Handle snapshots +also run after reconnect; replace your view before applying its subsequent updates. The installation +owns every observation created through its API and releases them on unload, including setup failure. +Mounted surfaces and command invocations have shorter API lifetimes. + +Keep the client entry synchronous: return its cleanup function immediately and start asynchronous +work inside it. See the maintained [composer pill example](../plugin-examples/local-plugin/client/main.tsx). + ## Contribute timeline items Timeline transformers and renderers are client contributions. The daemon's canonical rows and diff --git a/docs/protocol-compatibility.md b/docs/protocol-compatibility.md index 6a232b8061..089bf1a3e8 100644 --- a/docs/protocol-compatibility.md +++ b/docs/protocol-compatibility.md @@ -50,6 +50,17 @@ Connecting creates no timeline or event demand. Client subscriptions own their n release it on unsubscribe, and restore it after reconnect. Raw message observers inspect traffic without requesting streams. Application caches and which agents are visible remain caller-owned. +## Owned observations + +`owned_subscriptions` and `server_info.features.ownedSubscriptions` negotiate the entire source-owned +contract described in [architecture](architecture.md#websocket-protocol). The client gates observation +once and tells users to update an older host. It does not emulate independent handles over legacy slots. + +Old clients keep their existing wire shapes and slot behavior at the daemon's source boundary. +The adapter keys legacy slots by physical socket, so an old connection cannot replace a modern +sibling's observation even when both use the same logical client ID. Optional wire IDs stay accepted +for parsing compatibility; modern requests cannot select their subscription ID. + ## Every shim is tagged and dated A shim that exists for old-app or old-daemon support carries a comment naming it, the version it arrived in, and when it can go: diff --git a/packages/app/e2e/browser/worktree-restore.spec.ts b/packages/app/e2e/browser/worktree-restore.spec.ts index d8bac26fe4..4f6096ed07 100644 --- a/packages/app/e2e/browser/worktree-restore.spec.ts +++ b/packages/app/e2e/browser/worktree-restore.spec.ts @@ -205,7 +205,7 @@ test.describe("Worktree restore", () => { "restore-success", ); await worktreeClient.fetchWorkspaces({ - subscribe: { subscriptionId: `restore-secondary-${randomUUID()}` }, + subscribe: {}, }); let updateTimeout: ReturnType | null = null; let unsubscribeSecondaryWorkspaceUpdate = () => {}; diff --git a/packages/app/src/agent-stream/chat-outline/use-chat-outline.test.tsx b/packages/app/src/agent-stream/chat-outline/use-chat-outline.test.tsx index 6200b8b0f9..6b64d992eb 100644 --- a/packages/app/src/agent-stream/chat-outline/use-chat-outline.test.tsx +++ b/packages/app/src/agent-stream/chat-outline/use-chat-outline.test.tsx @@ -6,20 +6,12 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; import type { StreamViewportHandle } from "../strategy"; import { useChatOutline } from "./use-chat-outline"; -interface RefreshMessage { - type: "agent_stream"; - payload: { - agentId: string; - event: { type: "timeline"; item: { type: "user_message" } }; - }; -} - const runtime = vi.hoisted(() => ({ listAgentTimelinePrompts: vi.fn(), fetchAgentTimeline: vi.fn(), - on: vi.fn<(event: string, listener: (message: RefreshMessage) => void) => () => void>( - () => () => undefined, - ), + subscribeAgentTimeline: vi.fn(() => { + throw new Error("The outline must reuse the viewed timeline"); + }), })); vi.mock("@/constants/platform", () => ({ isWeb: true })); @@ -42,7 +34,7 @@ describe("useChatOutline", () => { beforeEach(() => { runtime.listAgentTimelinePrompts.mockReset(); runtime.fetchAgentTimeline.mockReset(); - runtime.on.mockClear(); + runtime.subscribeAgentTimeline.mockClear(); }); it("drops a late prompt index after the authoritative timeline epoch changes", async () => { @@ -88,7 +80,7 @@ describe("useChatOutline", () => { expect(result.current.prompts[0]?.seq).toBe(2); }); - it("keeps the newest prompt index response within one epoch", async () => { + it("refreshes from viewed user messages without another stream and keeps the newest index", async () => { const older = deferred<{ epoch: string; prompts: Array<{ seq: number; timestamp: string; preview: string }>; @@ -102,36 +94,31 @@ describe("useChatOutline", () => { .mockReturnValueOnce(older.promise) .mockReturnValueOnce(newer.promise); const viewportRef = createRef(); - const { result } = renderHook(() => - useChatOutline({ - agentId: "agent-1", - serverId: "server-1", - timelineEpoch: "epoch-1", - tail: [], - head: [], - enabled: true, - viewportRef, - onJumpError: vi.fn(), - }), + const { result, rerender } = renderHook( + ({ tail }) => + useChatOutline({ + agentId: "agent-1", + serverId: "server-1", + timelineEpoch: "epoch-1", + tail, + head: [], + enabled: true, + viewportRef, + onJumpError: vi.fn(), + }), + { initialProps: { tail: [] as import("@/types/stream").StreamItem[] } }, ); await waitFor(() => expect(runtime.listAgentTimelinePrompts).toHaveBeenCalledTimes(1)); - const refresh = runtime.on.mock.calls[0]?.[1]; - await act(async () => { - refresh?.({ - type: "agent_stream", - payload: { - agentId: "agent-1", - event: { type: "timeline", item: { type: "user_message" } }, - }, - }); - refresh?.({ - type: "agent_stream", - payload: { - agentId: "agent-1", - event: { type: "timeline", item: { type: "user_message" } }, - }, - }); + const loadedPrompt = (seq: number): import("@/types/stream").StreamItem => ({ + id: `prompt-${seq}`, + kind: "user_message", + text: "live prompt", + timestamp: new Date(seq), + timelineCursor: { epoch: "epoch-1", seq }, }); + rerender({ tail: [loadedPrompt(2)] }); + await waitFor(() => expect(runtime.listAgentTimelinePrompts).toHaveBeenCalledTimes(2)); + rerender({ tail: [loadedPrompt(2), loadedPrompt(3)] }); await waitFor(() => expect(runtime.listAgentTimelinePrompts).toHaveBeenCalledTimes(3)); await act(async () => newer.resolve({ @@ -147,6 +134,74 @@ describe("useChatOutline", () => { ); expect(result.current.prompts.map((prompt) => prompt.seq)).toEqual([3]); + expect(runtime.subscribeAgentTimeline).not.toHaveBeenCalled(); + }); + + it("refreshes after reconnect catch-up and visibility changes without reacting to assistant chunks", async () => { + runtime.listAgentTimelinePrompts.mockResolvedValue({ + epoch: "epoch-1", + prompts: [{ seq: 1, timestamp: new Date(1).toISOString(), preview: "Unloaded prompt" }], + }); + const viewportRef = createRef(); + const { result, rerender } = renderHook( + ({ enabled, head, timelineEpoch }) => + useChatOutline({ + agentId: "agent-1", + serverId: "server-1", + tail: [], + head, + timelineEpoch, + enabled, + viewportRef, + visibleItemIds: new Set(), + onJumpError: vi.fn(), + }), + { + initialProps: { + enabled: true, + head: [] as import("@/types/stream").StreamItem[], + timelineEpoch: "epoch-1", + }, + }, + ); + await waitFor(() => expect(result.current.prompts[0]?.preview).toBe("Unloaded prompt")); + rerender({ + enabled: true, + head: [ + { + id: "assistant", + kind: "assistant_message", + text: "chunk", + timestamp: new Date(), + timelineCursor: { epoch: "epoch-1", seq: 2 }, + }, + ], + timelineEpoch: "epoch-1", + }); + expect(runtime.listAgentTimelinePrompts).toHaveBeenCalledTimes(1); + rerender({ enabled: false, head: [], timelineEpoch: "epoch-1" }); + expect(result.current.prompts).toEqual([]); + // The viewed model publishes the catch-up from the reconnected transport. + runtime.listAgentTimelinePrompts.mockResolvedValue({ + epoch: "epoch-2", + prompts: [{ seq: 1, timestamp: new Date(2).toISOString(), preview: "Replaced conversation" }], + }); + rerender({ + enabled: true, + head: [ + { + id: "reconnected-user", + kind: "user_message", + text: "Replaced conversation", + timestamp: new Date(2), + timelineCursor: { epoch: "epoch-2", seq: 1 }, + }, + ], + timelineEpoch: "epoch-2", + }); + await waitFor(() => expect(result.current.prompts[0]?.preview).toBe("Replaced conversation")); + expect(runtime.listAgentTimelinePrompts).toHaveBeenCalledTimes(2); + expect(runtime.subscribeAgentTimeline).not.toHaveBeenCalled(); }); it("reports a failed unloaded prompt jump", async () => { diff --git a/packages/app/src/agent-stream/chat-outline/use-chat-outline.ts b/packages/app/src/agent-stream/chat-outline/use-chat-outline.ts index b88876ef49..6d66ec4379 100644 --- a/packages/app/src/agent-stream/chat-outline/use-chat-outline.ts +++ b/packages/app/src/agent-stream/chat-outline/use-chat-outline.ts @@ -65,12 +65,23 @@ export function useChatOutline({ const loadedItems = useMemo(() => [...tail, ...(head ?? NO_STREAM_ITEMS)], [head, tail]); const prompts = enabled ? (index?.prompts ?? NO_PROMPTS) : NO_PROMPTS; + // The viewed timeline already owns live delivery and reconnect catch-up. Its complete + // loaded items (including rows outside the mounted window) invalidate the prompt index. + const latestPromptSeq = loadedItems.reduce( + (latest, item) => + item.kind === "user_message" && item.timelineCursor?.epoch === timelineEpoch + ? Math.max(latest, item.timelineCursor.seq) + : latest, + -1, + ); + + useEffect(() => setIndex(null), [agentId, enabled, serverId, timelineEpoch]); + useEffect(() => { if (!isWeb || !enabled) { setIndex(null); return; } - setIndex(null); const client = getHostRuntimeStore().getClient(serverId); if (!client) return; let active = true; @@ -91,21 +102,10 @@ export function useChatOutline({ .catch(() => undefined); }; refresh(); - const unsubscribe = client.on("agent_stream", (message) => { - if ( - message.type === "agent_stream" && - message.payload.agentId === agentId && - message.payload.event.type === "timeline" && - message.payload.event.item.type === "user_message" - ) { - refresh(); - } - }); return () => { active = false; - unsubscribe(); }; - }, [agentId, enabled, serverId, timelineEpoch]); + }, [agentId, enabled, serverId, timelineEpoch, latestPromptSeq]); // The transcript names the row it is showing; the outline turns that into a prompt using the // complete index, so unloaded rows never have to exist in the DOM to be marked. diff --git a/packages/app/src/components/terminal-pane.tsx b/packages/app/src/components/terminal-pane.tsx index 3e98799035..ad87c5f8eb 100644 --- a/packages/app/src/components/terminal-pane.tsx +++ b/packages/app/src/components/terminal-pane.tsx @@ -449,31 +449,11 @@ export function TerminalPane({ [handleKeyboardChange], ); - useEffect(() => { - if (!client || !isConnected || !isWorkspaceFocused) { - return; - } - - return client.on("terminal_stream_exit", (message) => { - if (message.type !== "terminal_stream_exit") { - return; - } - - const exitedTerminalId = message.payload.terminalId; - if (!exitedTerminalId) { - return; - } - - workspaceTerminalSession.snapshots.clear({ terminalId: exitedTerminalId }); - if (terminalIdRef.current === exitedTerminalId) { - emulatorRef.current?.clear(); - } - streamControllerRef.current?.handleTerminalExit({ - terminalId: exitedTerminalId, - }); - setModifiers({ ...EMPTY_MODIFIERS }); - }); - }, [client, isConnected, isWorkspaceFocused, workspaceTerminalSession.snapshots]); + const handleStreamExit = useStableEvent((exitedTerminalId: string) => { + workspaceTerminalSession.snapshots.clear({ terminalId: exitedTerminalId }); + if (terminalIdRef.current === exitedTerminalId) emulatorRef.current?.clear(); + setModifiers({ ...EMPTY_MODIFIERS }); + }); useEffect(() => { measuredTerminalSizeRef.current = null; @@ -561,6 +541,7 @@ export function TerminalPane({ onOutput: handleStreamOutput, onRestore: handleStreamRestore, onSnapshot: handleStreamSnapshot, + onExit: handleStreamExit, getRestoreOptions: getStreamRestoreOptions, onStatusChange: handleStreamControllerStatus, }); @@ -581,6 +562,7 @@ export function TerminalPane({ handleStreamOutput, handleStreamRestore, handleStreamSnapshot, + handleStreamExit, isConnected, ]); diff --git a/packages/app/src/contexts/session-context.tsx b/packages/app/src/contexts/session-context.tsx index fff3a0f496..8adf2a188a 100644 --- a/packages/app/src/contexts/session-context.tsx +++ b/packages/app/src/contexts/session-context.tsx @@ -14,12 +14,7 @@ import type { StreamItem } from "@/types/stream"; import { deriveAgentStreamTurnLiveness } from "@/timeline/session-stream-reducers"; import { planTimelineTailFetch } from "@/timeline/timeline-sync-plan"; import { requestTimelineReplacement } from "@/timeline/timeline-replacement"; -import { - consumeForcedTimelineTailReplacement, - type TimelineDeliveryMode, - type TimelineResponsePayload, - type ViewedTimelineOwner, -} from "@/timeline/viewed-timeline-sync"; +import { type ViewedTimelineOwner } from "@/timeline/viewed-timeline-sync"; import type { AgentAttachment, SessionOutboundMessage } from "@getpaseo/protocol/messages"; import { parseServerInfoStatusPayload } from "@getpaseo/protocol/messages"; import { @@ -81,11 +76,6 @@ interface BufferedAudioChunk { id: string; } -// COMPAT(selectiveAgentTimeline): added in v0.1.106, remove after 2027-01-12. -function getTimelineDeliveryMode(selectiveAgentTimeline?: boolean): TimelineDeliveryMode { - return selectiveAgentTimeline ? "selective" : "legacy"; -} - function decodeBase64Chunk(base64: string): Uint8Array { return Buffer.from(base64, "base64"); } @@ -251,7 +241,6 @@ function SessionProviderInternal({ children, serverId, client }: SessionProvider const _sessionStateTimeoutRef = useRef | null>(null); const attentionNotifiedRef = useRef>(new Map()); const appStateRef = useRef(AppState.currentState); - const forcedTimelineTailReplacements = useRef(new Set()); const viewedTimelineSyncRef = useRef(null); const audioOutputBuffersRef = useRef>(new Map()); const activeAudioGroupsRef = useRef>(new Set()); @@ -417,24 +406,62 @@ function SessionProviderInternal({ children, serverId, client }: SessionProvider [serverId, upsertWorkspaceSetupProgress], ); - const applyTimelineResponse = useCallback((receivedPayload: TimelineResponsePayload) => { - const payload = consumeForcedTimelineTailReplacement( - receivedPayload, - forcedTimelineTailReplacements.current, - ); - const owner = viewedTimelineSyncRef.current; - if (!owner) throw new Error("Viewed timeline owner is unavailable"); - owner.applyTimelineResponse(payload); - }, []); - useEffect(() => { const setAgentInitializing = createSetAgentInitializing(serverId, setInitializingAgents); - const initialDeliveryMode = getTimelineDeliveryMode( - client.getLastServerInfoMessage()?.features?.selectiveAgentTimeline, - ); + const onStream = (message: SessionOutboundMessage) => { + if (message.type !== "agent_stream") return; + const { agentId, event, timestamp, seq, epoch } = message.payload; + const parsedTimestamp = new Date(timestamp); + const streamEvent = event; + if ( + event.type === "turn_started" || + event.type === "turn_completed" || + event.type === "turn_failed" || + event.type === "turn_canceled" + ) { + voiceRuntime?.onTurnEvent(serverId, agentId, event.type); + } + const turnLiveness = deriveAgentStreamTurnLiveness([ + { event: streamEvent, seq, epoch, timestamp: parsedTimestamp }, + ]); + if (turnLiveness.length > 0) { + getHostRuntimeStore().applyAgentTurnLiveness(serverId, agentId, turnLiveness); + } + sync.enqueueStreamEvent(agentId, { + event: streamEvent, + seq, + epoch, + timestamp: parsedTimestamp, + }); + + // NOTE: We don't update lastActivityAt on every stream event to prevent + // cascading rerenders. The agent_update handler updates agent.lastActivityAt + // on status changes, which is sufficient for sorting and display purposes. + }; + const onReplacement = (message: SessionOutboundMessage) => { + if (message.type !== "agent.timeline.replacement") return; + void requestTimelineReplacement( + { + fetchAgentTimeline: (agentId, request) => + getHostRuntimeStore().fetchAgentTimeline(serverId, agentId, request), + }, + message.payload.agentId, + ).catch((error: unknown) => { + console.warn("[Session] timeline replacement refresh failed", { serverId, error }); + }); + }; const sync = getHostRuntimeStore().createViewedTimelineOwner(serverId, { - initialDeliveryMode, - setSubscription: (agentIds) => client.setAgentTimelineSubscription(agentIds), + observe: (agentIds) => { + const observation = client.observeTimeline(agentIds); + observation.subscribe({ + snapshot: () => {}, + update: (message) => { + if (message.type === "agent_stream") onStream(message); + else if (message.type === "agent.timeline.replacement") onReplacement(message); + }, + }); + return observation; + }, readCursor: (agentId) => { const timeline = selectAgentTimelineState( useSessionStore.getState().sessions[serverId], @@ -474,18 +501,8 @@ function SessionProviderInternal({ children, serverId, client }: SessionProvider throw error; } }, - fetchLatestTail: async (agentId) => { - forcedTimelineTailReplacements.current.add(agentId); - try { - return await getHostRuntimeStore().fetchAgentTimeline( - serverId, - agentId, - planTimelineTailFetch(), - ); - } finally { - forcedTimelineTailReplacements.current.delete(agentId); - } - }, + fetchLatestTail: (agentId) => + getHostRuntimeStore().fetchAgentTimeline(serverId, agentId, planTimelineTailFetch()), reportError: (error) => { console.warn("[Session] viewed timeline synchronization failed", { serverId, error }); }, @@ -505,7 +522,7 @@ function SessionProviderInternal({ children, serverId, client }: SessionProvider setViewedTimelineSync(serverId, null); sync.dispose(); }; - }, [client, serverId, setInitializingAgents, setViewedTimelineSync]); + }, [client, serverId, setInitializingAgents, setViewedTimelineSync, voiceRuntime]); useEffect(() => { viewedTimelineSyncRef.current?.setConnected(isConnected); @@ -513,98 +530,57 @@ function SessionProviderInternal({ children, serverId, client }: SessionProvider // Daemon message handlers - directly update Zustand store useEffect(() => { - const owner = viewedTimelineSyncRef.current; - if (!owner) throw new Error("Viewed timeline owner is unavailable"); - - const unsubAgentStream = client.on("agent_stream", (message) => { - if (message.type !== "agent_stream") return; - const { agentId, event, timestamp, seq, epoch } = message.payload; - const parsedTimestamp = new Date(timestamp); - const streamEvent = event; - if ( - event.type === "turn_started" || - event.type === "turn_completed" || - event.type === "turn_failed" || - event.type === "turn_canceled" - ) { - voiceRuntime?.onTurnEvent(serverId, agentId, event.type); - } - const turnLiveness = deriveAgentStreamTurnLiveness([ - { event: streamEvent, seq, epoch, timestamp: parsedTimestamp }, - ]); - if (turnLiveness.length > 0) { - getHostRuntimeStore().applyAgentTurnLiveness(serverId, agentId, turnLiveness); - } - owner.enqueueStreamEvent(agentId, { - event: streamEvent, - seq, - epoch, - timestamp: parsedTimestamp, + const feeds = client.observeEvents( + [ + "agent_attention_required", + "terminal_attention_required", + "agent_permission_request", + "agent_permission_resolved", + "agent.provider_subagents.update", + "checkout_status_update", + "workspace_setup_progress", + "status.server_info", + ], + { notifications: true }, + ); + const onFeed = ( + type: SessionOutboundMessage["type"], + handler: (message: SessionOutboundMessage) => void, + ) => + feeds.subscribe({ + snapshot: () => {}, + update: (message) => { + if (message.type === type) handler(message); + }, }); - // NOTE: We don't update lastActivityAt on every stream event to prevent - // cascading rerenders. The agent_update handler updates agent.lastActivityAt - // on status changes, which is sufficient for sorting and display purposes. - }); - - const unsubAgentAttention = client.onAgentAttentionRequired((notification) => { + const unsubAgentAttention = onFeed("agent_attention_required", (message) => { + if (message.type !== "agent_attention_required") return; + const notification = message.payload; if (notification.shouldNotify) { notifyAgentAttention(notification); } }); - const unsubAgentTimeline = client.on("fetch_agent_timeline_response", (message) => { - if (message.type !== "fetch_agent_timeline_response") return; - owner.flushStreamAgent(message.payload.agentId); - applyTimelineResponse(message.payload); - }); - - const unsubTimelineReplacement = client.on("agent.timeline.replacement", (message) => { - if (message.type !== "agent.timeline.replacement") return; - void requestTimelineReplacement( - { - fetchAgentTimeline: (agentId, request) => - getHostRuntimeStore().fetchAgentTimeline(serverId, agentId, request), - }, - message.payload.agentId, - ).catch((error: unknown) => { - console.warn("[Session] timeline replacement refresh failed", { serverId, error }); - }); - }); - - const unsubProviderSubagentUpdate = client.on("agent.provider_subagents.update", (message) => { + const unsubProviderSubagentUpdate = onFeed("agent.provider_subagents.update", (message) => { if (message.type !== "agent.provider_subagents.update") return; useProviderSubagentStore.getState().applyUpdate(serverId, message.payload); }); - const unsubCheckoutStatusUpdate = client.on("checkout_status_update", (message) => { + const unsubCheckoutStatusUpdate = onFeed("checkout_status_update", (message) => { if (message.type !== "checkout_status_update") return; applyCheckoutStatusUpdateFromEvent({ queryClient, serverId, message }); }); - const unsubWorkspaceSetupProgress = client.on("workspace_setup_progress", (message) => { + const unsubWorkspaceSetupProgress = onFeed("workspace_setup_progress", (message) => { if (message.type !== "workspace_setup_progress") return; applyWorkspaceSetupProgress(message.payload); }); - const unsubWorkspaceSetupStatusResponse = client.on( - "workspace_setup_status_response", - (message) => { - if (message.type !== "workspace_setup_status_response") return; - const { workspaceId, snapshot } = message.payload; - if (snapshot) { - applyWorkspaceSetupProgress({ workspaceId, ...snapshot }); - } - }, - ); - - const unsubStatus = client.on("status", (message) => { + const unsubStatus = onFeed("status", (message) => { if (message.type !== "status") return; const serverInfo = parseServerInfoStatusPayload(message.payload); if (serverInfo) { - viewedTimelineSyncRef.current?.setDeliveryMode( - getTimelineDeliveryMode(serverInfo.features?.selectiveAgentTimeline), - ); updateSessionServerInfo(serverId, { serverId: serverInfo.serverId, hostname: serverInfo.hostname, @@ -619,7 +595,7 @@ function SessionProviderInternal({ children, serverId, client }: SessionProvider } }); - const unsubPermissionRequest = client.on("agent_permission_request", (message) => { + const unsubPermissionRequest = onFeed("agent_permission_request", (message) => { if (message.type !== "agent_permission_request") return; const { agentId, request } = message.payload; @@ -631,7 +607,7 @@ function SessionProviderInternal({ children, serverId, client }: SessionProvider }); }); - const unsubPermissionResolved = client.on("agent_permission_resolved", (message) => { + const unsubPermissionResolved = onFeed("agent_permission_resolved", (message) => { if (message.type !== "agent_permission_resolved") return; const { requestId, agentId } = message.payload; @@ -740,7 +716,7 @@ function SessionProviderInternal({ children, serverId, client }: SessionProvider voiceRuntime?.onServerSpeechStateChanged(serverId, message.payload.isSpeaking); }); - const unsubTerminalAttention = client.on("terminal_attention_required", (message) => { + const unsubTerminalAttention = onFeed("terminal_attention_required", (message) => { if (message.type !== "terminal_attention_required") { return; } @@ -762,14 +738,13 @@ function SessionProviderInternal({ children, serverId, client }: SessionProvider }); return () => { - unsubTimelineReplacement(); - unsubAgentStream(); - unsubAgentTimeline(); + void feeds + .release() + .catch((error) => console.warn("[Session] Failed to release feeds", error)); unsubProviderSubagentUpdate(); unsubAgentAttention(); unsubCheckoutStatusUpdate(); unsubWorkspaceSetupProgress(); - unsubWorkspaceSetupStatusResponse(); unsubStatus(); unsubPermissionRequest(); unsubPermissionResolved(); @@ -792,7 +767,6 @@ function SessionProviderInternal({ children, serverId, client }: SessionProvider setPendingPermissions, notifyAgentAttention, applyWorkspaceSetupProgress, - applyTimelineResponse, updateSessionServerInfo, toast, voiceRuntime, diff --git a/packages/app/src/data/push-router.test.ts b/packages/app/src/data/push-router.test.ts index ae99c5dfbc..682d607512 100644 --- a/packages/app/src/data/push-router.test.ts +++ b/packages/app/src/data/push-router.test.ts @@ -1,3 +1,4 @@ +import type { OwnedSubscription, SubscriptionObserver } from "@getpaseo/client"; import { QueryClient, QueryObserver, skipToken } from "@tanstack/react-query"; import { describe, expect, it } from "vitest"; import type { MutableDaemonConfig, SessionOutboundMessage } from "@getpaseo/protocol/messages"; @@ -94,37 +95,89 @@ function createFakeClient(config: { rejectCheckoutDiffSubscribe?: boolean } = {} } } + function observe( + snapshot: T, + types: RouterMessageType[], + released: () => void, + matches: (message: RouterMessage) => boolean = () => true, + ): OwnedSubscription { + const observers = new Set>(); + let active = true; + const cleanups = types.map((type) => + on(type, (message) => { + if (!matches(message)) return; + for (const observer of observers) { + if (message.type === "subscribe_checkout_diff_response") + observer.snapshot({ ...snapshot, ...message.payload }); + else observer.update(message); + } + }), + ); + return { + subscriptionId: snapshot.subscriptionId, + ready: Promise.resolve(snapshot), + subscribe: (observer) => { + observers.add(observer); + observer.snapshot(snapshot); + return () => { + observers.delete(observer); + }; + }, + release: async () => { + if (!active) return; + active = false; + cleanups.forEach((cleanup) => cleanup()); + observers.clear(); + released(); + }, + }; + } + return { client: { async getProvidersSnapshot() { throw new Error("Unexpected snapshot pull"); }, - on, - async subscribeCheckoutDiff(cwd, compare, requestOptions) { - subscribeCheckoutDiffCalls.push({ - cwd, - compare, - subscriptionId: requestOptions.subscriptionId, - }); - if (config.rejectCheckoutDiffSubscribe) { - throw new Error("subscribe failed"); - } + observeEvents: () => + observe( + { subscriptionId: "events", requestId: "events", events: [] }, + ["status", "providers_snapshot_update"], + () => {}, + ), + observeCheckoutDiff(cwd, compare) { + const subscriptionId = `server-diff-${subscribeCheckoutDiffCalls.length + 1}`; + subscribeCheckoutDiffCalls.push({ cwd, compare, subscriptionId }); + const handle = observe( + { subscriptionId, cwd, files: [], error: null, requestId: "subscribe-checkout-diff" }, + ["checkout_diff_update", "subscribe_checkout_diff_response"], + () => unsubscribeCheckoutDiffCalls.push(subscriptionId), + (message) => + "subscriptionId" in message.payload && + message.payload.subscriptionId === subscriptionId, + ); + if (!config.rejectCheckoutDiffSubscribe) return handle; return { - subscriptionId: requestOptions.subscriptionId, - cwd, - files: [], - error: null, - requestId: requestOptions.requestId ?? "subscribe-checkout-diff", + ...handle, + subscribe: (observer) => { + queueMicrotask(() => observer.error?.(new Error("subscribe failed"))); + return () => {}; + }, }; }, - unsubscribeCheckoutDiff(subscriptionId) { - unsubscribeCheckoutDiffCalls.push(subscriptionId); - }, - subscribeTerminals(subscription) { - subscribeTerminalCalls.push(subscription); - }, - unsubscribeTerminals(subscription) { - unsubscribeTerminalCalls.push(subscription); + observeTerminals(input) { + const { signal: _signal, ...query } = input; + subscribeTerminalCalls.push(query); + return observe( + { + ...query, + subscriptionId: `terminal-${subscribeTerminalCalls.length}`, + requestId: "terminals", + terminals: [], + }, + ["terminals_changed"], + () => unsubscribeTerminalCalls.push(query), + (message) => message.type === "terminals_changed" && message.payload.cwd === query.cwd, + ); }, }, emit, @@ -277,18 +330,19 @@ describe("server data push router", () => { const unsubscribeObserver = observer.subscribe(() => undefined); const unmount = mountServerDataPushRouter({ client: fake.client, queryClient, serverId }); + const serverSubscriptionId = fake.subscribeCheckoutDiffCalls[0]!.subscriptionId; expect(fake.subscribeCheckoutDiffCalls).toEqual([ { cwd, compare: { mode: "base", baseRef: "main", ignoreWhitespace: true }, - subscriptionId, + subscriptionId: serverSubscriptionId, }, ]); fake.emit({ type: "subscribe_checkout_diff_response", payload: { - subscriptionId, + subscriptionId: serverSubscriptionId, cwd, files: [], error: null, @@ -307,7 +361,13 @@ describe("server data push router", () => { fake.emit({ type: "checkout_diff_update", - payload: { subscriptionId, cwd, files: [], error: null, diffTooLarge: true }, + payload: { + subscriptionId: serverSubscriptionId, + cwd, + files: [], + error: null, + diffTooLarge: true, + }, }); expect(queryClient.getQueryData(queryKey)).toEqual({ @@ -315,12 +375,12 @@ describe("server data push router", () => { files: [], error: null, diffTooLarge: true, - requestId: `subscription:${subscriptionId}`, + requestId: `subscription:${serverSubscriptionId}`, }); unsubscribeObserver(); - expect(fake.unsubscribeCheckoutDiffCalls).toEqual([subscriptionId]); + expect(fake.unsubscribeCheckoutDiffCalls).toEqual([serverSubscriptionId]); unmount(); }); @@ -411,7 +471,7 @@ describe("server data push router", () => { unmount(); }); - it("re-sends active push subscriptions after reconnect", () => { + it("keeps stable handles while invalidating caches after reconnect", () => { const queryClient = new QueryClient(); const fake = createFakeClient(); const serverId = "server-1"; @@ -475,18 +535,10 @@ describe("server data push router", () => { { cwd, compare: { mode: "base", baseRef: "main", ignoreWhitespace: true }, - subscriptionId: checkoutDiffSubscriptionId, - }, - { - cwd, - compare: { mode: "base", baseRef: "main", ignoreWhitespace: true }, - subscriptionId: checkoutDiffSubscriptionId, + subscriptionId: "server-diff-1", }, ]); - expect(fake.subscribeTerminalCalls).toEqual([ - { cwd, workspaceId }, - { cwd, workspaceId }, - ]); + expect(fake.subscribeTerminalCalls).toEqual([{ cwd, workspaceId }]); fake.emit({ type: "terminals_changed", diff --git a/packages/app/src/data/push-router.ts b/packages/app/src/data/push-router.ts index 72c8ef20b2..fc6d51ce4f 100644 --- a/packages/app/src/data/push-router.ts +++ b/packages/app/src/data/push-router.ts @@ -1,3 +1,4 @@ +import type { OwnedSubscription } from "@getpaseo/client"; import type { Query, QueryCacheNotifyEvent, QueryClient, QueryKey } from "@tanstack/react-query"; import type { ListTerminalsResponse, @@ -21,19 +22,12 @@ type ProvidersSnapshotUpdateMessage = Extract< SessionOutboundMessage, { type: "providers_snapshot_update" } >; -type CheckoutDiffUpdateMessage = Extract; type SubscribeCheckoutDiffResponseMessage = Extract< SessionOutboundMessage, { type: "subscribe_checkout_diff_response" } >; type StatusMessage = Extract; type TerminalsChangedMessage = Extract; -type ServerDataEventType = - | "providers_snapshot_update" - | "checkout_diff_update" - | "subscribe_checkout_diff_response" - | "status" - | "terminals_changed"; type CheckoutDiffResponsePayload = SubscribeCheckoutDiffResponseMessage["payload"]; type CheckoutDiffCachePayload = Omit; type ListTerminalsPayload = ListTerminalsResponse["payload"]; @@ -53,6 +47,10 @@ interface CheckoutDiffRoute { compare: CheckoutDiffCompare; } +interface CheckoutDiffRegistration extends CheckoutDiffRoute { + subscription: OwnedSubscription; +} + interface WorkspaceTerminalsRoute { domain: "workspaceTerminals"; enabled: boolean; @@ -61,6 +59,10 @@ interface WorkspaceTerminalsRoute { workspaceId?: string; } +interface WorkspaceTerminalsRegistration extends WorkspaceTerminalsRoute { + subscription: OwnedSubscription; +} + type ServerDataRoute = CheckoutDiffRoute | WorkspaceTerminalsRoute; export interface ServerDataQueryMeta extends Record { @@ -71,18 +73,9 @@ export type ProvidersSnapshotUpdate = ProvidersSnapshotUpdateMessage; interface ServerDataPushClient { getProvidersSnapshot: import("@getpaseo/client/internal/daemon-client").DaemonClient["getProvidersSnapshot"]; - on( - type: TType, - handler: (message: Extract) => void, - ): () => void; - subscribeCheckoutDiff( - cwd: string, - compare: CheckoutDiffCompare, - options: { subscriptionId: string; requestId?: string }, - ): Promise; - unsubscribeCheckoutDiff(subscriptionId: string): void; - subscribeTerminals(input: { cwd: string; workspaceId?: string }): void; - unsubscribeTerminals(input: { cwd: string; workspaceId?: string }): void; + observeEvents: import("@getpaseo/client/internal/daemon-client").DaemonClient["observeEvents"]; + observeCheckoutDiff: import("@getpaseo/client/internal/daemon-client").DaemonClient["observeCheckoutDiff"]; + observeTerminals: import("@getpaseo/client/internal/daemon-client").DaemonClient["observeTerminals"]; } interface PushRouterInput { @@ -137,7 +130,6 @@ const RECONNECT_REPAIR_POLICIES: ReconnectRepairPolicy[] = [ }, }, ]; -const reconnectSubscriptionRepairsByServerId = new Map void>>(); export function checkoutDiffPushRoute(input: { enabled: boolean; @@ -182,10 +174,6 @@ export function invalidateServerDataQueriesAfterReconnect(input: { for (const policy of RECONNECT_REPAIR_POLICIES) { policy.invalidate(input); } - for (const repairSubscriptions of reconnectSubscriptionRepairsByServerId.get(input.serverId) ?? - []) { - repairSubscriptions(); - } } export async function applyProvidersSnapshotUpdate(input: { @@ -226,8 +214,8 @@ export async function applyProvidersSnapshotUpdate(input: { } export function mountServerDataPushRouter(input: PushRouterInput): () => void { - const activeCheckoutDiffSubscriptions = new Map(); - const activeTerminalSubscriptions = new Map(); + const activeCheckoutDiffSubscriptions = new Map(); + const activeTerminalSubscriptions = new Map(); let disposed = false; function reconcileSubscriptions( @@ -262,24 +250,24 @@ export function mountServerDataPushRouter(input: PushRouterInput): () => void { client: input.client, desired: desiredCheckoutDiffSubscriptions, serverId: input.serverId, + queryClient: input.queryClient, }); reconcileTerminalSubscriptions({ active: activeTerminalSubscriptions, client: input.client, desired: desiredTerminalSubscriptions, + apply: (route, payload) => + applyTerminalsChanged({ + activeCheckoutDiffSubscriptions, + activeTerminalSubscriptions, + queryClient: input.queryClient, + serverId: input.serverId, + route, + message: { type: "terminals_changed", payload }, + }), }); } - function resetSubscriptionsAfterReconnect(): void { - const fallbackActive = { - checkoutDiff: new Map(activeCheckoutDiffSubscriptions), - workspaceTerminals: new Map(activeTerminalSubscriptions), - }; - activeCheckoutDiffSubscriptions.clear(); - activeTerminalSubscriptions.clear(); - reconcileSubscriptions(fallbackActive); - } - const unsubscribeQueryCache = input.queryClient.getQueryCache().subscribe((event) => { if ( !shouldReconcileSubscriptionsForCacheEvent(event, input.serverId, { @@ -291,137 +279,131 @@ export function mountServerDataPushRouter(input: PushRouterInput): () => void { } reconcileSubscriptions(); }); - const unsubscribeProviders = input.client.on("providers_snapshot_update", (message) => { - void applyProvidersSnapshotUpdate({ - client: input.client, - queryClient: input.queryClient, - serverId: input.serverId, - message, - }).catch(() => { - /* Query state owns fetch failures; reconnect/refetch repairs them. */ - }); - }); - const unsubscribeDaemonConfig = input.client.on("status", (message) => { - applyDaemonConfigStatus({ queryClient: input.queryClient, serverId: input.serverId, message }); - }); - const unsubscribeCheckoutDiffUpdate = input.client.on("checkout_diff_update", (message) => { - applyCheckoutDiffUpdate({ - activeCheckoutDiffSubscriptions, - queryClient: input.queryClient, - serverId: input.serverId, - message, - }); - }); - const unsubscribeCheckoutDiffResponse = input.client.on( - "subscribe_checkout_diff_response", - (message) => { - applyCheckoutDiffSubscribeResponse({ - activeCheckoutDiffSubscriptions, - queryClient: input.queryClient, - serverId: input.serverId, - message, - }); + const events = input.client.observeEvents([ + "providers_snapshot_update", + "status.daemon_config_changed", + ]); + events.subscribe({ + snapshot: () => {}, + update: (message) => { + if (message.type === "providers_snapshot_update") { + void applyProvidersSnapshotUpdate({ + client: input.client, + queryClient: input.queryClient, + serverId: input.serverId, + message, + }).catch(() => { + /* Query state owns fetch failures; reconnect/refetch repairs them. */ + }); + } + if (message.type === "status") + applyDaemonConfigStatus({ + queryClient: input.queryClient, + serverId: input.serverId, + message, + }); }, - ); - const unsubscribeTerminalsChanged = input.client.on("terminals_changed", (message) => { - applyTerminalsChanged({ - activeCheckoutDiffSubscriptions, - activeTerminalSubscriptions, - queryClient: input.queryClient, - serverId: input.serverId, - message, - }); }); - let reconnectSubscriptionRepairs = reconnectSubscriptionRepairsByServerId.get(input.serverId); - if (!reconnectSubscriptionRepairs) { - reconnectSubscriptionRepairs = new Set(); - reconnectSubscriptionRepairsByServerId.set(input.serverId, reconnectSubscriptionRepairs); - } - reconnectSubscriptionRepairs.add(resetSubscriptionsAfterReconnect); reconcileSubscriptions(); return () => { disposed = true; - reconnectSubscriptionRepairs.delete(resetSubscriptionsAfterReconnect); - if (reconnectSubscriptionRepairs.size === 0) { - reconnectSubscriptionRepairsByServerId.delete(input.serverId); - } unsubscribeQueryCache(); - unsubscribeProviders(); - unsubscribeDaemonConfig(); - unsubscribeCheckoutDiffUpdate(); - unsubscribeCheckoutDiffResponse(); - unsubscribeTerminalsChanged(); - for (const subscriptionId of activeCheckoutDiffSubscriptions.keys()) { - unsubscribeCheckoutDiff(input.client, subscriptionId); + void events.release().catch(console.error); + for (const current of activeCheckoutDiffSubscriptions.values()) { + void current.subscription.release().catch(console.error); } activeCheckoutDiffSubscriptions.clear(); for (const route of activeTerminalSubscriptions.values()) { - input.client.unsubscribeTerminals(workspaceTerminalSubscriptionInput(route)); + void route.subscription.release().catch(console.error); } activeTerminalSubscriptions.clear(); }; } function reconcileCheckoutDiffSubscriptions(input: { - active: Map; + active: Map; client: ServerDataPushClient; desired: Map; serverId: string; + queryClient: QueryClient; }): void { - for (const [subscriptionId, current] of input.active) { - const desired = input.desired.get(subscriptionId); - if (desired && areCheckoutDiffRoutesEqual(current, desired)) { - continue; - } - unsubscribeCheckoutDiff(input.client, subscriptionId); - input.active.delete(subscriptionId); + for (const [key, current] of input.active) { + const desired = input.desired.get(key); + if (desired && areCheckoutDiffRoutesEqual(current, desired)) continue; + input.active.delete(key); + void current.subscription.release().catch(console.error); } - - for (const [subscriptionId, desired] of input.desired) { - if (input.active.has(subscriptionId)) { - continue; - } - input.active.set(subscriptionId, desired); - void input.client - .subscribeCheckoutDiff(desired.cwd, desired.compare, { - subscriptionId, - requestId: `push-router:${input.serverId}:${subscriptionId}`, - }) - .catch((error) => { - if (areCheckoutDiffRoutesEqual(input.active.get(subscriptionId), desired)) { - input.active.delete(subscriptionId); - } - console.error("[server-data] subscribeCheckoutDiff failed", { + for (const [key, desired] of input.desired) { + if (input.active.has(key)) continue; + const subscription = input.client.observeCheckoutDiff(desired.cwd, desired.compare); + const registration = { ...desired, subscription }; + input.active.set(key, registration); + const apply = ( + payload: Omit & { requestId?: string }, + ) => { + if (input.active.get(key) !== registration) return; + const { subscriptionId, ...snapshot } = payload; + setCheckoutDiffPayload({ + activeCheckoutDiffSubscriptions: input.active, + queryClient: input.queryClient, + serverId: input.serverId, + subscriptionId: key, + payload: { + ...snapshot, + files: orderCheckoutDiffFiles(payload.files), + requestId: payload.requestId ?? `subscription:${subscriptionId}`, + }, + }); + }; + subscription.subscribe({ + snapshot: apply, + update: (message) => { + if (message.type === "checkout_diff_update") apply(message.payload); + }, + error: (error) => { + if (input.active.get(key) === registration) input.active.delete(key); + console.error("[server-data] observeCheckoutDiff failed", { serverId: input.serverId, cwd: desired.cwd, error, }); - }); + }, + }); } } function reconcileTerminalSubscriptions(input: { - active: Map; + active: Map; client: ServerDataPushClient; desired: Map; + apply(route: WorkspaceTerminalsRoute, payload: TerminalsChangedMessage["payload"]): void; }): void { for (const [key, current] of input.active) { const desired = input.desired.get(key); - if (desired && areWorkspaceTerminalsRoutesEqual(current, desired)) { - continue; - } - input.client.unsubscribeTerminals(workspaceTerminalSubscriptionInput(current)); + if (desired && areWorkspaceTerminalsRoutesEqual(current, desired)) continue; input.active.delete(key); + void current.subscription.release().catch(console.error); } - for (const [key, desired] of input.desired) { - if (input.active.has(key)) { - continue; - } - input.active.set(key, desired); - input.client.subscribeTerminals(workspaceTerminalSubscriptionInput(desired)); + if (input.active.has(key)) continue; + const subscription = input.client.observeTerminals(workspaceTerminalSubscriptionInput(desired)); + const registration = { ...desired, subscription }; + input.active.set(key, registration); + const apply = (payload: TerminalsChangedMessage["payload"]) => { + if (input.active.get(key) === registration) input.apply(desired, payload); + }; + subscription.subscribe({ + snapshot: apply, + update: (message) => { + if (message.type === "terminals_changed") apply(message.payload); + }, + error: (error) => { + if (input.active.get(key) === registration) input.active.delete(key); + console.error("[server-data] observeTerminals failed", { cwd: desired.cwd, error }); + }, + }); } } @@ -443,52 +425,6 @@ function applyDaemonConfigStatus(input: { }); } -function applyCheckoutDiffUpdate(input: { - activeCheckoutDiffSubscriptions: Map; - queryClient: QueryClient; - serverId: string; - message: CheckoutDiffUpdateMessage; -}): void { - setCheckoutDiffPayload({ - activeCheckoutDiffSubscriptions: input.activeCheckoutDiffSubscriptions, - queryClient: input.queryClient, - serverId: input.serverId, - subscriptionId: input.message.payload.subscriptionId, - payload: { - cwd: input.message.payload.cwd, - files: orderCheckoutDiffFiles(input.message.payload.files), - error: input.message.payload.error, - ...(input.message.payload.diffTooLarge !== undefined - ? { diffTooLarge: input.message.payload.diffTooLarge } - : {}), - requestId: `subscription:${input.message.payload.subscriptionId}`, - }, - }); -} - -function applyCheckoutDiffSubscribeResponse(input: { - activeCheckoutDiffSubscriptions: Map; - queryClient: QueryClient; - serverId: string; - message: SubscribeCheckoutDiffResponseMessage; -}): void { - setCheckoutDiffPayload({ - activeCheckoutDiffSubscriptions: input.activeCheckoutDiffSubscriptions, - queryClient: input.queryClient, - serverId: input.serverId, - subscriptionId: input.message.payload.subscriptionId, - payload: { - cwd: input.message.payload.cwd, - files: orderCheckoutDiffFiles(input.message.payload.files), - error: input.message.payload.error, - ...(input.message.payload.diffTooLarge !== undefined - ? { diffTooLarge: input.message.payload.diffTooLarge } - : {}), - requestId: input.message.payload.requestId, - }, - }); -} - function setCheckoutDiffPayload(input: { activeCheckoutDiffSubscriptions: Map; queryClient: QueryClient; @@ -523,6 +459,7 @@ function applyTerminalsChanged(input: { queryClient: QueryClient; serverId: string; message: TerminalsChangedMessage; + route: WorkspaceTerminalsRoute; }): void { for (const query of input.queryClient.getQueryCache().getAll()) { const route = getActiveServerDataRoute(query, input.serverId, { @@ -532,7 +469,7 @@ function applyTerminalsChanged(input: { if ( !route || route.domain !== "workspaceTerminals" || - route.cwd !== input.message.payload.cwd + !areWorkspaceTerminalsRoutesEqual(route, input.route) ) { continue; } @@ -776,14 +713,6 @@ function workspaceTerminalSubscriptionInput(route: WorkspaceTerminalsRoute): { }; } -function unsubscribeCheckoutDiff(client: ServerDataPushClient, subscriptionId: string): void { - try { - client.unsubscribeCheckoutDiff(subscriptionId); - } catch { - // Disconnect cleanup can race with explicit subscription teardown. - } -} - function isQueryForServer(queryKey: QueryKey, kind: string, serverId: string): boolean { return queryKey.length >= 2 && queryKey[0] === kind && queryKey[1] === serverId; } diff --git a/packages/app/src/desktop/browser/automation/handler.ts b/packages/app/src/desktop/browser/automation/handler.ts index bc30fdadb4..08bb9927e5 100644 --- a/packages/app/src/desktop/browser/automation/handler.ts +++ b/packages/app/src/desktop/browser/automation/handler.ts @@ -1,3 +1,4 @@ +import { BROWSER_AUTOMATION_COMMAND_NAMES } from "@getpaseo/protocol/browser-automation/rpc-schemas"; import type { SessionInboundMessage, SessionOutboundMessage } from "@getpaseo/protocol/messages"; import { getDesktopHost, type DesktopHostBridge } from "@/desktop/host"; import { @@ -69,13 +70,33 @@ export function mountBrowserAutomationHandler( } export function mountBrowserAutomationDaemonClientHandler( - client: unknown, + client: import("@getpaseo/client/internal/daemon-client").DaemonClient, options?: { serverId?: string }, ): () => void { - return mountBrowserAutomationHandler({ - client: client as BrowserAutomationClient, + const observation = client.registerBrowserHost({ + hostKind: "desktop app", + supportedCommands: [...BROWSER_AUTOMATION_COMMAND_NAMES], + }); + const unmount = mountBrowserAutomationHandler({ + client: { + on: (_type, handler) => + observation.subscribe({ + snapshot: () => {}, + update: (message) => { + if (message.type === "browser.automation.execute.request") handler(message); + }, + }), + sendBrowserAutomationExecuteResponse: (response) => + client.sendBrowserAutomationExecuteResponse(response), + }, ...(options?.serverId ? { serverId: options.serverId } : {}), }); + return () => { + unmount(); + void observation + .release() + .catch((error) => console.warn("Failed to release browser host", error)); + }; } async function handleBrowserAutomationRequest(params: { diff --git a/packages/app/src/plugins/catalog-sync.tsx b/packages/app/src/plugins/catalog-sync.tsx index a7300d75c2..484124971e 100644 --- a/packages/app/src/plugins/catalog-sync.tsx +++ b/packages/app/src/plugins/catalog-sync.tsx @@ -48,25 +48,36 @@ export function PluginCatalogSync({ ); return refreshQueue; }; - void refresh(); - const unsubscribe = client.on("status", (message) => { - if (message.payload.status === "plugin_settings_changed") { - const { pluginId, settingsId } = message.payload; - if (typeof settingsId === "string") { - const plugin = pluginRegistry - .getSnapshot() - .find((item) => item.serverId === serverId && item.id === pluginId); - void plugin?.queryClient.invalidateQueries({ queryKey: pluginSettingsKey(settingsId) }); + const observation = client.observeEvents([ + "status.plugin_catalog_changed", + "status.plugin_settings_changed", + ]); + observation.subscribe({ + snapshot: () => { + void refresh(); + }, + update: (message) => { + if (message.type !== "status") return; + if (message.payload.status === "plugin_settings_changed") { + const { pluginId, settingsId } = message.payload; + if (typeof settingsId === "string") { + const plugin = pluginRegistry + .getSnapshot() + .find((item) => item.serverId === serverId && item.id === pluginId); + void plugin?.queryClient.invalidateQueries({ queryKey: pluginSettingsKey(settingsId) }); + } } - } - if (message.payload.status === "plugin_catalog_changed") { - const pluginId = message.payload.pluginId; - if (typeof pluginId === "string") void refresh(pluginId); - } + if (message.payload.status === "plugin_catalog_changed") { + const pluginId = message.payload.pluginId; + if (typeof pluginId === "string") void refresh(pluginId); + } + }, }); return () => { cancelled = true; - unsubscribe(); + void observation + .release() + .catch((error) => console.warn("[Plugins] Failed to release catalog", error)); }; }, [client, connected, serverId, supported]); diff --git a/packages/app/src/plugins/client-runtime.ts b/packages/app/src/plugins/client-runtime.ts index cb953d16b2..4424c240fa 100644 --- a/packages/app/src/plugins/client-runtime.ts +++ b/packages/app/src/plugins/client-runtime.ts @@ -16,7 +16,7 @@ export function createPluginClientRuntime( installation: InstalledPlugin, daemonClient: DaemonClient, ): PluginClientRuntime { - const runtime = createPluginSurfaceRuntime(daemonClient, installation.id); + const runtime = createPluginSurfaceRuntime(daemonClient, installation); if (!runtime) throw new Error("Plugin host is offline"); const state = createPluginClientStateSource(installation.serverId); const capabilities = createPluginCapabilities( diff --git a/packages/app/src/plugins/client-slash-commands/index.ts b/packages/app/src/plugins/client-slash-commands/index.ts index a99a0fa538..57d205a7b9 100644 --- a/packages/app/src/plugins/client-slash-commands/index.ts +++ b/packages/app/src/plugins/client-slash-commands/index.ts @@ -31,28 +31,10 @@ export function usePluginClientSlashCommands(input: { }); const commands = installed .filter((plugin) => plugin.serverId === input.serverId) - .flatMap((plugin) => { - const runtime = createPluginSurfaceRuntime(client, plugin.id); - if (!runtime) return []; - return (plugin.clientSlashCommands ?? []).flatMap((contribution) => { - const context = - contribution.context === "agent" - ? createPluginAgentActionContext({ - plugin, - runtime, - navigation, - state, - workspaceId, - agentId: input.agentId, - }) - : createPluginWorkspaceActionContext({ - plugin, - runtime, - navigation, - state, - workspaceId, - }); - if (!context) return []; + .flatMap((plugin) => + plugin.clientSlashCommands.flatMap((contribution) => { + if (contribution.context === "agent" && !state.getAgent(input.agentId)) return []; + if (!state.getWorkspace(workspaceId)) return []; return [ { pluginId: plugin.id, @@ -60,19 +42,38 @@ export function usePluginClientSlashCommands(input: { description: contribution.description, argumentHint: contribution.argumentHint, async run(args: string) { - if (contribution.context === "agent" && context.context === "agent") { - await contribution.onSubmit({ ...context, args }); - } else if ( - contribution.context === "workspace" && - context.context === "workspace" - ) { - await contribution.onSubmit({ ...context, args }); + const runtime = createPluginSurfaceRuntime(client, plugin); + if (!runtime) return; + try { + const context = + contribution.context === "agent" + ? createPluginAgentActionContext({ + plugin, + runtime, + navigation, + state, + workspaceId, + agentId: input.agentId, + }) + : createPluginWorkspaceActionContext({ + plugin, + runtime, + navigation, + state, + workspaceId, + }); + if (contribution.context === "agent" && context?.context === "agent") + await contribution.onSubmit({ ...context, args }); + else if (contribution.context === "workspace" && context?.context === "workspace") + await contribution.onSubmit({ ...context, args }); + } finally { + await runtime.paseo.dispose(); } }, }, ]; - }); - }); + }), + ); return commands; }, [client, input.agentId, input.serverId, input.workspaceId, installed]); } diff --git a/packages/app/src/plugins/command-center/contributions.test.ts b/packages/app/src/plugins/command-center/contributions.test.ts index dc40b342b9..89313f907c 100644 --- a/packages/app/src/plugins/command-center/contributions.test.ts +++ b/packages/app/src/plugins/command-center/contributions.test.ts @@ -60,6 +60,7 @@ function plugin(onAgentSelect: AgentCommandItem["onSelect"]): InstalledPlugin { id: "review", serverId: "host-1", clientBundle: "bundle", + lifetime: new AbortController(), queryClient: new QueryClient(), cleanup: () => {}, settingsScreens: [], @@ -120,7 +121,7 @@ function stateSource() { }; } -function createRuntime(pluginId: string) { +function createRuntime(installed: InstalledPlugin) { const client = new DaemonClient({ url: "ws://127.0.0.1:1", clientId: "plugin-command-test", @@ -129,7 +130,7 @@ function createRuntime(pluginId: string) { return { paseo: createPaseoApi(client), invoke: async (method: string, input: unknown) => { - expect(pluginId).toBe("review"); + expect(installed.id).toBe("review"); expect(method).toBe("review.inspect"); return { value: inspect.input.parse(input).value + 1 }; }, @@ -187,7 +188,7 @@ describe("plugin Command Center contributions", () => { context.openSurface("main"); context.openPanel("details", { location: "explorer" }); }); - const runtime = createRuntime("review"); + const runtime = createRuntime(installed); const actions = buildPluginCommandCenterContributions({ plugins: [installed], runtime: () => runtime, diff --git a/packages/app/src/plugins/command-center/contributions.ts b/packages/app/src/plugins/command-center/contributions.ts index 448b0068cb..9d40ee57e2 100644 --- a/packages/app/src/plugins/command-center/contributions.ts +++ b/packages/app/src/plugins/command-center/contributions.ts @@ -9,7 +9,7 @@ import { createPluginCapabilities, type PluginNavigation } from "../actions"; export interface PluginCommandCenterSource { plugins: readonly InstalledPlugin[]; - runtime(pluginId: string): PluginSurfaceRuntime; + runtime(plugin: InstalledPlugin): PluginSurfaceRuntime; state: PluginClientStateSource; workspaceId: string | null; agentId: string | null; @@ -22,12 +22,12 @@ export function buildPluginCommandCenterContributions( ): CommandCenterContribution[] { const contributions: CommandCenterContribution[] = []; for (const plugin of source.plugins) { - const runtime = source.runtime(plugin.id); - const common = createPluginCapabilities(plugin, runtime, source.navigation); for (const [rank, item] of plugin.commandCenterItems.entries()) { if (item.context === "workspace" && !source.workspaceId) continue; if (item.context === "agent" && (!source.workspaceId || !source.agentId)) continue; const run = async () => { + const runtime = source.runtime(plugin); + const common = createPluginCapabilities(plugin, runtime, source.navigation); try { if (item.context === "global") { await item.onSelect({ context: "global", ...common }); @@ -73,6 +73,8 @@ export function buildPluginCommandCenterContributions( }); } catch (error) { source.reportError(error); + } finally { + await runtime.paseo.dispose().catch(source.reportError); } }; contributions.push({ diff --git a/packages/app/src/plugins/command-center/registration.tsx b/packages/app/src/plugins/command-center/registration.tsx index 2ee0d24ff2..cd85f9bff6 100644 --- a/packages/app/src/plugins/command-center/registration.tsx +++ b/packages/app/src/plugins/command-center/registration.tsx @@ -53,8 +53,8 @@ export function PluginCommandCenterActions() { if (!client || !serverId || !stateSource) return []; return buildPluginCommandCenterContributions({ plugins, - runtime(pluginId) { - const runtime = createPluginSurfaceRuntime(client, pluginId); + runtime(plugin) { + const runtime = createPluginSurfaceRuntime(client, plugin); if (!runtime) throw new Error("Plugin host is offline"); return runtime; }, diff --git a/packages/app/src/plugins/evaluate.ts b/packages/app/src/plugins/evaluate.ts index e56eb0e5be..d3cb17b150 100644 --- a/packages/app/src/plugins/evaluate.ts +++ b/packages/app/src/plugins/evaluate.ts @@ -392,19 +392,11 @@ export function runPluginClientBundle( if (typeof setup !== "function") { throw new Error(`Plugin ${id} must default export a function`); } - let entryCleanup: PluginCleanup; + let entryCleanup: PluginCleanup | undefined; try { entryCleanup = setup(pluginContext); - if (typeof entryCleanup !== "function") { + if (typeof entryCleanup !== "function") throw new Error(`Plugin ${id} contribution must return a cleanup function`); - } - } catch (error) { - stopped = true; - for (const remove of removals) remove(); - throw error; - } - - try { for (const item of collector.sidebarItems) { if (!surfaceIds.has(item.surface)) { throw new Error(`Sidebar item ${item.id} references missing surface ${item.surface}`); @@ -412,22 +404,26 @@ export function runPluginClientBundle( } } catch (error) { stopped = true; - for (const remove of removals) remove(); try { - void Promise.resolve(entryCleanup()).catch((cleanupError) => { + void Promise.resolve( + typeof entryCleanup === "function" ? entryCleanup() : entryCleanup, + ).catch((cleanupError) => { console.warn(`[Plugins] Cleanup failed after setup error for ${id}`, cleanupError); }); } catch (cleanupError) { console.warn(`[Plugins] Cleanup failed after setup error for ${id}`, cleanupError); + } finally { + for (const remove of removals) remove(); } throw error; } setupComplete = true; + const cleanupEntry = entryCleanup; const cleanup = async () => { if (stopped) return; stopped = true; try { - await entryCleanup(); + await cleanupEntry(); } finally { for (const remove of removals) remove(); } diff --git a/packages/app/src/plugins/registry-lifecycle.test.ts b/packages/app/src/plugins/registry-lifecycle.test.ts new file mode 100644 index 0000000000..7e32f2adcf --- /dev/null +++ b/packages/app/src/plugins/registry-lifecycle.test.ts @@ -0,0 +1,73 @@ +import { expect, test } from "vitest"; +import { createPaseoApi } from "@getpaseo/client"; +import { DaemonClient } from "@getpaseo/client/internal/daemon-client"; +import { PluginRegistry } from "./registry"; + +function registry() { + const client = new DaemonClient({ url: "ws://127.0.0.1:1/ws", clientId: "plugin-lifetime-test" }); + const released: string[] = []; + const plugins = new PluginRegistry({ + version: "0.8.0", + createRuntime: (installation) => { + const api = createPaseoApi(client); + return { + paseo: { + ...api, + dispose: async () => { + released.push(installation.id); + await api.dispose(); + }, + }, + rpc: async () => { + throw new Error("Unexpected plugin RPC"); + }, + openSurface: () => {}, + openSettings: () => {}, + openPanel: () => {}, + addComposerPill: () => () => {}, + }; + }, + }); + return { client, released, plugins }; +} + +function catalog(id: string, body: string) { + return { + id, + requirements: { paseo: ">=0.8.0" }, + clientBundle: `(function() { return { default: function(plugin) { ${body} } }; })`, + }; +} + +test("failed plugin initialization disposes its API scope", async () => { + const h = registry(); + h.plugins.installCatalog("host", [catalog("failed", 'throw new Error("setup failed");')], { + client: h.client, + }); + await expect.poll(() => h.released).toEqual(["failed"]); + expect(h.plugins.getSnapshot()).toEqual([]); +}); + +test("unloading releases the API even when plugin cleanup throws, preserving another plugin", async () => { + const h = registry(); + const failing = catalog("failing", 'return function() { throw new Error("cleanup failed"); };'); + const surviving = catalog("surviving", "return function() {};"); + h.plugins.installCatalog("host", [failing, surviving], { client: h.client }); + h.plugins.installCatalog("host", [surviving], { client: h.client }); + await expect.poll(() => h.released).toEqual(["failing"]); + expect(h.plugins.getSnapshot().map((plugin) => plugin.id)).toEqual(["surviving"]); + h.plugins.removeHost("host"); + await expect.poll(() => h.released).toEqual(["failing", "surviving"]); +}); + +test("an invalid async client entry is disposed and its rejected continuation is observed", async () => { + const h = registry(); + h.plugins.installCatalog( + "host", + [catalog("async-entry", 'return Promise.reject(new Error("asynchronous setup failed"));')], + { client: h.client }, + ); + await expect.poll(() => h.released).toEqual(["async-entry"]); + await new Promise((resolve) => setTimeout(resolve, 0)); + expect(h.plugins.getSnapshot()).toEqual([]); +}); diff --git a/packages/app/src/plugins/registry.test.ts b/packages/app/src/plugins/registry.test.ts index 918798f7e4..45e1ba546f 100644 --- a/packages/app/src/plugins/registry.test.ts +++ b/packages/app/src/plugins/registry.test.ts @@ -8,7 +8,7 @@ vi.mock("./navigation", () => ({ })); vi.mock("./client-runtime", () => ({ createPluginClientRuntime: () => ({ - paseo: {}, + paseo: { dispose: async () => {} }, rpc: async () => undefined, openSurface: () => undefined, openPanel: () => undefined, diff --git a/packages/app/src/plugins/registry.ts b/packages/app/src/plugins/registry.ts index f228c7202f..ef640cb8b9 100644 --- a/packages/app/src/plugins/registry.ts +++ b/packages/app/src/plugins/registry.ts @@ -4,7 +4,7 @@ import type { DaemonClient } from "@getpaseo/client/internal/daemon-client"; import { assertPluginCompatibility } from "@getpaseo/protocol/plugin-requirements"; import { resolveAppVersion } from "@/utils/app-version"; import { createPluginClientRuntime } from "./client-runtime"; -import { runPluginClientBundle } from "./evaluate"; +import { runPluginClientBundle, type PluginClientRuntime } from "./evaluate"; import type { InstalledPlugin } from "./types"; type CatalogPlugin = Awaited>[number]; @@ -64,6 +64,8 @@ export class PluginRegistry { } const installed = catalog.flatMap((entry) => { const key = `${serverId}/${entry.id}`; + let runtime: PluginClientRuntime | undefined; + let lifetime: AbortController | undefined; try { if (!entry.clientBundle) return []; assertPluginCompatibility({ ...entry, version: this.dependencies.version, runtime: "app" }); @@ -74,7 +76,9 @@ export class PluginRegistry { this.evaluationErrors.delete(key); return [existing]; } + lifetime = new AbortController(); const installation: InstalledPlugin = { + lifetime, id: entry.id, serverId, clientBundle: entry.clientBundle, @@ -92,16 +96,31 @@ export class PluginRegistry { timelineTransformers: [], timelineRenderers: [], }; - const evaluated = runPluginClientBundle( - entry.id, - entry.clientBundle, - this.dependencies.createRuntime(installation, options.client), - () => this.publish(), + runtime = this.dependencies.createRuntime(installation, options.client); + const evaluated = runPluginClientBundle(entry.id, entry.clientBundle, runtime, () => + this.publish(), ); Object.assign(installation, evaluated); + const paseo = runtime.paseo; + installation.cleanup = async () => { + const results = await Promise.allSettled([ + paseo.dispose(), + Promise.resolve().then(evaluated.cleanup), + ]); + const failures = results.filter((result) => result.status === "rejected"); + if (failures.length) + throw new AggregateError( + failures.map((result) => result.reason), + "Plugin cleanup failed", + ); + }; this.evaluationErrors.delete(key); return [installation]; } catch (error) { + lifetime?.abort(); + void runtime?.paseo + .dispose() + .catch((failure) => console.warn(`[Plugins] API cleanup failed for ${key}`, failure)); this.evaluationErrors.set(key, error instanceof Error ? error.message : String(error)); console.warn(`[Plugins] Failed to evaluate ${serverId}/${entry.id}`, error); return []; @@ -138,6 +157,7 @@ export class PluginRegistry { private dispose(plugin: InstalledPlugin): void { if (this.disposed.has(plugin)) return; this.disposed.add(plugin); + plugin.lifetime.abort(); plugin.queryClient.clear(); try { void Promise.resolve(plugin.cleanup()).catch((error) => { diff --git a/packages/app/src/plugins/runtime-boundary.tsx b/packages/app/src/plugins/runtime-boundary.tsx index eaed2e5d54..7ae51af2b9 100644 --- a/packages/app/src/plugins/runtime-boundary.tsx +++ b/packages/app/src/plugins/runtime-boundary.tsx @@ -2,17 +2,20 @@ import { QueryClientProvider } from "@tanstack/react-query"; import { PaseoApiProvider, PluginRpcProvider } from "@getpaseo/plugin/client/host"; import type { ReactNode } from "react"; import type { InstalledPlugin } from "./types"; -import type { PluginSurfaceRuntime } from "./surface-runtime"; +import { usePluginSurfaceRuntime } from "./surface-runtime"; +import type { DaemonClient } from "@getpaseo/client/internal/daemon-client"; export function PluginRuntimeBoundary({ plugin, - runtime, + client, children, }: { plugin: InstalledPlugin; - runtime: PluginSurfaceRuntime; + client: DaemonClient; children: ReactNode; }) { + const runtime = usePluginSurfaceRuntime(client, plugin); + if (!runtime) return null; return ( diff --git a/packages/app/src/plugins/settings/index.tsx b/packages/app/src/plugins/settings/index.tsx index dbf9dec18d..f4c66c3b32 100644 --- a/packages/app/src/plugins/settings/index.tsx +++ b/packages/app/src/plugins/settings/index.tsx @@ -13,7 +13,6 @@ import type { Theme } from "@/styles/theme"; import { useInstalledPlugin } from "../registry"; import { PluginRuntimeBoundary } from "../runtime-boundary"; import { SurfaceErrorBoundary } from "../surface-error-boundary"; -import { createPluginSurfaceRuntime } from "../surface-runtime"; import { toPluginTheme } from "../theme"; import { buildPluginSettingsRoute } from "./routes"; @@ -101,8 +100,7 @@ function SettingsContent({ return {t("settings.plugins.screens.offline")}; // COMPAT(pluginSettings): added in v0.8, remove after 2027-03-05. if (!supported) return {t("settings.plugins.screens.update")}; - const runtime = createPluginSurfaceRuntime(client, pluginId); - if (!plugin || !screen || !runtime) + if (!plugin || !screen || !client) return {t("settings.plugins.screens.unavailable")}; const Component = screen.Component; return ( @@ -113,7 +111,7 @@ function SettingsContent({ resetKey={attempt} renderError={renderError} > - + diff --git a/packages/app/src/plugins/sidebar-groups.test.ts b/packages/app/src/plugins/sidebar-groups.test.ts index eb9ff4fa45..7a38bb8eb5 100644 --- a/packages/app/src/plugins/sidebar-groups.test.ts +++ b/packages/app/src/plugins/sidebar-groups.test.ts @@ -9,6 +9,7 @@ function installed(serverId: string, contributionId = "main"): InstalledPlugin { cleanup: () => undefined, serverId, clientBundle: serverId, + lifetime: new AbortController(), queryClient: new QueryClient(), settingsScreens: [], surfaces: [{ id: "surface", Component: () => null }], diff --git a/packages/app/src/plugins/surface-contribution.test.ts b/packages/app/src/plugins/surface-contribution.test.ts index 685b57d3c2..e6b2eea757 100644 --- a/packages/app/src/plugins/surface-contribution.test.ts +++ b/packages/app/src/plugins/surface-contribution.test.ts @@ -16,6 +16,7 @@ function installation( id: pluginId, serverId, clientBundle: serverId, + lifetime: new AbortController(), queryClient: new QueryClient(), cleanup: () => undefined, settingsScreens: [], diff --git a/packages/app/src/plugins/surface-runtime.test.tsx b/packages/app/src/plugins/surface-runtime.test.tsx index 88d8f694ec..30b2a7f76b 100644 --- a/packages/app/src/plugins/surface-runtime.test.tsx +++ b/packages/app/src/plugins/surface-runtime.test.tsx @@ -52,7 +52,10 @@ function borrowFromAppProvider(paseo: PaseoApi): PaseoApi { describe("plugin surface host runtime", () => { it("creates a PR worktree and agent through usePaseo on the selected app host", async () => { const selected = clientWithWorkspace("workspace-a"); - const runtime = createPluginSurfaceRuntime(selected.client, "workspace-plugin"); + const runtime = createPluginSurfaceRuntime(selected.client, { + id: "workspace-plugin", + lifetime: new AbortController(), + }); if (!runtime) throw new Error("Expected selected host runtime"); const paseo = borrowFromAppProvider(runtime.paseo); @@ -78,8 +81,14 @@ describe("plugin surface host runtime", () => { it("switches all plugin calls when the selected host changes", async () => { const hostA = clientWithWorkspace("workspace-a"); const hostB = clientWithWorkspace("workspace-b"); - const first = createPluginSurfaceRuntime(hostA.client, "same-plugin"); - const second = createPluginSurfaceRuntime(hostB.client, "same-plugin"); + const first = createPluginSurfaceRuntime(hostA.client, { + id: "same-plugin", + lifetime: new AbortController(), + }); + const second = createPluginSurfaceRuntime(hostB.client, { + id: "same-plugin", + lifetime: new AbortController(), + }); if (!first || !second) throw new Error("Expected online host runtimes"); await first.invoke("host", {}); @@ -95,7 +104,9 @@ describe("plugin surface host runtime", () => { it("keeps an offline selected host unavailable instead of borrowing another host", () => { const otherHost = clientWithWorkspace("workspace-online"); - expect(createPluginSurfaceRuntime(null, "same-plugin")).toBeNull(); + expect( + createPluginSurfaceRuntime(null, { id: "same-plugin", lifetime: new AbortController() }), + ).toBeNull(); expect(otherHost.createWorkspace).not.toHaveBeenCalled(); }); }); diff --git a/packages/app/src/plugins/surface-runtime.ts b/packages/app/src/plugins/surface-runtime.ts index b3635b9122..3c99e38aba 100644 --- a/packages/app/src/plugins/surface-runtime.ts +++ b/packages/app/src/plugins/surface-runtime.ts @@ -1,3 +1,5 @@ +import { useEffect, useState } from "react"; +import type { InstalledPlugin } from "./types"; import { createPaseoApi, type PaseoApi } from "@getpaseo/client"; import type { DaemonClient } from "@getpaseo/client/internal/daemon-client"; @@ -8,11 +10,35 @@ export interface PluginSurfaceRuntime { export function createPluginSurfaceRuntime( client: DaemonClient | null, - pluginId: string, + plugin: Pick, ): PluginSurfaceRuntime | null { - if (!client) return null; + if (!client || plugin.lifetime.signal.aborted) return null; return { - paseo: createPaseoApi(client), - invoke: (method, input) => client.invokePluginRpc(pluginId, method, input), + paseo: createPaseoApi(client, { signal: plugin.lifetime.signal }), + invoke: (method, input) => client.invokePluginRpc(plugin.id, method, input), }; } + +/** A mounted surface owns its API; creating a React element creates no server demand. */ +export function usePluginSurfaceRuntime( + client: DaemonClient | null, + plugin: InstalledPlugin | null | undefined, +): PluginSurfaceRuntime | null { + const [mounted, setMounted] = useState<{ + client: DaemonClient; + plugin: InstalledPlugin; + runtime: PluginSurfaceRuntime; + } | null>(null); + useEffect(() => { + if (!client || !plugin) return; + const runtime = createPluginSurfaceRuntime(client, plugin); + if (!runtime) return; + setMounted({ client, plugin, runtime }); + return () => { + void runtime.paseo + .dispose() + .catch((error) => console.warn(`[Plugins] Surface cleanup failed for ${plugin.id}`, error)); + }; + }, [client, plugin]); + return mounted?.client === client && mounted.plugin === plugin ? mounted.runtime : null; +} diff --git a/packages/app/src/plugins/surface-screen.tsx b/packages/app/src/plugins/surface-screen.tsx index 6139897c5b..c827cf158b 100644 --- a/packages/app/src/plugins/surface-screen.tsx +++ b/packages/app/src/plugins/surface-screen.tsx @@ -21,7 +21,7 @@ import { useInstalledPlugin, usePluginInstallations } from "./registry"; import { buildPluginSurfaceRoute } from "./routes"; import { rememberPluginContributionHost } from "./contribution-host"; import { SurfaceErrorBoundary } from "./surface-error-boundary"; -import { createPluginSurfaceRuntime } from "./surface-runtime"; +import type { DaemonClient } from "@getpaseo/client/internal/daemon-client"; import { PluginRuntimeBoundary } from "./runtime-boundary"; import { getPluginSurfaceContributionServerIds, @@ -55,14 +55,14 @@ const ThemedPluginHeaderIcon = withUnistyles(PluginHeaderIcon); function SurfaceRenderer({ Surface, - runtime, + client, plugin, layout, host, theme, }: { Surface: ComponentType; - runtime: NonNullable>; + client: DaemonClient; plugin: NonNullable>; layout: PluginSurfaceProps["layout"]; host: PluginSurfaceProps["host"]; @@ -70,7 +70,7 @@ function SurfaceRenderer({ }) { const navigation = usePluginHostNavigation(host.id); return ( - + ); @@ -162,7 +162,6 @@ export function PluginSurfaceScreen() { const installations = usePluginInstallations(pluginId); const hosts = useHosts(); const client = useHostRuntimeClient(serverId); - const runtime = useMemo(() => createPluginSurfaceRuntime(client, pluginId), [client, pluginId]); const compact = useIsCompactFormFactor(); const { sidebarItem, surface } = useMemo( () => resolvePluginSurfaceContribution(plugin, identity), @@ -225,7 +224,7 @@ export function PluginSurfaceScreen() { - {plugin && surface && runtime ? ( + {plugin && surface && client ? ( undefined, serverId, clientBundle: serverId, + lifetime: new AbortController(), queryClient: new QueryClient(), settingsScreens: [], surfaces: [], diff --git a/packages/app/src/plugins/timeline/model.test.ts b/packages/app/src/plugins/timeline/model.test.ts index 5db3d3b327..68e1378199 100644 --- a/packages/app/src/plugins/timeline/model.test.ts +++ b/packages/app/src/plugins/timeline/model.test.ts @@ -11,6 +11,7 @@ function plugin(input: { id: input.id, serverId: "host-1", clientBundle: "bundle", + lifetime: new AbortController(), queryClient: new QueryClient(), cleanup: () => {}, settingsScreens: [], diff --git a/packages/app/src/plugins/timeline/view.test.tsx b/packages/app/src/plugins/timeline/view.test.tsx index 9118a29a15..59d7c3ff36 100644 --- a/packages/app/src/plugins/timeline/view.test.tsx +++ b/packages/app/src/plugins/timeline/view.test.tsx @@ -2,14 +2,15 @@ * @vitest-environment jsdom */ import appPackage from "../../../package.json"; -import type { DaemonClient } from "@getpaseo/client/internal/daemon-client"; -import React, { act } from "react"; +import { DaemonClient, type DaemonTransport } from "@getpaseo/client/internal/daemon-client"; +import React, { act, StrictMode } from "react"; import { createRoot } from "react-dom/client"; -import { renderToStaticMarkup } from "react-dom/server"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +const hostClient = vi.hoisted(() => ({ invokePluginRpc: async () => null })); +const selectedHost = vi.hoisted(() => ({ client: null as DaemonClient | null })); vi.mock("@/runtime/host-runtime", () => ({ - useHostRuntimeClient: () => ({ invokePluginRpc: async () => null }) as unknown as DaemonClient, + useHostRuntimeClient: () => selectedHost.client ?? (hostClient as unknown as DaemonClient), useHosts: () => [{ serverId: "host-1", label: "Local" }], })); vi.mock("@/constants/layout", () => ({ @@ -21,6 +22,7 @@ vi.mock("../navigation", () => ({ vi.mock("../client-runtime", () => ({ createPluginClientRuntime: () => ({ paseo: {}, + dispose: () => {}, rpc: async () => undefined, openSurface: () => undefined, openPanel: () => undefined, @@ -107,12 +109,13 @@ afterEach(() => { for (const root of roots.splice(0)) act(() => root.unmount()); for (const container of containers.splice(0)) container.remove(); pluginRegistry.removeHost("host-1"); + selectedHost.client = null; vi.restoreAllMocks(); vi.unstubAllGlobals(); }); describe("PluginTimelineItemView", () => { - it("validates and renders the matching plugin component", () => { + it("validates and renders the matching plugin component", async () => { pluginRegistry.installCatalog( "host-1", [{ id: "reports", requirements: { paseo: `>=${appPackage.version}` }, clientBundle: bundle }], @@ -121,11 +124,16 @@ describe("PluginTimelineItemView", () => { }, ); - const markup = renderToStaticMarkup( - , + const container = document.createElement("div"); + containers.push(container); + const root = createRoot(container); + roots.push(root); + await act(async () => + root.render( + , + ), ); - - expect(markup).toContain("Four tests passed"); + expect(container.textContent).toContain("Four tests passed"); }); it("contains renderer crashes to one timeline item", async () => { @@ -201,3 +209,144 @@ describe("PluginTimelineItemView", () => { expect(container.textContent).toBe("Recovered"); }); }); + +it("releases a crashed renderer's observations and recovers a fresh scope in StrictMode", async () => { + let receive: Parameters[0] = () => {}; + let opened: () => void = () => {}; + let nextId = 0; + const retained = new Set(); + const released: string[] = []; + const deliver = (message: unknown) => + receive(JSON.stringify({ type: "session", message }), false); + const client = new DaemonClient({ + url: "ws://surface-observation.test", + clientId: "surface-lifetime-test", + transportFactory: () => ({ + send(data) { + if (typeof data !== "string") throw new Error("Expected a JSON request"); + const frame = JSON.parse(data) as { + type: string; + message?: { type: string; requestId: string; subscriptionId: string }; + }; + if (frame.type === "hello") + deliver({ + type: "status", + payload: { + status: "server_info", + serverId: "host-1", + hostname: null, + version: null, + features: { ownedSubscriptions: true }, + }, + }); + const request = frame.message; + if (request?.type === "session.events.set_subscription.request") { + const id = `surface-${++nextId}`; + retained.add(id); + deliver({ + type: "session.events.set_subscription.response", + payload: { + requestId: request.requestId, + subscriptionId: id, + events: ["project.update"], + snapshots: [], + }, + }); + } else if (request?.type === "subscription.release.request") { + retained.delete(request.subscriptionId); + released.push(request.subscriptionId); + deliver({ type: "subscription.release.response", payload: request }); + } + }, + close() {}, + onMessage(handler) { + receive = handler; + return () => {}; + }, + onOpen(handler) { + opened = handler; + return () => {}; + }, + onClose() { + return () => {}; + }, + onError() { + return () => {}; + }, + }), + }); + const connecting = client.connect(); + opened(); + await connecting; + selectedHost.client = client; + const liveBundle = `(function(require) { + const React = require("react"); + const { usePaseo } = require("@getpaseo/plugin/client"); + return { default(plugin) { + function Card(props) { + const paseo = usePaseo(); + const failed = React.useRef(false); + failed.current = props.item.data.label === "explode"; + React.useEffect(() => { + const owner = paseo.observeEvents(["project.update"]); + return () => { if (failed.current) throw new Error("plugin cleanup failed"); void owner.release(); }; + }, [paseo]); + if (props.item.data.label === "explode") throw new Error("owned renderer failed"); + return React.createElement("span", null, props.item.data.label); + } + plugin.addTimelineRenderer({ kind:"test-report", version:1, schema:{safeParse(data){return {success:true,data};}}, Component:Card }); + return () => {}; + }}; + })`; + const survivor = client.observeEvents(["project.update"]); + const updates: unknown[] = []; + survivor.subscribe({ snapshot: () => {}, update: (message) => updates.push(message) }); + await survivor.ready; + pluginRegistry.installCatalog( + "host-1", + [ + { + id: "reports", + requirements: { paseo: `>=${appPackage.version}` }, + clientBundle: liveBundle, + }, + ], + { client }, + ); + const container = document.createElement("div"); + containers.push(container); + const root = createRoot(container); + roots.push(root); + const render = (item: typeof timelineItem) => + act(async () => + root.render( + + + , + ), + ); + try { + await render(timelineItem); + expect(retained.size).toBe(2); + const initialIds = new Set(retained); + await render(failingTimelineItem); + expect(container.textContent).toContain("Plugin failed:"); + expect([...retained]).toEqual([survivor.subscriptionId]); + expect(released.some((id) => initialIds.has(id))).toBe(true); + deliver({ + type: "project.update", + payload: { subscriptionId: survivor.subscriptionId, kind: "remove", projectId: "still-live" }, + }); + expect(updates).toHaveLength(1); + await render(recoveredTimelineItem); + expect(container.textContent).toContain("Recovered"); + expect(retained.size).toBe(2); + expect([...retained].filter((id) => !initialIds.has(id))).toHaveLength(1); + await act(async () => root.unmount()); + roots.splice(roots.indexOf(root), 1); + expect([...retained]).toEqual([survivor.subscriptionId]); + } finally { + await survivor.release(); + await client.close(); + } +}); diff --git a/packages/app/src/plugins/timeline/view.tsx b/packages/app/src/plugins/timeline/view.tsx index 6eba06c32b..1490d8e14e 100644 --- a/packages/app/src/plugins/timeline/view.tsx +++ b/packages/app/src/plugins/timeline/view.tsx @@ -11,7 +11,6 @@ import type { PluginTimelineStreamItem } from "@/types/stream"; import { createPluginClientStateSource } from "../client-state/source"; import { useInstalledPlugin } from "../registry"; import { PluginRuntimeBoundary } from "../runtime-boundary"; -import { createPluginSurfaceRuntime } from "../surface-runtime"; import { SurfaceErrorBoundary } from "../surface-error-boundary"; import { toPluginTheme } from "../theme"; @@ -60,10 +59,6 @@ function PluginTimelineItemBody({ ); const parsed = parseRendererData(renderer, item.data); const client = useHostRuntimeClient(serverId); - const runtime = useMemo( - () => createPluginSurfaceRuntime(client, item.pluginId), - [client, item.pluginId], - ); const compact = useIsCompactFormFactor(); const hosts = useHosts(); const hostLabel = hosts.find((host) => host.serverId === serverId)?.label ?? serverId; @@ -71,7 +66,7 @@ function PluginTimelineItemBody({ const layout = useMemo(() => ({ compact, platform: resolvePlatform() }), [compact]); const stateSource = useMemo(() => createPluginClientStateSource(serverId), [serverId]); - if (!plugin || !renderer || !parsed || !runtime) { + if (!plugin || !renderer || !parsed || !client) { return ; } @@ -91,7 +86,7 @@ function PluginTimelineItemBody({ }; return ( - + diff --git a/packages/app/src/plugins/types.ts b/packages/app/src/plugins/types.ts index 651e5f7b28..9305cc3ba8 100644 --- a/packages/app/src/plugins/types.ts +++ b/packages/app/src/plugins/types.ts @@ -38,6 +38,7 @@ export interface EvaluatedPlugin { } export interface InstalledPlugin extends EvaluatedPlugin { + lifetime: AbortController; serverId: string; requirements?: PluginRequirements; clientBundle: string; diff --git a/packages/app/src/plugins/workspace-panels/panel.tsx b/packages/app/src/plugins/workspace-panels/panel.tsx index bf652140f5..78a8bb462a 100644 --- a/packages/app/src/plugins/workspace-panels/panel.tsx +++ b/packages/app/src/plugins/workspace-panels/panel.tsx @@ -24,7 +24,6 @@ import { toPluginTheme } from "../theme"; import { resolvePluginIcon } from "../icons"; import { useInstalledPlugin } from "../registry"; import { PluginRuntimeBoundary } from "../runtime-boundary"; -import { createPluginSurfaceRuntime } from "../surface-runtime"; import { SurfaceErrorBoundary } from "../surface-error-boundary"; import { resolvePluginWorkspacePanel } from "./resolution"; @@ -54,10 +53,6 @@ function PluginPanelBody({ theme }: { theme: PluginTheme }) { ); }); const client = useHostRuntimeClient(serverId); - const runtime = useMemo( - () => createPluginSurfaceRuntime(client, target.pluginId), - [client, target.pluginId], - ); const compact = useIsCompactFormFactor(); const hosts = useHosts(); const hostLabel = hosts.find((host) => host.serverId === serverId)?.label ?? serverId; @@ -69,7 +64,7 @@ function PluginPanelBody({ theme }: { theme: PluginTheme }) { if (!plugin || !contribution || !workspaceExists) { return ; } - if (!runtime) { + if (!client) { return ; } @@ -110,7 +105,7 @@ function PluginPanelBody({ theme }: { theme: PluginTheme }) { Surface={Surface} key={`${serverId}/${target.pluginId}/${target.panelId}/${target.context}`} > - + {panel} diff --git a/packages/app/src/plugins/workspace-panels/resolution.test.ts b/packages/app/src/plugins/workspace-panels/resolution.test.ts index 36f1e35282..c4ad2ef302 100644 --- a/packages/app/src/plugins/workspace-panels/resolution.test.ts +++ b/packages/app/src/plugins/workspace-panels/resolution.test.ts @@ -8,6 +8,7 @@ function installed(): InstalledPlugin { id: "review", serverId: "host-1", clientBundle: "bundle", + lifetime: new AbortController(), queryClient: new QueryClient(), cleanup: () => {}, settingsScreens: [], diff --git a/packages/app/src/runtime/directory-sync/index.test.ts b/packages/app/src/runtime/directory-sync/index.test.ts index 5fbeb3d3e9..9f64a4fc60 100644 --- a/packages/app/src/runtime/directory-sync/index.test.ts +++ b/packages/app/src/runtime/directory-sync/index.test.ts @@ -22,6 +22,8 @@ import { type DirectoryCheckpointStorage, } from "./index"; +import { subscriptionFixture } from "../subscription-fixture"; + type WorkspaceFetchResult = Awaited>; type ProjectListResult = Awaited>; type AgentFetchResult = Awaited>; @@ -123,6 +125,25 @@ class FakeDirectoryClient { }; } + observeAgents(options: Parameters[0]) { + return subscriptionFixture(this.fetchAgents({ ...options, subscribe: {} }), (receive) => + this.on("agent_update", receive), + ); + } + + observeWorkspaces(options: Parameters[0]) { + return subscriptionFixture(this.fetchWorkspaces(options), (receive) => + this.on("workspace_update", receive), + ); + } + + observeEvents(events: readonly SessionOutboundMessage["type"][]) { + return subscriptionFixture(Promise.resolve({ events }), (receive) => { + const stops = events.map((event) => this.on(event, receive)); + return () => stops.forEach((stop) => stop()); + }); + } + getLastServerInfoMessage(): null { return null; } @@ -304,7 +325,7 @@ describe("DirectorySync session readiness", () => { directory.dispose(); }); - it("persists accepted script status updates through the directory owner", () => { + it("persists accepted script status updates through the directory owner", async () => { const serverId = "script-status-owner"; serverIds.add(serverId); const client = new FakeDirectoryClient(); @@ -351,6 +372,14 @@ describe("DirectorySync session readiness", () => { }); const store = useSessionStore.getState(); store.initializeSession(serverId, client as unknown as DaemonClient, 1); + store.updateSessionServerInfo(serverId, { + serverId, + hostname: null, + version: "test", + features: { workspaceMultiplicity: true }, + }); + directory.setDemand({}, true); + await directory.refreshDemand(); directory.acceptWorkspaces([workspace]); commits.length = 0; @@ -652,6 +681,8 @@ describe("DirectorySync session readiness", () => { features: { workspaceMultiplicity: true, directorySync: true }, }); + directory.setAgentRouteDemand(["agent-1"]); + await directory.refreshDemand(); const refresh = directory.refreshWorkspaces(); await Promise.resolve(); client.emit({ @@ -735,10 +766,7 @@ describe("DirectorySync session readiness", () => { useSessionStore.getState().initializeSession(serverId, client as unknown as DaemonClient, 1); const load = directory.loadCachedAgent(cachedAgent.id); - client.emit({ - type: "agent_deleted", - payload: { agentId: cachedAgent.id, requestId: "delete-live" }, - }); + directory.removeAgent(cachedAgent.id); releaseAgent(cachedAgent); await load; @@ -843,14 +871,14 @@ describe("DirectorySync session readiness", () => { }); await directory.refreshAgents({ - subscribe: { subscriptionId: `app:${serverId}` }, + subscribe: {}, page: { limit: 200 }, }); expect(client.lastAgentOptions).toEqual({ scope: "active", sort: [{ key: "updated_at", direction: "desc" }], - subscribe: { subscriptionId: `app:${serverId}` }, + subscribe: {}, page: { limit: 200 }, sync: { generation: "generation", afterSeq: 12 }, }); @@ -1045,8 +1073,9 @@ describe("DirectorySync session readiness", () => { }); const completeFetch = client.holdWorkspaceFetch(); - const refresh = directory.refreshWorkspaces({ subscribe: true }); - await Promise.resolve(); + directory.setDemand({}, true); + const refresh = directory.refreshDemand(); + await expect.poll(() => client.fetchWorkspacesCalls).toBe(1); client.emit({ type: "workspace_update", payload: { @@ -1100,7 +1129,7 @@ describe("DirectorySync session readiness", () => { directory.dispose(); }); - it("buffers project updates from the online epoch before workspace hydration starts", async () => { + it("ignores passive project events before directory demand", async () => { const serverId = "project-before-workspace-hydration"; const { client, directory } = createDirectory(serverId); const store = useSessionStore.getState(); @@ -1130,12 +1159,9 @@ describe("DirectorySync session readiness", () => { await directory.refreshWorkspaces({ subscribe: true }); expect(useSessionStore.getState().sessions[serverId]?.hasHydratedWorkspaces).toBe(true); - expect( - useSessionStore.getState().sessions[serverId]?.projects.get("early-project"), - ).toMatchObject({ - projectDisplayName: "Early project", - projectRootPath: "/repo/early-project", - }); + expect(useSessionStore.getState().sessions[serverId]?.projects.has("early-project")).toBe( + false, + ); directory.dispose(); }); }); @@ -1229,6 +1255,14 @@ it("fills every cached workspace beneath live updates received during the SQLite status: "online", source: { clientGeneration: 1, connectionEpoch: 1 }, }); + useSessionStore.getState().updateSessionServerInfo(serverId, { + serverId, + hostname: null, + version: "test", + features: { workspaceMultiplicity: true }, + }); + directory.setAgentRouteDemand(["agent"]); + await directory.refreshDemand(); let updated = false; holdRead(async () => { if (updated) return; diff --git a/packages/app/src/runtime/directory-sync/index.ts b/packages/app/src/runtime/directory-sync/index.ts index a490421ceb..9d457f63b2 100644 --- a/packages/app/src/runtime/directory-sync/index.ts +++ b/packages/app/src/runtime/directory-sync/index.ts @@ -1,3 +1,4 @@ +import type { OwnedSubscription } from "@getpaseo/client"; import type { DaemonClient, FetchAgentsEntry, @@ -12,12 +13,6 @@ import { type ProjectDescriptor, type WorkspaceDescriptor, } from "@/stores/session-store"; -import { - readLegacyDaemonWorkspaceDirectory, - buildLegacyWorkspaces, - shouldUseLegacyDaemonWorkspaceDirectory, - stampLegacyWorkspaceIds, -} from "@/workspace/legacy-daemon-workspaces"; import type { AgentDirectoryDelta } from "@/utils/agent-directory-sync"; import { AgentDirectoryReplica } from "./agent-replica"; import { @@ -40,6 +35,9 @@ import type { } from "@/runtime/replica-cache"; import type { TurnLivenessTransition } from "@/timeline/turn-liveness"; +type FetchAgentsPayload = Awaited>; +type FetchWorkspacesPayload = Awaited>; + const PAGE_LIMIT = 200; const AGENT_SORT: NonNullable = [ { key: "updated_at", direction: "desc" }, @@ -58,7 +56,6 @@ function resolveAgentNextPage(pageInfo: AgentPageInfo): { interface AgentSnapshot { entries: FetchAgentsEntry[]; subscriptionId: string | null; - legacy: boolean; syncMode?: "snapshot" | "changes"; syncCursor?: DirectoryCursor; syncRemovals: Array<{ id: string; seq: number }>; @@ -146,7 +143,9 @@ export class DirectorySync { status: "offline", source: { clientGeneration: 0, connectionEpoch: 0 }, }; - private unsubscribe: (() => void) | null = null; + private agentSubscription: OwnedSubscription | null = null; + private workspaceSubscription: OwnedSubscription | null = null; + private eventSubscription: ReturnType | null = null; private readonly abortSessionWaits = new Set<() => void>(); private cacheLoad: Promise | null = null; private cacheAccepted = false; @@ -185,79 +184,11 @@ export class DirectorySync { return false; } this.flushAbortedTransactions(); - this.unsubscribe?.(); - this.unsubscribe = null; + this.releaseSubscriptions(); workspaceLabels.disconnect(this.serverId); this.connection = connection; this.abortPendingSessionWaits(); if (!connection.client || connection.status !== "online") return true; - const client = connection.client; - const source = connection.source; - const subscriptions = [ - client.on("agent_update", (message) => { - if (message.type !== "agent_update" || !this.isCurrent(client, source)) return; - this.revision += 1; - const recorded = this.agentTransactions.record(source, message.payload); - if (!recorded) { - this.agents.applyDelta(message.payload); - this.noteLiveCursor("agents", message.payload); - this.persistCheckpoint(); - } - }), - client.on("workspace_update", (message) => { - if (message.type !== "workspace_update" || !this.isCurrent(client, source)) return; - this.revision += 1; - this.workspaceRevision += 1; - const recorded = this.workspaceTransactions.record(source, message.payload); - if (!recorded) { - this.applyWorkspaceDelta(message.payload); - this.noteLiveCursor("workspaces", message.payload); - this.persistCheckpoint(); - } - }), - client.on("project.update", (message) => { - if (message.type !== "project.update" || !this.isCurrent(client, source)) return; - this.revision += 1; - this.workspaceRevision += 1; - const recorded = this.workspaceTransactions.record(source, message.payload); - if (!recorded) { - this.applyWorkspaceDelta(message.payload); - this.noteLiveCursor("projects", message.payload); - this.persistCheckpoint(); - } - }), - client.on("script_status_update", (message) => { - if (message.type !== "script_status_update" || !this.isCurrent(client, source)) return; - this.revision += 1; - this.workspaceRevision += 1; - const delta: WorkspaceDirectoryDelta = { - kind: "script_status", - update: message.payload, - }; - const recorded = this.workspaceTransactions.record(source, delta); - if (!recorded) { - this.applyWorkspaceDelta(delta); - this.persistCheckpoint(); - } - }), - client.on("agent_deleted", (message) => { - if (message.type === "agent_deleted" && this.isCurrent(client, source)) { - this.revision += 1; - this.agents.remove(message.payload.agentId); - this.persistCheckpoint(); - } - }), - client.on("agent_archived", (message) => { - if (message.type === "agent_archived" && this.isCurrent(client, source)) { - this.revision += 1; - this.agents.archive(message.payload.agentId, message.payload.archivedAt); - this.persistCheckpoint(); - } - }), - ]; - this.unsubscribe = () => { - for (const unsubscribe of subscriptions) unsubscribe(); - }; if (this.hasDemand()) void this.requestDemandRefresh().catch(() => undefined); return true; } @@ -266,6 +197,7 @@ export class DirectorySync { const wasDemanded = this.fullDemandSources.size > 0; if (demanded) this.fullDemandSources.add(source); else this.fullDemandSources.delete(source); + if (!this.hasDemand()) this.releaseSubscriptions(); if (!wasDemanded && this.fullDemandSources.size > 0) { void this.loadCachedDirectory().catch(() => undefined); if (this.getOnlineConnection()) void this.requestDemandRefresh().catch(() => undefined); @@ -282,6 +214,7 @@ export class DirectorySync { } this.routeDemandIds.clear(); for (const agentId of next) this.routeDemandIds.add(agentId); + if (!this.hasDemand()) this.releaseSubscriptions(); if (this.routeDemandIds.size > 0 && this.getOnlineConnection()) { void this.requestDemandRefresh().catch(() => undefined); } @@ -290,13 +223,65 @@ export class DirectorySync { dispose(): void { this.flushAbortedTransactions(); this.abortPendingSessionWaits(); - this.unsubscribe?.(); - this.unsubscribe = null; + this.releaseSubscriptions(); this.fullDemandSources.clear(); this.routeDemandIds.clear(); workspaceLabels.disconnect(this.serverId); } + private releaseSubscriptions(): void { + for (const subscription of [ + this.agentSubscription, + this.workspaceSubscription, + this.eventSubscription, + ]) { + void subscription?.release().catch(() => undefined); + } + this.agentSubscription = null; + this.workspaceSubscription = null; + this.eventSubscription = null; + this.satisfiedDemandSource = null; + } + + private receiveAgentDelta(source: DirectorySourceToken, delta: AgentDirectoryDelta): void { + this.revision += 1; + if (this.agentTransactions.record(source, delta)) return; + this.agents.applyDelta(delta); + this.noteLiveCursor("agents", delta); + this.persistCheckpoint(); + } + + private receiveWorkspaceDelta( + source: DirectorySourceToken, + delta: WorkspaceDirectoryDelta, + ): void { + this.revision += 1; + this.workspaceRevision += 1; + if (this.workspaceTransactions.record(source, delta)) return; + this.applyWorkspaceDelta(delta); + if (delta.kind !== "script_status") + this.noteLiveCursor( + "projectId" in delta || "project" in delta ? "projects" : "workspaces", + delta, + ); + this.persistCheckpoint(); + } + + private observeDirectoryEvents(): void { + if (this.eventSubscription) return; + const { client, source } = this.requireOnline(); + this.eventSubscription = client.observeEvents(["project.update", "script_status_update"]); + this.eventSubscription.subscribe({ + snapshot: () => {}, + update: (message) => { + if (!this.isCurrent(client, source)) return; + if (message.type === "project.update") this.receiveWorkspaceDelta(source, message.payload); + if (message.type === "script_status_update") + this.receiveWorkspaceDelta(source, { kind: "script_status", update: message.payload }); + }, + }); + } + private hasDemand(): boolean { return this.fullDemandSources.size > 0 || this.routeDemandIds.size > 0; } @@ -307,6 +292,7 @@ export class DirectorySync { if (!this.getOnlineConnection()) { return this.fullDemandSources.size > 0 ? this.loadCachedDirectory() : Promise.resolve(); } + this.observeDirectoryEvents(); const source = this.connection.source; if ( !force && @@ -449,32 +435,12 @@ export class DirectorySync { const transaction = this.agentTransactions.begin(source, () => ({ entries: [], subscriptionId: null, - legacy: false, syncRemovals: [], })); this.callbacks.markAgentLoading(); try { await this.waitForSession(client, source); - const session = useSessionStore.getState().sessions[this.serverId]; - if (!input.filter && shouldUseLegacyDaemonWorkspaceDirectory(session?.serverInfo)) { - const directory = await readLegacyDaemonWorkspaceDirectory({ - client, - subscribe: input.subscribe, - page: input.page, - }); - if ( - !directory || - !this.agentTransactions.isCurrent(transaction) || - !this.isCurrent(client, source) - ) { - throw new DirectoryRefreshSupersededError("legacy fetch no longer current"); - } - transaction.snapshot.entries.push(...stampLegacyWorkspaceIds(directory.entries)); - transaction.snapshot.subscriptionId = directory.subscriptionId; - transaction.snapshot.legacy = true; - } else { - await this.fetchAgents(client, source, transaction, input); - } + await this.fetchAgents(client, source, transaction, input); if (!this.isCurrent(client, source) || !this.hasMatchingSession(client, source)) { throw new DirectoryRefreshSupersededError("agent completion no longer current"); } @@ -482,36 +448,8 @@ export class DirectorySync { if (completion.kind === "stale") { throw new DirectoryRefreshSupersededError("agent completion was superseded"); } - if (completion.snapshot.legacy) { - const workspaces = buildLegacyWorkspaces(completion.snapshot.entries); - const previous = this.readWorkspaceState(); - this.workspaces.commitSnapshot( - { - workspaces, - projects: new Map( - Array.from(workspaces.values(), legacyProjectDescriptorFromWorkspace).map( - (project) => [project.projectId, project], - ), - ), - }, - [], - ); - const next = this.workspaces.snapshot(); - this.persistWorkspaceChanges( - previous, - new Set([...previous.workspaces.keys(), ...next.workspaces.keys()]), - new Set([...previous.projects.keys(), ...next.projects.keys()]), - ); - } - const deltas = completion.snapshot.legacy - ? completion.deltas.map((delta) => - delta.kind === "upsert" - ? { ...delta, agent: { ...delta.agent, workspaceId: delta.agent.cwd } } - : delta, - ) - : completion.deltas; this.revision += 1; - const agents = this.commitAgentSnapshot(completion.snapshot, deltas); + const agents = this.commitAgentSnapshot(completion.snapshot, completion.deltas); this.persistAgentCursors(completion.snapshot, completion.deltas); this.persistCheckpoint(); this.callbacks.markAgentReady(); @@ -589,12 +527,25 @@ export class DirectorySync { let cursor: string | null = null; let subscribe = initialSubscribe; while (true) { - const payload = await client.fetchWorkspaces({ + const query: Parameters[0] = { sort: [{ key: "activity_at", direction: "desc" }], - ...(subscribe ? { subscribe: {} } : {}), page: cursor ? { limit: PAGE_LIMIT, cursor } : { limit: PAGE_LIMIT }, ...(supportsDirectorySync ? { sync: this.readCursors().workspaces ?? {} } : {}), - }); + }; + let payload: FetchWorkspacesPayload; + if (subscribe) { + void this.workspaceSubscription?.release().catch(() => undefined); + const subscription = client.observeWorkspaces(query); + this.workspaceSubscription = subscription; + subscription.subscribe({ + snapshot: () => {}, + update: (message) => { + if (message.type === "workspace_update" && this.isCurrent(client, source)) + this.receiveWorkspaceDelta(source, message.payload); + }, + }); + payload = await subscription.ready; + } else payload = await client.fetchWorkspaces(query); this.assertWorkspaceTransactionCurrent(client, source, transaction); applyWorkspaceSnapshotPage(transaction.snapshot, payload, cursor === null); if (!payload.pageInfo.hasMore || !payload.pageInfo.nextCursor) return; @@ -672,15 +623,29 @@ export class DirectorySync { let subscribe = input.subscribe; while (true) { const limit = input.page?.limit ?? PAGE_LIMIT; - const payload = await client.fetchAgents({ + const query: Omit = { ...(input.filter ? { filter: input.filter } : { scope: "active" as const }), sort: AGENT_SORT, - ...(subscribe ? { subscribe } : {}), page: cursor ? { limit, cursor } : { limit }, ...(!input.filter && cursor === null && this.supportsDirectorySync() ? { sync: this.readCursors().agents ?? {} } : {}), - }); + }; + let payload: FetchAgentsPayload; + if (subscribe) { + if (subscribe.subscriptionId) throw new Error("Subscription IDs are assigned by the host"); + void this.agentSubscription?.release().catch(() => undefined); + const subscription = client.observeAgents(query); + this.agentSubscription = subscription; + subscription.subscribe({ + snapshot: () => {}, + update: (message) => { + if (message.type === "agent_update" && this.isCurrent(client, source)) + this.receiveAgentDelta(source, message.payload); + }, + }); + payload = await subscription.ready; + } else payload = await client.fetchAgents(query); this.assertAgentTransactionCurrent(client, source, transaction); transaction.snapshot.entries.push(...payload.entries); transaction.snapshot.subscriptionId ??= payload.subscriptionId ?? null; @@ -731,7 +696,6 @@ export class DirectorySync { this.agentTransactions.begin(connection.source, () => ({ entries: [], subscriptionId: null, - legacy: false, syncRemovals: [], })); } diff --git a/packages/app/src/runtime/host-runtime.test.ts b/packages/app/src/runtime/host-runtime.test.ts index cd6be83fae..52686df9c6 100644 --- a/packages/app/src/runtime/host-runtime.test.ts +++ b/packages/app/src/runtime/host-runtime.test.ts @@ -25,6 +25,8 @@ import { } from "./host-runtime"; import type { ReplicaRow, ReplicaRowStore } from "./replica-cache/row-store"; +import { subscriptionFixture } from "./subscription-fixture"; + class FakeDaemonClient { private state: ConnectionState = { status: "idle" }; private listeners = new Set<(status: ConnectionState) => void>(); @@ -58,6 +60,16 @@ class FakeDaemonClient { return () => this.agentUpdateListeners.delete(listener); } + observeAgents(options: Parameters[0]) { + return subscriptionFixture(this.fetchAgents({ ...options, subscribe: {} }), (receive) => + this.on("agent_update", receive), + ); + } + + observeEvents() { + return subscriptionFixture(Promise.resolve({}), () => () => {}); + } + async waitForAgentUpdates(): Promise { if (this.agentUpdateListeners.size > 0) return; await new Promise((resolve) => { @@ -119,8 +131,16 @@ class FakeDaemonClient { return this.state; } - getLastServerInfoMessage(): null { - return null; + public ownedSubscriptions = true; + + getLastServerInfoMessage(): ReturnType { + return { + status: "server_info", + serverId: "srv_test", + hostname: "test", + version: "0.8.0", + features: { ownedSubscriptions: this.ownedSubscriptions }, + }; } subscribeConnectionStatus(listener: (status: ConnectionState) => void): () => void { @@ -550,6 +570,42 @@ class BrowserClientLifecycle { } describe("HostRuntimeController", () => { + it("gates an old host before publishing the app client or mounting observations", async () => { + const host = makeHost(); + const client = new FakeDaemonClient(); + client.ownedSubscriptions = false; + let mounts = 0; + const controller = new HostRuntimeController({ + host, + deps: { + createClient: () => client as unknown as DaemonClient, + connectToDaemon: async () => ({ + client: client as unknown as DaemonClient, + serverId: host.serverId, + hostname: null, + }), + getClientId: async () => "app-version-gate", + mountClientHandlers: () => { + mounts++; + return () => {}; + }, + }, + }); + const publishedClients: Array = []; + const stop = controller.subscribe(() => publishedClients.push(controller.getSnapshot().client)); + await controller.activateConnection({ connectionId: host.connections[0]!.id }); + expect(controller.getSnapshot()).toMatchObject({ + connectionStatus: "error", + client: null, + lastError: "Update the host to use this version of Paseo.", + }); + expect(mounts).toBe(0); + expect(publishedClients.every((value) => value === null)).toBe(true); + expect(client.fetchAgentsCalls).toEqual([]); + stop(); + await controller.stop(); + }); + it("replaces the active relay client when re-pairing changes the daemon public key", async () => { const oldRelay: HostConnection = { id: "relay:wss:relay.paseo.sh:443", @@ -1952,6 +2008,7 @@ describe("HostRuntimeStore", () => { expect(fakeClient.fetchAgentsCalls).toHaveLength(1); expect(fakeClient.fetchAgentsCalls[0]).toEqual({ + scope: "active", sort: [{ key: "updated_at", direction: "desc" }], subscribe: {}, page: { limit: 200 }, @@ -1968,6 +2025,7 @@ describe("HostRuntimeStore", () => { await store.refreshAgentDirectory({ serverId: host.serverId }); expect(fakeClient.fetchAgentsCalls[2]).toEqual({ + scope: "active", sort: [{ key: "updated_at", direction: "desc" }], page: { limit: 200 }, }); @@ -2006,7 +2064,7 @@ describe("HostRuntimeStore", () => { await waitForHostOnline(store, host.serverId); const load = store.refreshAgentDirectory({ serverId: host.serverId, - subscribe: { subscriptionId: "app:srv_no_session" }, + subscribe: {}, page: { limit: 200 }, }); await fakeClient.waitForFetches(1); @@ -2019,7 +2077,7 @@ describe("HostRuntimeStore", () => { expect(fakeClient.fetchAgentsCalls[0]).toEqual({ scope: "active", sort: [{ key: "updated_at", direction: "desc" }], - subscribe: { subscriptionId: "app:srv_no_session" }, + subscribe: {}, page: { limit: 200 }, }); @@ -2027,83 +2085,7 @@ describe("HostRuntimeStore", () => { expect(useSessionStore.getState().sessions[host.serverId]).toBeUndefined(); }); - it("bootstraps legacy daemons from unscoped agents and creates path-backed workspaces", async () => { - const host = makeHost({ - serverId: "srv_legacy_workspace_daemon", - connections: [ - { - id: "direct:lan:6767", - type: "directTcp", - endpoint: "lan:6767", - }, - ], - }); - const fakeClient = new FakeDaemonClient(); - fakeClient.setConnectionState({ status: "connected" }); - fakeClient.fetchAgentsResponses.push( - makeFetchAgentsPayload({ - entries: [ - makeFetchAgentsEntry({ - id: "agent-legacy", - cwd: "/repo/legacy-app", - updatedAt: "2026-06-18T12:00:00.000Z", - title: "Legacy daemon agent", - }), - ], - subscriptionId: "app:srv_legacy_workspace_daemon", - }), - ); - const store = new HostRuntimeStore({ - deps: { - createClient: () => fakeClient as unknown as DaemonClient, - connectToDaemon: async ({ host: hostProfile }) => ({ - client: fakeClient as unknown as DaemonClient, - serverId: hostProfile.serverId, - hostname: hostProfile.label ?? null, - }), - getClientId: async () => "cid_test_runtime", - }, - }); - - const sessionStore = useSessionStore.getState(); - sessionStore.initializeSession(host.serverId, fakeClient as unknown as DaemonClient, 1); - sessionStore.updateSessionServerInfo(host.serverId, { - serverId: host.serverId, - hostname: null, - version: "0.1.96", - }); - store.syncHosts([host]); - await waitForHostOnline(store, host.serverId); - const load = store.refreshAgentDirectory({ - serverId: host.serverId, - subscribe: { subscriptionId: "app:srv_legacy_workspace_daemon" }, - page: { limit: 200 }, - }); - await fakeClient.waitForFetches(1); - await load; - - expect(fakeClient.fetchAgentsCalls).toEqual([ - { - sort: [{ key: "updated_at", direction: "desc" }], - subscribe: { subscriptionId: "app:srv_legacy_workspace_daemon" }, - page: { limit: 200 }, - }, - ]); - const session = useSessionStore.getState().sessions[host.serverId]; - expect(session?.agents.get("agent-legacy")?.workspaceId).toBe("/repo/legacy-app"); - expect(Array.from(session?.workspaces.values() ?? [])).toEqual([ - expect.objectContaining({ - id: "/repo/legacy-app", - workspaceDirectory: "/repo/legacy-app", - name: "legacy-app", - }), - ]); - - store.syncHosts([]); - useSessionStore.getState().clearSession(host.serverId); - }); - - it("drains legacy snapshot and buffered running transitions exactly once", async () => { + it("drains snapshot and buffered running transitions exactly once", async () => { const host = makeHost({ serverId: "srv_legacy_transitions", connections: [{ id: "direct:lan:6767", type: "directTcp", endpoint: "lan:6767" }], @@ -2190,7 +2172,7 @@ describe("HostRuntimeStore", () => { await waitForHostOnline(store, host.serverId); const load = store.refreshAgentDirectory({ serverId: host.serverId, - subscribe: { subscriptionId: `app:${host.serverId}` }, + subscribe: {}, page: { limit: 200 }, }); await fakeClient.waitForFetches(2); @@ -2212,8 +2194,8 @@ describe("HostRuntimeStore", () => { ({ id, status, workspaceId }) => [id, status, workspaceId], ), ).toEqual([ - ["legacy-snapshot", "idle", "/legacy/repo"], - ["legacy-buffered", "idle", "/legacy/repo"], + ["legacy-snapshot", "idle", snapshotAgent.agent.workspaceId], + ["legacy-buffered", "idle", bufferedAgent.agent.workspaceId], ]); store.syncHosts([]); @@ -2280,7 +2262,7 @@ describe("HostRuntimeStore", () => { await waitForHostOnline(store, host.serverId); const load = store.refreshAgentDirectory({ serverId: host.serverId, - subscribe: { subscriptionId: "app:srv_paged" }, + subscribe: {}, page: { limit: 200 }, }); await fakeClient.waitForFetches(2); @@ -2290,7 +2272,7 @@ describe("HostRuntimeStore", () => { expect(fakeClient.fetchAgentsCalls[0]).toEqual({ scope: "active", sort: [{ key: "updated_at", direction: "desc" }], - subscribe: { subscriptionId: "app:srv_paged" }, + subscribe: {}, page: { limit: 200 }, }); expect(fakeClient.fetchAgentsCalls[1]).toEqual({ @@ -2370,7 +2352,7 @@ describe("HostRuntimeStore", () => { await waitForHostOnline(store, host.serverId); const load = store.refreshAgentDirectory({ serverId: host.serverId, - subscribe: { subscriptionId: "app:srv_paged_delta" }, + subscribe: {}, page: { limit: 200 }, }); await fakeClient.waitForFetches(2); @@ -2457,12 +2439,13 @@ describe("HostRuntimeStore", () => { }); store.syncHosts([host]); - await fakeClient.waitForAgentUpdates(); + await waitForHostOnline(store, host.serverId); const load = store.refreshAgentDirectory({ serverId: host.serverId, - subscribe: { subscriptionId: `app:${host.serverId}` }, + subscribe: {}, page: { limit: 200 }, }); + await fakeClient.waitForAgentUpdates(); fakeClient.agentUpdate({ kind: "upsert", agent: { ...snapshotEntry.agent, title: "before-session" }, @@ -2508,7 +2491,7 @@ describe("HostRuntimeStore", () => { .initializeSession(host.serverId, fakeClient as unknown as DaemonClient, 1); store.syncHosts([host]); await waitForHostOnline(store, host.serverId); - await store.refreshAgentDirectory({ serverId: host.serverId }); + await store.refreshAgentDirectory({ serverId: host.serverId, subscribe: {} }); const olderPage = new Deferred>>(); fakeClient.fetchAgentsResponses.push(olderPage.promise); const olderRefresh = store.refreshAgentDirectory({ serverId: host.serverId }); @@ -2576,7 +2559,7 @@ describe("HostRuntimeStore", () => { .initializeSession(host.serverId, fakeClient as unknown as DaemonClient, 1); store.syncHosts([host]); await waitForHostOnline(store, host.serverId); - await store.refreshAgentDirectory({ serverId: host.serverId }); + await store.refreshAgentDirectory({ serverId: host.serverId, subscribe: {} }); const olderPage = new Deferred>>(); fakeClient.fetchAgentsResponses.push(olderPage.promise); @@ -2749,7 +2732,7 @@ describe("HostRuntimeStore", () => { ); store.syncHosts([host]); await waitForHostOnline(store, host.serverId); - const load = store.refreshAgentDirectory({ serverId: host.serverId }); + const load = store.refreshAgentDirectory({ serverId: host.serverId, subscribe: {} }); await fakeClient.waitForFetches(2); fakeClient.agentUpdate({ kind: "upsert", @@ -3051,7 +3034,7 @@ describe("HostRuntimeStore", () => { }); store.syncHosts([host]); await waitForHostOnline(store, host.serverId); - const load = store.refreshAgentDirectory({ serverId: host.serverId }); + const load = store.refreshAgentDirectory({ serverId: host.serverId, subscribe: {} }); await fakeClient.waitForFetches(2); fakeClient.agentUpdate({ kind: "upsert", @@ -3132,7 +3115,7 @@ describe("HostRuntimeStore", () => { await waitForHostOnline(store, host.serverId); await store.refreshAgentDirectory({ serverId: host.serverId, - subscribe: { subscriptionId: "app:srv_resubscribe" }, + subscribe: {}, page: { limit: 200 }, }); await fakeClient.waitForFetches(1); @@ -3150,7 +3133,7 @@ describe("HostRuntimeStore", () => { await waitForHostOnline(store, host.serverId); await store.refreshAgentDirectory({ serverId: host.serverId, - subscribe: { subscriptionId: "app:srv_resubscribe" }, + subscribe: {}, page: { limit: 200 }, }); await fakeClient.waitForFetches(2); @@ -3159,13 +3142,13 @@ describe("HostRuntimeStore", () => { { scope: "active", sort: [{ key: "updated_at", direction: "desc" }], - subscribe: { subscriptionId: "app:srv_resubscribe" }, + subscribe: {}, page: { limit: 200 }, }, { scope: "active", sort: [{ key: "updated_at", direction: "desc" }], - subscribe: { subscriptionId: "app:srv_resubscribe" }, + subscribe: {}, page: { limit: 200 }, }, ]); @@ -3242,7 +3225,7 @@ describe("HostRuntimeStore", () => { await waitForHostOnline(store, host.serverId); await store.refreshAgentDirectory({ serverId: host.serverId, - subscribe: { subscriptionId: "app:srv_archived_rehydrate" }, + subscribe: {}, page: { limit: 200 }, }); await fakeClient.waitForFetches(1); diff --git a/packages/app/src/runtime/host-runtime.ts b/packages/app/src/runtime/host-runtime.ts index 0dd8bf776e..6f4d4bd6ad 100644 --- a/packages/app/src/runtime/host-runtime.ts +++ b/packages/app/src/runtime/host-runtime.ts @@ -1244,56 +1244,62 @@ export class HostRuntimeController { } this.activeClient = client; - this.unsubscribeClientHandlers = - this.deps.mountClientHandlers?.({ client, host: this.host, connection }) ?? null; this.applyConnectionEvent({ type: "select_connection", connectionId: connection.id, connection: toActiveConnection(connection), }); - this.snapshot = { - ...this.snapshot, - serverId: this.host.serverId, + this.snapshot = { ...this.snapshot, clientGeneration: nextGeneration }; + this.updateSnapshot({ ...toSnapshotConnectionPatch(this.connectionMachineState, this.connectionEpoch), - client, - clientGeneration: nextGeneration, - }; - for (const listener of this.listeners) { - listener(); - } - - this.unsubscribeClientStatus = client.subscribeConnectionStatus((state) => { - if (!this.isCurrentSwitchRequest(requestVersion) || this.activeClient !== client) { - return; - } - this.applyConnectionEvent({ - type: "client_state", - state, - lastError: client.lastError, - }); - const patch: HostRuntimeSnapshotPatch = { - ...toSnapshotConnectionPatch(this.connectionMachineState, this.connectionEpoch), - ...this.buildAgentDirectoryStatusPatch(), - }; - this.updateSnapshot(patch); + client: null, }); - try { - if (!existingClient) { - await client.connect(); - } - } catch (error) { - if (!this.isCurrentSwitchRequest(requestVersion) || this.activeClient !== client) { - return; - } - const message = toErrorMessage(error); - this.applyConnectionEvent({ - type: "connect_failed", - message, - }); + const failConnection = async (error: unknown) => { + if (!this.isCurrentSwitchRequest(requestVersion) || this.activeClient !== client) return; + this.unsubscribeClientStatus?.(); + this.unsubscribeClientStatus = null; + this.unsubscribeClientHandlers?.(); + this.unsubscribeClientHandlers = null; + client.setReconnectEnabled(false); + this.applyConnectionEvent({ type: "connect_failed", message: toErrorMessage(error) }); this.updateSnapshot({ ...toSnapshotConnectionPatch(this.connectionMachineState, this.connectionEpoch), + client: null, }); + try { + await client.close(); + } catch { + /* Preserve the compatibility/connection error. */ + } + }; + const requireAppHost = () => { + if (client.getLastServerInfoMessage()?.features?.ownedSubscriptions !== true) + throw new Error("Update the host to use this version of Paseo."); + }; + try { + if (!existingClient) await client.connect(); + if (!this.isCurrentSwitchRequest(requestVersion) || this.activeClient !== client) return; + requireAppHost(); + this.unsubscribeClientHandlers = + this.deps.mountClientHandlers?.({ client, host: this.host, connection }) ?? null; + this.unsubscribeClientStatus = client.subscribeConnectionStatus((state) => { + if (!this.isCurrentSwitchRequest(requestVersion) || this.activeClient !== client) return; + try { + if (state.status === "connected") requireAppHost(); + } catch (error) { + void failConnection(error); + return; + } + this.applyConnectionEvent({ type: "client_state", state, lastError: client.lastError }); + this.updateSnapshot({ + ...toSnapshotConnectionPatch(this.connectionMachineState, this.connectionEpoch), + ...this.buildAgentDirectoryStatusPatch(), + client, + }); + }); + } catch (error) { + await failConnection(error); } } @@ -2397,14 +2403,20 @@ export class HostRuntimeStore { await replica.prepare(agentId); } - fetchAgentTimeline( + async fetchAgentTimeline( serverId: string, agentId: string, request: Parameters[1], ): Promise>> { const directory = this.directorySyncByServer.get(serverId); if (!directory) throw new Error(`Unknown host runtime for serverId ${serverId}`); - return directory.fetchTimeline(agentId, request); + const owner = useSessionStore.getState().sessions[serverId]?.viewedTimelineSync; + const page = await directory.fetchTimeline(agentId, request); + if (owner && useSessionStore.getState().sessions[serverId]?.viewedTimelineSync === owner) { + owner.flushStreamAgent(agentId); + owner.applyTimelineResponse(page); + } + return page; } createViewedTimelineOwner( diff --git a/packages/app/src/runtime/subscription-fixture.ts b/packages/app/src/runtime/subscription-fixture.ts new file mode 100644 index 0000000000..c6eb9f78fe --- /dev/null +++ b/packages/app/src/runtime/subscription-fixture.ts @@ -0,0 +1,43 @@ +import type { OwnedSubscription, SubscriptionObserver } from "@getpaseo/client"; +import type { SessionOutboundMessage } from "@getpaseo/protocol/messages"; + +/** Public observation dependency for the directory/host runtime tests. */ +export function subscriptionFixture( + snapshot: Promise, + listen: (receive: (message: SessionOutboundMessage) => void) => () => void, +): OwnedSubscription { + const subscriptionId = crypto.randomUUID(); + const observers = new Set>(); + const updates: SessionOutboundMessage[] = []; + let current: (T & { subscriptionId: string }) | undefined; + let released = false; + const stop = listen((message) => { + if (!current) updates.push(message); + else for (const observer of observers) observer.update(message); + }); + const ready = snapshot.then((value) => { + current = { ...value, subscriptionId }; + if (!released) { + for (const observer of observers) { + observer.snapshot(current); + for (const message of updates) observer.update(message); + } + } + return current; + }); + return { + subscriptionId, + ready, + subscribe(observer) { + if (released) return () => {}; + observers.add(observer); + if (current) observer.snapshot(current); + return () => observers.delete(observer); + }, + async release() { + released = true; + observers.clear(); + stop(); + }, + }; +} diff --git a/packages/app/src/screens/open-project-screen.tsx b/packages/app/src/screens/open-project-screen.tsx index 9cbe43d83e..4c340e0f15 100644 --- a/packages/app/src/screens/open-project-screen.tsx +++ b/packages/app/src/screens/open-project-screen.tsx @@ -1,3 +1,4 @@ +import { useHosts, useHostRuntimeLastError } from "@/runtime/host-runtime"; import { useCallback, useEffect, useState, type ComponentType } from "react"; import { useTranslation } from "react-i18next"; import { View, Text, Pressable } from "react-native"; @@ -24,6 +25,7 @@ import { buildSettingsHostSectionRoute } from "@/utils/host-routes"; export function OpenProjectScreen() { const { t } = useTranslation(); + const hosts = useHosts(); const router = useRouter(); const openDesktopAgentList = usePanelStore((s) => s.openDesktopAgentList); const openProjectPicker = useOpenAddProject(); @@ -64,6 +66,9 @@ export function OpenProjectScreen() { + {hosts.map((host) => ( + + ))} + {label}: {error} + + ) : null; +} + interface HomeTileProps { icon: ComponentType<{ size: number; color: string }>; title: string; @@ -178,6 +192,12 @@ const styles = StyleSheet.create((theme) => ({ logo: { marginBottom: theme.spacing[8], }, + hostError: { + color: theme.colors.destructive, + fontSize: theme.fontSize.base, + maxWidth: 452, + textAlign: "center", + }, tiles: { marginTop: { xs: theme.spacing[6], md: theme.spacing[12] }, width: "100%", diff --git a/packages/app/src/screens/settings/plugins-page.test.tsx b/packages/app/src/screens/settings/plugins-page.test.tsx index 8ec1e4df6b..f02cfa80a8 100644 --- a/packages/app/src/screens/settings/plugins-page.test.tsx +++ b/packages/app/src/screens/settings/plugins-page.test.tsx @@ -1,3 +1,4 @@ +import { subscriptionFixture } from "@/runtime/subscription-fixture"; /** * @vitest-environment jsdom */ @@ -103,7 +104,11 @@ function plugin(enabled = true): PluginListItem { function createClient() { return { - on: vi.fn(() => () => undefined), + observeEvents: () => + subscriptionFixture( + Promise.resolve({ events: ["status.plugin_catalog_changed"] }), + () => () => {}, + ), getDaemonConfig: vi.fn(async () => ({ config: { pluginsEnabled: true } })), patchDaemonConfig: vi.fn(async () => ({ config: { pluginsEnabled: true } })), listPlugins: vi.fn(async (): Promise => []), diff --git a/packages/app/src/screens/settings/plugins-page.tsx b/packages/app/src/screens/settings/plugins-page.tsx index 086b1f1a53..60a797b951 100644 --- a/packages/app/src/screens/settings/plugins-page.tsx +++ b/packages/app/src/screens/settings/plugins-page.tsx @@ -227,11 +227,21 @@ export function HostPluginsPage({ serverId }: { serverId: string }) { await refreshQueue.current; }, [client, refetchPlugins]); useEffect(() => { - if (!client) return; - return client.on("status", (message) => { - if (message.payload.status === "plugin_catalog_changed") void refresh(); + if (!client || !connected || !supported) return; + const observation = client.observeEvents(["status.plugin_catalog_changed"]); + observation.subscribe({ + snapshot: () => {}, + update: (message) => { + if (message.type === "status" && message.payload.status === "plugin_catalog_changed") + void refresh(); + }, }); - }, [client, refresh]); + return () => { + void observation + .release() + .catch((error) => console.warn("[Plugins] Failed to release settings feed", error)); + }; + }, [client, connected, supported, refresh]); const mutation = useMutation({ mutationFn: async (operation: { action: string; diff --git a/packages/app/src/stores/session-store.ts b/packages/app/src/stores/session-store.ts index 3f1ae6ffcf..dd5469df26 100644 --- a/packages/app/src/stores/session-store.ts +++ b/packages/app/src/stores/session-store.ts @@ -2,7 +2,7 @@ import equal from "fast-deep-equal"; import { create } from "zustand"; import { subscribeWithSelector } from "zustand/middleware"; import type { DaemonClient } from "@getpaseo/client/internal/daemon-client"; -import type { ViewedTimelineUiBridge } from "@/timeline/viewed-timeline-sync"; +import type { ViewedTimelineOwner } from "@/timeline/viewed-timeline-sync"; import type { AgentDirectoryEntry } from "@/types/agent-directory"; import { appendSubmittedUserMessage, @@ -366,7 +366,7 @@ export interface SessionState { // Daemon client (immutable reference) client: DaemonClient | null; clientGeneration: number; - viewedTimelineSync: ViewedTimelineUiBridge | null; + viewedTimelineSync: ViewedTimelineOwner | null; // Server metadata (from server_info handshake) serverInfo: DaemonServerInfo | null; @@ -442,7 +442,7 @@ interface SessionStoreActions { clearSession: (serverId: string) => void; getSession: (serverId: string) => SessionState | undefined; updateSessionClient: (serverId: string, client: DaemonClient, clientGeneration?: number) => void; - setViewedTimelineSync: (serverId: string, sync: ViewedTimelineUiBridge | null) => void; + setViewedTimelineSync: (serverId: string, sync: ViewedTimelineOwner | null) => void; updateSessionServerInfo: (serverId: string, info: DaemonServerInfo) => void; // Audio state diff --git a/packages/app/src/terminal/runtime/terminal-stream-controller.test.ts b/packages/app/src/terminal/runtime/terminal-stream-controller.test.ts index ccac3ecc93..388484d79d 100644 --- a/packages/app/src/terminal/runtime/terminal-stream-controller.test.ts +++ b/packages/app/src/terminal/runtime/terminal-stream-controller.test.ts @@ -1,3 +1,4 @@ +import type { SessionOutboundMessage } from "@getpaseo/protocol/messages"; import { describe, expect, it } from "vitest"; import { @@ -36,6 +37,10 @@ type TerminalStreamEvent = | { terminalId: string; type: "restore"; data: Uint8Array }; class FakeTerminalStreamClient implements TerminalStreamControllerClient { + private readonly updates = new Set<(message: SessionOutboundMessage) => void>(); + emitUpdate(message: SessionOutboundMessage): void { + for (const receive of this.updates) receive(message); + } private readonly listeners = new Set<(event: TerminalStreamEvent) => void>(); public subscribeCalls: Array<{ terminalId: string; options?: unknown }> = []; public unsubscribeCalls: string[] = []; @@ -47,18 +52,46 @@ class FakeTerminalStreamClient implements TerminalStreamControllerClient { }> = []; public nextSubscribeResults: Array<{ terminalId: string; error?: string | null }> = []; - async subscribeTerminal(terminalId: string, options?: unknown) { + observeTerminal: TerminalStreamControllerClient["observeTerminal"] = ( + terminalId, + receive, + options, + ) => { this.subscribeCalls.push({ terminalId, ...(options ? { options } : {}) }); const result = this.nextSubscribeResults.shift(); - if (!result) { - throw new Error("Missing fake subscribe result"); - } - return result; - } - - unsubscribeTerminal(terminalId: string): void { - this.unsubscribeCalls.push(terminalId); - } + if (!result) throw new Error("Missing fake subscribe result"); + const subscriptionId = `terminal-${this.subscribeCalls.length}`; + const listener = (event: TerminalStreamEvent) => { + if (event.terminalId === terminalId) receive({ ...event, subscriptionId }); + }; + this.listeners.add(listener); + const ready = result.error + ? Promise.reject(new Error(result.error)) + : Promise.resolve({ + terminalId, + subscriptionId, + slot: this.subscribeCalls.length, + error: null, + requestId: subscriptionId, + }); + let released = false; + return { + subscriptionId, + ready, + subscribe: (observer) => { + this.updates.add(observer.update); + return () => { + this.updates.delete(observer.update); + }; + }, + release: async () => { + if (released) return; + released = true; + this.listeners.delete(listener); + this.unsubscribeCalls.push(terminalId); + }, + }; + }; sendTerminalInput( terminalId: string, @@ -72,13 +105,6 @@ class FakeTerminalStreamClient implements TerminalStreamControllerClient { }); } - onTerminalStreamEvent(handler: (event: TerminalStreamEvent) => void): () => void { - this.listeners.add(handler); - return () => { - this.listeners.delete(handler); - }; - } - emit(event: TerminalStreamEvent): void { for (const listener of this.listeners) { listener(event); @@ -92,8 +118,10 @@ function createHarness(input?: { client?: FakeTerminalStreamClient }) { const restores: Array<{ terminalId: string; data: Uint8Array }> = []; const snapshots: Array<{ terminalId: string; text: string }> = []; const statuses: TerminalStreamControllerStatus[] = []; + const exits: string[] = []; const controller = new TerminalStreamController({ client, + onExit: (terminalId) => exits.push(terminalId), getPreferredSize: () => ({ rows: 24, cols: 80 }), onOutput: (output) => { outputs.push(output); @@ -112,7 +140,7 @@ function createHarness(input?: { client?: FakeTerminalStreamClient }) { }, }); - return { client, controller, outputs, restores, snapshots, statuses }; + return { client, controller, outputs, restores, snapshots, statuses, exits }; } async function flushAsyncWork(): Promise { @@ -239,7 +267,9 @@ describe("terminal-stream-controller", () => { }, }, ]); - expect(client.resizeCalls).toEqual([]); + expect(client.resizeCalls).toEqual([ + { terminalId: "term-1", rows: 24, cols: 80, intent: "claim" }, + ]); expect(harness.restores[0]?.data).toBe(restoreData); expect(harness.restores).toEqual([{ terminalId: "term-1", data: terminalOutput("restored") }]); expect(harness.outputs).toEqual([]); @@ -264,3 +294,28 @@ describe("terminal-stream-controller", () => { }); }); }); + +it("reports observation failure without declaring the PTY exited, then allows reattach", async () => { + const h = createHarness(); + h.client.nextSubscribeResults.push({ terminalId: "term-1", error: null }); + h.controller.setTerminal({ terminalId: "term-1" }); + await flushAsyncWork(); + h.client.emitUpdate({ + type: "terminal_stream_exit", + payload: { terminalId: "term-1", subscriptionId: "terminal-1", error: "Snapshot read failed" }, + }); + expect(h.statuses.at(-1)).toEqual({ + terminalId: "term-1", + isAttaching: false, + error: "Snapshot read failed", + }); + expect(h.exits).toEqual([]); + expect(h.client.unsubscribeCalls).toEqual(["term-1"]); + h.client.nextSubscribeResults.push({ terminalId: "term-1", error: null }); + h.controller.setTerminal({ terminalId: "term-1" }); + await flushAsyncWork(); + h.client.emit({ terminalId: "term-1", type: "output", data: terminalOutput("STILL ALIVE") }); + expect(h.outputs).toHaveLength(1); + expect(h.statuses.at(-1)?.error).toBe(null); + h.controller.dispose(); +}); diff --git a/packages/app/src/terminal/runtime/terminal-stream-controller.ts b/packages/app/src/terminal/runtime/terminal-stream-controller.ts index 6b008a4ff0..90d54be4d4 100644 --- a/packages/app/src/terminal/runtime/terminal-stream-controller.ts +++ b/packages/app/src/terminal/runtime/terminal-stream-controller.ts @@ -1,29 +1,12 @@ +import type { DaemonClient } from "@getpaseo/client/internal/daemon-client"; import type { SubscribeTerminalRequest, TerminalState } from "@getpaseo/protocol/messages"; import type { TerminalOutputData } from "./terminal-emulator-runtime"; import { i18n } from "@/i18n/i18next"; -export interface TerminalStreamControllerClient { - subscribeTerminal: ( - terminalId: string, - options?: { restore?: SubscribeTerminalRequest["restore"] }, - ) => Promise<{ - terminalId: string; - error?: string | null; - }>; - unsubscribeTerminal: (terminalId: string) => void; - sendTerminalInput: ( - terminalId: string, - message: { type: "resize"; rows: number; cols: number; intent?: "claim" | "update" }, - ) => void; - onTerminalStreamEvent: ( - handler: ( - event: - | { terminalId: string; type: "output"; data: Uint8Array } - | { terminalId: string; type: "snapshot"; state: TerminalState } - | { terminalId: string; type: "restore"; data: Uint8Array }, - ) => void, - ) => () => void; -} +export type TerminalStreamControllerClient = Pick< + DaemonClient, + "observeTerminal" | "sendTerminalInput" +>; export interface TerminalStreamControllerSize { rows: number; @@ -41,6 +24,7 @@ export interface TerminalStreamControllerOptions { getPreferredSize: () => TerminalStreamControllerSize | null; onOutput: (input: { terminalId: string; data: TerminalOutputData }) => void; onSnapshot: (input: { terminalId: string; state: TerminalState }) => void; + onExit?: (terminalId: string) => void; onRestore?: (input: { terminalId: string; data: TerminalOutputData }) => void; getRestoreOptions?: () => SubscribeTerminalRequest["restore"] | undefined; onStatusChange?: (status: TerminalStreamControllerStatus) => void; @@ -49,51 +33,76 @@ export interface TerminalStreamControllerOptions { const TERMINAL_EXITED_ERROR = "Terminal exited"; export class TerminalStreamController { - private readonly unsubscribeStreamEvents: () => void; + private subscription: ReturnType | null = null; private terminalId: string | null = null; private disposed = false; - constructor(private readonly options: TerminalStreamControllerOptions) { - this.unsubscribeStreamEvents = this.options.client.onTerminalStreamEvent((event) => { - if (this.disposed || event.terminalId !== this.terminalId) { - return; - } - if (event.type === "snapshot") { - this.options.onSnapshot({ terminalId: event.terminalId, state: event.state }); - return; - } - if (event.type === "restore") { - if (event.data.length > 0) { - this.options.onRestore?.({ terminalId: event.terminalId, data: event.data }); - } - return; - } + constructor(private readonly options: TerminalStreamControllerOptions) {} + + private receive: Parameters[1] = (event) => { + if (this.disposed || event.terminalId !== this.terminalId) { + return; + } + if (event.type === "snapshot") { + this.options.onSnapshot({ terminalId: event.terminalId, state: event.state }); + return; + } + if (event.type === "restore") { if (event.data.length > 0) { - this.options.onOutput({ terminalId: event.terminalId, data: event.data }); + this.options.onRestore?.({ terminalId: event.terminalId, data: event.data }); } - }); - } + return; + } + if (event.data.length > 0) { + this.options.onOutput({ terminalId: event.terminalId, data: event.data }); + } + }; setTerminal(input: { terminalId: string | null }): void { if (this.disposed || input.terminalId === this.terminalId) { return; } const nextTerminalId = input.terminalId; - const previousTerminalId = this.terminalId; this.terminalId = nextTerminalId; - if (previousTerminalId) { - this.options.client.unsubscribeTerminal(previousTerminalId); - } + void this.subscription?.release().catch(console.error); + this.subscription = null; if (!nextTerminalId) { this.options.onStatusChange?.({ terminalId: null, isAttaching: false, error: null }); return; } const restore = this.options.getRestoreOptions?.(); this.options.onStatusChange?.({ terminalId: nextTerminalId, isAttaching: true, error: null }); - void this.options.client - .subscribeTerminal(nextTerminalId, restore ? { restore } : undefined) + let subscription: ReturnType; + try { + const preferredSize = restore?.size ?? this.options.getPreferredSize(); + if (preferredSize) + this.options.client.sendTerminalInput(nextTerminalId, { + type: "resize", + ...preferredSize, + intent: "claim", + }); + subscription = this.options.client.observeTerminal( + nextTerminalId, + this.receive, + restore ? { restore } : undefined, + ); + } catch (error) { + this.failAttach(nextTerminalId, error); + return; + } + this.subscription = subscription; + subscription.subscribe({ + snapshot: () => {}, + update: (message) => { + if (message.type !== "terminal_stream_exit" || this.subscription !== subscription) return; + if (message.payload.error) + this.failAttach(nextTerminalId, new Error(message.payload.error)); + else this.handleTerminalExit({ terminalId: nextTerminalId }); + }, + }); + void subscription.ready .then((payload) => { - if (this.disposed || this.terminalId !== nextTerminalId) { + if (this.disposed || this.subscription !== subscription) { return; } if (payload.error) { @@ -105,15 +114,6 @@ export class TerminalStreamController { }); return; } - const preferredSize = restore?.size ? null : this.options.getPreferredSize(); - if (preferredSize) { - this.options.client.sendTerminalInput(nextTerminalId, { - type: "resize", - rows: preferredSize.rows, - cols: preferredSize.cols, - intent: "claim", - }); - } this.options.onStatusChange?.({ terminalId: nextTerminalId, isAttaching: false, @@ -122,24 +122,33 @@ export class TerminalStreamController { return; }) .catch((error: unknown) => { - if (this.disposed || this.terminalId !== nextTerminalId) { + if (this.disposed || this.subscription !== subscription) { return; } - this.terminalId = null; - this.options.onStatusChange?.({ - terminalId: nextTerminalId, - isAttaching: false, - error: - error instanceof Error ? error.message : i18n.t("workspace.terminal.unableToSubscribe"), - }); + this.failAttach(nextTerminalId, error); }); } + private failAttach(terminalId: string, error: unknown): void { + this.terminalId = null; + void this.subscription?.release().catch(console.error); + this.subscription = null; + this.options.onStatusChange?.({ + terminalId, + isAttaching: false, + error: + error instanceof Error ? error.message : i18n.t("workspace.terminal.unableToSubscribe"), + }); + } + handleTerminalExit(input: { terminalId: string }): void { if (this.disposed || input.terminalId !== this.terminalId) { return; } + this.options.onExit?.(input.terminalId); this.terminalId = null; + void this.subscription?.release().catch(console.error); + this.subscription = null; this.options.onStatusChange?.({ terminalId: input.terminalId, isAttaching: false, @@ -152,12 +161,9 @@ export class TerminalStreamController { return; } this.disposed = true; - const terminalId = this.terminalId; this.terminalId = null; - if (terminalId) { - this.options.client.unsubscribeTerminal(terminalId); - } - this.unsubscribeStreamEvents(); + void this.subscription?.release().catch(console.error); + this.subscription = null; this.options.onStatusChange?.({ terminalId: null, isAttaching: false, error: null }); } } diff --git a/packages/app/src/timeline/replica.test.ts b/packages/app/src/timeline/replica.test.ts index e01b0f5ee6..f77e95b8c6 100644 --- a/packages/app/src/timeline/replica.test.ts +++ b/packages/app/src/timeline/replica.test.ts @@ -51,8 +51,7 @@ function createOwner(storage: TimelineReplicaStorage): ViewedTimelineOwner { replaceDemandedAgentIds: () => undefined, drainQueuedAgentMessage: () => undefined, ports: { - initialDeliveryMode: "legacy", - setSubscription: async () => undefined, + observe: () => ({ ready: Promise.resolve(), release: async () => undefined }), readCursor: () => undefined, fetchPage: async () => ({ hasNewer: false, endCursor: null }), fetchLatestTail: async () => ({ hasNewer: false, endCursor: null }), diff --git a/packages/app/src/timeline/viewed-timeline-sync.test.ts b/packages/app/src/timeline/viewed-timeline-sync.test.ts index 8c7c156e6f..9a77ab8a6a 100644 --- a/packages/app/src/timeline/viewed-timeline-sync.test.ts +++ b/packages/app/src/timeline/viewed-timeline-sync.test.ts @@ -1,9 +1,12 @@ import { expect, test, vi } from "vitest"; +import { DaemonClient, type DaemonTransport } from "@getpaseo/client/internal/daemon-client"; +import type { SessionInboundMessage, SessionOutboundMessage } from "@getpaseo/protocol/messages"; import type { ProjectedTimelineForwardFetchPlan } from "./timeline-sync-plan"; import { consumeForcedTimelineTailReplacement, createViewedTimelineSync, type TimelineResponsePayload, + type ViewedTimelineSyncPorts, } from "./viewed-timeline-sync"; interface Deferred { @@ -36,7 +39,9 @@ interface TimelineFetch { } class TimelineWorld { + constructor(private readonly observeMembership?: ViewedTimelineSyncPorts["observe"]) {} readonly errors: string[] = []; + readonly releasedMemberships: string[][] = []; readonly cursors = new Map(); readonly cacheRequests: string[] = []; readonly forcedTimelineTailReplacements = new Set(); @@ -48,8 +53,8 @@ class TimelineWorld { this.cacheRequestWaiters.shift()?.(agentId); await this.cacheGate?.promise; }, - initialDeliveryMode: "selective", - setSubscription: async (agentIds) => { + observe: (agentIds) => { + if (this.observeMembership) return this.observeMembership(agentIds); const result = deferred(); this.memberships.push({ agentIds, @@ -57,7 +62,12 @@ class TimelineWorld { fail: (message) => result.reject(new Error(message)), }); this.releaseMembershipWaiter(); - return result.promise; + return { + ready: result.promise, + release: async () => { + this.releasedMemberships.push(agentIds); + }, + }; }, readCursor: (agentId) => this.cursors.get(agentId), fetchPage: async (agentId, request) => { @@ -380,33 +390,112 @@ test("all acknowledged agents begin catch-up independently", async () => { expect(membership.agentIds).toEqual(["agent-a", "agent-b"]); }); -test("an eviction during acknowledgement never catches up the stale hot set", async () => { - const world = new TimelineWorld(); - world.sync.setConnected(true); - world.sync.replaceVisibleAgentIds("workspace", [ - "agent-a", - "agent-b", - "agent-c", - "agent-d", - "agent-e", - ]); - const staleMembership = await world.nextMembership(); - world.sync.replaceVisibleAgentIds("workspace", ["agent-f"]); - staleMembership.succeed(); - const currentMembership = await world.nextMembership(); - currentMembership.succeed(); - const currentCatchUps = await Promise.all( - ["agent-a", "agent-b", "agent-c", "agent-d", "agent-f"].map((agentId) => - world.nextFetch(agentId), - ), - ); - for (const catchUp of currentCatchUps) catchUp.respond({ hasNewer: false }); - - expect({ stale: staleMembership.agentIds, current: currentMembership.agentIds }).toEqual({ - stale: ["agent-a", "agent-b", "agent-c", "agent-d", "agent-e"], - current: ["agent-a", "agent-b", "agent-c", "agent-d", "agent-f"], +test("an eviction starts and acknowledges B before A returns its late subscription ID", async () => { + const requests: SessionInboundMessage[] = []; + let receive!: (data: unknown, isBinary: boolean) => void; + let open!: () => void; + const reply = (message: SessionOutboundMessage) => + receive(JSON.stringify({ type: "session", message }), false); + const transport: DaemonTransport = { + send(data) { + if (typeof data !== "string") throw new Error("No binary request expected"); + const frame = JSON.parse(data); + if (frame.type === "ping") receive(JSON.stringify({ type: "pong" }), false); + if (frame.type !== "session") return; + requests.push(frame.message); + if (frame.message.type === "subscription.release.request") + reply({ + type: "subscription.release.response", + payload: { + requestId: frame.message.requestId, + subscriptionId: frame.message.subscriptionId, + }, + }); + }, + close() {}, + onMessage(handler) { + receive = handler; + return () => {}; + }, + onOpen(handler) { + open = handler; + return () => {}; + }, + onClose() { + return () => {}; + }, + onError() { + return () => {}; + }, + }; + const client = new DaemonClient({ + url: "ws://timeline-fixture", + clientId: "timeline-replacement", + transportFactory: () => transport, + reconnect: { enabled: false }, }); - world.expectNoPendingFetch(); + const world = new TimelineWorld((agentIds) => client.observeTimeline(agentIds)); + const membershipRequests = () => + requests.filter((request) => request.type === "agent.timeline.set_subscription.request"); + try { + const connected = client.connect(); + open(); + reply({ + type: "status", + payload: { + status: "server_info", + serverId: "timeline-fixture", + hostname: null, + version: null, + features: { ownedSubscriptions: true }, + }, + }); + await connected; + world.sync.replaceVisibleAgentIds("workspace", [ + "agent-a", + "agent-b", + "agent-c", + "agent-d", + "agent-e", + ]); + world.sync.setConnected(true); + await vi.waitFor(() => expect(membershipRequests()).toHaveLength(1)); + const a = membershipRequests()[0]!; + world.sync.replaceVisibleAgentIds("workspace", ["agent-f"]); + await vi.waitFor(() => expect(membershipRequests()).toHaveLength(2)); + const b = membershipRequests()[1]!; + reply({ + type: "agent.timeline.set_subscription.response", + payload: { requestId: b.requestId, agentIds: b.agentIds, subscriptionId: "server-b" }, + }); + const catchUps = await Promise.all(b.agentIds.map((agentId) => world.nextFetch(agentId))); + for (const catchUp of catchUps) catchUp.respond({ hasNewer: false }); + await vi.waitFor(() => expect(world.sync.getAgentTimelineStatus("agent-f")).toBe("ready")); + reply({ + type: "agent.timeline.set_subscription.response", + payload: { requestId: a.requestId, agentIds: a.agentIds, subscriptionId: "server-a-late" }, + }); + await vi.waitFor(() => + expect(requests.filter((request) => request.type === "subscription.release.request")).toEqual( + [expect.objectContaining({ subscriptionId: "server-a-late" })], + ), + ); + expect(a.agentIds).toEqual(["agent-a", "agent-b", "agent-c", "agent-d", "agent-e"]); + expect(b.agentIds).toEqual(["agent-a", "agent-b", "agent-c", "agent-d", "agent-f"]); + world.expectNoPendingFetch(); + expect(world.errors).toEqual([]); + world.sync.dispose(); + await vi.waitFor(() => + expect( + requests + .filter((request) => request.type === "subscription.release.request") + .map((request) => request.subscriptionId), + ).toEqual(["server-a-late", "server-b"]), + ); + } finally { + world.sync.dispose(); + await client.close(); + } }); test("disconnect cancels paging and reconnect restores membership before fresh catch-up", async () => { @@ -877,75 +966,14 @@ test("disconnect clears hidden hot agents before reconnecting the visible set", expect(reopened.agentIds).toEqual(["agent-a", "agent-b"]); }); -test("legacy delivery skips subscription RPCs while retaining visibility catch-up and gap recovery", async () => { +test("disposing a view releases its pending observation before bootstrap completes", async () => { const world = new TimelineWorld(); - world.sync.setDeliveryMode("legacy"); world.sync.replaceVisibleAgentIds("workspace", ["agent-a"]); world.sync.setConnected(true); - - world.expectNoPendingMembership(); - const initial = await world.nextFetch("agent-a"); - initial.respond({ hasNewer: false }); - await vi.waitFor(() => expect(world.sync.getAgentTimelineStatus("agent-a")).toBe("ready")); - - world.sync.recoverGap("agent-a", { epoch: "epoch-agent-a", endSeq: 10 }); - const recovery = await world.nextFetch("agent-a"); - recovery.respond({ hasNewer: false }); - - expect(recovery.request).toEqual({ - direction: "after", - cursor: { epoch: "epoch-agent-a", seq: 10 }, - limit: 40, - projection: "projected", - }); -}); - -test("legacy delivery catches up after returning to a view or foreground", async () => { - const world = new TimelineWorld(); - world.sync.setDeliveryMode("legacy"); - world.sync.setConnected(true); - world.sync.replaceVisibleAgentIds("workspace", ["agent-a"]); - const firstAgentA = await world.nextFetch("agent-a"); - firstAgentA.respond({ hasNewer: false }); - await vi.waitFor(() => expect(world.sync.getAgentTimelineStatus("agent-a")).toBe("ready")); - - world.sync.replaceVisibleAgentIds("workspace", ["agent-b"]); - const agentB = await world.nextFetch("agent-b"); - agentB.respond({ hasNewer: false }); - await vi.waitFor(() => expect(world.sync.getAgentTimelineStatus("agent-b")).toBe("ready")); - - world.sync.replaceVisibleAgentIds("workspace", ["agent-a"]); - const secondAgentA = await world.nextFetch("agent-a"); - secondAgentA.respond({ hasNewer: false }); - await vi.waitFor(() => expect(world.sync.getAgentTimelineStatus("agent-a")).toBe("ready")); - - world.sync.setActive(false); - world.sync.setActive(true); - const foregroundAgentA = await world.nextFetch("agent-a"); - foregroundAgentA.respond({ hasNewer: false }); - - world.expectNoPendingMembership(); -}); - -test("switching from legacy to selective delivery publishes membership and catches up once", async () => { - const world = new TimelineWorld(); - world.sync.setDeliveryMode("legacy"); - world.sync.replaceVisibleAgentIds("workspace", ["agent-a"]); - world.sync.setConnected(true); - const legacyCatchUp = await world.nextFetch("agent-a"); - legacyCatchUp.respond({ hasNewer: false }); - await vi.waitFor(() => expect(world.sync.getAgentTimelineStatus("agent-a")).toBe("ready")); - - world.sync.setDeliveryMode("selective"); - expect(world.sync.getAgentTimelineStatus("agent-a")).toBe("pending"); - const membership = await world.nextMembership(); - membership.succeed(); - const catchUp = await world.nextFetch("agent-a"); - catchUp.respond({ hasNewer: false }); - - await vi.waitFor(() => expect(world.sync.getAgentTimelineStatus("agent-a")).toBe("ready")); - - expect(membership.agentIds).toEqual(["agent-a"]); - world.expectNoPendingMembership(); + const request = await world.nextMembership(); + world.sync.dispose(); + expect(world.releasedMemberships).toEqual([["agent-a"]]); + request.succeed(); + await Promise.resolve(); world.expectNoPendingFetch(); }); diff --git a/packages/app/src/timeline/viewed-timeline-sync.ts b/packages/app/src/timeline/viewed-timeline-sync.ts index d11084cdf2..ea73f9e8e2 100644 --- a/packages/app/src/timeline/viewed-timeline-sync.ts +++ b/packages/app/src/timeline/viewed-timeline-sync.ts @@ -308,10 +308,9 @@ function applyAuthoritativeTimelineResponse(input: { } export interface ViewedTimelineSyncPorts { - initialDeliveryMode: TimelineDeliveryMode; prepare(agentId: string): Promise; replaceDemandedAgentIds(agentIds: string[]): void; - setSubscription(agentIds: string[]): Promise; + observe(agentIds: string[]): { ready: Promise; release(): Promise }; readCursor(agentId: string): { epoch: string; endSeq: number } | undefined; fetchPage( agentId: string, @@ -322,7 +321,6 @@ export interface ViewedTimelineSyncPorts { schedule(task: () => void, delayMs: number): () => void; } -export type TimelineDeliveryMode = "legacy" | "selective"; export type ViewedTimelineStatus = "ready" | "pending" | "error" | "retrying"; export interface ViewedTimelineUiBridge { @@ -336,7 +334,6 @@ export interface ViewedTimelineUiBridge { export interface ViewedTimelineSync extends ViewedTimelineUiBridge { setActive(active: boolean): void; setConnected(connected: boolean): void; - setDeliveryMode(mode: TimelineDeliveryMode): void; recoverGap(agentId: string, cursor: { epoch: string; endSeq: number }): void; dispose(): void; } @@ -359,8 +356,17 @@ export function createViewedTimelineOwner(input: { drainQueuedAgentMessage: (agentId: string) => void; ports: ViewedTimelineOwnerPorts; }): ViewedTimelineOwner { + const forcedTailReplacements = new Set(); const sync = createViewedTimelineSync({ ...input.ports, + fetchLatestTail: async (agentId) => { + forcedTailReplacements.add(agentId); + try { + return await input.ports.fetchLatestTail(agentId); + } finally { + forcedTailReplacements.delete(agentId); + } + }, prepare: (agentId) => input.replica.prepare(agentId), readCursor: (agentId) => input.replica.readCursor(agentId) ?? input.ports.readCursor(agentId), replaceDemandedAgentIds: input.replaceDemandedAgentIds, @@ -374,7 +380,8 @@ export function createViewedTimelineOwner(input: { }); return { ...sync, - applyTimelineResponse(payload) { + applyTimelineResponse(receivedPayload) { + const payload = consumeForcedTimelineTailReplacement(receivedPayload, forcedTailReplacements); const accepted = applyAuthoritativeTimelineResponse({ serverId: input.serverId, payload, @@ -477,13 +484,14 @@ export function createViewedTimelineSync(ports: ViewedTimelineSyncPorts): Viewed const listeners = new Set<() => void>(); let active = true; let connected = false; - let deliveryMode = ports.initialDeliveryMode; + let observation: { + handle: ReturnType; + agentIds: string[]; + } | null = null; let disposed = false; let desired: string[] = []; let acknowledged: string[] = []; let membershipGeneration = 0; - let reconciling = false; - let reconcileRequested = false; let membershipNeedsRetry = false; let membershipRetryDelayMs: number | undefined; let cancelMembershipRetry: (() => void) | null = null; @@ -678,78 +686,46 @@ export function createViewedTimelineSync(ports: ViewedTimelineSyncPorts): Viewed } }; - const reconcileLatestMembership = async (): Promise => { - if (disposed || !connected || deliveryMode !== "selective") return; + const reconcileMembership = async (): Promise => { + if (disposed || !connected) return; const generation = membershipGeneration; const requested = desired; - if (!membershipNeedsRetry && sameAgentIds(requested, acknowledged)) return; + if (!membershipNeedsRetry && sameAgentIds(requested, observation?.agentIds ?? acknowledged)) + return; membershipNeedsRetry = false; try { - await ports.setSubscription(requested); + const previous = observation; + const handle = ports.observe(requested); + observation = { handle, agentIds: requested }; + void previous?.handle.release().catch(ports.reportError); + await handle.ready; } catch (error) { + if (disposed || !connected || generation !== membershipGeneration) return; membershipNeedsRetry = true; setVisibilityCatchUpError(requested, error); cancelMembershipRetry?.(); const nextRetryDelayMs = getNextRetryDelayMs(membershipRetryDelayMs); cancelMembershipRetry = ports.schedule(() => { cancelMembershipRetry = null; - if ( - disposed || - !connected || - membershipGeneration !== generation || - !sameAgentIds(desired, requested) - ) { - return; - } + if (disposed || !connected || membershipGeneration !== generation) return; void reconcileMembership(); }, nextRetryDelayMs); membershipRetryDelayMs = nextRetryDelayMs; ports.reportError(error); return; } + if (disposed || !connected || generation !== membershipGeneration) return; cancelMembershipRetry?.(); cancelMembershipRetry = null; membershipRetryDelayMs = undefined; - if (disposed || !connected || deliveryMode !== "selective") return; acknowledged = requested; - if (generation !== membershipGeneration) { - await reconcileLatestMembership(); - return; - } startAcknowledgedCatchUps(); - if (!sameAgentIds(desired, acknowledged)) await reconcileLatestMembership(); - }; - - const reconcileMembership = async () => { - if (reconciling) { - reconcileRequested = true; - return; - } - if (disposed || !connected) return; - reconciling = true; - try { - await reconcileLatestMembership(); - } finally { - reconciling = false; - if (reconcileRequested && !disposed && connected && deliveryMode === "selective") { - reconcileRequested = false; - void reconcileMembership(); - } else if ( - !disposed && - connected && - deliveryMode === "selective" && - !membershipNeedsRetry && - !sameAgentIds(desired, acknowledged) - ) { - void reconcileMembership(); - } - } }; const retryVisibleAgentTimeline = (agentId: string) => { if (!isDesired(agentId) || manualRetries.has(agentId)) return; const catchUp = catchUps.get(agentId); - const membershipRetryable = deliveryMode === "selective" && membershipNeedsRetry && connected; + const membershipRetryable = membershipNeedsRetry && connected; if (catchUp?.status !== "error" && !membershipRetryable) return; manualRetries.add(agentId); notifyListeners(); @@ -761,7 +737,6 @@ export function createViewedTimelineSync(ports: ViewedTimelineSyncPorts): Viewed cancelMembershipRetry?.(); cancelMembershipRetry = null; membershipRetryDelayMs = undefined; - membershipNeedsRetry = false; void reconcileMembership(); }; @@ -807,17 +782,12 @@ export function createViewedTimelineSync(ports: ViewedTimelineSyncPorts): Viewed ports.replaceDemandedAgentIds(desired); membershipGeneration += 1; notifyListeners(); - if (deliveryMode === "legacy") { - acknowledged = connected ? desired : []; - if (connected) startAcknowledgedCatchUps(); - return; - } void reconcileMembership(); }; const publishVisibleMembership = () => { const visible = visibleAgentIds(); - if (!connected || deliveryMode !== "selective") { + if (!connected) { const activeVisible = active ? visible : []; recentlyViewedAgentIds = activeVisible; commitDesiredMembership(activeVisible); @@ -862,40 +832,15 @@ export function createViewedTimelineSync(ports: ViewedTimelineSyncPorts): Viewed cancelMembershipRetry?.(); cancelMembershipRetry = null; membershipRetryDelayMs = undefined; + void observation?.handle.release().catch(ports.reportError); + observation = null; acknowledged = []; membershipGeneration += 1; for (const agentId of desired) cancelCatchUp(agentId); return; } membershipGeneration += 1; - if (deliveryMode === "legacy") { - acknowledged = desired; - startAcknowledgedCatchUps(); - } else { - void reconcileMembership(); - } - }, - setDeliveryMode(nextMode) { - if (deliveryMode === nextMode) return; - deliveryMode = nextMode; - cancelMembershipRetry?.(); - cancelMembershipRetry = null; - membershipRetryDelayMs = undefined; - membershipNeedsRetry = false; - membershipGeneration += 1; - for (const agentId of desired) cancelCatchUp(agentId); - const visible = active ? visibleAgentIds() : []; - recentlyViewedAgentIds = visible; - desired = visible; - ports.replaceDemandedAgentIds(desired); - visibilityCatchUpPending.clear(); - visibilityCatchUpErrors.clear(); - manualRetries.clear(); - for (const agentId of desired) visibilityCatchUpPending.add(agentId); - acknowledged = deliveryMode === "legacy" && connected ? desired : []; - notifyListeners(); - if (deliveryMode === "selective" && connected) void reconcileMembership(); - else if (connected) startAcknowledgedCatchUps(); + void reconcileMembership(); }, recoverGap(agentId, cursor) { if (!isDesired(agentId)) return; @@ -906,6 +851,8 @@ export function createViewedTimelineSync(ports: ViewedTimelineSyncPorts): Viewed }, dispose() { disposed = true; + void observation?.handle.release().catch(ports.reportError); + observation = null; cancelMembershipRetry?.(); cancelMembershipRetry = null; membershipNeedsRetry = false; diff --git a/packages/app/src/workspace-labels/index.ts b/packages/app/src/workspace-labels/index.ts index b2c46ab748..49a2e6b1a9 100644 --- a/packages/app/src/workspace-labels/index.ts +++ b/packages/app/src/workspace-labels/index.ts @@ -155,18 +155,38 @@ class WorkspaceLabelsController { return; } - let connection: HostConnection | undefined; - const unsubscribe = input.client.on("workspace.label.update", (message) => { - if (this.connections.get(input.serverId) !== connection) return; - if (!replica.applyUpdate(message)) { - void this.refresh(input.serverId).catch(() => undefined); - return; - } - this.publish(input.serverId, replica, "online", null); - }); - connection = { client: input.client, replica, unsubscribe }; + const subscription = input.client.observeWorkspaceLabels(); + const connection: HostConnection = { + client: input.client, + replica, + unsubscribe: () => { + void subscription.release().catch(() => undefined); + }, + }; this.connections.set(input.serverId, connection); - await this.refresh(input.serverId); + subscription.subscribe({ + snapshot: (payload) => { + replica.applyList(payload); + this.publish(input.serverId, replica, "online", null); + }, + update: (message) => { + if (message.type !== "workspace.label.update") return; + if (!replica.applyUpdate(message)) { + void this.refresh(input.serverId).catch(() => undefined); + return; + } + this.publish(input.serverId, replica, "online", null); + }, + error: (error) => { + this.publish( + input.serverId, + replica, + "online", + error instanceof Error ? error.message : i18n.t("workspaceLabels.errors.load"), + ); + }, + }); + await subscription.ready; } disconnect(serverId: string): void { @@ -207,7 +227,6 @@ class WorkspaceLabelsController { if (!connection) return; try { const payload = await connection.client.listWorkspaceLabels({ - subscriptionId: `workspace-labels:${serverId}`, sync: connection.replica.snapshot().cursor, }); if (this.connections.get(serverId) !== connection) return; diff --git a/packages/app/src/workspace/legacy-daemon-workspaces.test.ts b/packages/app/src/workspace/legacy-daemon-workspaces.test.ts index 50dc1a8ca1..263596badc 100644 --- a/packages/app/src/workspace/legacy-daemon-workspaces.test.ts +++ b/packages/app/src/workspace/legacy-daemon-workspaces.test.ts @@ -1,26 +1,12 @@ import { afterEach, describe, expect, it } from "vitest"; import type { DaemonClient, FetchAgentsEntry } from "@getpaseo/client/internal/daemon-client"; -import { useSessionStore, type Agent, type WorkspaceDescriptor } from "@/stores/session-store"; +import { useSessionStore, type Agent } from "@/stores/session-store"; import { deriveWorkspaceAgentVisibility } from "@/workspace-tabs/agent-visibility"; -import { buildWorkspaceStructureProjects } from "@/projects/workspace-structure"; -import { - applyLegacyDaemonWorkspaceOwnership, - buildLegacyDaemonWorkspaceSnapshot, -} from "./legacy-daemon-workspaces"; +import { buildAgentDirectoryState } from "@/utils/agent-directory-sync"; +import { applyLegacyDaemonWorkspaceOwnership } from "./legacy-daemon-workspaces"; const SERVER_ID = "srv_legacy"; -function legacyProjectFromWorkspace(workspace: WorkspaceDescriptor) { - return { - projectId: workspace.projectId, - projectKey: null, - projectDisplayName: workspace.projectDisplayName, - projectCustomName: workspace.projectCustomName ?? null, - projectRootPath: workspace.projectRootPath, - projectKind: workspace.projectKind, - }; -} - function legacyAgent(input: { id: string; cwd: string; @@ -82,87 +68,9 @@ afterEach(() => { useSessionStore.getState().clearSession(SERVER_ID); }); -describe("buildLegacyDaemonWorkspaceSnapshot", () => { - it("creates path-backed workspace rows and stamps legacy agents with their workspace id", () => { - const snapshot = buildLegacyDaemonWorkspaceSnapshot({ - serverId: SERVER_ID, - entries: [ - legacyAgent({ id: "agent-running", cwd: "/repo/app", status: "running" }), - legacyAgent({ id: "agent-idle", cwd: "/repo/app", status: "idle" }), - ], - }); - - expect(Array.from(snapshot.workspaces.values())).toEqual([ - expect.objectContaining({ - id: "/repo/app", - projectId: "/repo", - projectDisplayName: "repo", - projectRootPath: "/repo", - workspaceDirectory: "/repo/app", - projectKind: "git", - workspaceKind: "checkout", - name: "app", - status: "running", - scripts: [], - }), - ]); - expect( - Array.from(snapshot.agents.values()).map((agent) => ({ - id: agent.id, - serverId: agent.serverId, - cwd: agent.cwd, - workspaceId: agent.workspaceId, - })), - ).toEqual([ - { - id: "agent-running", - serverId: SERVER_ID, - cwd: "/repo/app", - workspaceId: "/repo/app", - }, - { - id: "agent-idle", - serverId: SERVER_ID, - cwd: "/repo/app", - workspaceId: "/repo/app", - }, - ]); - }); - - it("keeps matching legacy path projects separate across hosts", () => { - const first = buildLegacyDaemonWorkspaceSnapshot({ - serverId: "host-a", - entries: [legacyAgent({ id: "agent-a", cwd: "/repo/app" })], - }); - const second = buildLegacyDaemonWorkspaceSnapshot({ - serverId: "host-b", - entries: [legacyAgent({ id: "agent-b", cwd: "/repo/app" })], - }); - - const projects = buildWorkspaceStructureProjects({ - sessions: [ - { - serverId: "host-a", - projects: Array.from(first.workspaces.values(), legacyProjectFromWorkspace), - workspaces: first.workspaces.values(), - }, - { - serverId: "host-b", - projects: Array.from(second.workspaces.values(), legacyProjectFromWorkspace), - workspaces: second.workspaces.values(), - }, - ], - }); - - expect(projects).toHaveLength(2); - expect(projects.map((project) => project.hosts[0]?.serverId).sort()).toEqual([ - "host-a", - "host-b", - ]); - }); - +describe("applyLegacyDaemonWorkspaceOwnership", () => { it("keeps old-daemon agent updates attached to the path-backed workspace", () => { - const snapshot = buildLegacyDaemonWorkspaceSnapshot({ + const snapshot = buildAgentDirectoryState({ serverId: SERVER_ID, entries: [legacyAgent({ id: "agent-running", cwd: "/repo/app", status: "running" })], }); @@ -173,7 +81,8 @@ describe("buildLegacyDaemonWorkspaceSnapshot", () => { hostname: null, version: "0.1.96", }); - store.setWorkspaces(SERVER_ID, snapshot.workspaces); + const cachedAgent = getSnapshotAgent(snapshot, "agent-running"); + snapshot.agents.set(cachedAgent.id, { ...cachedAgent, workspaceId: "/repo/app" }); store.setAgents(SERVER_ID, snapshot.agents); const existingAgent = getSnapshotAgent(snapshot, "agent-running"); diff --git a/packages/app/src/workspace/legacy-daemon-workspaces.ts b/packages/app/src/workspace/legacy-daemon-workspaces.ts index 8303cfadec..08c0ddf3ee 100644 --- a/packages/app/src/workspace/legacy-daemon-workspaces.ts +++ b/packages/app/src/workspace/legacy-daemon-workspaces.ts @@ -1,113 +1,19 @@ -import type { - DaemonClient, - FetchAgentsEntry, - FetchAgentsOptions, -} from "@getpaseo/client/internal/daemon-client"; -import { - deriveAgentStateBucket, - getWorkspaceStateBucketPriority, -} from "@getpaseo/protocol/agent-state-bucket"; import { useSessionStore, type Agent, type DaemonServerInfo, type WorkspaceDescriptor, } from "@/stores/session-store"; -import { buildAgentDirectoryState } from "@/utils/agent-directory-sync"; import { normalizeWorkspacePath } from "@/utils/workspace-identity"; -export interface LegacyDaemonWorkspaceSnapshot { - agents: Map; - workspaces: Map; -} - -interface LegacyDaemonWorkspaceDirectoryReadResult { - entries: FetchAgentsEntry[]; - subscriptionId: string | null; -} - -const LEGACY_AGENT_DIRECTORY_SORT: NonNullable = [ - { key: "updated_at", direction: "desc" }, -]; - -// COMPAT(legacyWorkspaceDaemon): v0.1.97 app talking to <=v0.1.96 daemons. -// Older daemons expose agents by cwd but may have no workspace registry rows. -// Keep all cwd -> synthetic workspace behavior in this file so the shim is -// deleted by removing this module and its call sites once the daemon floor is v0.1.97. -export function buildLegacyDaemonWorkspaceSnapshot(input: { - serverId: string; - entries: FetchAgentsEntry[]; -}): LegacyDaemonWorkspaceSnapshot { - const entries = stampLegacyWorkspaceIds(input.entries); - const { agents } = buildAgentDirectoryState({ - serverId: input.serverId, - entries, - }); - - return { - agents, - workspaces: buildLegacyWorkspaces(entries), - }; -} - -export function shouldUseLegacyDaemonWorkspaceDirectory( - serverInfo: DaemonServerInfo | null | undefined, -): boolean { - return ( - serverInfo !== null && - serverInfo !== undefined && - serverInfo.features?.workspaceMultiplicity !== true - ); -} - +// COMPAT(legacyWorkspaceDaemon): added in v0.1.97, remove after 2027-03-09. +// Cached or partial agent records can predate workspace IDs; preserve their placement. function shouldBackfillLegacyDaemonWorkspaceDirectory( serverInfo: DaemonServerInfo | null | undefined, ): boolean { return serverInfo?.features?.workspaceMultiplicity !== true; } -export async function readLegacyDaemonWorkspaceDirectory(input: { - client: Pick; - subscribe?: FetchAgentsOptions["subscribe"]; - page?: FetchAgentsOptions["page"]; - isCancelled?: () => boolean; -}): Promise { - const entries: FetchAgentsEntry[] = []; - let cursor = input.page?.cursor ?? null; - let includeSubscribe = true; - let subscriptionId: string | null = null; - const pageLimit = input.page?.limit ?? 200; - - while (true) { - if (input.isCancelled?.()) { - return null; - } - - const payload = await input.client.fetchAgents({ - sort: LEGACY_AGENT_DIRECTORY_SORT, - ...(includeSubscribe && input.subscribe ? { subscribe: input.subscribe } : {}), - page: cursor ? { limit: pageLimit, cursor } : { limit: pageLimit }, - }); - if (input.isCancelled?.()) { - return null; - } - - entries.push(...payload.entries); - subscriptionId = subscriptionId ?? payload.subscriptionId ?? null; - includeSubscribe = false; - if (!readFetchAgentsHasMore(payload.pageInfo)) { - break; - } - const nextCursor = readFetchAgentsNextCursor(payload.pageInfo); - if (!nextCursor) { - break; - } - cursor = nextCursor; - } - - return { entries, subscriptionId }; -} - export function applyLegacyDaemonWorkspaceOwnership(input: { serverId: string; agent: Agent; @@ -139,144 +45,6 @@ export function applyLegacyDaemonWorkspaceOwnership(input: { }; } -function readFetchAgentsHasMore( - pageInfo: Awaited>["pageInfo"], -): boolean { - const page = pageInfo as { - hasMore?: boolean; - hasMoreAfter?: boolean; - }; - if (typeof page.hasMore === "boolean") { - return page.hasMore; - } - if (typeof page.hasMoreAfter === "boolean") { - return page.hasMoreAfter; - } - return false; -} - -function readFetchAgentsNextCursor( - pageInfo: Awaited>["pageInfo"], -): string | null { - const page = pageInfo as { - nextCursor?: string | null; - afterCursor?: string | null; - }; - if (typeof page.nextCursor === "string") { - return page.nextCursor; - } - if (typeof page.afterCursor === "string") { - return page.afterCursor; - } - return null; -} - -export function stampLegacyWorkspaceIds(entries: FetchAgentsEntry[]): FetchAgentsEntry[] { - return entries.map((entry) => { - const workspaceId = resolveLegacyWorkspaceId(entry); - return { - ...entry, - agent: { - ...entry.agent, - workspaceId, - }, - }; - }); -} - -export function buildLegacyWorkspaces( - entries: FetchAgentsEntry[], -): Map { - const workspaces = new Map(); - for (const entry of entries) { - const workspaceId = entry.agent.workspaceId ?? resolveLegacyWorkspaceId(entry); - const status = deriveAgentStateBucket({ - status: entry.agent.status, - pendingPermissionCount: entry.agent.pendingPermissions.length, - requiresAttention: entry.agent.requiresAttention, - attentionReason: entry.agent.attentionReason, - }); - const statusEnteredAt = parseLegacyAgentTimestamp(entry); - const existing = workspaces.get(workspaceId); - if (!existing) { - workspaces.set(workspaceId, createLegacyWorkspace(entry, status, statusEnteredAt)); - continue; - } - if ( - getWorkspaceStateBucketPriority(status) < getWorkspaceStateBucketPriority(existing.status) - ) { - workspaces.set(workspaceId, { - ...existing, - status, - statusEnteredAt, - }); - } - } - return workspaces; -} - -function createLegacyWorkspace( - entry: FetchAgentsEntry, - status: WorkspaceDescriptor["status"], - statusEnteredAt: Date | null, -): WorkspaceDescriptor { - const workspaceDirectory = resolveLegacyWorkspaceId(entry); - const checkout = entry.project.checkout; - const projectRootPath = - normalizeWorkspacePath(checkout.mainRepoRoot ?? checkout.worktreeRoot ?? checkout.cwd) ?? - workspaceDirectory; - return { - id: workspaceDirectory, - projectId: entry.project.projectKey, - projectDisplayName: entry.project.projectName, - projectCustomName: null, - projectRootPath, - workspaceDirectory, - projectKind: checkout.isGit ? "git" : "non_git", - workspaceKind: resolveLegacyWorkspaceKind(checkout), - name: resolveLegacyWorkspaceName(entry, workspaceDirectory), - title: null, - status, - statusEnteredAt, - archivingAt: null, - diffStat: null, - scripts: [], - gitRuntime: checkout.isGit - ? { - currentBranch: checkout.currentBranch, - remoteUrl: checkout.remoteUrl, - isPaseoOwnedWorktree: checkout.isPaseoOwnedWorktree, - isDirty: null, - aheadBehind: null, - aheadOfOrigin: null, - behindOfOrigin: null, - } - : null, - githubRuntime: null, - project: entry.project, - }; -} - -function resolveLegacyWorkspaceKind( - checkout: FetchAgentsEntry["project"]["checkout"], -): WorkspaceDescriptor["workspaceKind"] { - if (!checkout.isGit) { - return "directory"; - } - if (checkout.isPaseoOwnedWorktree) { - return "worktree"; - } - return "checkout"; -} - -function resolveLegacyWorkspaceId(entry: FetchAgentsEntry): string { - return ( - normalizeWorkspacePath(entry.project.checkout.cwd) ?? - normalizeWorkspacePath(entry.agent.cwd) ?? - entry.agent.cwd - ); -} - function resolveLegacyWorkspaceIdFromAgent( agent: Agent, workspaces: ReadonlyMap | undefined, @@ -294,30 +62,3 @@ function resolveLegacyWorkspaceIdFromAgent( return cwd; } - -function resolveLegacyWorkspaceName(entry: FetchAgentsEntry, workspaceDirectory: string): string { - const explicitName = entry.project.workspaceName?.trim(); - if (explicitName) { - return explicitName; - } - const branchName = entry.project.checkout.currentBranch?.trim(); - if (branchName && branchName !== "HEAD") { - return branchName; - } - return workspaceDirectoryName(workspaceDirectory); -} - -function workspaceDirectoryName(directory: string): string { - const trimmed = directory.trim().replace(/[/]+$/g, ""); - const separator = Math.max(trimmed.lastIndexOf("/"), trimmed.lastIndexOf("\\")); - return separator >= 0 ? trimmed.slice(separator + 1) : trimmed; -} - -function parseLegacyAgentTimestamp(entry: FetchAgentsEntry): Date | null { - const value = entry.agent.attentionTimestamp ?? entry.agent.updatedAt; - if (!value) { - return null; - } - const date = new Date(value); - return Number.isNaN(date.getTime()) ? null : date; -} diff --git a/packages/cli/src/commands/agent/attach.ts b/packages/cli/src/commands/agent/attach.ts index e73bb0a2f3..1118d4c905 100644 --- a/packages/cli/src/commands/agent/attach.ts +++ b/packages/cli/src/commands/agent/attach.ts @@ -155,6 +155,11 @@ export async function runAttachCommand( const unsubscribe = client.subscribeAgentTimeline(resolvedId, (message) => { if (message.type === "agent.timeline.replacement") { console.log("\n[Timeline replaced; earlier output is no longer current]"); + } else if (message.type === "agent.timeline.snapshot") { + console.log("\n[Reconnected; current recent history follows]"); + for (const entry of message.payload.page.entries) printTimelineItem(entry.item); + } else if (message.type === "agent.timeline.error") { + console.error(`Timeline observation stopped: ${message.payload.error}`); } else { printStreamEvent(message.payload.event); } diff --git a/packages/cli/src/commands/agent/logs.ts b/packages/cli/src/commands/agent/logs.ts index e29c9f6c69..32652d3e0e 100644 --- a/packages/cli/src/commands/agent/logs.ts +++ b/packages/cli/src/commands/agent/logs.ts @@ -208,6 +208,18 @@ async function runFollowMode( return; } + if (message.type === "agent.timeline.error") { + console.error(`Timeline observation stopped: ${message.payload.error}`); + return; + } + if (message.type === "agent.timeline.snapshot") { + console.log("\n[Reconnected; current recent history follows]"); + const items = message.payload.page.entries + .map((entry) => entry.item) + .filter((item) => !options.filter || matchesFilter(item, options.filter)); + console.log(formatAgentActivityTranscript(items, tailCount)); + return; + } if (message.payload.event.type === "timeline") { const item = message.payload.event.item; // Apply filter diff --git a/packages/client/examples/events-and-timeline.ts b/packages/client/examples/events-and-timeline.ts index 84855426de..a56a826677 100644 --- a/packages/client/examples/events-and-timeline.ts +++ b/packages/client/examples/events-and-timeline.ts @@ -10,13 +10,12 @@ export async function subscribeToEvents( url: string, agentId: string, workspaceId: string, -): Promise<() => void> { +): Promise<() => Promise> { const client = createClient(url); await client.connect(); - await client.workspaces.list({ - subscribe: { subscriptionId: `workspace-${workspaceId}` }, - }); + const agents = await client.agents.list({ subscribe: {} }); + const workspaces = await client.workspaces.list({ subscribe: {} }); const unsubscribeAgentUpdates = client.agents.subscribe((update) => { if (update.kind === "upsert" && update.agent.id === agentId) { @@ -31,14 +30,24 @@ export async function subscribeToEvents( }); const unsubscribeTimeline = client.agents.ref(agentId).timeline.subscribe((event) => { - void event.event; + if (event.event.type === "snapshot") { + console.log("Replace recent history", event.event.page.epoch, event.event.page.entries); + } else if (event.event.type === "error") { + console.error(event.event.error); + } }); - return () => { + await unsubscribeTimeline.ready; + + return async () => { unsubscribeTimeline(); unsubscribeWorkspaceUpdates(); unsubscribeAgentUpdates(); - void client.close(); + try { + await Promise.all([agents.subscription.release(), workspaces.subscription.release()]); + } finally { + await client.close(); + } }; } diff --git a/packages/client/src/connection.test.ts b/packages/client/src/connection.test.ts index 6807c4bf63..5a87b64e9c 100644 --- a/packages/client/src/connection.test.ts +++ b/packages/client/src/connection.test.ts @@ -2,11 +2,23 @@ import { expect, test } from "vitest"; import { CLIENT_CAPS } from "@getpaseo/protocol/client-capabilities"; import { DaemonClient, type DaemonTransport } from "./daemon-client"; -function connection(options: { acknowledgeSubscriptions?: boolean } = {}) { +function connection( + options: { + acknowledgeSubscriptions?: boolean; + ownedSubscriptions?: boolean; + acknowledgeTimelineReads?: boolean; + } = {}, +) { const sent: Array<{ type: string; capabilities?: Record; - message?: { type: string; requestId?: string; agentIds?: string[]; events?: string[] }; + message?: { + type: string; + requestId?: string; + agentIds?: string[]; + events?: string[]; + subscriptionId?: string; + }; }> = []; let receive = (_data: unknown) => {}; let open = () => {}; @@ -26,7 +38,11 @@ function connection(options: { acknowledgeSubscriptions?: boolean } = {}) { serverId: "test", hostname: null, version: null, - features: { selectiveAgentTimeline: true, explicitEventSubscriptions: true }, + features: { + ...(options.ownedSubscriptions === false ? {} : { ownedSubscriptions: true }), + selectiveAgentTimeline: true, + explicitEventSubscriptions: true, + }, }, }, }), @@ -41,7 +57,36 @@ function connection(options: { acknowledgeSubscriptions?: boolean } = {}) { type: "session", message: { type: frame.message.type.replace(".request", ".response"), - payload: { requestId: frame.message.requestId, agentIds: frame.message.agentIds }, + payload: { + requestId: frame.message.requestId, + agentIds: frame.message.agentIds, + subscriptionId: `server-${sent.length}`, + }, + }, + }), + ); + } + if ( + frame.message?.type === "fetch_agent_timeline_request" && + options.acknowledgeTimelineReads !== false + ) { + receive( + JSON.stringify({ + type: "session", + message: timelinePage(frame.message.requestId, "epoch", 0), + }), + ); + } + if (frame.message?.type === "subscription.release.request") { + receive( + JSON.stringify({ + type: "session", + message: { + type: "subscription.release.response", + payload: { + requestId: frame.message.requestId, + subscriptionId: frame.message.subscriptionId, + }, }, }), ); @@ -49,7 +94,7 @@ function connection(options: { acknowledgeSubscriptions?: boolean } = {}) { }, close() {}, onMessage(handler) { - receive = handler; + receive = (data) => handler(data, typeof data !== "string"); return () => {}; }, onOpen(handler) { @@ -96,35 +141,36 @@ test("a plain client advertises every protocol capability and no browser host", } }); -test("SDK timeline listeners own their union across unsubscribe and reconnect", async () => { +test("SDK timelines have independent lifetimes and fresh IDs on reconnect", async () => { const { createPaseoApi } = await import("./index"); const h = connection(); const api = createPaseoApi(h.client); try { - const ready = h.client.connect(); + const connecting = h.client.connect(); h.open(); - await ready; - const first = api.agents.ref("a").timeline.subscribe(() => {}); + await connecting; + const a = api.agents.ref("a").timeline.subscribe(() => {}); const same = api.agents.ref("a").timeline.subscribe(() => {}); - const second = api.agents.ref("b").timeline.subscribe(() => {}); - await Promise.resolve(); + const b = api.agents.ref("b").timeline.subscribe(() => {}); + await Promise.all([a.ready, same.ready, b.ready]); + expect(new Set([a.subscriptionId, same.subscriptionId, b.subscriptionId]).size).toBe(3); const memberships = () => h.sent - .filter((f) => f.message?.type === "agent.timeline.set_subscription.request") - .map((f) => f.message?.agentIds); - expect(memberships()).toEqual([["a"], ["a"], ["a", "b"]]); - first(); - expect(memberships()).toEqual([["a"], ["a"], ["a", "b"]]); - same(); - expect(memberships().at(-1)).toEqual(["b"]); + .filter((frame) => frame.message?.type === "agent.timeline.set_subscription.request") + .map((frame) => frame.message?.agentIds); + expect(memberships()).toEqual([["a"], ["a"], ["b"]]); + await a.release(); + await same.release(); + const previousId = b.subscriptionId; h.disconnect(); - const reconnect = h.client.connect(); + const reconnecting = h.client.connect(); h.open(); - await reconnect; - expect(memberships().at(-1)).toEqual(["b"]); - second(); - expect(memberships().at(-1)).toEqual([]); + await reconnecting; + await expect.poll(() => b.subscriptionId).not.toBe(previousId); + expect(memberships()).toEqual([["a"], ["a"], ["b"], ["b"]]); + await b.release(); } finally { + await api.dispose(); await h.client.close(); } }); @@ -140,7 +186,11 @@ test("SDK subscribers receive timeline replacement instead of silently losing hi const off = createPaseoApi(h.client) .agents.ref("agent") .timeline.subscribe((event) => received.push(event)); - h.receive({ type: "agent.timeline.replacement", payload: { agentId: "agent", epoch: "next" } }); + await off.ready; + h.receive({ + type: "agent.timeline.replacement", + payload: { agentId: "agent", epoch: "next", subscriptionId: off.subscriptionId }, + }); expect(received).toEqual([{ agentId: "agent", event: { type: "replacement", epoch: "next" } }]); off(); } finally { @@ -148,190 +198,110 @@ test("SDK subscribers receive timeline replacement instead of silently losing hi } }); -test("event listeners release demand and restore only remaining interests", async () => { +test("local listeners create no demand before or after reconnect", async () => { const h = connection(); try { - const ready = h.client.connect(); + const connecting = h.client.connect(); h.open(); - await ready; - const projects = h.client.on("project.update", () => {}); - const providers = h.client.on("providers_snapshot_update", () => {}); - const subscriptions = () => - h.sent - .filter((f) => f.message?.type === "session.events.set_subscription.request") - .map((f) => f.message?.events); - expect(subscriptions()).toEqual([ - ["project.update"], - ["project.update", "providers_snapshot_update"], - ]); - projects(); + await connecting; + const off = h.client.on("project.update", () => {}); h.disconnect(); - const reconnect = h.client.connect(); + const reconnecting = h.client.connect(); h.open(); - await reconnect; - expect(subscriptions().at(-1)).toEqual(["providers_snapshot_update"]); - providers(); - expect(subscriptions().at(-1)).toEqual([]); + await reconnecting; + expect(h.sent.filter((frame) => frame.type === "session")).toEqual([]); + off(); } finally { await h.client.close(); } }); -test("provider references fetch a body only for interested SDK listeners", async () => { +test("provider reference hydration preserves independent owners and drops released work", async () => { const h = connection(); - const received: unknown[] = []; - const payload = { - entries: [], - snapshotHash: "catalog", - fetchedAt: { test: "2026-09-08T00:00:00.000Z" }, - generatedAt: "2026-09-08T00:00:00.000Z", - }; + const updates: string[] = []; try { - const ready = h.client.connect(); + const connecting = h.client.connect(); h.open(); - await ready; - h.receive({ type: "providers_snapshot_update", payload }); - expect(h.sent).toHaveLength(1); - const off = h.client.on("providers_snapshot_update", (m) => received.push(m.payload.entries)); - h.receive({ type: "providers_snapshot_update", payload }); - h.receive({ type: "providers_snapshot_update", payload }); - const requests = h.sent.filter((f) => f.message?.type === "get_providers_snapshot_request"); - expect(requests).toHaveLength(1); - h.receive({ - type: "get_providers_snapshot_response", - payload: { - ...payload, - requestId: requests[0].message?.requestId, - compactSnapshot: { - entries: [ - { - provider: "test", - enabled: true, - status: "ready", - models: [{ id: "one", label: "One" }], - }, - ], - thinkingSets: [], + await connecting; + const a = h.client.observeEvents(["providers_snapshot_update"]); + const b = h.client.observeEvents(["providers_snapshot_update"]); + await Promise.all([a.ready, b.ready]); + for (const owner of [a, b]) + owner.subscribe({ + snapshot: () => {}, + update: (message) => { + if (message.type === "providers_snapshot_update") + updates.push(message.payload.subscriptionId!); }, - }, - }); - await expect - .poll(() => received) - .toEqual([ - [ - { - provider: "test", - enabled: true, - status: "ready", - fetchedAt: "2026-09-08T00:00:00.000Z", - models: [{ provider: "test", id: "one", label: "One" }], - }, - ], - ]); - off(); - h.receive({ type: "providers_snapshot_update", payload: { ...payload, snapshotHash: "next" } }); - expect(h.sent.filter((f) => f.message?.type === "get_providers_snapshot_request")).toHaveLength( - 1, + }); + const payload = { + entries: [], + snapshotHash: "catalog", + generatedAt: "2026-09-08T00:00:00.000Z", + }; + for (const owner of [a, b]) + h.receive({ + type: "providers_snapshot_update", + payload: { ...payload, subscriptionId: owner.subscriptionId }, + }); + const requests = h.sent.filter( + (frame) => frame.message?.type === "get_providers_snapshot_request", ); + expect(requests).toHaveLength(2); + await a.release(); + for (const request of requests) + h.receive({ + type: "get_providers_snapshot_response", + payload: { + ...payload, + requestId: request.message!.requestId, + compactSnapshot: { entries: [], thinkingSets: [] }, + }, + }); + await expect.poll(() => updates).toEqual([b.subscriptionId]); + await b.release(); } finally { await h.client.close(); } }); -test("SDK listeners preserve the app's explicit timeline membership", async () => { - const h = connection(); - try { - const ready = h.client.connect(); - h.open(); - await ready; - await h.client.setAgentTimelineSubscription(["visible"]); - const off = h.client.subscribeAgentTimeline("plugin", () => {}); - await h.client.setAgentTimelineSubscription(["next"]); - expect(h.sent.at(-1)?.message?.agentIds).toEqual(["next", "plugin"]); - off(); - expect(h.sent.at(-1)?.message?.agentIds).toEqual(["next"]); - } finally { - await h.client.close(); - } -}); - -test("timeline readiness waits for daemon acknowledgement, including shared listeners", async () => { +test("timeline readiness and updates belong to the acknowledged handle", async () => { const h = connection({ acknowledgeSubscriptions: false }); try { - const connect = h.client.connect(); + const connecting = h.client.connect(); h.open(); - await connect; - const first = h.client.subscribeAgentTimeline("agent", () => {}); - const second = h.client.subscribeAgentTimeline("agent", () => {}); - expect(first.ready).toBeInstanceOf(Promise); - let ready = false; - void second.ready.then(() => { - return (ready = true); - }); - await Promise.resolve(); - expect(ready).toBe(false); - const request = h.sent.at(-1)!.message!; - h.receive({ - type: "agent.timeline.set_subscription.response", - payload: { requestId: request.requestId, agentIds: ["agent"] }, - }); - await Promise.all([first.ready, second.ready]); - expect(ready).toBe(true); - h.disconnect(); - const third = h.client.subscribeAgentTimeline("agent", () => {}); - let reconnected = false; - void third.ready.then(() => { - return (reconnected = true); - }); - const reconnect = h.client.connect(); - h.open(); - await reconnect; - expect(reconnected).toBe(false); - h.receive({ - type: "agent.timeline.set_subscription.response", - payload: { requestId: h.sent.at(-1)!.message!.requestId, agentIds: ["agent"] }, - }); - await third.ready; - expect(reconnected).toBe(true); - first(); - second(); - third(); - } finally { - await h.client.close(); - } -}); - -test("an unresolved provider reference is fetched again after reconnect", async () => { - const h = connection(); - const received: unknown[] = []; - const payload = { entries: [], snapshotHash: "updated", generatedAt: "2026-09-08T00:00:00.000Z" }; - const requests = () => h.sent.filter((f) => f.message?.type === "get_providers_snapshot_request"); - try { - const connect = h.client.connect(); - h.open(); - await connect; - const off = h.client.on("providers_snapshot_update", (m) => - received.push(m.payload.snapshotHash), + await connecting; + const a = h.client.subscribeAgentTimeline("agent", () => {}); + const b = h.client.subscribeAgentTimeline("agent", () => {}); + const requests = h.sent.filter( + (frame) => frame.message?.type === "agent.timeline.set_subscription.request", ); - h.receive({ type: "providers_snapshot_update", payload }); - expect(requests()).toHaveLength(1); - h.disconnect(); - await Promise.resolve(); - await Promise.resolve(); - const reconnect = h.client.connect(); - h.open(); - await reconnect; - expect(requests()).toHaveLength(2); + let bReady = false; + void b.ready.then(() => { + bReady = true; + return undefined; + }); h.receive({ - type: "get_providers_snapshot_response", + type: "agent.timeline.set_subscription.response", payload: { - ...payload, - requestId: requests()[1].message?.requestId, - compactSnapshot: { entries: [], thinkingSets: [] }, + requestId: requests[0].message!.requestId, + agentIds: ["agent"], + subscriptionId: "a", }, }); - await expect.poll(() => received).toEqual(["updated"]); - off(); + await a.ready; + expect(bReady).toBe(false); + h.receive({ + type: "agent.timeline.set_subscription.response", + payload: { + requestId: requests[1].message!.requestId, + agentIds: ["agent"], + subscriptionId: "b", + }, + }); + await b.ready; + await a.release(); + await b.release(); } finally { await h.client.close(); } @@ -362,30 +332,306 @@ test("timeline readiness remains pending before connect and rejects when release } }); -test("unsubscribing while disconnected discards unresolved provider demand", async () => { +test("passive provider listeners never hydrate unowned references", async () => { const h = connection(); try { - const connect = h.client.connect(); + const connecting = h.client.connect(); h.open(); - await connect; + await connecting; const off = h.client.on("providers_snapshot_update", () => {}); h.receive({ type: "providers_snapshot_update", - payload: { - entries: [], - snapshotHash: "updated", - generatedAt: "2026-09-08T00:00:00.000Z", - }, + payload: { entries: [], snapshotHash: "updated", generatedAt: "2026-09-08T00:00:00.000Z" }, }); h.disconnect(); off(); - const reconnect = h.client.connect(); + const reconnecting = h.client.connect(); h.open(); - await reconnect; - expect(h.sent.filter((f) => f.message?.type === "get_providers_snapshot_request")).toHaveLength( + await reconnecting; + expect(h.sent.filter((frame) => frame.type === "session")).toEqual([]); + } finally { + await h.client.close(); + } +}); + +test("failed terminal bootstrap reports the domain error without reconnecting or retaining a route", async () => { + const h = connection(); + try { + const connecting = h.client.connect(); + h.open(); + await connecting; + const terminal = h.client.observeTerminal("missing-terminal", () => {}); + const request = h.sent.at(-1)!.message!; + h.receive({ + type: "subscribe_terminal_response", + payload: { + requestId: request.requestId, + terminalId: "missing-terminal", + error: "Terminal not found", + }, + }); + await expect(terminal.ready).rejects.toThrow("Terminal not found"); + await terminal.release(); + expect(terminal.subscriptionId).toBeNull(); + expect(h.client.isConnected).toBe(true); + expect( + h.sent.filter((frame) => frame.message?.type === "subscription.release.request"), + ).toEqual([]); + } finally { + await h.client.close(); + } +}); + +test("an old host produces an update-host error without sending a legacy subscription", async () => { + const h = connection({ ownedSubscriptions: false }); + try { + const connecting = h.client.connect(); + h.open(); + await connecting; + const observation = h.client.observeAgents({ filter: { labels: { role: "orchestrator" } } }); + await expect(observation.ready).rejects.toThrow( + "Update the host to use independent subscriptions.", + ); + await observation.release(); + expect(h.sent.filter((frame) => frame.type === "session")).toEqual([]); + expect(h.client.isConnected).toBe(true); + } finally { + await h.client.close(); + } +}); + +test("invalid observation input rejects without reconnecting the transport", async () => { + const h = connection(); + try { + const connecting = h.client.connect(); + h.open(); + await connecting; + const invalid = h.client.observeAgents({ page: { limit: -1 } }); + await expect(invalid.ready).rejects.toThrow(); + await invalid.release(); + expect(h.client.isConnected).toBe(true); + expect(h.sent.filter((frame) => frame.message?.type === "fetch_agents_request")).toHaveLength( + 0, + ); + } finally { + await h.client.close(); + } +}); + +test("a timed-out subscription rejects and cannot replay on reconnect", async () => { + const h = connection(); + try { + const connecting = h.client.connect(); + h.open(); + await connecting; + const observation = h.client.observeAgents({ timeout: 10 }); + await expect(observation.ready).rejects.toThrow(/timed out|timeout/i); + await observation.release(); + const reconnecting = h.client.connect(); + h.open(); + await reconnecting; + expect(h.sent.filter((frame) => frame.message?.type === "fetch_agents_request")).toHaveLength( 1, ); } finally { await h.client.close(); } }); + +function timelinePage(requestId: string | undefined, epoch: string, seq: number) { + return { + type: "fetch_agent_timeline_response", + payload: { + requestId, + agentId: "agent", + agent: null, + direction: "before", + projection: "projected", + epoch, + reset: false, + staleCursor: false, + gap: false, + window: { minSeq: 1, maxSeq: seq, nextSeq: seq + 1 }, + startCursor: { epoch, seq }, + endCursor: { epoch, seq }, + hasOlder: seq > 1, + hasNewer: false, + error: null, + entries: seq + ? [ + { + provider: "codex", + item: { type: "user_message", text: `persisted-${epoch}-${seq}` }, + timestamp: new Date(0).toISOString(), + seqStart: seq, + seqEnd: seq, + sourceSeqRanges: [{ startSeq: seq, endSeq: seq }], + collapsed: [], + }, + ] + : [], + }, + }; +} + +test("timeline recovery snapshots precede live updates for independent owners and survive epoch changes", async () => { + const { createPaseoApi } = await import("./index"); + const h = connection({ acknowledgeTimelineReads: false }); + const api = createPaseoApi(h.client); + const received: import("./index").PaseoAgentTimelineEvent[][] = [[], [], []]; + try { + const connected = h.client.connect(); + h.open(); + await connected; + const owners = received.map((events) => + api.agents.ref("agent").timeline.subscribe((event) => events.push(event)), + ); + await Promise.all(owners.map((owner) => owner.ready)); + const reads = () => + h.sent.filter((frame) => frame.message?.type === "fetch_agent_timeline_request"); + expect(reads()).toHaveLength(0); // Initial subscription remains live-only. + const oldIds = new Set(owners.map((owner) => owner.subscriptionId)); + h.disconnect(); + const reconnected = h.client.connect(); + h.open(); + await reconnected; + await expect.poll(() => reads().length).toBe(3); + expect(new Set(owners.map((owner) => owner.subscriptionId)).size).toBe(3); + expect(owners.every((owner) => !oldIds.has(owner.subscriptionId))).toBe(true); + await owners[2].release(); // The history reply can arrive after the owner is gone. + const live = (index: number, seq: number, epoch: string) => + h.receive({ + type: "agent_stream", + payload: { + agentId: "agent", + subscriptionId: owners[index].subscriptionId, + seq, + epoch, + timestamp: new Date(0).toISOString(), + event: { + type: "timeline", + provider: "codex", + item: { type: "user_message", text: `live-${seq}` }, + }, + }, + }); + live(0, 4, "old"); + live(0, 5, "old"); + h.receive(timelinePage(reads()[1].message?.requestId, "new", 4)); + h.receive(timelinePage(reads()[2].message?.requestId, "new", 4)); + await expect.poll(() => received[1].length).toBe(1); + expect(received[0]).toEqual([]); + live(1, 5, "new"); + h.receive(timelinePage(reads()[0].message?.requestId, "old", 4)); + await expect.poll(() => received[0].length).toBe(2); + for (const [index, epoch] of [ + [0, "old"], + [1, "new"], + ] as const) { + expect(received[index][0]).toMatchObject({ + event: { + type: "snapshot", + reason: "reconnect", + page: { + epoch, + hasOlder: true, + startCursor: { epoch, seq: 4 }, + entries: [{ item: { text: `persisted-${epoch}-4` } }], + }, + }, + }); + expect(received[index][1]).toMatchObject({ seq: 5 }); + } + expect(received[2]).toEqual([]); + await owners[0].release(); + live(1, 6, "new"); + expect(received[1]).toHaveLength(3); + } finally { + await api.dispose(); + await h.client.close(); + } +}); + +test("timeline recovery replaces an obsolete read after bounded overflow and epoch invalidation", async () => { + const h = connection({ acknowledgeTimelineReads: false }); + const messages: unknown[] = []; + try { + const connected = h.client.connect(); + h.open(); + await connected; + const owner = h.client.subscribeAgentTimeline("agent", (message) => messages.push(message)); + await owner.ready; + h.disconnect(); + const reconnected = h.client.connect(); + h.open(); + await reconnected; + const reads = () => + h.sent.filter((frame) => frame.message?.type === "fetch_agent_timeline_request"); + await expect.poll(() => reads().length).toBe(1); + for (let seq = 1; seq <= 130; seq++) + h.receive({ + type: "agent_stream", + payload: { + agentId: "agent", + subscriptionId: owner.subscriptionId, + seq, + epoch: "old", + timestamp: new Date(0).toISOString(), + event: { + type: "timeline", + provider: "codex", + item: { type: "assistant_message", text: "piece" }, + }, + }, + }); + h.receive(timelinePage(reads()[0].message?.requestId, "old", 1)); + await expect.poll(() => reads().length).toBe(2); + expect(messages).toEqual([]); + h.receive({ + type: "agent.timeline.replacement", + payload: { agentId: "agent", subscriptionId: owner.subscriptionId, epoch: "new" }, + }); + h.receive(timelinePage(reads()[1].message?.requestId, "old", 130)); + await expect.poll(() => reads().length).toBe(3); + expect(messages).toEqual([]); + h.receive(timelinePage(reads()[2].message?.requestId, "new", 2)); + await expect.poll(() => messages.length).toBe(2); + expect(messages).toMatchObject([ + { type: "agent.timeline.replacement", payload: { epoch: "new" } }, + { type: "agent.timeline.snapshot", payload: { page: { epoch: "new" } } }, + ]); + await owner.release(); + } finally { + await h.client.close(); + } +}); + +test("timeline recovery failure is observable and releases its owner", async () => { + const h = connection({ acknowledgeTimelineReads: false }); + const messages: unknown[] = []; + try { + const connected = h.client.connect(); + h.open(); + await connected; + const owner = h.client.subscribeAgentTimeline("agent", (message) => messages.push(message)); + await owner.ready; + h.disconnect(); + const reconnected = h.client.connect(); + h.open(); + await reconnected; + const reads = () => + h.sent.filter((frame) => frame.message?.type === "fetch_agent_timeline_request"); + await expect.poll(() => reads().length).toBe(1); + const page = timelinePage(reads()[0].message?.requestId, "epoch", 0); + h.receive({ ...page, payload: { ...page.payload, error: "Agent no longer available" } }); + await expect.poll(() => owner.subscriptionId).toBe(null); + expect(messages).toEqual([ + { + type: "agent.timeline.error", + payload: { agentId: "agent", error: "Agent no longer available" }, + }, + ]); + } finally { + await h.client.close(); + } +}); diff --git a/packages/client/src/connection/index.ts b/packages/client/src/connection/index.ts index fe8553588d..8d36083c99 100644 --- a/packages/client/src/connection/index.ts +++ b/packages/client/src/connection/index.ts @@ -1,9 +1,10 @@ -import type { SessionEventSubscription } from "@getpaseo/protocol/messages"; +export { OwnedSubscriptions, type OwnedSubscription, type SubscriptionObserver } from "./owned.js"; import { CLIENT_CAPS, type ClientCapability } from "@getpaseo/protocol/client-capabilities"; // Protocol support belongs to the installed client. Only browser hosting needs // a resource supplied by the caller. Keep this exhaustive as the protocol evolves. export const DEFAULT_CLIENT_CAPABILITIES = { + [CLIENT_CAPS.ownedSubscriptions]: true, [CLIENT_CAPS.allProviders]: true, [CLIENT_CAPS.selectiveAgentTimeline]: true, [CLIENT_CAPS.reasoningMergeEnum]: true, @@ -21,99 +22,8 @@ export const DEFAULT_CLIENT_CAPABILITIES = { } satisfies Record, true>; /** Calling releases demand; ready acknowledges the initial daemon membership. */ -export type TimelineSubscription = (() => void) & { readonly ready: Promise }; - -class TimelineInterest { - resolve!: () => void; - reject!: (error: unknown) => void; - readonly ready = new Promise((resolve, reject) => { - this.resolve = resolve; - this.reject = reject; - }); - - constructor() { - // Readiness is optional for fire-and-forget listeners. - void this.ready.catch(() => {}); - } -} - -/** Owns connection demand, independently of individual facades and React lifetimes. */ -export class ConnectionSubscriptions { - private viewed = new Set(); - private timelines = new Map>(); - private events: SessionEventSubscription[] = []; - - constructor( - private readonly send: { - timelines(agentIds: string[]): Promise | null; - events(events: SessionEventSubscription[]): Promise; - failed(error: unknown): void; - }, - ) {} - - private agentIds(): string[] { - return [...new Set([...this.viewed, ...this.timelines.keys()])].sort(); - } - - setViewed(agentIds: string[]): Promise { - this.viewed = new Set(agentIds); - return this.syncTimelines(); - } - - observeTimeline(agentId: string): TimelineSubscription { - const interests = this.timelines.get(agentId) ?? new Set(); - const interest = new TimelineInterest(); - interests.add(interest); - this.timelines.set(agentId, interests); - // Each caller gets acknowledgement for its own registration, even when an - // existing listener first subscribed on an earlier connection. - void this.syncTimelines().catch(this.send.failed); - let active = true; - return Object.assign( - () => { - if (!active) return; - active = false; - interest.reject(new Error("Timeline subscription released before it was ready")); - interests.delete(interest); - if (interests.size === 0) { - this.timelines.delete(agentId); - void this.syncTimelines().catch(this.send.failed); - } - }, - { ready: interest.ready }, - ); - } - - private async syncTimelines(): Promise { - const interests: TimelineInterest[] = []; - for (const listeners of this.timelines.values()) { - for (const interest of listeners) interests.push(interest); - } - try { - const sent = this.send.timelines(this.agentIds()); - if (!sent) return; - await sent; - for (const interest of interests) interest.resolve(); - } catch (error) { - for (const interest of interests) interest.reject(error); - throw error; - } - } - - setEvents(events: SessionEventSubscription[]): void { - if (JSON.stringify(this.events) === JSON.stringify(events)) return; - this.events = events; - void this.send.events(events).catch(this.send.failed); - } - - restore(): void { - if (this.agentIds().length) void this.syncTimelines().catch(this.send.failed); - if (this.events.length) void this.send.events(this.events).catch(this.send.failed); - } - - close(): void { - for (const interests of this.timelines.values()) { - for (const interest of interests) interest.reject(new Error("Daemon client closed")); - } - } -} +export type TimelineSubscription = (() => void) & { + readonly ready: Promise; + readonly subscriptionId: string | null; + release(): Promise; +}; diff --git a/packages/client/src/connection/owned.test.ts b/packages/client/src/connection/owned.test.ts new file mode 100644 index 0000000000..eca52f1749 --- /dev/null +++ b/packages/client/src/connection/owned.test.ts @@ -0,0 +1,129 @@ +import { expect, test } from "vitest"; +import { OwnedSubscriptions } from "./owned.js"; +import type { SessionOutboundMessage } from "@getpaseo/protocol/messages"; + +interface Snapshot { + subscriptionId: string; + requestId: string; +} + +function connection() { + const released: string[] = []; + const failures: unknown[] = []; + const requests: Array<(id: string) => void> = []; + const subscriptions = new OwnedSubscriptions({ + release: async (id) => { + released.push(id); + }, + failed: (error) => { + failures.push(error); + }, + }); + subscriptions.restore(); + return { + subscriptions, + released, + failures, + requests, + observe: (signal?: AbortSignal) => + subscriptions.observe( + (accept) => + new Promise((resolve) => { + requests.push((subscriptionId) => { + const snapshot = { subscriptionId, requestId: `request-${requests.length}` }; + accept(snapshot); + resolve(snapshot); + }); + }), + signal, + ), + }; +} + +function update(subscriptionId: string, seq: number): SessionOutboundMessage { + return { + type: "agent_update", + payload: { kind: "remove", agentId: `agent-${seq}`, subscriptionId, seq }, + }; +} + +test("a caller consuming the snapshot later receives its buffered updates in order", async () => { + const c = connection(); + const a = c.observe(); + const b = c.observe(); + c.requests[0]!("a"); + c.requests[1]!("b"); + c.subscriptions.receive(update("a", 1)); + c.subscriptions.receive(update("b", 2)); + await a.ready; + const seen: string[] = []; + a.subscribe({ + snapshot: (s) => seen.push(s.subscriptionId), + update: (m) => seen.push(JSON.stringify(m)), + }); + expect(seen).toEqual(["a", JSON.stringify(update("a", 1))]); + await a.release(); + c.subscriptions.receive(update("a", 3)); + expect(seen).toHaveLength(2); + expect(c.released).toEqual(["a"]); + expect(b.subscriptionId).toBe("b"); + await b.release(); +}); + +test("cancellation before the ID waits for bootstrap and releases exactly that ID", async () => { + const c = connection(); + const abort = new AbortController(); + const handle = c.observe(abort.signal); + abort.abort(); + const released = handle.release(); + expect(c.released).toEqual([]); + c.requests[0]!("cancelled"); + await released; + await expect(handle.ready).rejects.toThrow("released"); + expect(c.released).toEqual(["cancelled"]); +}); + +test("surviving handles use new remote IDs after reconnect and released handles stay gone", async () => { + const c = connection(); + const a = c.observe(); + const b = c.observe(); + c.requests[0]!("old-a"); + c.requests[1]!("old-b"); + await Promise.all([a.ready, b.ready]); + await b.release(); + const seen: string[] = []; + a.subscribe({ snapshot: (s) => seen.push(s.subscriptionId), update: () => seen.push("update") }); + c.subscriptions.disconnected(); + c.subscriptions.restore(); + expect(c.requests).toHaveLength(3); + c.requests[2]!("new-a"); + c.subscriptions.receive(update("old-a", 1)); + c.subscriptions.receive(update("new-a", 2)); + expect(seen).toEqual(["old-a", "new-a", "update"]); + expect(a.subscriptionId).toBe("new-a"); + await a.release(); +}); + +test("a late listener after bounded replay overflow gets a fresh snapshot", async () => { + const c = connection(); + const handle = c.observe(); + c.requests[0]!("old"); + await handle.ready; + const first: string[] = []; + handle.subscribe({ snapshot: (s) => first.push(s.subscriptionId), update: () => {} }); + for (let seq = 0; seq < 129; seq++) c.subscriptions.receive(update("old", seq)); + const late: string[] = []; + handle.subscribe({ + snapshot: (s) => late.push(s.subscriptionId), + update: () => late.push("update"), + }); + await expect.poll(() => c.requests.length).toBe(2); + expect(c.released).toEqual(["old"]); + expect(late).toEqual([]); + c.requests[1]!("fresh"); + c.subscriptions.receive(update("old", 130)); + c.subscriptions.receive(update("fresh", 131)); + expect(first).toEqual(["old", "fresh"]); + expect(late).toEqual(["fresh", "update"]); + await handle.release(); +}); diff --git a/packages/client/src/connection/owned.ts b/packages/client/src/connection/owned.ts new file mode 100644 index 0000000000..8aa3c94b17 --- /dev/null +++ b/packages/client/src/connection/owned.ts @@ -0,0 +1,235 @@ +import type { SessionOutboundMessage } from "@getpaseo/protocol/messages"; + +export type SubscriptionSnapshot = T & { subscriptionId: string }; + +export interface SubscriptionObserver { + snapshot(snapshot: T): void; + update(message: SessionOutboundMessage): void; + error?(error: unknown): void; +} + +/** A stable local lifetime. The host ID changes when the transport reconnects. */ +export interface OwnedSubscription { + readonly subscriptionId: string | null; + readonly ready: Promise>; + subscribe(observer: SubscriptionObserver>): () => void; + release(): Promise; +} + +interface Registration { + start(): void; + disconnected(): void; + receive(message: SessionOutboundMessage): void; + release(): Promise; +} + +export interface OwnedSubscriptionsHost { + release(subscriptionId: string): Promise; + failed(error: unknown): void; +} + +const MAX_BOOTSTRAP_UPDATES = 128; + +/** Transport routing and lifetime only; query semantics remain with each domain. */ +export class OwnedSubscriptions { + private readonly registrations = new Set(); + private readonly routes = new Map(); + private generation = 0; + private connected = false; + + constructor(private readonly host: OwnedSubscriptionsHost) {} + + observe( + request: (accept: (snapshot: T) => void) => Promise, + signal?: AbortSignal, + requestFailed?: () => void, + ): OwnedSubscription { + let id: string | null = null; + let snapshot: SubscriptionSnapshot | undefined; + let pending: Promise | null = null; + let teardown: Promise | null = null; + let disposed = false; + let overflow = false; + const buffered: SessionOutboundMessage[] = []; + const observers = new Set>>(); + let resolve!: (snapshot: SubscriptionSnapshot) => void; + let reject!: (error: unknown) => void; + const ready = new Promise>((accept, fail) => { + resolve = accept; + reject = fail; + }); + void ready.catch(() => {}); + + const deliver = ( + observer: SubscriptionObserver>, + message?: SessionOutboundMessage, + ) => { + try { + if (message) observer.update(message); + else if (snapshot) observer.snapshot(snapshot); + } catch (error) { + this.host.failed(error); + } + }; + const releaseId = async () => { + const released = id; + id = null; + if (!released) return; + this.routes.delete(released); + await this.host.release(released); + }; + const registration: Registration = { + start: () => { + if (disposed || pending || id || !this.connected) return; + const generation = this.generation; + pending = request((value) => { + if (generation !== this.generation) return; + assertSubscriptionSnapshot(value); + id = value.subscriptionId; + snapshot = value; + buffered.length = 0; + overflow = false; + if (disposed) return; + this.routes.set(id, registration); + for (const observer of observers) deliver(observer); + }) + .then(() => { + if (generation === this.generation && !disposed) { + if (!snapshot) throw new Error("The host omitted its subscription snapshot"); + resolve(snapshot); + } + return undefined; + }) + .catch((error) => { + if (generation !== this.generation) return; + reject(error); + for (const observer of observers) { + try { + observer.error?.(error); + } catch (failure) { + this.host.failed(failure); + } + } + void handle.release().catch(this.host.failed); + this.host.failed(error); + requestFailed?.(); + return undefined; + }) + .finally(() => { + pending = null; + if (generation !== this.generation && !disposed) registration.start(); + }); + }, + disconnected: () => { + id = null; + snapshot = undefined; + buffered.length = 0; + overflow = false; + }, + receive: (message) => { + if (disposed || ("payload" in message && Object.is(message.payload, snapshot))) return; + if (buffered.length < MAX_BOOTSTRAP_UPDATES && !overflow) buffered.push(message); + else { + buffered.length = 0; + overflow = true; + } + for (const observer of observers) deliver(observer, message); + }, + release: () => { + if (teardown) return teardown; + disposed = true; + signal?.removeEventListener("abort", abort); + observers.clear(); + buffered.length = 0; + if (id) this.routes.delete(id); + reject(new Error("Subscription released")); + teardown = (async () => { + await pending; + await releaseId(); + this.registrations.delete(registration); + })(); + return teardown; + }, + }; + const abort = () => { + void handle.release().catch(this.host.failed); + }; + const handle: OwnedSubscription = { + get subscriptionId() { + return id; + }, + ready, + release: registration.release, + subscribe: (observer) => { + if (disposed) throw new Error("Subscription released"); + observers.add(observer); + if (overflow) { + overflow = false; + snapshot = undefined; + void releaseId() + .then(() => { + registration.start(); + return undefined; + }) + .catch(this.host.failed); + } else { + deliver(observer); + for (const message of buffered) deliver(observer, message); + } + return () => { + observers.delete(observer); + }; + }, + }; + this.registrations.add(registration); + if (signal?.aborted) abort(); + else { + signal?.addEventListener("abort", abort, { once: true }); + registration.start(); + } + return handle; + } + + owns(message: SessionOutboundMessage): boolean { + const payload = "payload" in message ? message.payload : message; + return ( + "subscriptionId" in payload && + typeof payload.subscriptionId === "string" && + this.routes.has(payload.subscriptionId) + ); + } + + receive(message: SessionOutboundMessage): void { + const payload = "payload" in message ? message.payload : message; + if ("subscriptionId" in payload && typeof payload.subscriptionId === "string") + this.routes.get(payload.subscriptionId)?.receive(message); + } + + restore(): void { + this.connected = true; + for (const registration of this.registrations) registration.start(); + } + + disconnected(): void { + this.connected = false; + this.generation++; + this.routes.clear(); + for (const registration of this.registrations) registration.disconnected(); + } + + async close(): Promise { + await Promise.all([...this.registrations].map((registration) => registration.release())); + } +} + +function assertSubscriptionSnapshot(value: unknown): asserts value is { subscriptionId: string } { + if ( + value && + typeof value === "object" && + "subscriptionId" in value && + typeof value.subscriptionId === "string" && + value.subscriptionId + ) + return; + throw new Error("The host omitted its subscription ID"); +} diff --git a/packages/client/src/daemon-client-transport.test.ts b/packages/client/src/daemon-client-transport.test.ts index 378275c673..6f80717dd1 100644 --- a/packages/client/src/daemon-client-transport.test.ts +++ b/packages/client/src/daemon-client-transport.test.ts @@ -21,7 +21,7 @@ describe("daemon-client transport helpers", () => { createClientChannelMock.mockReset(); createClientChannelMock.mockRejectedValueOnce(new Error("handshake failed")); - let openHandler: (() => void) | null = null; + const connection: { open: (() => void) | null } = { open: null }; const close = vi.fn(); createEncryptedTransport( @@ -29,10 +29,10 @@ describe("daemon-client transport helpers", () => { send: vi.fn(), close, onOpen: (handler) => { - openHandler = handler; + connection.open = handler; return () => { - if (openHandler === handler) { - openHandler = null; + if (connection.open === handler) { + connection.open = null; } }; }, @@ -44,8 +44,8 @@ describe("daemon-client transport helpers", () => { { warn: vi.fn() }, ); - expect(openHandler).not.toBeNull(); - openHandler?.(); + expect(connection.open).not.toBeNull(); + connection.open?.(); await vi.waitFor(() => { expect(close).toHaveBeenCalledWith(4001, "E2EE handshake failed"); diff --git a/packages/client/src/daemon-client.test.ts b/packages/client/src/daemon-client.test.ts index d03fd7c754..72d78b4fe0 100644 --- a/packages/client/src/daemon-client.test.ts +++ b/packages/client/src/daemon-client.test.ts @@ -14,9 +14,6 @@ import { FileTransferOpcode, } from "@getpaseo/protocol/binary-frames/index"; import { - asUint8Array, - decodeTerminalResizePayload, - decodeTerminalStreamFrame, encodeTerminalSnapshotPayload, encodeTerminalStreamFrame, TerminalStreamOpcode, @@ -79,7 +76,7 @@ function createMockTransport() { }, close: () => {}, onMessage: (handler) => { - onMessage = handler; + onMessage = (data) => handler(data, typeof data !== "string"); return () => {}; }, onOpen: (handler) => { @@ -115,7 +112,7 @@ function createMockTransport() { serverId: `srv_test_${serverInfoOrdinal++}`, hostname: null, version: null, - ...(options?.features ? { features: options.features } : {}), + features: options?.features ?? { ownedSubscriptions: true }, }, }, }), @@ -255,7 +252,7 @@ test("does not infer browser automation capabilities from Electron runtime", asy const hello = z .object({ type: z.literal("hello"), - capabilities: z.record(z.unknown()), + capabilities: z.record(z.string(), z.unknown()), }) .parse(JSON.parse(assertStr(mock.sent[0]))); expect(hello.capabilities[CLIENT_CAPS.browserHost]).toBeUndefined(); @@ -285,7 +282,7 @@ test("advertises consumer-provided browser automation capabilities", async () => const hello = z .object({ type: z.literal("hello"), - capabilities: z.record(z.unknown()), + capabilities: z.record(z.string(), z.unknown()), }) .parse(JSON.parse(assertStr(mock.sent[0]))); expect(hello.capabilities[CLIENT_CAPS.browserHost]).toEqual({ @@ -331,57 +328,21 @@ test("Hub management requires daemon support before dispatching requests", async expect(mock.sent).toEqual([]); }); -test("sets the complete viewed timeline subscription only when the daemon supports it", async () => { - const supportedTransport = createMockTransport(); - const supportedClient = new DaemonClient({ - url: "ws://test", - clientId: "timeline_supported", - transportFactory: () => supportedTransport.transport, - reconnect: { enabled: false }, - }); - const legacyTransport = createMockTransport(); - const legacyClient = new DaemonClient({ +test("owned timeline observation requires the host feature", async () => { + const mock = createMockTransport(); + const client = new DaemonClient({ url: "ws://test", clientId: "timeline_legacy", - transportFactory: () => legacyTransport.transport, + transportFactory: () => mock.transport, reconnect: { enabled: false }, }); - clients.push(supportedClient, legacyClient); - - const supportedConnect = supportedClient.connect(); - supportedTransport.triggerOpen({ features: { selectiveAgentTimeline: true } }); - await supportedConnect; - const legacyConnect = legacyClient.connect(); - legacyTransport.triggerOpen(); - await legacyConnect; - - expect(supportedClient.getLastServerInfoMessage()?.features).toEqual({ - selectiveAgentTimeline: true, - }); - - const setPromise = supportedClient.setAgentTimelineSubscription(["agent-b", "agent-a"]); - await Promise.resolve(); - const request = parseSentFrame(supportedTransport.sent[0]); - supportedTransport.triggerMessage( - wrapSessionMessage({ - type: "agent.timeline.set_subscription.response", - payload: { - requestId: request.requestId, - agentIds: ["agent-a", "agent-b"], - }, - }), - ); - await setPromise; - await legacyClient.setAgentTimelineSubscription(["agent-a"]); - - expect({ request, legacyFrames: legacyTransport.sent }).toEqual({ - request: { - type: "agent.timeline.set_subscription.request", - requestId: expect.any(String), - agentIds: ["agent-a", "agent-b"], - }, - legacyFrames: [], - }); + clients.push(client); + const connecting = client.connect(); + mock.triggerOpen({ features: {} }); + await connecting; + const observation = client.observeTimeline(["agent"]); + await expect(observation.ready).rejects.toThrow("Update the host"); + expect(mock.sent).toEqual([]); }); test("normalizes legacy and dedicated agent attention notifications", async () => { @@ -468,7 +429,7 @@ class FakeDaemon { this.closeEvents.push({ code, reason }); }, onMessage: (handler) => { - this.onMessage = handler; + this.onMessage = (data) => handler(data, typeof data !== "string"); return () => {}; }, onOpen: (handler) => { @@ -760,6 +721,7 @@ test("advertises client capabilities in hello", async () => { timeline_replacement_invalidation: true, provider_snapshot_references: true, explicit_event_subscriptions: true, + owned_subscriptions: true, compact_provider_snapshots: true, custom_mode_icons: true, project_updates: true, @@ -797,7 +759,7 @@ test("allows callers to disable default client capabilities", async () => { const hello = z .object({ type: z.literal("hello"), - capabilities: z.record(z.unknown()), + capabilities: z.record(z.string(), z.unknown()), }) .parse(JSON.parse(assertStr(mock.sent[0]))); expect(hello.capabilities[CLIENT_CAPS.projectUpdates]).toBe(false); @@ -3642,16 +3604,14 @@ test("subscribes to checkout diff updates via RPC handshake", async () => { mock.triggerOpen(); await connectPromise; - const promise = client.subscribeCheckoutDiff( - "/tmp/project", - { mode: "uncommitted" }, - { subscriptionId: "checkout-sub-1" }, - ); + const subscription = client.observeCheckoutDiff("/tmp/project", { mode: "uncommitted" }); + const promise = subscription.ready; + await vi.waitFor(() => expect(mock.sent).toHaveLength(1)); expect(mock.sent).toHaveLength(1); const request = parseSentFrame(mock.sent[0]); expect(request.type).toBe("subscribe_checkout_diff_request"); - expect(request.subscriptionId).toBe("checkout-sub-1"); + expect(request.subscriptionId).toBeUndefined(); expect(request.cwd).toBe("/tmp/project"); expect(request.compare).toEqual({ mode: "uncommitted" }); @@ -3680,7 +3640,7 @@ test("subscribes to checkout diff updates via RPC handshake", async () => { }); }); -test("getCheckoutDiff uses one-shot subscription protocol", async () => { +test("getCheckoutDiff reads a snapshot without creating a subscription", async () => { const logger = createMockLogger(); const mock = createMockTransport(); @@ -3701,7 +3661,7 @@ test("getCheckoutDiff uses one-shot subscription protocol", async () => { expect(mock.sent).toHaveLength(1); const subscribeRequest = parseSentFrame(mock.sent[0]); - expect(subscribeRequest.type).toBe("subscribe_checkout_diff_request"); + expect(subscribeRequest.type).toBe("checkout.diff.get.request"); expect(subscribeRequest.cwd).toBe("/tmp/project"); expect(subscribeRequest.compare).toEqual({ mode: "base", @@ -3712,9 +3672,8 @@ test("getCheckoutDiff uses one-shot subscription protocol", async () => { JSON.stringify({ type: "session", message: { - type: "subscribe_checkout_diff_response", + type: "checkout.diff.get.response", payload: { - subscriptionId: subscribeRequest.subscriptionId, cwd: "/tmp/project", files: [], error: null, @@ -3731,10 +3690,7 @@ test("getCheckoutDiff uses one-shot subscription protocol", async () => { requestId: subscribeRequest.requestId, }); - expect(mock.sent).toHaveLength(2); - const unsubscribeRequest = parseSentFrame(mock.sent[1]); - expect(unsubscribeRequest.type).toBe("unsubscribe_checkout_diff_request"); - expect(unsubscribeRequest.subscriptionId).toBe(subscribeRequest.subscriptionId); + expect(mock.sent).toHaveLength(1); }); test("requests branch suggestions via RPC", async () => { @@ -4279,7 +4235,7 @@ test("renames a branch via RPC", async () => { }); expect(mock.sent).toHaveLength(1); - const request = JSON.parse(mock.sent[0]) as { + const request = JSON.parse(assertStr(mock.sent[0])) as { type: "session"; message: { type: "checkout.rename_branch.request"; @@ -4381,29 +4337,52 @@ test("resubscribes checkout diff streams after reconnect", async () => { }); clients.push(client); - const internal = client as unknown as { - checkoutDiffSubscriptions: Map< - string, - { cwd: string; compare: { mode: "uncommitted" | "base"; baseRef?: string } } - >; - }; - internal.checkoutDiffSubscriptions.set("checkout-sub-1", { - cwd: "/tmp/project", - compare: { mode: "base", baseRef: "main" }, - }); - const connectPromise = client.connect(); mock.triggerOpen(); await connectPromise; - expect(mock.sent).toHaveLength(1); + const observation = client.observeCheckoutDiff("/tmp/project", { mode: "base", baseRef: "main" }); + await vi.waitFor(() => expect(mock.sent).toHaveLength(1)); + const first = parseSentFrame(mock.sent[0]); + mock.triggerMessage( + wrapSessionMessage({ + type: "subscribe_checkout_diff_response", + payload: { + requestId: first.requestId, + subscriptionId: "server-first", + cwd: "/tmp/project", + files: [], + error: null, + }, + }), + ); + await observation.ready; + mock.triggerClose(); + const reconnect = client.connect(); + mock.triggerOpen(); + await reconnect; + await vi.waitFor(() => expect(mock.sent).toHaveLength(1)); const request = parseSentFrame(mock.sent[0]); - expect(request.type).toBe("subscribe_checkout_diff_request"); - expect(request.subscriptionId).toBe("checkout-sub-1"); - expect(request.cwd).toBe("/tmp/project"); - expect(request.compare).toEqual({ mode: "base", baseRef: "main" }); - expect(typeof request.requestId).toBe("string"); - expect(z.string().parse(request.requestId).length).toBeGreaterThan(0); + expect(request).toMatchObject({ + type: "subscribe_checkout_diff_request", + cwd: "/tmp/project", + compare: { mode: "base", baseRef: "main" }, + }); + expect(request.subscriptionId).toBeUndefined(); + expect(request.requestId).not.toBe(first.requestId); + mock.triggerMessage( + wrapSessionMessage({ + type: "subscribe_checkout_diff_response", + payload: { + requestId: request.requestId, + subscriptionId: "server-second", + cwd: "/tmp/project", + files: [], + error: null, + }, + }), + ); + await vi.waitFor(() => expect(observation.subscriptionId).toBe("server-second")); }); test("fetches agents via RPC with filters, sort, and pagination", async () => { @@ -4430,10 +4409,10 @@ test("fetches agents via RPC with filters, sort, and pagination", async () => { { key: "created_at", direction: "desc" }, ], page: { limit: 25, cursor: "cursor-1" }, - subscribe: { subscriptionId: "sub-1" }, + subscribe: {}, }); - expect(mock.sent).toHaveLength(1); + await vi.waitFor(() => expect(mock.sent).toHaveLength(1)); const request = parseSentFrame(mock.sent[0]); expect(request.type).toBe("fetch_agents_request"); expect(request.sort).toEqual([ @@ -4441,7 +4420,7 @@ test("fetches agents via RPC with filters, sort, and pagination", async () => { { key: "created_at", direction: "desc" }, ]); expect(request.page).toEqual({ limit: 25, cursor: "cursor-1" }); - expect(request.subscribe).toEqual({ subscriptionId: "sub-1" }); + expect(request.subscribe).toEqual({}); mock.triggerMessage( JSON.stringify({ @@ -4465,6 +4444,10 @@ test("fetches agents via RPC with filters, sort, and pagination", async () => { await expect(promise).resolves.toEqual({ requestId: request.requestId, subscriptionId: "sub-1", + subscription: expect.objectContaining({ + subscriptionId: "sub-1", + release: expect.any(Function), + }), entries: [], pageInfo: { nextCursor: null, @@ -5310,11 +5293,15 @@ test("emits output events for the active terminal stream", async () => { seen.push(new TextDecoder().decode(event.data)); }); - const subscribePromise = client.subscribeTerminal("term-1", "sub-1"); + const subscribePromise = client.subscribeTerminal("term-1", { requestId: "sub-1" }); + await vi.waitFor(() => + expect(parseSentFrame(mock.sent.at(-1)).type).toBe("subscribe_terminal_request"), + ); mock.triggerMessage( wrapSessionMessage({ type: "subscribe_terminal_response", payload: { + subscriptionId: "server-" + String(parseSentFrame(mock.sent.at(-1)).requestId), terminalId: "term-1", slot: 11, error: null, @@ -5362,11 +5349,15 @@ test("emits snapshot events for the subscribed terminal stream", async () => { snapshots.push(event.state); }); - const subscribePromise = client.subscribeTerminal("term-1", "sub-2"); + const subscribePromise = client.subscribeTerminal("term-1", { requestId: "sub-2" }); + await vi.waitFor(() => + expect(parseSentFrame(mock.sent.at(-1)).type).toBe("subscribe_terminal_request"), + ); mock.triggerMessage( wrapSessionMessage({ type: "subscribe_terminal_response", payload: { + subscriptionId: "server-" + String(parseSentFrame(mock.sent.at(-1)).requestId), terminalId: "term-1", slot: 12, error: null, @@ -5394,7 +5385,7 @@ test("emits snapshot events for the subscribed terminal stream", async () => { expect(snapshots).toEqual([state]); }); -test("sends input and resize frames for the subscribed terminal slot", async () => { +test("sends explicit terminal input and resize commands", async () => { const logger = createMockLogger(); const mock = createMockTransport(); @@ -5411,11 +5402,15 @@ test("sends input and resize frames for the subscribed terminal slot", async () mock.triggerOpen(); await connectPromise; - const subscribePromise = client.subscribeTerminal("term-1", "sub-3"); + const subscribePromise = client.subscribeTerminal("term-1", { requestId: "sub-3" }); + await vi.waitFor(() => + expect(parseSentFrame(mock.sent.at(-1)).type).toBe("subscribe_terminal_request"), + ); mock.triggerMessage( wrapSessionMessage({ type: "subscribe_terminal_response", payload: { + subscriptionId: "server-" + String(parseSentFrame(mock.sent.at(-1)).requestId), terminalId: "term-1", slot: 13, error: null, @@ -5437,19 +5432,18 @@ test("sends input and resize frames for the subscribed terminal slot", async () intent: "update", }); - const inputFrame = decodeTerminalStreamFrame(asUint8Array(mock.sent[0])!); - const resizeFrame = decodeTerminalStreamFrame(asUint8Array(mock.sent[1])!); - - expect(inputFrame?.opcode).toBe(TerminalStreamOpcode.Input); - expect(inputFrame?.slot).toBe(13); - expect(new TextDecoder().decode(inputFrame?.payload ?? new Uint8Array())).toBe("echo hello\r"); - expect(resizeFrame?.opcode).toBe(TerminalStreamOpcode.Resize); - expect(resizeFrame?.slot).toBe(13); - expect(decodeTerminalResizePayload(resizeFrame?.payload ?? new Uint8Array())).toEqual({ - rows: 24, - cols: 80, - intent: "update", - }); + expect(mock.sent.map(parseSentFrame)).toEqual([ + { + type: "terminal_input", + terminalId: "term-1", + message: { type: "input", data: "echo hello\r" }, + }, + { + type: "terminal_input", + terminalId: "term-1", + message: { type: "resize", rows: 24, cols: 80, intent: "update" }, + }, + ]); }); test("routes concurrent terminal stream frames by slot", async () => { @@ -5477,11 +5471,15 @@ test("routes concurrent terminal stream frames by slot", async () => { seen.push(`${event.terminalId}:${new TextDecoder().decode(event.data)}`); }); - const subscribeFirstPromise = client.subscribeTerminal("term-1", "sub-multi-1"); + const subscribeFirstPromise = client.subscribeTerminal("term-1", { requestId: "sub-multi-1" }); + await vi.waitFor(() => + expect(parseSentFrame(mock.sent.at(-1)).type).toBe("subscribe_terminal_request"), + ); mock.triggerMessage( wrapSessionMessage({ type: "subscribe_terminal_response", payload: { + subscriptionId: "server-" + String(parseSentFrame(mock.sent.at(-1)).requestId), terminalId: "term-1", slot: 21, error: null, @@ -5491,11 +5489,15 @@ test("routes concurrent terminal stream frames by slot", async () => { ); await subscribeFirstPromise; - const subscribeSecondPromise = client.subscribeTerminal("term-2", "sub-multi-2"); + const subscribeSecondPromise = client.subscribeTerminal("term-2", { requestId: "sub-multi-2" }); + await vi.waitFor(() => + expect(parseSentFrame(mock.sent.at(-1)).type).toBe("subscribe_terminal_request"), + ); mock.triggerMessage( wrapSessionMessage({ type: "subscribe_terminal_response", payload: { + subscriptionId: "server-" + String(parseSentFrame(mock.sent.at(-1)).requestId), terminalId: "term-2", slot: 22, error: null, @@ -5531,14 +5533,19 @@ test("routes concurrent terminal stream frames by slot", async () => { cols: 20, }); - const inputFrame = decodeTerminalStreamFrame(asUint8Array(mock.sent[0])!); - const resizeFrame = decodeTerminalStreamFrame(asUint8Array(mock.sent[1])!); - expect(seen).toEqual(["term-2:beta", "term-1:alpha"]); - expect(inputFrame?.opcode).toBe(TerminalStreamOpcode.Input); - expect(inputFrame?.slot).toBe(22); - expect(resizeFrame?.opcode).toBe(TerminalStreamOpcode.Resize); - expect(resizeFrame?.slot).toBe(21); + expect(mock.sent.map(parseSentFrame)).toEqual([ + { + type: "terminal_input", + terminalId: "term-2", + message: { type: "input", data: "echo beta\r" }, + }, + { + type: "terminal_input", + terminalId: "term-1", + message: { type: "resize", rows: 10, cols: 20 }, + }, + ]); }); test("ignores terminal stream frames after terminal_stream_exit", async () => { @@ -5566,11 +5573,15 @@ test("ignores terminal stream frames after terminal_stream_exit", async () => { seen.push(new TextDecoder().decode(event.data)); }); - const subscribePromise = client.subscribeTerminal("term-1", "sub-4"); + const subscribePromise = client.subscribeTerminal("term-1", { requestId: "sub-4" }); + await vi.waitFor(() => + expect(parseSentFrame(mock.sent.at(-1)).type).toBe("subscribe_terminal_request"), + ); mock.triggerMessage( wrapSessionMessage({ type: "subscribe_terminal_response", payload: { + subscriptionId: "server-" + String(parseSentFrame(mock.sent.at(-1)).requestId), terminalId: "term-1", slot: 14, error: null, @@ -5593,6 +5604,7 @@ test("ignores terminal stream frames after terminal_stream_exit", async () => { wrapSessionMessage({ type: "terminal_stream_exit", payload: { + subscriptionId: "server-sub-4", terminalId: "term-1", }, }), @@ -5607,6 +5619,21 @@ test("ignores terminal stream frames after terminal_stream_exit", async () => { ); expect(seen).toEqual(["before-exit"]); + await vi.waitFor(() => + expect(parseSentFrame(mock.sent.at(-1)).type).toBe("subscription.release.request"), + ); + const release = parseSentFrame(mock.sent.at(-1)); + mock.triggerMessage( + wrapSessionMessage({ + type: "subscription.release.response", + payload: { + requestId: release.requestId, + subscriptionId: "server-sub-4", + released: true, + }, + }), + ); + await Promise.resolve(); unsubscribe(); }); @@ -5952,24 +5979,41 @@ test("sends subscribe/unsubscribe terminals messages", async () => { mock.triggerOpen(); await connectPromise; - client.subscribeTerminals({ cwd: "/tmp/project" }); - client.unsubscribeTerminals({ cwd: "/tmp/project" }); - - expect(mock.sent).toHaveLength(2); - expect(JSON.parse(assertStr(mock.sent[0]))).toEqual({ - type: "session", - message: { - type: "subscribe_terminals_request", - cwd: "/tmp/project", - }, - }); - expect(JSON.parse(assertStr(mock.sent[1]))).toEqual({ - type: "session", - message: { - type: "unsubscribe_terminals_request", - cwd: "/tmp/project", - }, + const observation = client.observeTerminals({ cwd: "/tmp/project" }); + await vi.waitFor(() => expect(mock.sent).toHaveLength(1)); + const request = parseSentFrame(mock.sent[0]); + expect(request).toMatchObject({ type: "subscribe_terminals_request", cwd: "/tmp/project" }); + expect(request.subscriptionId).toBeUndefined(); + mock.triggerMessage( + wrapSessionMessage({ + type: "terminals_changed", + payload: { + requestId: request.requestId, + subscriptionId: "server-terminals", + cwd: "/tmp/project", + terminals: [], + }, + }), + ); + await observation.ready; + const release = observation.release(); + await vi.waitFor(() => expect(mock.sent).toHaveLength(2)); + const requestRelease = parseSentFrame(mock.sent[1]); + expect(requestRelease).toMatchObject({ + type: "subscription.release.request", + subscriptionId: "server-terminals", }); + mock.triggerMessage( + wrapSessionMessage({ + type: "subscription.release.response", + payload: { + requestId: requestRelease.requestId, + subscriptionId: "server-terminals", + released: true, + }, + }), + ); + await release; }); test("dispatches terminals_changed events to typed listeners", async () => { diff --git a/packages/client/src/daemon-client.ts b/packages/client/src/daemon-client.ts index e22534da35..9529b3179e 100644 --- a/packages/client/src/daemon-client.ts +++ b/packages/client/src/daemon-client.ts @@ -1,12 +1,14 @@ +import { subscribeTimeline, type TimelineMessage } from "./timeline-subscription/index.js"; import { ProviderSnapshotUpdates } from "./provider-snapshots/index.js"; -import type { SessionEventSubscription } from "@getpaseo/protocol/messages"; import { - ConnectionSubscriptions, + OwnedSubscriptions, + type OwnedSubscription, DEFAULT_CLIENT_CAPABILITIES, type TimelineSubscription, } from "./connection/index.js"; import type { z } from "zod"; -import { CLIENT_CAPS, type ClientCapability } from "@getpaseo/protocol/client-capabilities"; +import type { SessionEventSubscription } from "@getpaseo/protocol/messages"; +import type { ClientCapability } from "@getpaseo/protocol/client-capabilities"; import type { AgentAttentionNotificationPayload } from "@getpaseo/protocol/agent-attention-notification"; import { parsePluginSourceReference } from "@getpaseo/protocol/plugin-source-reference"; import { @@ -133,7 +135,6 @@ import type { MutableDaemonConfigPatch, } from "@getpaseo/protocol/messages"; import { isRelayClientWebSocketUrl } from "@getpaseo/protocol/daemon-endpoints"; -import { terminalSubscriptionKey } from "@getpaseo/protocol/terminal-subscription-key"; import { asUint8Array, decodeFileTransferFrame, @@ -652,6 +653,7 @@ type FetchAgentsPayload = Extract< >["payload"]; type FetchAgentsRequest = Extract; export type FetchAgentsOptions = Omit & { + signal?: AbortSignal; requestId?: string; timeout?: number; }; @@ -691,6 +693,7 @@ type FetchWorkspacesPayload = Extract< >["payload"]; type FetchWorkspacesRequest = Extract; export type FetchWorkspacesOptions = Omit & { + signal?: AbortSignal; requestId?: string; }; export type FetchWorkspacesEntry = FetchWorkspacesPayload["entries"][number]; @@ -871,13 +874,15 @@ type SetDaemonConfigResponse = Extract< >; type CorrelatedResponseMessage = | Extract + | Extract | GetDaemonConfigResponse | SetDaemonConfigResponse; type CorrelatedResponseType = CorrelatedResponseMessage["type"]; -type CorrelatedResponsePayload = Extract< - CorrelatedResponseMessage, - { type: TType } ->["payload"]; +type CorrelatedResponsePayloads = { + [Message in CorrelatedResponseMessage as Message["type"]]: Message["payload"]; +}; +type CorrelatedResponsePayload = + CorrelatedResponsePayloads[TType]; class DaemonRpcError extends Error { readonly requestId: string; @@ -1074,15 +1079,26 @@ interface PingProbe { export class DaemonClient { private readonly providerSnapshotUpdates = new ProviderSnapshotUpdates({ + active: (message) => this.owned.owns(message), fetch: (cwd) => this.requestProvidersSnapshot({ cwd }), emit: (message) => this.deliverSessionMessage(message), failed: (error) => this.logger.error({ err: error }, "Failed to resolve provider snapshot"), }); - private readonly subscriptions = new ConnectionSubscriptions({ - timelines: (agentIds) => (this.isConnected ? this.sendTimelineSubscription(agentIds) : null), - events: (events) => this.sendEventSubscription(events), - failed: (error) => - this.logger.error({ err: error }, "Failed to update connection subscriptions"), + private readonly owned = new OwnedSubscriptions({ + release: async (subscriptionId) => { + if (!this.isConnected) return; + try { + await this.sendCorrelatedSessionRequest({ + message: { type: "subscription.release.request", subscriptionId }, + responseType: "subscription.release.response", + }); + } catch (error) { + this.disposeTransport(1001, "Subscription release failed"); + this.scheduleReconnect({ reason: "Subscription release failed" }); + throw error; + } + }, + failed: (error) => this.logger.error({ err: error }, "Subscription failed"), }); private transport: DaemonTransport | null = null; private transportCleanup: Array<() => void> = []; @@ -1105,18 +1121,6 @@ export class DaemonClient { private connectReject: ((error: Error) => void) | null = null; private lastErrorValue: string | null = null; private connectionState: ConnectionState = { status: "idle" }; - private checkoutDiffSubscriptions = new Map< - string, - { - cwd: string; - compare: { mode: "uncommitted" | "base"; baseRef?: string; ignoreWhitespace?: boolean }; - } - >(); - private terminalDirectorySubscriptions = new Map(); - private fileSubscriptions = new Map< - string, - { cwd: string; path: string; onUpdate: (version: FileVersion) => void } - >(); private readonly terminalStreams = new TerminalStreamRouter(); private pendingBinaryFileReads = new Map(); private activeBinaryFileTransfers = new Map(); @@ -1346,7 +1350,9 @@ export class DaemonClient { reasonCode: "transport_error", }); }), - transport.onMessage((data) => this.handleTransportMessage(data)), + transport.onMessage((data) => { + if (this.transport === transport) this.handleTransportMessage(data); + }), ]; } catch (error) { this.resetConnectTimeout(); @@ -1384,7 +1390,6 @@ export class DaemonClient { return; } this.shouldReconnect = false; - this.subscriptions.close(); this.connectPromise = null; this.connectResolve = null; this.connectReject = null; @@ -1396,10 +1401,10 @@ export class DaemonClient { this.disposeTransport(1000, "Client closed"); this.providerSnapshotUpdates.clear(); this.clearWaiters(new Error("Daemon client closed")); + await this.owned.close(); this.rejectPendingSendQueue(new Error("Daemon client closed")); this.rejectPingProbe(new Error("Daemon client closed")); this.terminalStreams.clearSlots(); - this.fileSubscriptions.clear(); this.lastServerInfoMessage = null; if (this.runtimeMetricsInterval) { clearInterval(this.runtimeMetricsInterval); @@ -1471,10 +1476,8 @@ export class DaemonClient { subscribe(handler: DaemonEventHandler): () => void { this.eventListeners.add(handler); - this.updateEventSubscriptions(); return () => { this.eventListeners.delete(handler); - this.updateEventSubscriptions(); }; } @@ -1505,7 +1508,6 @@ export class DaemonClient { this.messageHandlers.set(type, new Set()); } this.messageHandlers.get(type)!.add(handler); - this.updateEventSubscriptions(); return () => { const handlers = this.messageHandlers.get(type); @@ -1516,7 +1518,6 @@ export class DaemonClient { if (handlers.size === 0) { this.messageHandlers.delete(type); } - this.updateEventSubscriptions(); }; } @@ -2082,7 +2083,122 @@ export class DaemonClient { // Agent RPCs (requestId-correlated) // ============================================================================ - async fetchAgents(options?: FetchAgentsOptions): Promise { + private requireOwnedSubscriptions(): void { + if (this.lastServerInfoMessage?.features?.ownedSubscriptions !== true) + throw new Error("Update the host to use independent subscriptions."); + } + + private observe( + responseType: T, + message: { type: SessionInboundMessage["type"] } & Record, + options?: { requestId?: string; timeout?: number; signal?: AbortSignal }, + ): OwnedSubscription> { + let resetSource = false; + return this.owned.observe>( + async (accept) => { + resetSource = false; + this.requireOwnedSubscriptions(); + const requestId = this.createRequestId(options?.requestId); + const request = SessionInboundMessageSchema.parse({ ...message, requestId }); + try { + return await this.sendCorrelatedRequest>({ + message: request, + responseType, + requestId, + timeout: options?.timeout, + options: { skipQueue: true }, + selectPayload: (payload): CorrelatedResponsePayload | null => { + if ("error" in payload && typeof payload.error === "string") + throw new DaemonRpcError({ + requestId: payload.requestId, + requestType: message.type, + error: payload.error, + }); + accept(payload); + return payload; + }, + }); + } catch (error) { + resetSource = !(error instanceof DaemonRpcError) && this.isConnected; + throw error; + } + }, + options?.signal, + () => { + // Reject and release this handle before closing a source with an unknown bootstrap outcome. + // Closing first would classify the failure as a reconnect and replay the failed request. + if (resetSource) { + this.disposeTransport(1001, "Subscription request failed"); + this.scheduleReconnect({ reason: "Subscription request failed" }); + } + }, + ); + } + + registerBrowserHost( + registration: Omit< + Extract, + "type" | "requestId" + >, + options?: { signal?: AbortSignal }, + ): OwnedSubscription> { + return this.observe( + "browser.host.register.response", + { type: "browser.host.register.request", ...registration }, + options, + ); + } + + observeEvents( + events: SessionEventSubscription[], + options?: { signal?: AbortSignal; notifications?: boolean }, + ): OwnedSubscription> { + return this.observe( + "session.events.set_subscription.response", + { + type: "session.events.set_subscription.request", + events, + notifications: options?.notifications, + }, + options, + ); + } + + observeAgents( + options: Omit = {}, + ): OwnedSubscription { + const { signal, requestId, timeout, ...query } = options; + return this.observe( + "fetch_agents_response", + { ...query, type: "fetch_agents_request", subscribe: {} }, + { signal, requestId, timeout }, + ); + } + + observeWorkspaces( + options: Omit = {}, + ): OwnedSubscription { + const { signal, requestId, ...query } = options; + return this.observe( + "fetch_workspaces_response", + { ...query, type: "fetch_workspaces_request", subscribe: {} }, + { signal, requestId }, + ); + } + + fetchAgents( + options: FetchAgentsOptions & { subscribe: {} }, + ): Promise }>; + fetchAgents(options?: FetchAgentsOptions): Promise; + async fetchAgents( + options?: FetchAgentsOptions, + ): Promise }> { + if (options?.subscribe) { + if (options.subscribe.subscriptionId !== undefined) + throw new Error("Subscription IDs are assigned by the host"); + const subscription = this.observeAgents(options); + return { ...(await subscription.ready), subscription }; + } const resolvedRequestId = this.createRequestId(options?.requestId); const message = SessionInboundMessageSchema.parse({ type: "fetch_agents_request", @@ -2166,7 +2282,21 @@ export class DaemonClient { }); } - async fetchWorkspaces(options?: FetchWorkspacesOptions): Promise { + fetchWorkspaces( + options: FetchWorkspacesOptions & { subscribe: {} }, + ): Promise }>; + fetchWorkspaces(options?: FetchWorkspacesOptions): Promise; + async fetchWorkspaces( + options?: FetchWorkspacesOptions, + ): Promise< + FetchWorkspacesPayload & { subscription?: OwnedSubscription } + > { + if (options?.subscribe) { + if (options.subscribe.subscriptionId !== undefined) + throw new Error("Subscription IDs are assigned by the host"); + const subscription = this.observeWorkspaces(options); + return { ...(await subscription.ready), subscription }; + } const resolvedRequestId = this.createRequestId(options?.requestId); const message = SessionInboundMessageSchema.parse({ type: "fetch_workspaces_request", @@ -2193,21 +2323,31 @@ export class DaemonClient { }); } - listWorkspaceLabels(options: { - subscriptionId: string; - sync?: { generation: string; afterSeq: number }; - requestId?: string; - }): Promise { + listWorkspaceLabels( + options: { + sync?: { generation: string; afterSeq: number }; + requestId?: string; + } = {}, + ): Promise { return this.sendNamespacedCorrelatedSessionRequest({ requestId: options.requestId, message: { type: "workspace.label.list.request", - subscribe: { subscriptionId: options.subscriptionId }, ...(options.sync ? { sync: options.sync } : {}), }, }); } + observeWorkspaceLabels(options?: { + signal?: AbortSignal; + }): OwnedSubscription { + return this.observe( + "workspace.label.list.response", + { type: "workspace.label.list.request", subscribe: {} }, + options, + ); + } + setWorkspaceLabel(options: { workspaceId: string; label: Extract< @@ -2495,53 +2635,6 @@ export class DaemonClient { return { agent: payload.agent, project: payload.project ?? null }; } - private resubscribeCheckoutDiffSubscriptions(): void { - if (this.checkoutDiffSubscriptions.size === 0) { - return; - } - for (const [subscriptionId, subscription] of this.checkoutDiffSubscriptions) { - const message = SessionInboundMessageSchema.parse({ - type: "subscribe_checkout_diff_request", - subscriptionId, - cwd: subscription.cwd, - compare: subscription.compare, - requestId: this.createRequestId(), - }); - this.sendSessionMessage(message); - } - } - - private resubscribeTerminalDirectorySubscriptions(): void { - if (this.terminalDirectorySubscriptions.size === 0) { - return; - } - for (const subscription of this.terminalDirectorySubscriptions.values()) { - this.sendSessionMessage({ - type: "subscribe_terminals_request", - cwd: subscription.cwd, - ...(subscription.workspaceId !== undefined - ? { workspaceId: subscription.workspaceId } - : {}), - }); - } - } - - private resubscribeFileSubscriptions(): void { - for (const [subscriptionId, subscription] of this.fileSubscriptions) { - void this.sendCorrelatedSessionRequest({ - message: { - type: "fs.file.subscribe.request", - cwd: subscription.cwd, - path: subscription.path, - subscriptionId, - }, - responseType: "fs.file.subscribe.response", - }) - .then((payload) => subscription.onUpdate(payload.initial)) - .catch(() => undefined); - } - } - // ============================================================================ // Agent Lifecycle // ============================================================================ @@ -3054,98 +3147,35 @@ export class DaemonClient { return payload; } - setAgentTimelineSubscription(agentIds: string[]): Promise { - return this.subscriptions.setViewed(agentIds); + observeTimeline( + agentIds: string[], + options?: { signal?: AbortSignal }, + ): OwnedSubscription> { + return this.observe( + "agent.timeline.set_subscription.response", + { type: "agent.timeline.set_subscription.request", agentIds }, + options, + ); } subscribeAgentTimeline( agentId: string, - handler: ( - message: Extract< - SessionOutboundMessage, - { type: "agent_stream" | "agent.timeline.replacement" } - >, - ) => void, + handler: (message: TimelineMessage) => void, ): TimelineSubscription { - const stream = this.on("agent_stream", (message) => { - if (message.payload.agentId === agentId) handler(message); - }); - const replacement = this.on("agent.timeline.replacement", (message) => { - if (message.payload.agentId === agentId) handler(message); - }); - const release = this.subscriptions.observeTimeline(agentId); - return Object.assign( - () => { - stream(); - replacement(); - release(); - }, - { ready: release.ready }, + return subscribeTimeline( + agentId, + this.observeTimeline([agentId]), + () => + this.fetchAgentTimeline(agentId, { + direction: "before", + limit: 100, + projection: "projected", + }), + handler, + (error) => this.logger.error({ err: error }, "Timeline observation failed"), ); } - private updateEventSubscriptions(): void { - const events: SessionEventSubscription[] = [ - "project.update", - "providers_snapshot_update", - "agent_attention_required", - "agent_permission_request", - "agent_permission_resolved", - ]; - if (this.eventListeners.size === 0 && !this.messageHandlers.has("providers_snapshot_update")) { - this.providerSnapshotUpdates.clear(); - } - this.subscriptions.setEvents( - events.filter( - (type) => this.eventListeners.size > 0 || (this.messageHandlers.get(type)?.size ?? 0) > 0, - ), - ); - } - - private async sendEventSubscription(events: SessionEventSubscription[]): Promise { - if (this.connectionState.status !== "connected") return; - // COMPAT(explicitEventSubscriptions): added in v0.8.0, remove legacy broadcast handling after 2027-03-08. - if ( - !this.lastServerInfoMessage?.features?.explicitEventSubscriptions || - this.config.capabilities?.[CLIENT_CAPS.explicitEventSubscriptions] === false - ) - return; - await this.sendCorrelatedSessionRequest({ - message: { type: "session.events.set_subscription.request", events }, - responseType: "session.events.set_subscription.response", - }); - } - - private async sendTimelineSubscription(agentIds: string[]): Promise { - if (this.connectionState.status !== "connected") return; - // COMPAT(selectiveAgentTimeline): added in v0.1.106. Old daemons keep their - // legacy global stream and do not understand this RPC. Remove after - // 2027-01-12 once the supported daemon floor is >= v0.1.106. - if (!this.lastServerInfoMessage?.features?.selectiveAgentTimeline) { - return; - } - - const requestId = this.createRequestId(); - const normalizedAgentIds = [...new Set(agentIds)].sort(); - const message = SessionInboundMessageSchema.parse({ - type: "agent.timeline.set_subscription.request", - agentIds: normalizedAgentIds, - requestId, - }); - - await this.sendRequest({ - requestId, - message, - options: { skipQueue: true }, - select: (response) => { - if (response.type !== "agent.timeline.set_subscription.response") { - return null; - } - return response.payload.requestId === requestId ? response.payload : null; - }, - }); - } - async buildAgentForkContext( agentId: string, options: AgentForkContextOptions = {}, @@ -3850,79 +3880,32 @@ export class DaemonClient { compare: { mode: "uncommitted" | "base"; baseRef?: string; ignoreWhitespace?: boolean }, requestId?: string, ): Promise { - const oneShotSubscriptionId = `oneshot-checkout-diff:${crypto.randomUUID()}`; - try { - const payload = await this.subscribeCheckoutDiff(cwd, compare, { - subscriptionId: oneShotSubscriptionId, - requestId, - }); - return { - cwd: payload.cwd, - files: payload.files, - error: payload.error, - diffTooLarge: payload.diffTooLarge, - requestId: payload.requestId, - }; - } finally { - try { - this.unsubscribeCheckoutDiff(oneShotSubscriptionId); - } catch { - // Ignore disconnect races during one-shot cleanup. - } - } + this.requireOwnedSubscriptions(); + return this.sendCorrelatedSessionRequest({ + message: { + type: "checkout.diff.get.request", + cwd, + compare: this.normalizeCheckoutDiffCompare(compare), + }, + responseType: "checkout.diff.get.response", + requestId, + }); } - async subscribeCheckoutDiff( + observeCheckoutDiff( cwd: string, compare: { mode: "uncommitted" | "base"; baseRef?: string; ignoreWhitespace?: boolean }, - options?: { subscriptionId?: string; requestId?: string }, - ): Promise { - const subscriptionId = options?.subscriptionId ?? crypto.randomUUID(); - const normalizedCompare = this.normalizeCheckoutDiffCompare(compare); - const previousSubscription = this.checkoutDiffSubscriptions.get(subscriptionId) ?? null; - this.checkoutDiffSubscriptions.set(subscriptionId, { - cwd, - compare: normalizedCompare, - }); - - const resolvedRequestId = this.createRequestId(options?.requestId); - const message = SessionInboundMessageSchema.parse({ - type: "subscribe_checkout_diff_request", - subscriptionId, - cwd, - compare: normalizedCompare, - requestId: resolvedRequestId, - }); - - try { - return await this.sendCorrelatedRequest({ - requestId: resolvedRequestId, - message, - responseType: "subscribe_checkout_diff_response", - options: { skipQueue: true }, - selectPayload: (payload) => { - if (payload.subscriptionId !== subscriptionId) { - return null; - } - return payload; - }, - }); - } catch (error) { - if (previousSubscription) { - this.checkoutDiffSubscriptions.set(subscriptionId, previousSubscription); - } else { - this.checkoutDiffSubscriptions.delete(subscriptionId); - } - throw error; - } - } - - unsubscribeCheckoutDiff(subscriptionId: string): void { - this.checkoutDiffSubscriptions.delete(subscriptionId); - this.sendSessionMessage({ - type: "unsubscribe_checkout_diff_request", - subscriptionId, - }); + options?: { requestId?: string; signal?: AbortSignal }, + ): OwnedSubscription { + return this.observe( + "subscribe_checkout_diff_response", + { + type: "subscribe_checkout_diff_request", + cwd, + compare: this.normalizeCheckoutDiffCompare(compare), + }, + options, + ); } async checkoutCommit( @@ -4526,36 +4509,35 @@ export class DaemonClient { } } + observeFile(input: { + cwd: string; + path: string; + signal?: AbortSignal; + }): OwnedSubscription> { + return this.observe( + "fs.file.subscribe.response", + { type: "fs.file.subscribe.request", cwd: input.cwd, path: input.path }, + { signal: input.signal }, + ); + } + async subscribeFile( - input: { cwd: string; path: string }, + input: { cwd: string; path: string; signal?: AbortSignal }, onUpdate: (version: FileVersion) => void, - ): Promise<{ initial: FileVersion; unsubscribe: () => void }> { - const subscriptionId = this.createRequestId(); - this.fileSubscriptions.set(subscriptionId, { ...input, onUpdate }); - try { - const payload = await this.sendCorrelatedSessionRequest({ - message: { - type: "fs.file.subscribe.request", - cwd: input.cwd, - path: input.path, - subscriptionId, - }, - responseType: "fs.file.subscribe.response", - }); - return { - initial: payload.initial, - unsubscribe: () => { - if (!this.fileSubscriptions.delete(subscriptionId)) return; - void this.sendCorrelatedSessionRequest({ - message: { type: "fs.file.unsubscribe.request", subscriptionId }, - responseType: "fs.file.unsubscribe.response", - }).catch(() => undefined); - }, - }; - } catch (error) { - this.fileSubscriptions.delete(subscriptionId); - throw error; - } + ): Promise<{ + initial: FileVersion; + subscription: OwnedSubscription>; + unsubscribe: () => Promise; + }> { + const subscription = this.observeFile(input); + subscription.subscribe({ + snapshot: (snapshot) => onUpdate(snapshot.initial), + update: (message) => { + if (message.type === "fs.file.update") onUpdate(message.payload.version); + }, + }); + const snapshot = await subscription.ready; + return { initial: snapshot.initial, subscription, unsubscribe: subscription.release }; } async writeFile(input: { @@ -5402,33 +5384,17 @@ export class DaemonClient { // Terminals // ============================================================================ - subscribeTerminals(input: { cwd: string; workspaceId?: string }): void { - this.terminalDirectorySubscriptions.set(terminalSubscriptionKey(input.cwd, input.workspaceId), { - cwd: input.cwd, - workspaceId: input.workspaceId, - }); - if (!this.transport || this.connectionState.status !== "connected") { - return; - } - this.sendSessionMessage({ - type: "subscribe_terminals_request", - cwd: input.cwd, - ...(input.workspaceId !== undefined ? { workspaceId: input.workspaceId } : {}), - }); - } - - unsubscribeTerminals(input: { cwd: string; workspaceId?: string }): void { - this.terminalDirectorySubscriptions.delete( - terminalSubscriptionKey(input.cwd, input.workspaceId), + observeTerminals(input: { + cwd: string; + workspaceId?: string; + signal?: AbortSignal; + }): OwnedSubscription> { + const { signal, ...query } = input; + return this.observe( + "terminals_changed", + { type: "subscribe_terminals_request", ...query }, + { signal }, ); - if (!this.transport || this.connectionState.status !== "connected") { - return; - } - this.sendSessionMessage({ - type: "unsubscribe_terminals_request", - cwd: input.cwd, - ...(input.workspaceId !== undefined ? { workspaceId: input.workspaceId } : {}), - }); } async listTerminals( @@ -5495,53 +5461,92 @@ export class DaemonClient { }); } - async subscribeTerminal( + /** + * Snapshot/restore and output arrive in receive. Subscribe to the returned handle + * for terminal_stream_exit: payload.error means observation failure, not PTY exit. + * Either outcome detaches this slot and releases the handle automatically. + */ + observeTerminal( terminalId: string, - optionsOrRequestId?: - | { restore?: SubscribeTerminalRequest["restore"]; requestId?: string } - | string, - ): Promise { - const restore = typeof optionsOrRequestId === "object" ? optionsOrRequestId.restore : undefined; - const requestId = - typeof optionsOrRequestId === "object" ? optionsOrRequestId.requestId : optionsOrRequestId; - const resolvedRequestId = this.createRequestId(requestId); - const message = SessionInboundMessageSchema.parse({ - type: "subscribe_terminal_request", - terminalId, - requestId: resolvedRequestId, - ...(restore ? { restore } : {}), + receive: (event: TerminalStreamEvent) => void, + options?: { + restore?: SubscribeTerminalRequest["restore"]; + signal?: AbortSignal; + requestId?: string; + }, + ): OwnedSubscription { + options?.signal?.throwIfAborted(); + const observation = this.observe( + "subscribe_terminal_response", + { + type: "subscribe_terminal_request", + terminalId, + ...(options?.restore ? { restore: options.restore } : {}), + }, + options, + ); + let detach = () => {}; + let released = false; + const release = () => { + released = true; + detach(); + options?.signal?.removeEventListener("abort", abort); + return observation.release(); + }; + const abort = () => { + void release().catch((error) => this.logger.error({ err: error }, "Terminal release failed")); + }; + options?.signal?.addEventListener("abort", abort, { once: true }); + observation.subscribe({ + snapshot: (payload) => { + detach(); + if (released || options?.signal?.aborted || payload.error !== null) return; + const subscriptionId = payload.subscriptionId; + detach = this.terminalStreams.attach(subscriptionId, terminalId, payload.slot, (event) => { + if (released || observation.subscriptionId !== subscriptionId) return; + try { + receive(event); + } catch (error) { + this.logger.error({ err: error }, "Terminal observer failed"); + } + }); + }, + update: (message) => { + if (message.type === "terminal_stream_exit") { + detach(); + queueMicrotask(abort); + } + }, + error: () => { + detach(); + }, }); - const payload = await this.sendCorrelatedRequest({ - requestId: resolvedRequestId, - message, - responseType: "subscribe_terminal_response", - options: { skipQueue: true }, - }); - if (payload.error === null) { - this.terminalStreams.setSlot(terminalId, payload.slot); - } - return payload; + return { + get subscriptionId() { + return observation.subscriptionId; + }, + ready: observation.ready, + subscribe: observation.subscribe, + release, + }; } - unsubscribeTerminal(terminalId: string): void { - this.terminalStreams.removeTerminal(terminalId); - this.sendSessionMessage({ - type: "unsubscribe_terminal_request", - terminalId, - }); + async subscribeTerminal( + terminalId: string, + options?: { + restore?: SubscribeTerminalRequest["restore"]; + signal?: AbortSignal; + requestId?: string; + }, + ): Promise< + SubscribeTerminalPayload & { subscription: OwnedSubscription } + > { + const subscription = this.observeTerminal(terminalId, () => {}, options); + return { ...(await subscription.ready), subscription }; } sendTerminalInput(terminalId: string, message: TerminalInput["message"]): void { - const frame = this.terminalStreams.encodeInput(terminalId, message); - if (frame) { - this.sendBinaryFrame(frame); - return; - } - this.sendSessionMessage({ - type: "terminal_input", - terminalId, - message, - }); + this.sendSessionMessage({ type: "terminal_input", terminalId, message }); } async killTerminal(terminalId: string, requestId?: string): Promise { @@ -5799,7 +5804,8 @@ export class DaemonClient { } private disposeTransport(code = 1001, reason = "Reconnecting"): void { - this.providerSnapshotUpdates.pause(); + this.owned.disconnected(); + this.providerSnapshotUpdates.clear(); this.stopLivenessHeartbeat(); this.cleanupTransport(); if (this.transport) { @@ -5845,10 +5851,11 @@ export class DaemonClient { rawData instanceof Blob && typeof rawData.arrayBuffer === "function" ) { + const transport = this.transport; void rawData .arrayBuffer() .then((buffer) => { - this.handleTransportMessage(buffer); + if (this.transport === transport) this.handleTransportMessage(buffer); return; }) .catch(() => { @@ -6111,7 +6118,7 @@ export class DaemonClient { this.lastErrorValue = reason.trim(); } - this.providerSnapshotUpdates.pause(); + this.providerSnapshotUpdates.clear(); // Clear all pending waiters and queued sends since the connection was lost // and responses from the previous connection will never arrive. @@ -6217,9 +6224,7 @@ export class DaemonClient { msg.payload.snapshotHash && !msg.payload.compactSnapshot ) { - if (this.messageHandlers.has(msg.type) || this.eventListeners.size > 0) { - this.providerSnapshotUpdates.receive(msg); - } + if (this.owned.owns(msg)) this.providerSnapshotUpdates.receive(msg); return; } this.deliverSessionMessage(msg); @@ -6240,27 +6245,13 @@ export class DaemonClient { this.reconnectAttempt = 0; this.updateConnectionState({ status: "connected" }, { event: "HELLO_SERVER_INFO" }); this.startLivenessHeartbeat(); - this.subscriptions.restore(); - this.providerSnapshotUpdates.resume(); - this.resubscribeCheckoutDiffSubscriptions(); - this.resubscribeTerminalDirectorySubscriptions(); - this.resubscribeFileSubscriptions(); + this.owned.restore(); this.flushPendingSendQueue(); this.resolveConnect(); } } } - if (consumerMessage.type === "terminal_stream_exit") { - this.terminalStreams.removeTerminal(consumerMessage.payload.terminalId); - } - - if (consumerMessage.type === "fs.file.update") { - this.fileSubscriptions - .get(consumerMessage.payload.subscriptionId) - ?.onUpdate(consumerMessage.payload.version); - } - if (this.rawMessageListeners.size > 0) { for (const handler of this.rawMessageListeners) { try { @@ -6290,17 +6281,21 @@ export class DaemonClient { } this.resolveWaiters(consumerMessage); + this.owned.receive(consumerMessage); } private resolveWaiters(msg: SessionOutboundMessage): void { for (const waiter of Array.from(this.waiters)) { - const result = waiter.predicate(msg); - if (result !== null) { + try { + const result = waiter.predicate(msg); + if (result === null) continue; this.waiters.delete(waiter); - if (waiter.timeoutHandle) { - clearTimeout(waiter.timeoutHandle); - } + if (waiter.timeoutHandle) clearTimeout(waiter.timeoutHandle); waiter.resolve(result); + } catch (error) { + this.waiters.delete(waiter); + if (waiter.timeoutHandle) clearTimeout(waiter.timeoutHandle); + waiter.reject(error instanceof Error ? error : new Error(String(error))); } } } diff --git a/packages/client/src/index.test.ts b/packages/client/src/index.test.ts index 7cf75b5806..508eba4c61 100644 --- a/packages/client/src/index.test.ts +++ b/packages/client/src/index.test.ts @@ -1,3 +1,5 @@ +import { execFile } from "node:child_process"; +import { promisify } from "node:util"; import { afterEach, expect, test, vi } from "vitest"; import { createPaseoApi, createPaseoClient } from "./index.js"; import { DaemonClient } from "./daemon-client.js"; @@ -24,6 +26,17 @@ class FakeWebSocket { send(data: string | ArrayBuffer | Uint8Array): void { this.sent.push(data); + const frame = JSON.parse(String(data)); + if (frame.message?.type === "subscription.release.request") + this.message( + sessionMessage({ + type: "subscription.release.response", + payload: { + requestId: frame.message.requestId, + subscriptionId: frame.message.subscriptionId, + }, + }), + ); } close(): void { @@ -86,6 +99,7 @@ async function connectClient( features: Record = { providerUsageList: true, providersSnapshotCwd: true, + ownedSubscriptions: true, }, ): Promise<{ client: PaseoClient; ws: FakeWebSocket }> { vi.stubGlobal("WebSocket", FakeWebSocket); @@ -121,6 +135,27 @@ async function connectClient( return { client, ws }; } +function acknowledgeObservation(ws: FakeWebSocket, subscriptionId: string): void { + const request = parseSentSessionMessage(ws.sent.at(-1)); + ws.message( + sessionMessage({ + type: request.type.replace(/([._])request$/, "$1response"), + payload: { + requestId: request.requestId, + subscriptionId, + entries: [], + pageInfo: { nextCursor: null, prevCursor: null, hasMore: false }, + }, + }), + ); +} + +async function observeAgents(client: PaseoClient, ws: FakeWebSocket): Promise { + const ready = client.agents.list({ subscribe: {} }); + acknowledgeObservation(ws, "agents-sdk"); + return (await ready).subscriptionId!; +} + function createWorkspace(input: Partial = {}): PaseoWorkspace { return { id: "workspace_sdk", @@ -231,6 +266,8 @@ test("createPaseoApi borrows daemon capabilities without exposing connection own expect(Object.keys(paseo).sort()).toEqual([ "agents", "config", + "dispose", + "observeEvents", "projects", "providers", "terminals", @@ -340,6 +377,9 @@ test("project actions list registered projects through the existing RPC", async test("project actions subscribe to existing project updates", async () => { const { client, ws } = await connectClient(); + const observation = client.observeEvents(["project.update"]); + acknowledgeObservation(ws, "projects-sdk"); + await observation.ready; const updates: string[] = []; const unsubscribe = client.projects.subscribe((update) => { updates.push(update.kind === "upsert" ? update.project.projectDisplayName : update.projectId); @@ -349,6 +389,7 @@ test("project actions subscribe to existing project updates", async () => { sessionMessage({ type: "project.update", payload: { + subscriptionId: "projects-sdk", kind: "upsert", project: { projectId: "project_sdk", @@ -370,6 +411,7 @@ test("project actions subscribe to existing project updates", async () => { sessionMessage({ type: "project.update", payload: { + subscriptionId: "projects-sdk", kind: "remove", projectId: "project_removed", generation: "daemon-generation", @@ -385,6 +427,7 @@ test("project actions subscribe to existing project updates", async () => { sessionMessage({ type: "project.update", payload: { + subscriptionId: "projects-sdk", kind: "remove", projectId: "project_after_unsubscribe", }, @@ -522,6 +565,9 @@ test("workspace handles keep identity and refresh snapshots through existing dri await expect(refetchPromise).resolves.toEqual(refreshedWorkspace); expect(workspace.current()).toEqual(refreshedWorkspace); + const directoryReady = client.workspaces.list({ subscribe: {} }); + acknowledgeObservation(ws, "workspaces-sdk"); + await directoryReady; const updates: string[] = []; const unsubscribe = workspace.subscribe((update) => { if (update.kind === "upsert") { @@ -532,10 +578,7 @@ test("workspace handles keep identity and refresh snapshots through existing dri ws.message( sessionMessage({ type: "workspace_update", - payload: { - kind: "upsert", - workspace: pushedWorkspace, - }, + payload: { subscriptionId: "workspaces-sdk", kind: "upsert", workspace: pushedWorkspace }, }), ); expect(updates).toEqual(["sdk pushed"]); @@ -567,6 +610,7 @@ test("workspace handles keep identity and refresh snapshots through existing dri sessionMessage({ type: "workspace_update", payload: { + subscriptionId: "workspaces-sdk", kind: "upsert", workspace: createWorkspace({ name: "sdk after unsubscribe" }), }, @@ -681,6 +725,7 @@ test("agent handles delegate create, send, timeline refetch, archive, and local expect(agent.id).toBe("agent_sdk"); expect(agent.current()).toEqual(createdAgent); + await observeAgents(client, ws); const updatedAgents: string[] = []; const unsubscribe = agent.subscribe((update) => { if (update.kind === "upsert") { @@ -691,11 +736,7 @@ test("agent handles delegate create, send, timeline refetch, archive, and local ws.message( sessionMessage({ type: "agent_update", - payload: { - kind: "upsert", - agent: updatedAgent, - project: null, - }, + payload: { subscriptionId: "agents-sdk", kind: "upsert", agent: updatedAgent, project: null }, }), ); expect(updatedAgents).toEqual(["Updated"]); @@ -959,11 +1000,13 @@ test("agent handles expose the observed snapshot through readonly properties", a runtimeInfo: { provider: "codex", sessionId: "session-sdk", model: "gpt-5.4" }, archivedAt: "2026-05-16T02:00:00.000Z", }); + await observeAgents(client, ws); const unsubscribe = agent.subscribe(() => {}); ws.message( sessionMessage({ type: "agent_update", payload: { + subscriptionId: "agents-sdk", kind: "upsert", agent: observedAgent, project: null, @@ -1138,6 +1181,8 @@ test("provider actions delegate to existing provider RPCs and local snapshot upd }); const readyPromise = client.providers.waitForReady({ cwd: "/repo/sdk", timeoutMs: 5_000 }); + acknowledgeObservation(ws, "provider-wait"); + await new Promise((resolve) => setTimeout(resolve, 0)); const readyRequest = parseSentSessionMessage(ws.sent.at(-1)); expect(readyRequest).toMatchObject({ type: "get_providers_snapshot_request", @@ -1158,6 +1203,7 @@ test("provider actions delegate to existing provider RPCs and local snapshot upd sessionMessage({ type: "providers_snapshot_update", payload: { + subscriptionId: "provider-wait", cwd: "/repo/other", entries: [{ provider: "codex", status: "ready", enabled: true }], generatedAt: "2026-05-16T00:00:30.000Z", @@ -1168,6 +1214,7 @@ test("provider actions delegate to existing provider RPCs and local snapshot upd sessionMessage({ type: "providers_snapshot_update", payload: { + subscriptionId: "provider-wait", cwd: "/repo/sdk", entries: [{ provider: "codex", status: "ready", enabled: true }], generatedAt: "2026-05-16T00:00:01.000Z", @@ -1184,11 +1231,14 @@ test("provider actions delegate to existing provider RPCs and local snapshot upd cwd: "/repo/./sdk", timeoutMs: 5_000, }); + acknowledgeObservation(ws, "provider-canonical-wait"); + await new Promise((resolve) => setTimeout(resolve, 0)); const canonicalReadyRequest = parseSentSessionMessage(ws.sent.at(-1)); ws.message( sessionMessage({ type: "providers_snapshot_update", payload: { + subscriptionId: "provider-canonical-wait", cwd: "/repo/sdk", entries: [{ provider: "codex", status: "ready", enabled: true }], generatedAt: "2026-05-16T00:00:02.000Z", @@ -1317,6 +1367,9 @@ test("provider actions delegate to existing provider RPCs and local snapshot upd ], }); + const observation = client.observeEvents(["providers_snapshot_update"]); + acknowledgeObservation(ws, "provider-updates"); + await observation.ready; const snapshotUpdates: string[] = []; const snapshotModelDefaults: Array = []; const unsubscribe = client.providers.subscribe((update) => { @@ -1327,6 +1380,7 @@ test("provider actions delegate to existing provider RPCs and local snapshot upd sessionMessage({ type: "providers_snapshot_update", payload: { + subscriptionId: "provider-updates", cwd: "/repo/sdk", entries: [ { @@ -1354,12 +1408,12 @@ test("provider actions delegate to existing provider RPCs and local snapshot upd await client.close(); }); -test("waitForReady requires canonical provider snapshot identity from the host", async () => { +test("waitForReady requires owned subscriptions from the host", async () => { const { client, ws } = await connectClient({}); const sentBeforeWait = ws.sent.length; await expect(client.providers.waitForReady({ cwd: "/repo/./sdk" })).rejects.toThrow( - "Update the host to wait for provider discovery.", + "Update the host to use independent subscriptions.", ); expect(ws.sent).toHaveLength(sentBeforeWait); @@ -1546,3 +1600,62 @@ test("agent config requires provider/model syntax", async () => { await client.close(); }); + +test("canceled timeline handles and captured state are collectible while their API stays alive", async () => { + const source = ` + import assert from "node:assert/strict"; + import { createPaseoApi } from ${JSON.stringify(new URL("./index.ts", import.meta.url).href)}; + import { DaemonClient } from ${JSON.stringify(new URL("./daemon-client.ts", import.meta.url).href)}; + const drivers = [], apis = []; + let errors = 0; + async function batch(mode) { + const driver = new DaemonClient({ url: "ws://127.0.0.1:1/ws", clientId: mode, reconnect: { enabled: false } }); + const scope = new AbortController(); + const api = createPaseoApi(driver, { signal: scope.signal }); + drivers.push(driver); apis.push(api); + const refs = [], ready = []; + for (let i = 0; i < 100; i++) { + const state = { bytes: new Uint8Array(64 * 1024) }; + const handle = api.agents.ref("unconnected-agent").timeline.subscribe(update => { + if (update.event.type === "error") errors++; + return state.bytes[0]; + }); + refs.push({ handle: new WeakRef(handle), state: new WeakRef(state) }); + ready.push(handle.ready.catch(() => {})); + if (mode === "callable") handle(); + if (mode === "release") await handle.release(); + } + if (mode === "scope-abort") scope.abort(); + if (mode === "client-close") await driver.close(); + if (mode !== "active") await Promise.all(ready); + return refs; + } + const groups = {}; + for (const mode of ["callable", "release", "scope-abort", "client-close", "active"]) groups[mode] = await batch(mode); + async function collect() { + for (let i = 0; i < 12; i++) { await new Promise(r => setImmediate(r)); global.gc(); } + } + const count = refs => ({ handles: refs.filter(r => r.handle.deref()).length, states: refs.filter(r => r.state.deref()).length }); + await collect(); + const live = Object.fromEntries(Object.entries(groups).map(([mode, refs]) => [mode, count(refs)])); + console.log(JSON.stringify({ phase: "API alive", live, errors })); + for (const api of apis) await api.dispose(); + await collect(); + const disposed = Object.fromEntries(Object.entries(groups).map(([mode, refs]) => [mode, count(refs)])); + console.log(JSON.stringify({ phase: "API disposed", disposed })); + for (const driver of drivers) await driver.close(); + for (const mode of ["callable", "release", "scope-abort", "client-close"]) assert.deepEqual(live[mode], { handles: 0, states: 0 }, mode); + assert.deepEqual(live.active, { handles: 100, states: 100 }, "active ownership is retained"); + assert.equal(errors, 100, "client shutdown reports failure; explicit cancellation does not"); + for (const result of Object.values(disposed)) assert.deepEqual(result, { handles: 0, states: 0 }); + `; + const env = Object.fromEntries( + Object.entries(process.env).filter(([key]) => !/^(PASEO_|EXPO_|E2E_|AGENT_BROWSER_)/.test(key)), + ); + const result = await promisify(execFile)( + process.execPath, + ["--expose-gc", "--import", "tsx", "--input-type=module", "-e", source], + { env, timeout: 15000 }, + ); + expect(result.stdout).toContain('"phase":"API alive"'); +}, 20000); diff --git a/packages/client/src/index.ts b/packages/client/src/index.ts index 7068cdc0bf..fb67a4228a 100644 --- a/packages/client/src/index.ts +++ b/packages/client/src/index.ts @@ -1,3 +1,5 @@ +import type { OwnedSubscription } from "./connection/index.js"; +export type { OwnedSubscription, SubscriptionObserver } from "./connection/index.js"; import type { DaemonClientConfig } from "./daemon-client.js"; import type { AgentPermissionResponse } from "@getpaseo/protocol/agent-types"; import type { @@ -114,6 +116,7 @@ export type PaseoProjectUpdate = Extract< export type PaseoProjectUpdateHandler = (update: PaseoProjectUpdate) => void; export interface PaseoAgentListResult { + subscription?: OwnedSubscription; requestId: string; subscriptionId?: string | null; entries: FetchAgentsEntry[]; @@ -127,6 +130,7 @@ export type PaseoWorkspaceListOptions = Omit< }; export interface PaseoWorkspaceListResult { + subscription?: OwnedSubscription; requestId: string; subscriptionId?: string | null; entries: PaseoWorkspace[]; @@ -185,6 +189,12 @@ export interface PaseoProjectActions { } export interface PaseoWorkspaceActions { + list(options: PaseoWorkspaceListOptions & { subscribe: {} }): Promise< + PaseoWorkspaceListResult & { + subscriptionId: string; + subscription: OwnedSubscription; + } + >; list(options?: PaseoWorkspaceListOptions): Promise; ref(workspace: string | PaseoWorkspace): PaseoWorkspaceHandle; open( @@ -289,7 +299,13 @@ export type PaseoAgentTimelineEvent = | { agentId: string; event: { type: "replacement"; epoch: string }; - }; + } + | { + agentId: string; + subscriptionId: string; + event: { type: "snapshot"; reason: "reconnect"; page: FetchAgentTimelinePayload }; + } + | { agentId: string; event: { type: "error"; error: string } }; export type PaseoAgentTimelineSubscription = ReturnType; @@ -302,8 +318,10 @@ export interface PaseoAgentTimelineHandle { */ refetch(options?: PaseoAgentTimelineRefetchOptions): Promise; /** - * Subscribe to this agent and restore demand after reconnect. A replacement - * event invalidates previously fetched history; refetch the page you need. + * Initially delivers live events only. Reconnect delivers a projected snapshot + * of the latest 100 entries before subsequent updates. Replace your recent view + * with this page; use its cursors to load older history. A replacement event + * invalidates the previous epoch. Recovery errors release this observation. * Await the returned unsubscribe function's `ready` promise before starting * work that must be observed. It rejects if establishment fails. */ @@ -354,6 +372,12 @@ export interface PaseoAgentHandle { } export interface PaseoAgentActions { + list(options: PaseoAgentListOptions & { subscribe: {} }): Promise< + PaseoAgentListResult & { + subscriptionId: string; + subscription: OwnedSubscription; + } + >; list(options?: PaseoAgentListOptions): Promise; ref(agent: string | PaseoAgent): PaseoAgentHandle; create(options: PaseoAgentCreateOptions): Promise; @@ -450,6 +474,8 @@ export interface PaseoConfigActions { } export interface PaseoApi { + dispose(): Promise; + observeEvents: DaemonClient["observeEvents"]; readonly terminals: PaseoTerminalActions; readonly workspaces: PaseoWorkspaceActions; readonly projects: PaseoProjectActions; @@ -471,17 +497,62 @@ export function createPaseoClient(config: PaseoClientConfig): PaseoClient { clientId: config.clientId ?? createGeneratedClientId(), clientType: "cli", }); + const api = createPaseoApi(daemonClient); return { - ...createPaseoApi(daemonClient), + ...api, connect: () => daemonClient.connect(), - close: () => daemonClient.close(), + close: async () => { + try { + await api.dispose(); + } finally { + await daemonClient.close(); + } + }, ensureConnected: () => daemonClient.ensureConnected(), getConnectionState: () => daemonClient.getConnectionState(), }; } -export function createPaseoApi(daemonClient: DaemonClient): PaseoApi { - const createAgentHandle = createAgentHandleFactory(daemonClient); +export function createPaseoApi( + daemonClient: DaemonClient, + scopeOptions?: { signal?: AbortSignal }, +): PaseoApi { + const handles = new Set<{ release(): Promise }>(); + const agentListeners = new Set(); + const workspaceListeners = new Set(); + const projectListeners = new Set(); + const providerListeners = new Set<(update: PaseoProviderSnapshotUpdate) => void>(); + const lifetime = new AbortController(); + const own = }>(create: () => T): T => { + if (lifetime.signal.aborted) throw new Error("Paseo API is disposed"); + const handle = create(); + handles.add(handle); + const release = handle.release.bind(handle); + handle.release = async () => { + await release(); + handles.delete(handle); + }; + return handle; + }; + const listenAgents = (handler: PaseoAgentUpdateHandler) => { + if (lifetime.signal.aborted) throw new Error("Paseo API is disposed"); + agentListeners.add(handler); + return () => { + agentListeners.delete(handler); + }; + }; + const listenWorkspaces = (handler: PaseoWorkspaceUpdateHandler) => { + if (lifetime.signal.aborted) throw new Error("Paseo API is disposed"); + workspaceListeners.add(handler); + return () => { + workspaceListeners.delete(handler); + }; + }; + const createAgentHandle = createAgentHandleFactory( + daemonClient, + listenAgents, + (agentId, handler) => own(() => daemonClient.subscribeAgentTimeline(agentId, handler)), + ); const createAgent = async ( options: PaseoAgentCreateOptions, placement?: { workspaceId: string; cwd: string }, @@ -513,19 +584,117 @@ export function createPaseoApi(daemonClient: DaemonClient): PaseoApi { } return workspace.workspaceDirectory; }); - const createWorkspaceHandle = createWorkspaceHandleFactory(daemonClient, createAgent, terminals); + const createWorkspaceHandle = createWorkspaceHandleFactory( + daemonClient, + createAgent, + terminals, + listenWorkspaces, + ); + + let disposal: Promise | null = null; + const dispose = (): Promise => { + if (disposal) return disposal; + lifetime.abort(); + scopeOptions?.signal?.removeEventListener("abort", abort); + agentListeners.clear(); + workspaceListeners.clear(); + projectListeners.clear(); + providerListeners.clear(); + disposal = Promise.allSettled([...handles].map((handle) => handle.release())).then( + (results) => { + handles.clear(); + const failures = results.flatMap((result) => + result.status === "rejected" ? [result.reason] : [], + ); + if (failures.length) + throw new AggregateError(failures, "Failed to release API subscriptions"); + return undefined; + }, + ); + return disposal; + }; + const abort = () => { + void dispose().catch((error) => console.error("API subscription cleanup failed", error)); + }; + if (scopeOptions?.signal?.aborted) abort(); + else scopeOptions?.signal?.addEventListener("abort", abort, { once: true }); + + const observeEvents: DaemonClient["observeEvents"] = (events, options) => { + const subscription = own(() => daemonClient.observeEvents(events, options)); + subscription.subscribe({ + snapshot: () => {}, + update: (message) => { + if (message.type === "project.update") + for (const listener of projectListeners) listener(message.payload); + if (message.type === "providers_snapshot_update") + for (const listener of providerListeners) listener(message.payload); + }, + }); + return subscription; + }; + + function listWorkspaces(options: PaseoWorkspaceListOptions & { subscribe: {} }): Promise< + PaseoWorkspaceListResult & { + subscriptionId: string; + subscription: OwnedSubscription; + } + >; + function listWorkspaces(options?: PaseoWorkspaceListOptions): Promise; + async function listWorkspaces( + options?: PaseoWorkspaceListOptions, + ): Promise { + if (!options?.subscribe) return daemonClient.fetchWorkspaces(options); + if (options.subscribe.subscriptionId !== undefined) + throw new Error("Subscription IDs are assigned by the host"); + const subscription = own(() => daemonClient.observeWorkspaces(options)); + subscription.subscribe({ + snapshot: () => {}, + update: (message) => { + if (message.type === "workspace_update") + for (const listener of workspaceListeners) listener(message.payload); + }, + }); + return { ...(await subscription.ready), subscription }; + } + + function listAgents(options: PaseoAgentListOptions & { subscribe: {} }): Promise< + PaseoAgentListResult & { + subscriptionId: string; + subscription: OwnedSubscription; + } + >; + function listAgents(options?: PaseoAgentListOptions): Promise; + async function listAgents(options?: PaseoAgentListOptions): Promise { + if (!options?.subscribe) return daemonClient.fetchAgents(options); + if (options.subscribe.subscriptionId !== undefined) + throw new Error("Subscription IDs are assigned by the host"); + const subscription = own(() => daemonClient.observeAgents(options)); + subscription.subscribe({ + snapshot: () => {}, + update: (message) => { + if (message.type === "agent_update") + for (const listener of agentListeners) listener(message.payload); + }, + }); + return { ...(await subscription.ready), subscription }; + } return { + dispose, + observeEvents, terminals, projects: { list: (options) => daemonClient.listProjects(options), - subscribe: (handler) => - daemonClient.on("project.update", (message) => { - handler(message.payload); - }), + subscribe: (handler) => { + if (lifetime.signal.aborted) throw new Error("Paseo API is disposed"); + projectListeners.add(handler); + return () => { + projectListeners.delete(handler); + }; + }, }, workspaces: { - list: (options) => daemonClient.fetchWorkspaces(options), + list: listWorkspaces, ref: (workspace) => createWorkspaceHandle(workspace), open: (input, requestId) => openWorkspace(daemonClient, createWorkspaceHandle, input, requestId), @@ -538,19 +707,13 @@ export function createPaseoApi(daemonClient: DaemonClient): PaseoApi { }, archive: (workspace, requestId) => daemonClient.archiveWorkspace(resolveWorkspaceId(workspace), requestId), - subscribe: (handler) => - daemonClient.on("workspace_update", (message) => { - handler(message.payload); - }), + subscribe: listenWorkspaces, }, agents: { - list: (options) => daemonClient.fetchAgents(options), + list: listAgents, ref: (agent) => createAgentHandle(agent), create: (options) => createAgent(options), - subscribe: (handler) => - daemonClient.on("agent_update", (message) => { - handler(message.payload); - }), + subscribe: listenAgents, }, providers: { listModels: (provider, options) => daemonClient.listProviderModels(provider, options), @@ -561,14 +724,23 @@ export function createPaseoApi(daemonClient: DaemonClient): PaseoApi { }, listAvailable: (options) => daemonClient.listAvailableProviders(options), snapshot: (options) => daemonClient.getProvidersSnapshot(options), - waitForReady: (options) => waitForProvidersReady(daemonClient, options), + waitForReady: (options) => + waitForProvidersReady( + daemonClient, + observeEvents(["providers_snapshot_update"]), + lifetime.signal, + options, + ), refresh: (options) => daemonClient.refreshProvidersSnapshot(options), diagnostic: (provider, options) => daemonClient.getProviderDiagnostic(provider, options), listUsage: (options) => listProviderUsage(daemonClient, options), - subscribe: (handler) => - daemonClient.on("providers_snapshot_update", (message) => { - handler(message.payload); - }), + subscribe: (handler) => { + if (lifetime.signal.aborted) throw new Error("Paseo API is disposed"); + providerListeners.add(handler); + return () => { + providerListeners.delete(handler); + }; + }, }, config: { get: (requestId) => daemonClient.getDaemonConfig(requestId), @@ -588,6 +760,7 @@ function createWorkspaceHandleFactory( daemonClient: DaemonClient, createAgent: CreateAgent, terminals: PaseoTerminalActions, + listen: (handler: PaseoWorkspaceUpdateHandler) => () => void, ): WorkspaceHandleFactory { return (workspace) => { const id = typeof workspace === "string" ? workspace : workspace.id; @@ -654,14 +827,12 @@ function createWorkspaceHandleFactory( return result; }, subscribe: (handler) => - daemonClient.on("workspace_update", (message) => { - const update = message.payload; + listen((update) => { if (update.kind === "upsert" && update.workspace.id === id) { current = update.workspace; handler(update); } if (update.kind === "remove" && update.id === id) { - current = null; handler(update); } }), @@ -669,7 +840,11 @@ function createWorkspaceHandleFactory( }; } -function createAgentHandleFactory(daemonClient: DaemonClient): AgentHandleFactory { +function createAgentHandleFactory( + daemonClient: DaemonClient, + listen: (handler: PaseoAgentUpdateHandler) => () => void, + subscribeTimeline: DaemonClient["subscribeAgentTimeline"], +): AgentHandleFactory { return (agent) => { const id = typeof agent === "string" ? agent : agent.id; let current = typeof agent === "string" ? null : agent; @@ -686,15 +861,27 @@ function createAgentHandleFactory(daemonClient: DaemonClient): AgentHandleFactor return result; }, subscribe: (handler) => - daemonClient.subscribeAgentTimeline(id, (message) => { - handler( - message.type === "agent_stream" - ? message.payload - : { - agentId: message.payload.agentId, - event: { type: "replacement", epoch: message.payload.epoch }, - }, - ); + subscribeTimeline(id, (message) => { + switch (message.type) { + case "agent_stream": + return handler(message.payload); + case "agent.timeline.snapshot": + return handler({ + agentId: id, + subscriptionId: message.payload.subscriptionId, + event: { type: "snapshot", reason: "reconnect", page: message.payload.page }, + }); + case "agent.timeline.error": + return handler({ + agentId: id, + event: { type: "error", error: message.payload.error }, + }); + case "agent.timeline.replacement": + return handler({ + agentId: id, + event: { type: "replacement", epoch: message.payload.epoch }, + }); + } }), }, get workspaceId() { @@ -779,14 +966,12 @@ function createAgentHandleFactory(daemonClient: DaemonClient): AgentHandleFactor await daemonClient.detachAgent(id); }, subscribe: (handler) => - daemonClient.on("agent_update", (message) => { - const update = message.payload; + listen((update) => { if (update.kind === "upsert" && update.agent.id === id) { current = update.agent; handler(update); } if (update.kind === "remove" && update.agentId === id) { - current = null; handler(update); } }), @@ -840,86 +1025,96 @@ function listProviderUsage( return daemonClient.listProviderUsage(options); } -function waitForProvidersReady( +async function waitForProvidersReady( daemonClient: DaemonClient, + observation: ReturnType, + signal: AbortSignal, options: PaseoProviderWaitOptions = {}, ): Promise { - // COMPAT(providersSnapshotCwd): added in v0.3.2, remove gate after 2027-02-10. - if (daemonClient.getLastServerInfoMessage()?.features?.providersSnapshotCwd !== true) { - return Promise.reject(new Error("Update the host to wait for provider discovery.")); - } - const { timeoutMs = 60_000, ...snapshotOptions } = options; - return new Promise((resolve, reject) => { - let settled = false; - let requestId: string | null = null; - let snapshotCwd: string | undefined; - const pendingUpdates = new Map(); - let latestEntries: PaseoProviderSnapshotResult["entries"] = []; + try { + await observation.ready; + signal.throwIfAborted(); + return await new Promise((resolve, reject) => { + let settled = false; + let requestId: string | null = null; + let snapshotCwd: string | undefined; + const pendingUpdates = new Map(); + let latestEntries: PaseoProviderSnapshotResult["entries"] = []; - const cleanup = () => { - clearTimeout(timeout); - unsubscribe(); - }; - const finish = (snapshot: PaseoProviderSnapshotResult) => { - if (settled) return; - settled = true; - cleanup(); - resolve(snapshot); - }; - const fail = (error: unknown) => { - if (settled) return; - settled = true; - cleanup(); - reject(error instanceof Error ? error : new Error(String(error))); - }; - const updateMatches = (update: PaseoProviderSnapshotUpdate) => update.cwd === snapshotCwd; + const cleanup = () => { + clearTimeout(timeout); + unsubscribe(); + signal.removeEventListener("abort", abort); + }; + const finish = (snapshot: PaseoProviderSnapshotResult) => { + if (settled) return; + settled = true; + cleanup(); + resolve(snapshot); + }; + const fail = (error: unknown) => { + if (settled) return; + settled = true; + cleanup(); + reject(error instanceof Error ? error : new Error(String(error))); + }; + const updateMatches = (update: PaseoProviderSnapshotUpdate) => update.cwd === snapshotCwd; - const unsubscribe = daemonClient.on("providers_snapshot_update", (message) => { - const update = message.payload; - if (!requestId) { - pendingUpdates.set(update.cwd, update); - return; - } - if (!updateMatches(update)) return; - latestEntries = update.entries; - if (update.entries.some((entry) => entry.status === "loading")) return; - finish({ ...update, requestId }); + const unsubscribe = observation.subscribe({ + snapshot: () => {}, + update: (message) => { + if (message.type !== "providers_snapshot_update") return; + const update = message.payload; + if (!requestId) { + pendingUpdates.set(update.cwd, update); + return; + } + if (!updateMatches(update)) return; + latestEntries = update.entries; + if (update.entries.some((entry) => entry.status === "loading")) return; + finish({ ...update, requestId }); + }, + }); + const abort = () => fail(new Error("Paseo API is disposed")); + signal.addEventListener("abort", abort, { once: true }); + + const timeout = setTimeout(() => { + const loading = latestEntries + .filter((entry) => entry.status === "loading") + .map((entry) => entry.provider) + .join(", "); + fail( + new Error( + loading + ? `Timed out waiting for providers: ${loading}` + : "Timed out waiting for provider discovery", + ), + ); + }, timeoutMs); + + void daemonClient + .getProvidersSnapshot(snapshotOptions) + .then((snapshot) => { + requestId = snapshot.requestId; + snapshotCwd = snapshot.cwd; + latestEntries = snapshot.entries; + if (!snapshot.entries.some((entry) => entry.status === "loading")) { + finish(snapshot); + return; + } + const pendingUpdate = pendingUpdates.get(snapshotCwd); + if (pendingUpdate && !pendingUpdate.entries.some((entry) => entry.status === "loading")) { + finish({ ...pendingUpdate, requestId }); + } + return undefined; + }) + .catch(fail); }); - - const timeout = setTimeout(() => { - const loading = latestEntries - .filter((entry) => entry.status === "loading") - .map((entry) => entry.provider) - .join(", "); - fail( - new Error( - loading - ? `Timed out waiting for providers: ${loading}` - : "Timed out waiting for provider discovery", - ), - ); - }, timeoutMs); - - void daemonClient - .getProvidersSnapshot(snapshotOptions) - .then((snapshot) => { - requestId = snapshot.requestId; - snapshotCwd = snapshot.cwd; - latestEntries = snapshot.entries; - if (!snapshot.entries.some((entry) => entry.status === "loading")) { - finish(snapshot); - return; - } - const pendingUpdate = pendingUpdates.get(snapshotCwd); - if (pendingUpdate && !pendingUpdate.entries.some((entry) => entry.status === "loading")) { - finish({ ...pendingUpdate, requestId }); - } - return undefined; - }) - .catch(fail); - }); + } finally { + await observation.release(); + } } function createGeneratedClientId(): string { diff --git a/packages/client/src/provider-snapshots/index.ts b/packages/client/src/provider-snapshots/index.ts index 7450a61639..b73a88e944 100644 --- a/packages/client/src/provider-snapshots/index.ts +++ b/packages/client/src/provider-snapshots/index.ts @@ -13,11 +13,11 @@ interface PendingSnapshot { /** Resolve announcements only while observed, coalescing changes during a fetch. */ export class ProviderSnapshotUpdates { - private pending = new Map(); - private paused = false; + private pending = new Map(); constructor( private readonly host: { + active(update: Update): boolean; fetch(cwd: string | undefined): Promise; emit(update: Update): void; failed(error: unknown): void; @@ -28,35 +28,25 @@ export class ProviderSnapshotUpdates { this.pending.clear(); } - pause(): void { - this.paused = true; - // Invalidate old responses while retaining the announcements they would resolve. - for (const [cwd, pending] of this.pending) { - this.pending.set(cwd, { latest: pending.latest, running: false }); - } - } - - resume(): void { - this.paused = false; - for (const pending of this.pending.values()) this.receive(pending.latest); - } - receive(update: Update): void { - const cwd = update.payload.cwd; - const pending = this.pending.get(cwd) ?? { latest: update, running: false }; + if (!this.host.active(update)) return; + const key = JSON.stringify([update.payload.subscriptionId, update.payload.cwd]); + const pending = this.pending.get(key) ?? { latest: update, running: false }; pending.latest = update; - this.pending.set(cwd, pending); - if (this.paused || pending.running) return; + this.pending.set(key, pending); + if (pending.running) return; pending.running = true; - void this.resolve(cwd, pending); + void this.resolve(key, pending); } - private async resolve(cwd: string | undefined, pending: PendingSnapshot): Promise { + private async resolve(key: string, pending: PendingSnapshot): Promise { try { - while (this.pending.get(cwd) === pending) { + while (this.pending.get(key) === pending) { const announced = pending.latest; - const snapshot = await this.host.fetch(cwd); - if (this.pending.get(cwd) !== pending) return; + if (!this.host.active(announced)) return; + const snapshot = await this.host.fetch(announced.payload.cwd); + if (!this.host.active(announced)) return; + if (this.pending.get(key) !== pending) return; // A change arriving while the body was in flight needs the current body. // If that response already covers it, no second fetch is necessary. if ( @@ -64,14 +54,17 @@ export class ProviderSnapshotUpdates { pending.latest.payload.snapshotHash !== snapshot.snapshotHash ) continue; - this.pending.delete(cwd); - this.host.emit({ type: "providers_snapshot_update", payload: snapshot }); + this.pending.delete(key); + this.host.emit({ + type: "providers_snapshot_update", + payload: { ...snapshot, subscriptionId: announced.payload.subscriptionId }, + }); return; } } catch (error) { - if (this.pending.get(cwd) === pending) this.host.failed(error); + if (this.pending.get(key) === pending) this.host.failed(error); } finally { - if (this.pending.get(cwd) === pending) this.pending.delete(cwd); + if (this.pending.get(key) === pending) this.pending.delete(key); } } } diff --git a/packages/client/src/terminal-stream-router.test.ts b/packages/client/src/terminal-stream-router.test.ts index f4a6c294c7..88b79efc29 100644 --- a/packages/client/src/terminal-stream-router.test.ts +++ b/packages/client/src/terminal-stream-router.test.ts @@ -9,8 +9,7 @@ describe("terminal-stream-router", () => { const events: TerminalStreamEvent[] = []; const payload = new TextEncoder().encode("restored screen"); - router.setSlot("term-1", 7); - router.onEvent((event) => events.push(event)); + router.attach("subscription-1", "term-1", 7, (event) => events.push(event)); router.handleFrame({ opcode: TerminalStreamOpcode.Restore, slot: 7, @@ -20,9 +19,28 @@ describe("terminal-stream-router", () => { expect(events).toEqual([ { terminalId: "term-1", + subscriptionId: "subscription-1", type: "restore", data: payload, }, ]); }); }); + +test("two registrations for one terminal retain independent slots and release", () => { + const router = new TerminalStreamRouter(); + const a: TerminalStreamEvent[] = []; + const b: TerminalStreamEvent[] = []; + const releaseA = router.attach("subscription-a", "same-terminal", 1, (event) => a.push(event)); + router.attach("subscription-b", "same-terminal", 2, (event) => b.push(event)); + const payload = new TextEncoder().encode("output"); + router.handleFrame({ opcode: TerminalStreamOpcode.Output, slot: 1, payload }); + router.handleFrame({ opcode: TerminalStreamOpcode.Output, slot: 2, payload }); + expect(a).toEqual([expect.objectContaining({ subscriptionId: "subscription-a" })]); + expect(b).toEqual([expect.objectContaining({ subscriptionId: "subscription-b" })]); + releaseA(); + router.handleFrame({ opcode: TerminalStreamOpcode.Output, slot: 1, payload }); + router.handleFrame({ opcode: TerminalStreamOpcode.Output, slot: 2, payload }); + expect(a).toHaveLength(1); + expect(b).toHaveLength(2); +}); diff --git a/packages/client/src/terminal-stream-router.ts b/packages/client/src/terminal-stream-router.ts index 5760af28eb..fa4d5e7613 100644 --- a/packages/client/src/terminal-stream-router.ts +++ b/packages/client/src/terminal-stream-router.ts @@ -1,22 +1,28 @@ import { decodeTerminalSnapshotPayload, - encodeTerminalResizePayload, - encodeTerminalStreamFrame, TerminalStreamOpcode, type TerminalStreamFrame, } from "@getpaseo/protocol/binary-frames/index"; -import type { TerminalInput, TerminalState } from "@getpaseo/protocol/messages"; +import type { TerminalState } from "@getpaseo/protocol/messages"; -export type TerminalStreamEvent = - | { terminalId: string; type: "output"; data: Uint8Array } - | { terminalId: string; type: "snapshot"; state: TerminalState } - | { terminalId: string; type: "restore"; data: Uint8Array }; +export type TerminalStreamEvent = { terminalId: string; subscriptionId: string } & ( + | { type: "output"; data: Uint8Array } + | { type: "snapshot"; state: TerminalState } + | { type: "restore"; data: Uint8Array } +); export class TerminalStreamRouter { - private readonly terminalSlots = new Map(); - private readonly slotTerminals = new Map(); + private readonly slots = new Map< + number, + { + subscriptionId: string; + terminalId: string; + receive: (event: TerminalStreamEvent) => void; + } + >(); private readonly listeners = new Set<(event: TerminalStreamEvent) => void>(); + /** Passive wire diagnostics. App state consumes the registration's callback. */ onEvent(handler: (event: TerminalStreamEvent) => void): () => void { this.listeners.add(handler); return () => { @@ -24,109 +30,46 @@ export class TerminalStreamRouter { }; } - setSlot(terminalId: string, slot: number): void { - const existingTerminalId = this.slotTerminals.get(slot); - if (existingTerminalId && existingTerminalId !== terminalId) { - this.terminalSlots.delete(existingTerminalId); - } - - const existingSlot = this.terminalSlots.get(terminalId); - if (typeof existingSlot === "number" && existingSlot !== slot) { - this.slotTerminals.delete(existingSlot); - } - - this.terminalSlots.set(terminalId, slot); - this.slotTerminals.set(slot, terminalId); - } - - removeTerminal(terminalId: string): void { - const slot = this.terminalSlots.get(terminalId); - if (typeof slot !== "number") { - return; - } - this.terminalSlots.delete(terminalId); - if (this.slotTerminals.get(slot) === terminalId) { - this.slotTerminals.delete(slot); - } + attach( + subscriptionId: string, + terminalId: string, + slot: number, + receive: (event: TerminalStreamEvent) => void, + ): () => void { + const registration = { subscriptionId, terminalId, receive }; + this.slots.set(slot, registration); + return () => { + if (this.slots.get(slot) === registration) this.slots.delete(slot); + }; } clearSlots(): void { - this.terminalSlots.clear(); - this.slotTerminals.clear(); - } - - encodeInput(terminalId: string, message: TerminalInput["message"]): Uint8Array | null { - const slot = this.terminalSlots.get(terminalId); - if (typeof slot !== "number") { - return null; - } - - if (message.type === "input") { - return encodeTerminalStreamFrame({ - opcode: TerminalStreamOpcode.Input, - slot, - payload: message.data, - }); - } - - if (message.type === "resize") { - return encodeTerminalStreamFrame({ - opcode: TerminalStreamOpcode.Resize, - slot, - payload: encodeTerminalResizePayload({ - rows: message.rows, - cols: message.cols, - ...(message.intent ? { intent: message.intent } : {}), - }), - }); - } - - return null; + this.slots.clear(); } handleFrame(frame: TerminalStreamFrame): void { - const terminalId = this.slotTerminals.get(frame.slot); - if (!terminalId) { - return; - } - - if (frame.opcode === TerminalStreamOpcode.Output) { - this.emit({ - terminalId, - type: "output", - data: frame.payload, - }); - return; - } - - if (frame.opcode === TerminalStreamOpcode.Restore) { - this.emit({ - terminalId, - type: "restore", - data: frame.payload, - }); - return; - } - - if (frame.opcode === TerminalStreamOpcode.Snapshot) { + const registration = this.slots.get(frame.slot); + if (!registration) return; + const identity = { + subscriptionId: registration.subscriptionId, + terminalId: registration.terminalId, + }; + let event: TerminalStreamEvent; + if (frame.opcode === TerminalStreamOpcode.Output) + event = { ...identity, type: "output", data: frame.payload }; + else if (frame.opcode === TerminalStreamOpcode.Restore) + event = { ...identity, type: "restore", data: frame.payload }; + else if (frame.opcode === TerminalStreamOpcode.Snapshot) { const state = decodeTerminalSnapshotPayload(frame.payload); - if (!state) { - return; - } - this.emit({ - terminalId, - type: "snapshot", - state, - }); - } - } - - private emit(event: TerminalStreamEvent): void { + if (!state) return; + event = { ...identity, type: "snapshot", state }; + } else return; + registration.receive(event); for (const listener of this.listeners) { try { listener(event); } catch { - // no-op + /* Passive diagnostics cannot interrupt delivery. */ } } } diff --git a/packages/client/src/terminals/index.test.ts b/packages/client/src/terminals/index.test.ts index 23cadbe6a9..2e9a51a93f 100644 --- a/packages/client/src/terminals/index.test.ts +++ b/packages/client/src/terminals/index.test.ts @@ -11,6 +11,8 @@ test("an older host rejects terminal operations before sending requests", async getLastServerInfoMessage: () => ({ status: "server_info", serverId: "older-host", + hostname: null, + version: null, features: {}, }), createTerminal: unexpectedRequest, diff --git a/packages/client/src/timeline-subscription/index.ts b/packages/client/src/timeline-subscription/index.ts new file mode 100644 index 0000000000..6671b26707 --- /dev/null +++ b/packages/client/src/timeline-subscription/index.ts @@ -0,0 +1,134 @@ +import type { SessionOutboundMessage } from "@getpaseo/protocol/messages"; +import type { FetchAgentTimelinePayload } from "../daemon-client.js"; +import type { OwnedSubscription, TimelineSubscription } from "../connection/index.js"; + +type TimelineUpdate = Extract< + SessionOutboundMessage, + { type: "agent_stream" | "agent.timeline.replacement" } +>; + +/** Local SDK recovery messages; these are not additional wire RPCs. */ +export type TimelineMessage = + | TimelineUpdate + | { + type: "agent.timeline.snapshot"; + payload: { agentId: string; subscriptionId: string; page: FetchAgentTimelinePayload }; + } + | { type: "agent.timeline.error"; payload: { agentId: string; error: string } }; + +const MAX_RECOVERY_UPDATES = 128; + +/** A live-only initial observation, with a fresh bounded history view after each outage. */ +export function subscribeTimeline( + agentId: string, + observation: OwnedSubscription<{ agentIds: string[] }>, + readSnapshot: () => Promise, + handler: (message: TimelineMessage) => void, + reportError: (error: unknown) => void, +): TimelineSubscription { + let established = false; + let released = false; + interface Recovery { + id: string; + revision: number; + updates: TimelineUpdate[]; + replacement?: Extract; + } + let recovery: Recovery | null = null; + const current = (state: Recovery) => + !released && recovery === state && observation.subscriptionId === state.id; + const notify = (message: TimelineMessage) => { + if (released) return; + try { + handler(message); + } catch (error) { + reportError(error); + } + }; + const release = () => { + released = true; + recovery = null; + return observation.release(); + }; + const stop = () => { + // Use the public release entry point so the owning API also forgets this handle. + void subscription.release().catch(reportError); + }; + const fail = (error: unknown) => { + notify({ + type: "agent.timeline.error", + payload: { agentId, error: error instanceof Error ? error.message : String(error) }, + }); + stop(); + }; + const recover = async (state: Recovery) => { + while (current(state)) { + const revision = state.revision; + let page: FetchAgentTimelinePayload; + try { + page = await readSnapshot(); + } catch (error) { + if (current(state)) fail(error); + return; + } + if (!current(state)) return; + // Overflow or replacement makes this in-flight read obsolete. Read a fresh + // bounded page instead of retaining an unbounded backlog of transient events. + if (revision !== state.revision) continue; + if (state.replacement) notify(state.replacement); + if (!current(state)) return; + notify({ + type: "agent.timeline.snapshot", + payload: { agentId, subscriptionId: state.id, page }, + }); + if (!current(state)) return; + recovery = null; + for (const message of state.updates) { + if (released || observation.subscriptionId !== state.id) break; + if (message.type === "agent_stream" && typeof message.payload.seq === "number") { + if ( + message.payload.epoch !== page.epoch || + message.payload.seq <= (page.window.maxSeq ?? 0) + ) + continue; + } + notify(message); + } + } + }; + observation.subscribe({ + snapshot: ({ subscriptionId }) => { + if (!established) { + established = true; + return; + } + const state: Recovery = { id: subscriptionId, revision: 0, updates: [] }; + recovery = state; + void recover(state); + }, + update: (message) => { + if (message.type !== "agent_stream" && message.type !== "agent.timeline.replacement") return; + if (!recovery) return notify(message); + if (message.type === "agent.timeline.replacement") { + recovery.replacement = message; + recovery.revision++; + recovery.updates.length = 0; + } else { + if (recovery.updates.length === MAX_RECOVERY_UPDATES) { + recovery.revision++; + recovery.updates.length = 0; + } + recovery.updates.push(message); + } + }, + error: fail, + }); + const subscription: TimelineSubscription = Object.assign(stop, { + ready: observation.ready.then(() => undefined), + release, + subscriptionId: null, + }); + Object.defineProperty(subscription, "subscriptionId", { get: () => observation.subscriptionId }); + void subscription.ready.catch(fail); + return subscription; +} diff --git a/packages/protocol/src/browser-automation/rpc-schemas.ts b/packages/protocol/src/browser-automation/rpc-schemas.ts index 55785dd69d..d2c5870e71 100644 --- a/packages/protocol/src/browser-automation/rpc-schemas.ts +++ b/packages/protocol/src/browser-automation/rpc-schemas.ts @@ -498,6 +498,7 @@ export const BrowserAutomationDialogEventSchema = z.object({ export const BrowserAutomationExecuteRequestSchema = z .object({ type: z.literal("browser.automation.execute.request"), + subscriptionId: z.string().optional(), requestId: z.string().min(1), agentId: z.string().min(1).optional(), cwd: z.string().min(1).optional(), diff --git a/packages/protocol/src/client-capabilities.ts b/packages/protocol/src/client-capabilities.ts index f60bf40a34..d7f5da1b7c 100644 --- a/packages/protocol/src/client-capabilities.ts +++ b/packages/protocol/src/client-capabilities.ts @@ -1,4 +1,6 @@ export const CLIENT_CAPS = { + // COMPAT(ownedSubscriptions): added in v0.8.0, remove legacy ownership after 2027-03-09. + ownedSubscriptions: "owned_subscriptions", // Clients outside the Paseo app version series can explicitly accept provider IDs. // COMPAT(explicitEventSubscriptions): added in v0.8.0, remove legacy broadcasts after 2027-03-08. explicitEventSubscriptions: "explicit_event_subscriptions", diff --git a/packages/protocol/src/messages.ts b/packages/protocol/src/messages.ts index ed36eeb1ad..b8d0e56cc2 100644 --- a/packages/protocol/src/messages.ts +++ b/packages/protocol/src/messages.ts @@ -56,6 +56,7 @@ import { LoopStopResponseSchema, } from "./loop/rpc-schemas.js"; import { + BROWSER_AUTOMATION_COMMAND_NAMES, BrowserAutomationExecuteRequestSchema, BrowserAutomationExecuteResponseSchema, } from "./browser-automation/rpc-schemas.js"; @@ -1036,7 +1037,7 @@ const WorkspaceLabelSyncCursorSchema = z.object({ export const WorkspaceLabelListRequestSchema = z.object({ type: z.literal("workspace.label.list.request"), requestId: z.string(), - subscribe: z.object({ subscriptionId: z.string() }), + subscribe: z.object({ subscriptionId: z.string().optional() }).optional(), sync: WorkspaceLabelSyncCursorSchema.optional(), }); export const WorkspaceLabelAssignmentSetRequestSchema = z.object({ @@ -2116,7 +2117,14 @@ export const CheckoutStatusRequestSchema = z.object({ export const SubscribeCheckoutDiffRequestSchema = z.object({ type: z.literal("subscribe_checkout_diff_request"), - subscriptionId: z.string(), + subscriptionId: z.string().optional(), + cwd: z.string(), + compare: CheckoutDiffCompareSchema, + requestId: z.string(), +}); + +export const CheckoutDiffGetRequestSchema = z.object({ + type: z.literal("checkout.diff.get.request"), cwd: z.string(), compare: CheckoutDiffCompareSchema, requestId: z.string(), @@ -2692,7 +2700,7 @@ export const FileSubscribeRequestSchema = z.object({ type: z.literal("fs.file.subscribe.request"), cwd: z.string(), path: z.string(), - subscriptionId: z.string(), + subscriptionId: z.string().optional(), requestId: z.string(), }); @@ -2847,6 +2855,7 @@ export const ListTerminalsRequestSchema = z.object({ export const SubscribeTerminalsRequestSchema = z.object({ type: z.literal("subscribe_terminals_request"), + requestId: z.string().optional(), cwd: z.string(), workspaceId: z.string().optional(), }); @@ -3053,19 +3062,55 @@ export const SessionEventSubscriptionSchema = z.enum([ "agent_attention_required", "agent_permission_request", "agent_permission_resolved", + "checkout_status_update", + "script_status_update", + "workspace_setup_progress", + "agent.provider_subagents.update", + "terminal_attention_required", + "status.server_info", + "status.daemon_config_changed", + "status.plugin_catalog_changed", + "status.plugin_settings_changed", + "activity_log", + "hub.execution.agent.update", + "hub.execution.agent.stream", ]); export type SessionEventSubscription = z.infer; export const SessionEventsSetSubscriptionRequestSchema = z.object({ type: z.literal("session.events.set_subscription.request"), requestId: z.string(), events: z.array(SessionEventSubscriptionSchema), + notifications: z.boolean().optional(), }); export const SessionEventsSetSubscriptionResponseSchema = z.object({ type: z.literal("session.events.set_subscription.response"), - payload: z.object({ requestId: z.string() }), + payload: z.object({ requestId: z.string(), subscriptionId: z.string().optional() }), +}); + +export const BrowserHostRegisterRequestSchema = z.object({ + type: z.literal("browser.host.register.request"), + requestId: z.string(), + hostKind: z.string().min(1), + supportedCommands: z.array(z.enum(BROWSER_AUTOMATION_COMMAND_NAMES)).min(1), +}); +export const BrowserHostRegisterResponseSchema = z.object({ + type: z.literal("browser.host.register.response"), + payload: z.object({ requestId: z.string(), subscriptionId: z.string() }), +}); + +export const SubscriptionReleaseRequestSchema = z.object({ + type: z.literal("subscription.release.request"), + requestId: z.string(), + subscriptionId: z.string(), +}); +export const SubscriptionReleaseResponseSchema = z.object({ + type: z.literal("subscription.release.response"), + payload: z.object({ requestId: z.string(), subscriptionId: z.string() }), }); export const SessionInboundMessageSchema = z.discriminatedUnion("type", [ + BrowserHostRegisterRequestSchema, + SubscriptionReleaseRequestSchema, SessionEventsSetSubscriptionRequestSchema, HubExecutionAgentCreateRequestSchema, HubExecutionAgentValidateRequestSchema, @@ -3165,6 +3210,7 @@ export const SessionInboundMessageSchema = z.discriminatedUnion("type", [ AgentRewindRequestMessageSchema, AgentPermissionResponseMessageSchema, CheckoutStatusRequestSchema, + CheckoutDiffGetRequestSchema, SubscribeCheckoutDiffRequestSchema, UnsubscribeCheckoutDiffRequestSchema, CheckoutCommitRequestSchema, @@ -3272,6 +3318,7 @@ export type SessionInboundMessage = z.infer; // ============================================================================ export const ActivityLogPayloadSchema = z.object({ + subscriptionId: z.string().optional(), id: z.string(), timestamp: z.coerce.date(), type: z.enum(["transcript", "assistant", "tool_call", "tool_result", "error", "system"]), @@ -3566,6 +3613,7 @@ export const ServerInfoStatusPayloadSchema = z // COMPAT(selectiveAgentTimeline): added in v0.1.106, remove after 2027-01-12. selectiveAgentTimeline: z.boolean().optional(), explicitEventSubscriptions: z.boolean().optional(), + ownedSubscriptions: z.boolean().optional(), // COMPAT(canonicalSubmittedPrompts): added in v0.2.6, remove gate after 2027-01-30. canonicalSubmittedPrompts: z.boolean().optional(), // COMPAT(agentTurnIdentity): accept peers that observed pre-release v0.2.6 through 2027-01-31. @@ -3934,6 +3982,7 @@ export const AgentUpdateMessageSchema = z.object({ type: z.literal("agent_update"), payload: z.discriminatedUnion("kind", [ z.object({ + subscriptionId: z.string().optional(), kind: z.literal("upsert"), agent: AgentSnapshotPayloadSchema, project: ProjectPlacementPayloadSchema.nullable().optional(), @@ -3941,6 +3990,7 @@ export const AgentUpdateMessageSchema = z.object({ seq: z.number().int().positive().optional(), }), z.object({ + subscriptionId: z.string().optional(), kind: z.literal("remove"), agentId: z.string(), generation: z.string().optional(), @@ -3952,6 +4002,7 @@ export const AgentUpdateMessageSchema = z.object({ export const AgentStreamMessageSchema = z.object({ type: z.literal("agent_stream"), payload: z.object({ + subscriptionId: z.string().optional(), agentId: z.string(), event: AgentStreamEventPayloadSchema, timestamp: z.string(), @@ -4098,12 +4149,14 @@ export const WorkspaceUpdateMessageSchema = z.object({ type: z.literal("workspace_update"), payload: z.discriminatedUnion("kind", [ z.object({ + subscriptionId: z.string().optional(), kind: z.literal("upsert"), workspace: WorkspaceDescriptorPayloadSchema, generation: z.string().optional(), seq: z.number().int().positive().optional(), }), z.object({ + subscriptionId: z.string().optional(), kind: z.literal("remove"), id: z.string(), // COMPAT(workspaceProjects): added in v0.1.97, drop the optional gate when floor >= v0.1.97. @@ -4132,6 +4185,7 @@ export const WorkspaceLabelListResponseSchema = z.object({ type: z.literal("workspace.label.list.response"), payload: z.object({ requestId: z.string(), + subscriptionId: z.string().optional(), labels: z.array(WorkspaceLabelDefinitionSchema), sync: WorkspaceLabelSyncMetadataSchema, }), @@ -4140,6 +4194,7 @@ export const WorkspaceLabelUpdateSchema = z.object({ type: z.literal("workspace.label.update"), payload: z.discriminatedUnion("kind", [ z.object({ + subscriptionId: z.string().optional(), kind: z.literal("upsert"), label: WorkspaceLabelDefinitionSchema, previousName: z.string().optional(), @@ -4147,6 +4202,7 @@ export const WorkspaceLabelUpdateSchema = z.object({ seq: z.number().int().positive(), }), z.object({ + subscriptionId: z.string().optional(), kind: z.literal("remove"), name: z.string(), generation: z.string(), @@ -4189,12 +4245,14 @@ export const ProjectUpdateMessageSchema = z.object({ type: z.literal("project.update"), payload: z.discriminatedUnion("kind", [ z.object({ + subscriptionId: z.string().optional(), kind: z.literal("upsert"), project: WorkspaceProjectDescriptorPayloadSchema, generation: z.string().optional(), seq: z.number().int().positive().optional(), }), z.object({ + subscriptionId: z.string().optional(), kind: z.literal("remove"), projectId: z.string(), generation: z.string().optional(), @@ -4215,6 +4273,7 @@ export const ProjectListResponseMessageSchema = z.object({ export const ScriptStatusUpdateMessageSchema = z.object({ type: z.literal("script_status_update"), payload: z.object({ + subscriptionId: z.string().optional(), workspaceId: z.string(), scripts: z.array(WorkspaceScriptPayloadSchema), }), @@ -4223,6 +4282,7 @@ export const ScriptStatusUpdateMessageSchema = z.object({ export const WorkspaceSetupProgressMessageSchema = z.object({ type: z.literal("workspace_setup_progress"), payload: z.object({ + subscriptionId: z.string().optional(), workspaceId: z.string(), status: z.enum(["running", "completed", "failed", "blocked"]), detail: WorktreeSetupDetailPayloadSchema, @@ -4484,6 +4544,7 @@ export const FetchAgentTimelineResponseMessageSchema = z.object({ export const AgentTimelineReplacementMessageSchema = z.object({ type: z.literal("agent.timeline.replacement"), payload: z.object({ + subscriptionId: z.string().optional(), agentId: z.string(), epoch: z.string(), }), @@ -4572,10 +4633,12 @@ export const ProviderSubagentUpdateMessageSchema = z.object({ type: z.literal("agent.provider_subagents.update"), payload: z.discriminatedUnion("kind", [ z.object({ + subscriptionId: z.string().optional(), kind: z.literal("upsert"), subagent: ProviderSubagentDescriptorPayloadSchema, }), z.object({ + subscriptionId: z.string().optional(), kind: z.literal("timeline"), parentAgentId: z.string(), subagentId: z.string(), @@ -4586,6 +4649,7 @@ export const ProviderSubagentUpdateMessageSchema = z.object({ epoch: z.string(), }), z.object({ + subscriptionId: z.string().optional(), kind: z.literal("remove"), parentAgentId: z.string(), subagentId: z.string(), @@ -4596,6 +4660,7 @@ export const ProviderSubagentUpdateMessageSchema = z.object({ export const SetAgentTimelineSubscriptionResponseMessageSchema = z.object({ type: z.literal("agent.timeline.set_subscription.response"), payload: z.object({ + subscriptionId: z.string().optional(), agentIds: z.array(z.string()), requestId: z.string(), }), @@ -4604,6 +4669,7 @@ export const SetAgentTimelineSubscriptionResponseMessageSchema = z.object({ export const AgentAttentionRequiredMessageSchema = z.object({ type: z.literal("agent_attention_required"), payload: z.object({ + subscriptionId: z.string().optional(), agentId: z.string(), reason: z.enum(["finished", "error", "permission"]), timestamp: z.string(), @@ -4887,6 +4953,7 @@ export const WriteProjectConfigResponseMessageSchema = z.object({ export const AgentPermissionRequestMessageSchema = z.object({ type: z.literal("agent_permission_request"), payload: z.object({ + subscriptionId: z.string().optional(), agentId: z.string(), request: AgentPermissionRequestPayloadSchema, }), @@ -4895,6 +4962,7 @@ export const AgentPermissionRequestMessageSchema = z.object({ export const AgentPermissionResolvedMessageSchema = z.object({ type: z.literal("agent_permission_resolved"), payload: z.object({ + subscriptionId: z.string().optional(), agentId: z.string(), requestId: z.string(), resolution: AgentPermissionResponseSchema, @@ -5135,6 +5203,7 @@ const CheckoutPrStatusPayloadSchema = z.object({ }); const CheckoutStatusUpdateMetadataSchema = z.object({ + subscriptionId: z.string().optional(), prStatus: CheckoutPrStatusPayloadSchema.optional(), }); @@ -5165,6 +5234,13 @@ export const SubscribeCheckoutDiffResponseSchema = z.object({ }), }); +export const CheckoutDiffGetResponseSchema = z.object({ + type: z.literal("checkout.diff.get.response"), + payload: CheckoutDiffSubscriptionPayloadSchema.omit({ subscriptionId: true }).extend({ + requestId: z.string(), + }), +}); + export const CheckoutDiffUpdateSchema = z.object({ type: z.literal("checkout_diff_update"), payload: CheckoutDiffSubscriptionPayloadSchema, @@ -5929,6 +6005,7 @@ export const GetProvidersSnapshotResponseMessageSchema = z.object({ export const ProvidersSnapshotUpdateMessageSchema = z.object({ type: z.literal("providers_snapshot_update"), payload: z.object({ + subscriptionId: z.string().optional(), cwd: z.string().optional(), entries: z.array(ProviderSnapshotEntrySchema), compactSnapshot: CompactProviderSnapshotSchema.optional(), @@ -6094,6 +6171,9 @@ export const ListTerminalsResponseSchema = z.object({ export const TerminalsChangedSchema = z.object({ type: z.literal("terminals_changed"), payload: z.object({ + subscriptionId: z.string().optional(), + requestId: z.string().optional(), + workspaceId: z.string().optional(), cwd: z.string(), terminals: z.array(TerminalInfoSchema.omit({ cwd: true })), }), @@ -6123,6 +6203,7 @@ export const SubscribeTerminalResponseSchema = z.object({ z.object({ terminalId: z.string(), slot: z.number().int().min(0).max(255), + subscriptionId: z.string().optional(), error: z.null(), requestId: z.string(), }), @@ -6156,13 +6237,17 @@ export const CaptureTerminalResponseSchema = z.object({ export const TerminalStreamExitSchema = z.object({ type: z.literal("terminal_stream_exit"), payload: z.object({ + subscriptionId: z.string().optional(), terminalId: z.string(), + // Observation failure; the underlying terminal process may still be running. + error: z.string().optional(), }), }); export const TerminalAttentionRequiredSchema = z.object({ type: z.literal("terminal_attention_required"), payload: z.object({ + subscriptionId: z.string().optional(), serverId: z.string().optional(), terminalId: z.string(), cwd: z.string(), @@ -6241,6 +6326,7 @@ export const HubExecutionControlResponseSchema = z.object({ export const HubExecutionAgentUpdateSchema = z.object({ type: z.literal("hub.execution.agent.update"), payload: z.object({ + subscriptionId: z.string().optional(), executionId: z.string(), agentId: z.string(), agent: AgentSnapshotPayloadSchema, @@ -6250,6 +6336,7 @@ export const HubExecutionAgentUpdateSchema = z.object({ export const HubExecutionAgentStreamSchema = z.object({ type: z.literal("hub.execution.agent.stream"), payload: z.object({ + subscriptionId: z.string().optional(), executionId: z.string(), agentId: z.string(), event: AgentStreamEventPayloadSchema, @@ -6459,6 +6546,8 @@ export const AgentSkillsImportLegacySelectionResponseSchema = z.object({ }); export const SessionOutboundMessageSchema = z.discriminatedUnion("type", [ + BrowserHostRegisterResponseSchema, + SubscriptionReleaseResponseSchema, SessionEventsSetSubscriptionResponseSchema, HubExecutionAgentCreateResponseSchema, HubExecutionAgentValidateResponseSchema, @@ -6584,6 +6673,7 @@ export const SessionOutboundMessageSchema = z.discriminatedUnion("type", [ CloseItemsResponseSchema, CheckoutStatusResponseSchema, CheckoutStatusUpdateSchema, + CheckoutDiffGetResponseSchema, SubscribeCheckoutDiffResponseSchema, CheckoutDiffUpdateSchema, CheckoutCommitResponseSchema, diff --git a/packages/server/scripts/benchmark-startup-git-real-home.ts b/packages/server/scripts/benchmark-startup-git-real-home.ts index ba1d1129e4..4dde4e190d 100644 --- a/packages/server/scripts/benchmark-startup-git-real-home.ts +++ b/packages/server/scripts/benchmark-startup-git-real-home.ts @@ -140,14 +140,15 @@ async function main(): Promise { await client.connect(); const seen = new Set(); client.on("checkout_status_update", (message) => seen.add(message.payload.cwd)); + await client.observeEvents(["checkout_status_update"]).ready; const workspaces = await client.fetchWorkspaces({ - subscribe: { subscriptionId: `startup-git-${scenario}` }, + subscribe: {}, sort: [{ key: "activity_at", direction: "desc" }], page: { limit: 200 }, }); await client.fetchAgents({ scope: "active", - subscribe: { subscriptionId: `startup-agents-${scenario}` }, + subscribe: {}, page: { limit: 200 }, }); diff --git a/packages/server/scripts/test-mcp-inject.ts b/packages/server/scripts/test-mcp-inject.ts index be5d5f6db4..e9ec1deaa7 100644 --- a/packages/server/scripts/test-mcp-inject.ts +++ b/packages/server/scripts/test-mcp-inject.ts @@ -151,7 +151,7 @@ async function main(): Promise { await client.connect(); await client.fetchAgents({ - subscribe: { subscriptionId: "mcp-inject-real-claude" }, + subscribe: {}, }); const results: ProviderRunResult[] = []; diff --git a/packages/server/scripts/voice-main-daemon-e2e.ts b/packages/server/scripts/voice-main-daemon-e2e.ts index 27ca9d247a..1e59d052e3 100644 --- a/packages/server/scripts/voice-main-daemon-e2e.ts +++ b/packages/server/scripts/voice-main-daemon-e2e.ts @@ -47,7 +47,7 @@ async function runVoiceRoundTrip(params: { const client = new DaemonClient({ url: `${params.daemonUrl}/ws` }); await client.connect(); await client.fetchAgents({ - subscribe: { subscriptionId: `voice-e2e-${randomUUID()}` }, + subscribe: {}, }); const mode = await client.setVoiceMode(true, params.voiceAgentId); @@ -77,7 +77,7 @@ async function runVoiceRoundTrip(params: { }); }); - const offStream = client.on("agent_stream", (msg) => { + const offStream = client.subscribeAgentTimeline(activeVoiceAgentId, (msg) => { if (msg.type !== "agent_stream") return; if (msg.payload.agentId !== activeVoiceAgentId) return; if (msg.payload.event.type !== "timeline") return; @@ -88,6 +88,8 @@ async function runVoiceRoundTrip(params: { } }); + await offStream.ready; + const chunkBytes = 4800; for (let offset = 0; offset < pcm.length; offset += chunkBytes) { const chunk = pcm.subarray(offset, Math.min(pcm.length, offset + chunkBytes)); diff --git a/packages/server/scripts/voice-roundtrip-debug.ts b/packages/server/scripts/voice-roundtrip-debug.ts index 108b3cb035..17ca7b6b19 100644 --- a/packages/server/scripts/voice-roundtrip-debug.ts +++ b/packages/server/scripts/voice-roundtrip-debug.ts @@ -49,7 +49,7 @@ async function main(): Promise { try { await client.connect(); - await client.fetchAgents({ subscribe: { subscriptionId: "voice-debug" } }); + await client.fetchAgents({ subscribe: {} }); const voiceCwd = mkdtempSync(path.join(tmpdir(), "voice-roundtrip-debug-")); const voiceAgent = await client.createAgent({ @@ -88,7 +88,7 @@ async function main(): Promise { } }); - const offStream = client.on("agent_stream", (msg) => { + const offStream = client.subscribeAgentTimeline(voiceAgentId, (msg) => { if (msg.type !== "agent_stream") return; if (msg.payload.event.type !== "timeline") return; const item = msg.payload.event.item; @@ -100,6 +100,8 @@ async function main(): Promise { }); }); + await offStream.ready; + let audioChunkCount = 0; const firstAudio = new Promise((resolve) => { const offAudio = client.on("audio_output", (msg) => { diff --git a/packages/server/src/server/agent/agent-prompt.ts b/packages/server/src/server/agent/agent-prompt.ts index 96f5ce5ee3..e66e80784c 100644 --- a/packages/server/src/server/agent/agent-prompt.ts +++ b/packages/server/src/server/agent/agent-prompt.ts @@ -229,6 +229,7 @@ const AGENT_RUN_START_TIMEOUT_MS = 60_000; export async function waitForAgentRunStartWithTimeout( agentManager: AgentManager, agentId: string, + signal?: AbortSignal, ): Promise { const provider = agentManager.getAgent(agentId)?.provider ?? "provider"; const startAbort = new AbortController(); @@ -243,7 +244,9 @@ export async function waitForAgentRunStartWithTimeout( ); try { - await agentManager.waitForAgentRunStart(agentId, { signal: startAbort.signal }); + await agentManager.waitForAgentRunStart(agentId, { + signal: signal ? AbortSignal.any([startAbort.signal, signal]) : startAbort.signal, + }); } finally { clearTimeout(startTimeout); } diff --git a/packages/server/src/server/authorization/operation-permissions.ts b/packages/server/src/server/authorization/operation-permissions.ts index ec6c4db2dd..6a6a1e78ec 100644 --- a/packages/server/src/server/authorization/operation-permissions.ts +++ b/packages/server/src/server/authorization/operation-permissions.ts @@ -20,6 +20,8 @@ const INBOUND_PERMISSION = { "agent.skills.uninstall.request": "daemon.manage", "agent.timeline.list_prompts.request": "workspace.read", "agent.timeline.append.request": "workspace.write", + "browser.host.register.request": ["workspace.write"], + "subscription.release.request": null, "session.events.set_subscription.request": ["workspace.read", "daemon.read", "hub.execute"], "agent.timeline.set_subscription.request": ["workspace.read", "hub.execute"], agent_permission_response: "workspace.write", @@ -172,6 +174,7 @@ const INBOUND_PERMISSION = { stash_list_request: "workspace.read", stash_pop_request: "workspace.write", stash_save_request: "workspace.write", + "checkout.diff.get.request": "workspace.read", subscribe_checkout_diff_request: "workspace.read", subscribe_terminal_request: "workspace.read", subscribe_terminals_request: "workspace.read", @@ -222,6 +225,8 @@ const OUTBOUND_PERMISSION = { "agent.timeline.list_prompts.response": "workspace.read", "agent.timeline.append.response": "workspace.write", "agent.timeline.replacement": ["workspace.read", "hub.execute"], + "browser.host.register.response": ["workspace.write"], + "subscription.release.response": null, "session.events.set_subscription.response": ["workspace.read", "daemon.read", "hub.execute"], "agent.timeline.set_subscription.response": ["workspace.read", "hub.execute"], agent_archived: ["workspace.read", "hub.execute"], @@ -385,6 +390,7 @@ const OUTBOUND_PERMISSION = { stash_pop_response: "workspace.read", stash_save_response: "workspace.read", status: "daemon.read", + "checkout.diff.get.response": "workspace.read", subscribe_checkout_diff_response: "workspace.read", subscribe_terminal_response: "workspace.read", "terminal.rename.response": "workspace.write", diff --git a/packages/server/src/server/browser-tools/broker.ts b/packages/server/src/server/browser-tools/broker.ts index dc1eca7f2d..b59094de20 100644 --- a/packages/server/src/server/browser-tools/broker.ts +++ b/packages/server/src/server/browser-tools/broker.ts @@ -158,7 +158,10 @@ export class BrowserToolsBroker { }); } - public receiveResponse(response: BrowserAutomationExecuteResponse): boolean { + public receiveResponse( + response: BrowserAutomationExecuteResponse, + clientIds?: readonly string[], + ): boolean { const parsed = BrowserAutomationExecuteResponseSchema.safeParse(response); if (!parsed.success) { const requestId = getBrowserAutomationResponseRequestId(response); @@ -167,7 +170,7 @@ export class BrowserToolsBroker { } const pending = this.pending.get(requestId); - if (!pending) { + if (!pending || (clientIds && !clientIds.includes(pending.clientId))) { return false; } @@ -184,7 +187,7 @@ export class BrowserToolsBroker { } const pending = this.pending.get(parsed.data.payload.requestId); - if (!pending) { + if (!pending || (clientIds && !clientIds.includes(pending.clientId))) { return false; } diff --git a/packages/server/src/server/checkout-diff-manager.ts b/packages/server/src/server/checkout-diff-manager.ts index 9bf0439aff..628bb32a0a 100644 --- a/packages/server/src/server/checkout-diff-manager.ts +++ b/packages/server/src/server/checkout-diff-manager.ts @@ -73,6 +73,12 @@ export class CheckoutDiffManager { this.workspaceGitService = options.workspaceGitService; } + async read( + params: Omit, + ): Promise { + return this.computeCheckoutDiffSnapshot(params.cwd, this.normalizeCompare(params.compare)); + } + async subscribe( params: CheckoutDiffSubscriptionRequest, listener: (snapshot: CheckoutDiffSnapshotPayload) => void, diff --git a/packages/server/src/server/cli-run-workspace-precedence.e2e.test.ts b/packages/server/src/server/cli-run-workspace-precedence.e2e.test.ts index e0524c8948..0247e7af97 100644 --- a/packages/server/src/server/cli-run-workspace-precedence.e2e.test.ts +++ b/packages/server/src/server/cli-run-workspace-precedence.e2e.test.ts @@ -40,7 +40,7 @@ test("daemon resolves human and managed CLI workspace ownership", async () => { try { await client.connect(); - await client.fetchAgents({ subscribe: { subscriptionId: "test" } }); + await client.fetchAgents({ subscribe: {} }); // A bare run mints a fresh local workspace for the cwd, then the agent is // stamped with that workspace's id. diff --git a/packages/server/src/server/daemon-client.e2e.test.ts b/packages/server/src/server/daemon-client.e2e.test.ts index 934125af11..f429cb672c 100644 --- a/packages/server/src/server/daemon-client.e2e.test.ts +++ b/packages/server/src/server/daemon-client.e2e.test.ts @@ -121,7 +121,7 @@ test("createAgent without an initial prompt returns an idle snapshot", async () try { await client.connect(); - await client.fetchAgents({ subscribe: { subscriptionId: "create-no-prompt" } }); + await client.fetchAgents({ subscribe: {} }); const agent = await client.createAgent({ provider: "codex", @@ -185,7 +185,7 @@ test("createAgent with background initialPrompt returns a running snapshot befor try { await client.connect(); - await client.fetchAgents({ subscribe: { subscriptionId: "create-background-prompt" } }); + await client.fetchAgents({ subscribe: {} }); const agent = await client.createAgent({ provider: "codex", @@ -415,7 +415,7 @@ test("createAgent fails when the initial turn cannot start", async () => { try { await client.connect(); - await client.fetchAgents({ subscribe: { subscriptionId: "create-start-failure" } }); + await client.fetchAgents({ subscribe: {} }); await expect( client.createAgent({ @@ -1325,7 +1325,7 @@ test("creates agent and exercises lifecycle", async () => { const cwd = tmpCwd(); await ctx.client.fetchAgents({ - subscribe: { subscriptionId: "daemon-client-lifecycle" }, + subscribe: {}, }); const agentUpdatePromise = waitForSignal(15000, (resolve) => { diff --git a/packages/server/src/server/daemon-e2e/agent-archive.native.real.e2e.test.ts b/packages/server/src/server/daemon-e2e/agent-archive.native.real.e2e.test.ts index 17dce841cf..c3e58f8b94 100644 --- a/packages/server/src/server/daemon-e2e/agent-archive.native.real.e2e.test.ts +++ b/packages/server/src/server/daemon-e2e/agent-archive.native.real.e2e.test.ts @@ -41,7 +41,7 @@ async function expectContextAfterWorkspaceRestore(legacyNativeArchive: boolean): }); client = new DaemonClient({ url: `ws://127.0.0.1:${daemon.port}/ws`, appVersion: "0.8.0" }); await client.connect(); - await client.fetchAgents({ subscribe: { subscriptionId: "archive-qa" } }); + await client.fetchAgents({ subscribe: {} }); const agent = await client.createAgent({ config: { provider: "codex", diff --git a/packages/server/src/server/daemon-e2e/agent-operations.e2e.test.ts b/packages/server/src/server/daemon-e2e/agent-operations.e2e.test.ts index c5618b9f09..e8bb548688 100644 --- a/packages/server/src/server/daemon-e2e/agent-operations.e2e.test.ts +++ b/packages/server/src/server/daemon-e2e/agent-operations.e2e.test.ts @@ -130,7 +130,7 @@ describe("cancelAgent", () => { const cwd = tmpCwd(); await ctx.client.fetchAgents({ - subscribe: { subscriptionId: "agent-operations-cancel" }, + subscribe: {}, }); // Create Codex agent @@ -203,7 +203,7 @@ describe("setAgentMode", () => { const cwd = tmpCwd(); await ctx.client.fetchAgents({ - subscribe: { subscriptionId: "agent-operations-mode" }, + subscribe: {}, }); // Create a Codex agent with default mode ("auto") diff --git a/packages/server/src/server/daemon-e2e/agent-refresh-rehydrates-timeline.e2e.test.ts b/packages/server/src/server/daemon-e2e/agent-refresh-rehydrates-timeline.e2e.test.ts index 44e774d7a3..301df35e4b 100644 --- a/packages/server/src/server/daemon-e2e/agent-refresh-rehydrates-timeline.e2e.test.ts +++ b/packages/server/src/server/daemon-e2e/agent-refresh-rehydrates-timeline.e2e.test.ts @@ -113,7 +113,7 @@ describe("daemon E2E - refresh rehydrates timeline from on-disk session", () => client = new DaemonClient({ url: `ws://127.0.0.1:${daemon.port}/ws` }); await client.connect(); await client.fetchAgents({ - subscribe: { subscriptionId: "refresh-rehydrate-test" }, + subscribe: {}, }); const imported = await client.importAgent({ provider: "claude", sessionId, cwd }); diff --git a/packages/server/src/server/daemon-e2e/agent-reload.native.real.e2e.test.ts b/packages/server/src/server/daemon-e2e/agent-reload.native.real.e2e.test.ts index ac9d2f83cb..cbbacde969 100644 --- a/packages/server/src/server/daemon-e2e/agent-reload.native.real.e2e.test.ts +++ b/packages/server/src/server/daemon-e2e/agent-reload.native.real.e2e.test.ts @@ -61,7 +61,7 @@ async function withNativeConversation( }); client = new DaemonClient({ url: `ws://127.0.0.1:${daemon.port}/ws`, appVersion: "0.7.2" }); await client.connect(); - await client.fetchAgents({ subscribe: { subscriptionId: "native-reload-qa" } }); + await client.fetchAgents({ subscribe: {} }); const agent = await client.createAgent({ provider, cwd, ...config }); process.stdout.write(`${provider}: model=${agent.model ?? "default"}\n`); const manager = daemon.daemon.agentManager; diff --git a/packages/server/src/server/daemon-e2e/auto-archive-unrelated-github-merge.real.e2e.test.ts b/packages/server/src/server/daemon-e2e/auto-archive-unrelated-github-merge.real.e2e.test.ts index b3a3d4854f..67d39b3134 100644 --- a/packages/server/src/server/daemon-e2e/auto-archive-unrelated-github-merge.real.e2e.test.ts +++ b/packages/server/src/server/daemon-e2e/auto-archive-unrelated-github-merge.real.e2e.test.ts @@ -126,7 +126,7 @@ githubTest( throw new Error("Failed to register both worktrees"); } await context.client.fetchWorkspaces({ - subscribe: { subscriptionId: "real-unrelated-merge" }, + subscribe: {}, }); writeFileSync(path.join(merged.worktreePath, "feature.txt"), "merged\n"); diff --git a/packages/server/src/server/daemon-e2e/checkout-diff-subscription.e2e.test.ts b/packages/server/src/server/daemon-e2e/checkout-diff-subscription.e2e.test.ts index 7ce8cbb3a3..4b56fc9378 100644 --- a/packages/server/src/server/daemon-e2e/checkout-diff-subscription.e2e.test.ts +++ b/packages/server/src/server/daemon-e2e/checkout-diff-subscription.e2e.test.ts @@ -77,12 +77,9 @@ test("pushes file-level checkout diff updates with deterministic path order", as initGitRepo(cwd); commitFile(cwd, "base.txt", "base\n"); - const subscriptionId = "checkout-diff-e2e-subscription"; - const initial = await ctx.client.subscribeCheckoutDiff( - cwd, - { mode: "uncommitted" }, - { subscriptionId }, - ); + const subscription = ctx.client.observeCheckoutDiff(cwd, { mode: "uncommitted" }); + const initial = await subscription.ready; + const subscriptionId = initial.subscriptionId; expect(initial.error).toBeNull(); expect(initial.files).toEqual([]); @@ -98,7 +95,7 @@ test("pushes file-level checkout diff updates with deterministic path order", as expect(update.error).toBeNull(); expect(update.files.map((file) => file.path)).toEqual(["alpha.txt", "zeta.txt"]); - ctx.client.unsubscribeCheckoutDiff(subscriptionId); + await subscription.release(); } finally { rmSync(cwd, { recursive: true, force: true }); } @@ -114,12 +111,9 @@ test("pushes updates when subscribed from a subdirectory and files change outsid const nestedDir = path.join(cwd, "nested", "dir"); mkdirSync(nestedDir, { recursive: true }); - const subscriptionId = "checkout-diff-subdir-e2e-subscription"; - const initial = await ctx.client.subscribeCheckoutDiff( - nestedDir, - { mode: "uncommitted" }, - { subscriptionId }, - ); + const subscription = ctx.client.observeCheckoutDiff(nestedDir, { mode: "uncommitted" }); + const initial = await subscription.ready; + const subscriptionId = initial.subscriptionId; expect(initial.error).toBeNull(); expect(initial.files).toEqual([]); @@ -133,7 +127,7 @@ test("pushes updates when subscribed from a subdirectory and files change outsid expect(update.error).toBeNull(); expect(update.files.some((file) => file.path === "outside-subdir.txt")).toBe(true); - ctx.client.unsubscribeCheckoutDiff(subscriptionId); + await subscription.release(); } finally { rmSync(cwd, { recursive: true, force: true }); } @@ -150,11 +144,8 @@ test("keeps the socket usable after rejecting an oversized structured diff", asy writeFileSync(path.join(cwd, "large-a.js"), denseExpression); writeFileSync(path.join(cwd, "large-b.js"), denseExpression); - const initial = await ctx.client.subscribeCheckoutDiff( - cwd, - { mode: "uncommitted" }, - { subscriptionId: "oversized-checkout-diff" }, - ); + const subscription = ctx.client.observeCheckoutDiff(cwd, { mode: "uncommitted" }); + const initial = await subscription.ready; expect(initial).toMatchObject({ cwd, @@ -165,6 +156,7 @@ test("keeps the socket usable after rejecting an oversized structured diff", asy const status = await ctx.client.getCheckoutStatus(cwd); expect(status).toMatchObject({ cwd, isGit: true }); + await subscription.release(); } finally { rmSync(cwd, { recursive: true, force: true }); } diff --git a/packages/server/src/server/daemon-e2e/claude-autonomous-wake-simple.real.e2e.test.ts b/packages/server/src/server/daemon-e2e/claude-autonomous-wake-simple.real.e2e.test.ts index 77bd164732..0631cfd867 100644 --- a/packages/server/src/server/daemon-e2e/claude-autonomous-wake-simple.real.e2e.test.ts +++ b/packages/server/src/server/daemon-e2e/claude-autonomous-wake-simple.real.e2e.test.ts @@ -45,7 +45,7 @@ describe("daemon E2E (real claude) - autonomous wake simple", () => { try { await client.connect(); await client.fetchAgents({ - subscribe: { subscriptionId: "claude-autonomous-simple-real" }, + subscribe: {}, }); const agent = await client.createAgent({ diff --git a/packages/server/src/server/daemon-e2e/claude-autonomous-wake.real.e2e.test.ts b/packages/server/src/server/daemon-e2e/claude-autonomous-wake.real.e2e.test.ts index fe482d00a5..5899a91bc8 100644 --- a/packages/server/src/server/daemon-e2e/claude-autonomous-wake.real.e2e.test.ts +++ b/packages/server/src/server/daemon-e2e/claude-autonomous-wake.real.e2e.test.ts @@ -451,7 +451,7 @@ describe("daemon E2E (real claude) - autonomous wake from background task", () = try { await client.connect(); await client.fetchAgents({ - subscribe: { subscriptionId: "claude-autonomous-abc-a" }, + subscribe: {}, }); const agent = await client.createAgent({ @@ -524,7 +524,7 @@ describe("daemon E2E (real claude) - autonomous wake from background task", () = try { await client.connect(); await client.fetchAgents({ - subscribe: { subscriptionId: "claude-autonomous-abc-b" }, + subscribe: {}, }); const agent = await client.createAgent({ @@ -597,7 +597,7 @@ describe("daemon E2E (real claude) - autonomous wake from background task", () = try { await client.connect(); await client.fetchAgents({ - subscribe: { subscriptionId: "claude-autonomous-abc-c" }, + subscribe: {}, }); const agent = await client.createAgent({ @@ -663,7 +663,7 @@ describe("daemon E2E (real claude) - autonomous wake from background task", () = try { await client.connect(); await client.fetchAgents({ - subscribe: { subscriptionId: "claude-autonomous-wake-real" }, + subscribe: {}, }); const agent = await client.createAgent({ @@ -721,7 +721,7 @@ describe("daemon E2E (real claude) - autonomous wake from background task", () = try { await client.connect(); await client.fetchAgents({ - subscribe: { subscriptionId: "claude-autonomous-followup-real" }, + subscribe: {}, }); const agent = await client.createAgent({ @@ -767,7 +767,7 @@ describe("daemon E2E (real claude) - autonomous wake from background task", () = try { await client.connect(); await client.fetchAgents({ - subscribe: { subscriptionId: "claude-hang-repro-real" }, + subscribe: {}, }); const agent = await client.createAgent({ @@ -869,7 +869,7 @@ describe("daemon E2E (real claude) - autonomous wake from background task", () = try { await client.connect(); await client.fetchAgents({ - subscribe: { subscriptionId: "claude-background-repro-real" }, + subscribe: {}, }); const agent = await client.createAgent({ @@ -941,7 +941,7 @@ describe("daemon E2E (real claude) - autonomous wake from background task", () = try { await client.connect(); await client.fetchAgents({ - subscribe: { subscriptionId: "claude-autonomous-race-stress-real" }, + subscribe: {}, }); const agent = await client.createAgent({ @@ -1027,7 +1027,7 @@ describe("daemon E2E (real claude) - autonomous wake from background task", () = try { await client.connect(); await client.fetchAgents({ - subscribe: { subscriptionId: "claude-transcript-parity-race" }, + subscribe: {}, }); const agent = await client.createAgent({ diff --git a/packages/server/src/server/daemon-e2e/claude-live-usage.e2e.test.ts b/packages/server/src/server/daemon-e2e/claude-live-usage.e2e.test.ts index 4d4ceb3a57..39b3d7d176 100644 --- a/packages/server/src/server/daemon-e2e/claude-live-usage.e2e.test.ts +++ b/packages/server/src/server/daemon-e2e/claude-live-usage.e2e.test.ts @@ -150,7 +150,7 @@ describe("daemon E2E (claude live usage)", () => { try { await client.connect(); - await client.fetchAgents({ subscribe: { subscriptionId: "claude-live-usage" } }); + await client.fetchAgents({ subscribe: {} }); const agent = await client.createAgent({ provider: "claude", diff --git a/packages/server/src/server/daemon-e2e/claude-rewind.real.e2e.test.ts b/packages/server/src/server/daemon-e2e/claude-rewind.real.e2e.test.ts index c362529e23..23ab49e67d 100644 --- a/packages/server/src/server/daemon-e2e/claude-rewind.real.e2e.test.ts +++ b/packages/server/src/server/daemon-e2e/claude-rewind.real.e2e.test.ts @@ -234,7 +234,7 @@ describe("daemon E2E (real claude) - rewind", () => { }); await client.connect(); - await client.fetchAgents({ subscribe: { subscriptionId: "claude-rewind-real" } }); + await client.fetchAgents({ subscribe: {} }); harness = { client, daemon }; }, 30_000); diff --git a/packages/server/src/server/daemon-e2e/claude-thinking-memory.real.e2e.test.ts b/packages/server/src/server/daemon-e2e/claude-thinking-memory.real.e2e.test.ts index 937f3d0c6a..c1640a7d2d 100644 --- a/packages/server/src/server/daemon-e2e/claude-thinking-memory.real.e2e.test.ts +++ b/packages/server/src/server/daemon-e2e/claude-thinking-memory.real.e2e.test.ts @@ -72,7 +72,7 @@ describe("daemon E2E (real claude) - thinking effort memory", () => { try { await client.connect(); await client.fetchAgents({ - subscribe: { subscriptionId: "claude-thinking-memory-real" }, + subscribe: {}, }); const modelList = await client.listProviderModels("claude", { cwd }); diff --git a/packages/server/src/server/daemon-e2e/codex-auto-review.local.e2e.test.ts b/packages/server/src/server/daemon-e2e/codex-auto-review.local.e2e.test.ts index c0e9bc6154..8237ce9ef7 100644 --- a/packages/server/src/server/daemon-e2e/codex-auto-review.local.e2e.test.ts +++ b/packages/server/src/server/daemon-e2e/codex-auto-review.local.e2e.test.ts @@ -288,7 +288,7 @@ async function runScenario(modeId: "auto" | "auto-review") { try { writeMockCodexConfig(codexHome, mockServer.url); await client.connect(); - await client.fetchAgents({ subscribe: { subscriptionId: `codex-auto-review-${modeId}` } }); + await client.fetchAgents({ subscribe: {} }); const agent = await client.createAgent({ provider: "codex", diff --git a/packages/server/src/server/daemon-e2e/codex-goal-mid-turn.real.e2e.test.ts b/packages/server/src/server/daemon-e2e/codex-goal-mid-turn.real.e2e.test.ts index 9e4722ea26..6f43929f2d 100644 --- a/packages/server/src/server/daemon-e2e/codex-goal-mid-turn.real.e2e.test.ts +++ b/packages/server/src/server/daemon-e2e/codex-goal-mid-turn.real.e2e.test.ts @@ -116,7 +116,7 @@ describe("daemon E2E (real codex) - /goal command", () => { try { await client.connect(); - await client.fetchAgents({ subscribe: { subscriptionId: "primary" } }); + await client.fetchAgents({ subscribe: {} }); const agent = await client.createAgent({ cwd, @@ -170,7 +170,7 @@ describe("daemon E2E (real codex) - /goal command", () => { try { await client.connect(); - await client.fetchAgents({ subscribe: { subscriptionId: "primary" } }); + await client.fetchAgents({ subscribe: {} }); const agent = await client.createAgent({ cwd, @@ -247,7 +247,7 @@ describe("daemon E2E (real codex) - /goal command", () => { try { await client.connect(); - await client.fetchAgents({ subscribe: { subscriptionId: "primary" } }); + await client.fetchAgents({ subscribe: {} }); const agent = await client.createAgent({ cwd, diff --git a/packages/server/src/server/daemon-e2e/codex-rewind.real.e2e.test.ts b/packages/server/src/server/daemon-e2e/codex-rewind.real.e2e.test.ts index e61346a6a2..c55322de0e 100644 --- a/packages/server/src/server/daemon-e2e/codex-rewind.real.e2e.test.ts +++ b/packages/server/src/server/daemon-e2e/codex-rewind.real.e2e.test.ts @@ -164,7 +164,7 @@ describe("daemon E2E (real codex) - rewind", () => { }); await client.connect(); - await client.fetchAgents({ subscribe: { subscriptionId: "codex-rewind-real" } }); + await client.fetchAgents({ subscribe: {} }); harness = { client, daemon }; }, 30_000); diff --git a/packages/server/src/server/daemon-e2e/empty-project-persists.e2e.test.ts b/packages/server/src/server/daemon-e2e/empty-project-persists.e2e.test.ts index 1c5fed39ed..6b2e0bef4b 100644 --- a/packages/server/src/server/daemon-e2e/empty-project-persists.e2e.test.ts +++ b/packages/server/src/server/daemon-e2e/empty-project-persists.e2e.test.ts @@ -108,7 +108,7 @@ test("archiving the last workspace leaves the project parent with no workspaces" const client = new DaemonClient({ url: `ws://127.0.0.1:${daemon.port}/ws` }); cleanupClients.add(client); await client.connect(); - await client.fetchAgents({ subscribe: { subscriptionId: "empty-project-agents" } }); + await client.fetchAgents({ subscribe: {} }); const created = await client.createWorkspace({ source: { kind: "directory", path: repoRoot } }); expect(created.error).toBeNull(); diff --git a/packages/server/src/server/daemon-e2e/live-preferences.e2e.test.ts b/packages/server/src/server/daemon-e2e/live-preferences.e2e.test.ts index 25c74c62a0..9eb1ff929a 100644 --- a/packages/server/src/server/daemon-e2e/live-preferences.e2e.test.ts +++ b/packages/server/src/server/daemon-e2e/live-preferences.e2e.test.ts @@ -96,7 +96,7 @@ beforeEach(async () => { unsubscribe = ctx.client.subscribeRawMessages((message) => { messages.push(message); }); - await ctx.client.fetchAgents({ subscribe: { subscriptionId: "live-preferences" } }); + await ctx.client.fetchAgents({ subscribe: {} }); }); afterEach(async () => { diff --git a/packages/server/src/server/daemon-e2e/mode-switch-propagation.e2e.test.ts b/packages/server/src/server/daemon-e2e/mode-switch-propagation.e2e.test.ts index d1acba0e55..81d60901a7 100644 --- a/packages/server/src/server/daemon-e2e/mode-switch-propagation.e2e.test.ts +++ b/packages/server/src/server/daemon-e2e/mode-switch-propagation.e2e.test.ts @@ -115,7 +115,7 @@ describe("mode-switch update propagation", () => { await client2.connect(); const bootstrapResult = await client2.fetchAgents({ - subscribe: { subscriptionId: "bootstrap-test" }, + subscribe: {}, }); const found = bootstrapResult.entries.find((e) => e.agent.id === agent.id); diff --git a/packages/server/src/server/daemon-e2e/model-resolution-on-init.real.e2e.test.ts b/packages/server/src/server/daemon-e2e/model-resolution-on-init.real.e2e.test.ts index 71e9f9ed73..52bee9f79f 100644 --- a/packages/server/src/server/daemon-e2e/model-resolution-on-init.real.e2e.test.ts +++ b/packages/server/src/server/daemon-e2e/model-resolution-on-init.real.e2e.test.ts @@ -41,7 +41,7 @@ describe("daemon E2E (real claude) - model resolution on init", () => { try { await client.connect(); await client.fetchAgents({ - subscribe: { subscriptionId: "model-init-test" }, + subscribe: {}, }); const modelsResult = await client.listProviderModels("claude", { cwd }); diff --git a/packages/server/src/server/daemon-e2e/model-runtime-reconcile-claude.real.e2e.test.ts b/packages/server/src/server/daemon-e2e/model-runtime-reconcile-claude.real.e2e.test.ts index 83fe12e1e3..fba40a681c 100644 --- a/packages/server/src/server/daemon-e2e/model-runtime-reconcile-claude.real.e2e.test.ts +++ b/packages/server/src/server/daemon-e2e/model-runtime-reconcile-claude.real.e2e.test.ts @@ -37,7 +37,7 @@ describe("daemon E2E (real claude) - runtime model reconciliation", () => { try { await client.connect(); await client.fetchAgents({ - subscribe: { subscriptionId: "claude-runtime-model-reconcile" }, + subscribe: {}, }); const modelsResult = await client.listProviderModels("claude", { cwd }); diff --git a/packages/server/src/server/daemon-e2e/omp.real.e2e.test.ts b/packages/server/src/server/daemon-e2e/omp.real.e2e.test.ts index a0634e2bd7..f336f325bf 100644 --- a/packages/server/src/server/daemon-e2e/omp.real.e2e.test.ts +++ b/packages/server/src/server/daemon-e2e/omp.real.e2e.test.ts @@ -1,5 +1,4 @@ import { execFile } from "node:child_process"; -import { randomUUID } from "node:crypto"; import { mkdtempSync, rmSync } from "node:fs"; import { tmpdir } from "node:os"; import path from "node:path"; @@ -69,7 +68,7 @@ async function createHarness(): Promise { }); await client.connect(); await client.fetchAgents({ - subscribe: { subscriptionId: `omp-real-${randomUUID()}` }, + subscribe: {}, }); return { daemon, client, cwd, paseoHomeRoot, staticDir }; } diff --git a/packages/server/src/server/daemon-e2e/open-project-missing-directory.e2e.test.ts b/packages/server/src/server/daemon-e2e/open-project-missing-directory.e2e.test.ts index ebd93a4ff5..8989f9ebac 100644 --- a/packages/server/src/server/daemon-e2e/open-project-missing-directory.e2e.test.ts +++ b/packages/server/src/server/daemon-e2e/open-project-missing-directory.e2e.test.ts @@ -48,7 +48,7 @@ test("openProject on a nonexistent directory does not broadcast an upsert that i const client = new DaemonClient({ url: `ws://127.0.0.1:${daemon.port}/ws` }); cleanupClients.add(client); await client.connect(); - await client.fetchAgents({ subscribe: { subscriptionId: "missing-dir-agents" } }); + await client.fetchAgents({ subscribe: {} }); const workspaceEvents: Array<{ kind: "upsert" | "remove"; workspaceId: string }> = []; client.subscribe((event: DaemonEvent) => { @@ -57,7 +57,7 @@ test("openProject on a nonexistent directory does not broadcast an upsert that i } workspaceEvents.push({ kind: event.payload.kind, workspaceId: event.workspaceId }); }); - await client.fetchWorkspaces({ subscribe: { subscriptionId: "missing-dir-workspaces" } }); + await client.fetchWorkspaces({ subscribe: {} }); const tempParent = await mkdtemp(path.join(os.tmpdir(), "paseo-open-project-")); cleanupPaths.add(tempParent); @@ -77,8 +77,8 @@ test("openProject expands tilde before creating the workspace", async () => { const client = new DaemonClient({ url: `ws://127.0.0.1:${daemon.port}/ws` }); cleanupClients.add(client); await client.connect(); - await client.fetchAgents({ subscribe: { subscriptionId: "tilde-project-agents" } }); - await client.fetchWorkspaces({ subscribe: { subscriptionId: "tilde-project-workspaces" } }); + await client.fetchAgents({ subscribe: {} }); + await client.fetchWorkspaces({ subscribe: {} }); const home = process.env.HOME || os.homedir(); const workspacePath = await mkdtemp(path.join(home, ".paseo-open-project-")); diff --git a/packages/server/src/server/daemon-e2e/open-project-worktree-reclassification.e2e.test.ts b/packages/server/src/server/daemon-e2e/open-project-worktree-reclassification.e2e.test.ts index ab49ecf9d3..e463d2936a 100644 --- a/packages/server/src/server/daemon-e2e/open-project-worktree-reclassification.e2e.test.ts +++ b/packages/server/src/server/daemon-e2e/open-project-worktree-reclassification.e2e.test.ts @@ -106,7 +106,7 @@ test("openProject preserves a worktree's exact-root project without rehoming it" const client = new DaemonClient({ url: `ws://127.0.0.1:${daemon.port}/ws` }); cleanupClients.add(client); await client.connect(); - await client.fetchAgents({ subscribe: { subscriptionId: "worktree-reclassification" } }); + await client.fetchAgents({ subscribe: {} }); const response = await client.openProject(worktreeRoot); const persistedProjects = await readRegistry(projectsPath); diff --git a/packages/server/src/server/daemon-e2e/opencode-custom-agents.real.e2e.test.ts b/packages/server/src/server/daemon-e2e/opencode-custom-agents.real.e2e.test.ts index 048a8dd0c6..93e227ecdd 100644 --- a/packages/server/src/server/daemon-e2e/opencode-custom-agents.real.e2e.test.ts +++ b/packages/server/src/server/daemon-e2e/opencode-custom-agents.real.e2e.test.ts @@ -23,7 +23,7 @@ async function createHarness(): Promise<{ }); const client = new DaemonClient({ url: `ws://127.0.0.1:${daemon.port}/ws` }); await client.connect(); - await client.fetchAgents({ subscribe: { subscriptionId: "opencode-custom-agents" } }); + await client.fetchAgents({ subscribe: {} }); return { client, daemon }; } diff --git a/packages/server/src/server/daemon-e2e/opencode-draft-features.real.e2e.test.ts b/packages/server/src/server/daemon-e2e/opencode-draft-features.real.e2e.test.ts index d66ca028eb..a06880d612 100644 --- a/packages/server/src/server/daemon-e2e/opencode-draft-features.real.e2e.test.ts +++ b/packages/server/src/server/daemon-e2e/opencode-draft-features.real.e2e.test.ts @@ -1,4 +1,3 @@ -import { randomUUID } from "node:crypto"; import { mkdtempSync, realpathSync, rmSync } from "node:fs"; import { tmpdir } from "node:os"; import path from "node:path"; @@ -30,7 +29,7 @@ async function withConnectedOpenCodeDaemon( try { await client.connect(); await client.fetchAgents({ - subscribe: { subscriptionId: `opencode-draft-features-${randomUUID()}` }, + subscribe: {}, }); await run({ client }); } finally { diff --git a/packages/server/src/server/daemon-e2e/opencode-import-persistence.real.e2e.test.ts b/packages/server/src/server/daemon-e2e/opencode-import-persistence.real.e2e.test.ts index 3d1aec0a96..d518dac227 100644 --- a/packages/server/src/server/daemon-e2e/opencode-import-persistence.real.e2e.test.ts +++ b/packages/server/src/server/daemon-e2e/opencode-import-persistence.real.e2e.test.ts @@ -36,7 +36,7 @@ async function withConnectedOpenCodeDaemon( try { await client.connect(); await client.fetchAgents({ - subscribe: { subscriptionId: `opencode-import-persistence-${randomUUID()}` }, + subscribe: {}, }); await run({ client }); } finally { diff --git a/packages/server/src/server/daemon-e2e/opencode-initial-prompt-wait.real.e2e.test.ts b/packages/server/src/server/daemon-e2e/opencode-initial-prompt-wait.real.e2e.test.ts index 9fa2f13cf6..e03c66bca0 100644 --- a/packages/server/src/server/daemon-e2e/opencode-initial-prompt-wait.real.e2e.test.ts +++ b/packages/server/src/server/daemon-e2e/opencode-initial-prompt-wait.real.e2e.test.ts @@ -29,7 +29,7 @@ async function createHarness(): Promise<{ }); const client = new DaemonClient({ url: `ws://127.0.0.1:${daemon.port}/ws` }); await client.connect(); - await client.fetchAgents({ subscribe: { subscriptionId: "opencode-init-prompt" } }); + await client.fetchAgents({ subscribe: {} }); return { client, daemon }; } diff --git a/packages/server/src/server/daemon-e2e/opencode-invalid-mode.real.e2e.test.ts b/packages/server/src/server/daemon-e2e/opencode-invalid-mode.real.e2e.test.ts index 8235937f64..0291c5e338 100644 --- a/packages/server/src/server/daemon-e2e/opencode-invalid-mode.real.e2e.test.ts +++ b/packages/server/src/server/daemon-e2e/opencode-invalid-mode.real.e2e.test.ts @@ -23,7 +23,7 @@ async function createHarness(): Promise<{ }); const client = new DaemonClient({ url: `ws://127.0.0.1:${daemon.port}/ws` }); await client.connect(); - await client.fetchAgents({ subscribe: { subscriptionId: "opencode-invalid-mode" } }); + await client.fetchAgents({ subscribe: {} }); return { client, daemon }; } diff --git a/packages/server/src/server/daemon-e2e/opencode-invalid-model.real.e2e.test.ts b/packages/server/src/server/daemon-e2e/opencode-invalid-model.real.e2e.test.ts index 455d099088..8cdc409c7f 100644 --- a/packages/server/src/server/daemon-e2e/opencode-invalid-model.real.e2e.test.ts +++ b/packages/server/src/server/daemon-e2e/opencode-invalid-model.real.e2e.test.ts @@ -23,7 +23,7 @@ async function createHarness(): Promise<{ }); const client = new DaemonClient({ url: `ws://127.0.0.1:${daemon.port}/ws` }); await client.connect(); - await client.fetchAgents({ subscribe: { subscriptionId: "opencode-invalid-model" } }); + await client.fetchAgents({ subscribe: {} }); return { client, daemon }; } diff --git a/packages/server/src/server/daemon-e2e/opencode-omo-real-runtime.ts b/packages/server/src/server/daemon-e2e/opencode-omo-real-runtime.ts index 923431f9bf..70296864ed 100644 --- a/packages/server/src/server/daemon-e2e/opencode-omo-real-runtime.ts +++ b/packages/server/src/server/daemon-e2e/opencode-omo-real-runtime.ts @@ -187,7 +187,7 @@ export async function createOpenCodeOmoRealRuntime(): Promise { }); await client.connect(); - await client.fetchAgents({ subscribe: { subscriptionId: "opencode-rewind-real" } }); + await client.fetchAgents({ subscribe: {} }); harness = { client, daemon }; }, 30_000); diff --git a/packages/server/src/server/daemon-e2e/opencode-send-interrupt.real.e2e.test.ts b/packages/server/src/server/daemon-e2e/opencode-send-interrupt.real.e2e.test.ts index bdf5012f67..6fafc0f40c 100644 --- a/packages/server/src/server/daemon-e2e/opencode-send-interrupt.real.e2e.test.ts +++ b/packages/server/src/server/daemon-e2e/opencode-send-interrupt.real.e2e.test.ts @@ -256,7 +256,7 @@ async function createHarness(): Promise<{ }); const client = new DaemonClient({ url: `ws://127.0.0.1:${daemon.port}/ws` }); await client.connect(); - await client.fetchAgents({ subscribe: { subscriptionId: "opencode-send-interrupt-real" } }); + await client.fetchAgents({ subscribe: {} }); return { client, daemon }; } diff --git a/packages/server/src/server/daemon-e2e/opencode-slow-plugin-readiness.real.e2e.test.ts b/packages/server/src/server/daemon-e2e/opencode-slow-plugin-readiness.real.e2e.test.ts index 3645b018f1..ebb603e0ee 100644 --- a/packages/server/src/server/daemon-e2e/opencode-slow-plugin-readiness.real.e2e.test.ts +++ b/packages/server/src/server/daemon-e2e/opencode-slow-plugin-readiness.real.e2e.test.ts @@ -66,7 +66,7 @@ describe("daemon E2E (real opencode) - slow plugin readiness", () => { }); const client = new DaemonClient({ url: `ws://127.0.0.1:${daemon.port}/ws` }); await client.connect(); - await client.fetchAgents({ subscribe: { subscriptionId: "opencode-slow-plugin" } }); + await client.fetchAgents({ subscribe: {} }); try { const agent = await client.createAgent({ diff --git a/packages/server/src/server/daemon-e2e/permissions-codex.e2e.test.ts b/packages/server/src/server/daemon-e2e/permissions-codex.e2e.test.ts index a3a84a17ea..16ba0da1f5 100644 --- a/packages/server/src/server/daemon-e2e/permissions-codex.e2e.test.ts +++ b/packages/server/src/server/daemon-e2e/permissions-codex.e2e.test.ts @@ -42,8 +42,9 @@ describe("permission flow: Codex", () => { thinkingOptionId: CODEX_TEST_THINKING_OPTION_ID, cwd, title: "Codex Permission Test", - modeId: "read-only", + modeId: "always-ask", }); + await ctx.client.subscribeAgentTimeline(agent.id, () => {}).ready; expect(agent.id).toBeTruthy(); expect(agent.status).toBe("idle"); @@ -81,17 +82,18 @@ describe("permission flow: Codex", () => { // Verify permission_resolved event was received const queue = messages; - const hasPermissionResolved = queue.some((m) => { - if (m.type === "agent_stream" && m.payload.agentId === agent.id) { - return ( - m.payload.event.type === "permission_resolved" && - m.payload.event.requestId === permission.id && - m.payload.event.resolution.behavior === "allow" - ); - } - return false; - }); - expect(hasPermissionResolved).toBe(true); + const hasPermissionResolved = () => + queue.some((m) => { + if (m.type === "agent_stream" && m.payload.agentId === agent.id) { + return ( + m.payload.event.type === "permission_resolved" && + m.payload.event.requestId === permission.id && + m.payload.event.resolution.behavior === "allow" + ); + } + return false; + }); + await expect.poll(hasPermissionResolved).toBe(true); rmSync(cwd, { recursive: true, force: true }); }, 30_000); @@ -107,8 +109,9 @@ describe("permission flow: Codex", () => { thinkingOptionId: CODEX_TEST_THINKING_OPTION_ID, cwd, title: "Codex Permission Deny Test", - modeId: "read-only", + modeId: "always-ask", }); + await ctx.client.subscribeAgentTimeline(agent.id, () => {}).ready; expect(agent.id).toBeTruthy(); @@ -145,17 +148,18 @@ describe("permission flow: Codex", () => { // Verify permission_resolved event was received with deny const queue = messages; - const hasPermissionDenied = queue.some((m) => { - if (m.type === "agent_stream" && m.payload.agentId === agent.id) { - return ( - m.payload.event.type === "permission_resolved" && - m.payload.event.requestId === permission.id && - m.payload.event.resolution.behavior === "deny" - ); - } - return false; - }); - expect(hasPermissionDenied).toBe(true); + const hasPermissionDenied = () => + queue.some((m) => { + if (m.type === "agent_stream" && m.payload.agentId === agent.id) { + return ( + m.payload.event.type === "permission_resolved" && + m.payload.event.requestId === permission.id && + m.payload.event.resolution.behavior === "deny" + ); + } + return false; + }); + await expect.poll(hasPermissionDenied).toBe(true); rmSync(cwd, { recursive: true, force: true }); }, 30_000); @@ -172,6 +176,7 @@ describe("permission flow: Codex", () => { title: "Codex Interrupt Test", modeId: "full-access", }); + await ctx.client.subscribeAgentTimeline(agent.id, () => {}).ready; expect(agent.id).toBeTruthy(); expect(agent.currentModeId).toBe("full-access"); diff --git a/packages/server/src/server/daemon-e2e/persistence.e2e.test.ts b/packages/server/src/server/daemon-e2e/persistence.e2e.test.ts index 9e9e185895..78161f842a 100644 --- a/packages/server/src/server/daemon-e2e/persistence.e2e.test.ts +++ b/packages/server/src/server/daemon-e2e/persistence.e2e.test.ts @@ -61,6 +61,7 @@ describe("daemon E2E - persistence", () => { const resumed = await ctx.client.resumeAgent(handle); expect(resumed.provider).toBe("codex"); expect(resumed.cwd).toBe(cwd); + await ctx.client.subscribeAgentTimeline(resumed.id, () => {}).ready; messages.length = 0; await ctx.client.sendMessage(resumed.id, "What did I ask you to say earlier?"); diff --git a/packages/server/src/server/daemon-e2e/pi-rewind.real.e2e.test.ts b/packages/server/src/server/daemon-e2e/pi-rewind.real.e2e.test.ts index e013328a12..51efc705cf 100644 --- a/packages/server/src/server/daemon-e2e/pi-rewind.real.e2e.test.ts +++ b/packages/server/src/server/daemon-e2e/pi-rewind.real.e2e.test.ts @@ -122,7 +122,7 @@ describe("daemon E2E (real pi) - rewind", () => { }); await client.connect(); - await client.fetchAgents({ subscribe: { subscriptionId: "pi-rewind-real" } }); + await client.fetchAgents({ subscribe: {} }); harness = { client, daemon }; }, 30_000); diff --git a/packages/server/src/server/daemon-e2e/pi.real.e2e.test.ts b/packages/server/src/server/daemon-e2e/pi.real.e2e.test.ts index 5a0510c1ea..984aa2fdcc 100644 --- a/packages/server/src/server/daemon-e2e/pi.real.e2e.test.ts +++ b/packages/server/src/server/daemon-e2e/pi.real.e2e.test.ts @@ -1,7 +1,6 @@ import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import path from "node:path"; -import { randomUUID } from "node:crypto"; import { beforeAll, beforeEach, expect, test } from "vitest"; import pino from "pino"; @@ -118,7 +117,7 @@ async function withConnectedPiDaemon( try { await client.connect(); await client.fetchAgents({ - subscribe: { subscriptionId: `pi-real-${randomUUID()}` }, + subscribe: {}, }); await run({ client, daemon }); } finally { diff --git a/packages/server/src/server/daemon-e2e/project-becomes-git.e2e.test.ts b/packages/server/src/server/daemon-e2e/project-becomes-git.e2e.test.ts index e7843f78ae..a4db7ce7bf 100644 --- a/packages/server/src/server/daemon-e2e/project-becomes-git.e2e.test.ts +++ b/packages/server/src/server/daemon-e2e/project-becomes-git.e2e.test.ts @@ -62,7 +62,7 @@ test("an empty project becomes Git without changing its identity or creating a w const client = new DaemonClient({ url: `ws://127.0.0.1:${daemon.port}/ws` }); cleanupClients.add(client); await client.connect(); - await client.fetchAgents({ subscribe: { subscriptionId: "project-becomes-git" } }); + await client.fetchAgents({ subscribe: {} }); const added = await client.addProject(projectRoot); diff --git a/packages/server/src/server/daemon-e2e/rewind-user-message-dedupe-claude.real.e2e.test.ts b/packages/server/src/server/daemon-e2e/rewind-user-message-dedupe-claude.real.e2e.test.ts index aaea430b7f..bafb61e2b4 100644 --- a/packages/server/src/server/daemon-e2e/rewind-user-message-dedupe-claude.real.e2e.test.ts +++ b/packages/server/src/server/daemon-e2e/rewind-user-message-dedupe-claude.real.e2e.test.ts @@ -41,7 +41,7 @@ describe("daemon E2E (real claude) - rewind user message dedupe", () => { try { await client.connect(); await client.fetchAgents({ - subscribe: { subscriptionId: "rewind-user-message-dedupe" }, + subscribe: {}, }); const agent = await client.createAgent({ diff --git a/packages/server/src/server/daemon-e2e/send-during-tool-call-claude.real.e2e.test.ts b/packages/server/src/server/daemon-e2e/send-during-tool-call-claude.real.e2e.test.ts index 6638c425a0..2471ff8c5a 100644 --- a/packages/server/src/server/daemon-e2e/send-during-tool-call-claude.real.e2e.test.ts +++ b/packages/server/src/server/daemon-e2e/send-during-tool-call-claude.real.e2e.test.ts @@ -345,7 +345,7 @@ describe("daemon E2E (real claude) - send message during tool call", () => { await within( "subscribe Claude steering test client", 15_000, - client.fetchAgents({ subscribe: { subscriptionId: "steer" } }), + client.fetchAgents({ subscribe: {} }), ); const agent = await within( "create Claude steering test agent", @@ -518,7 +518,7 @@ describe("daemon E2E (real claude) - send message during tool call", () => { await within( "subscribe queued-steer Stop client", 15_000, - client.fetchAgents({ subscribe: { subscriptionId: "queued-steer-stop" } }), + client.fetchAgents({ subscribe: {} }), ); const agent = await within( "create queued-steer Stop agent", @@ -587,7 +587,7 @@ describe("daemon E2E (real claude) - send message during tool call", () => { try { await client.connect(); - await client.fetchAgents({ subscribe: { subscriptionId: "primary" } }); + await client.fetchAgents({ subscribe: {} }); const agent = await client.createAgent({ cwd, diff --git a/packages/server/src/server/daemon-e2e/send-during-tool-call-codex.real.e2e.test.ts b/packages/server/src/server/daemon-e2e/send-during-tool-call-codex.real.e2e.test.ts index 73ccc01988..39cc2554cc 100644 --- a/packages/server/src/server/daemon-e2e/send-during-tool-call-codex.real.e2e.test.ts +++ b/packages/server/src/server/daemon-e2e/send-during-tool-call-codex.real.e2e.test.ts @@ -254,11 +254,7 @@ describe("daemon E2E (real codex) - send message during tool call", () => { const { client, cwd } = resources; if (!client) throw new Error("Codex steering test client was not created"); await within("connect steering test client", 15_000, client.connect()); - await within( - "subscribe steering test client", - 15_000, - client.fetchAgents({ subscribe: { subscriptionId: "steer" } }), - ); + await within("subscribe steering test client", 15_000, client.fetchAgents({ subscribe: {} })); const agent = await within( "create Codex steering test agent", 30_000, @@ -399,7 +395,7 @@ describe("daemon E2E (real codex) - send message during tool call", () => { try { await client.connect(); - await client.fetchAgents({ subscribe: { subscriptionId: "primary" } }); + await client.fetchAgents({ subscribe: {} }); const agent = await client.createAgent({ cwd, @@ -478,7 +474,7 @@ describe("daemon E2E (real codex) - send message during tool call", () => { try { await client.connect(); - await client.fetchAgents({ subscribe: { subscriptionId: "primary" } }); + await client.fetchAgents({ subscribe: {} }); const agent = await client.createAgent({ cwd, diff --git a/packages/server/src/server/daemon-e2e/send-while-running-stuck-claude.real.e2e.test.ts b/packages/server/src/server/daemon-e2e/send-while-running-stuck-claude.real.e2e.test.ts index f9f6eb6aff..310b7b5cd4 100644 --- a/packages/server/src/server/daemon-e2e/send-while-running-stuck-claude.real.e2e.test.ts +++ b/packages/server/src/server/daemon-e2e/send-while-running-stuck-claude.real.e2e.test.ts @@ -44,8 +44,8 @@ describe("daemon E2E (real claude) - send while running recovery", () => { try { await primary.connect(); await secondary.connect(); - await primary.fetchAgents({ subscribe: { subscriptionId: "primary" } }); - await secondary.fetchAgents({ subscribe: { subscriptionId: "secondary" } }); + await primary.fetchAgents({ subscribe: {} }); + await secondary.fetchAgents({ subscribe: {} }); const agent = await primary.createAgent({ cwd, @@ -104,7 +104,7 @@ describe("daemon E2E (real claude) - send while running recovery", () => { }); const initial = await reconnected.fetchAgents({ - subscribe: { subscriptionId: "reconnected" }, + subscribe: {}, }); const hydratedSnapshot = initial.entries.find( (candidate) => candidate.agent.id === agent.id, diff --git a/packages/server/src/server/daemon-e2e/send-while-running-stuck.real.e2e.test.ts b/packages/server/src/server/daemon-e2e/send-while-running-stuck.real.e2e.test.ts index aa75a77ee2..4f179998ab 100644 --- a/packages/server/src/server/daemon-e2e/send-while-running-stuck.real.e2e.test.ts +++ b/packages/server/src/server/daemon-e2e/send-while-running-stuck.real.e2e.test.ts @@ -44,8 +44,8 @@ describe("daemon E2E (real codex) - send while running recovery", () => { try { await primary.connect(); await secondary.connect(); - await primary.fetchAgents({ subscribe: { subscriptionId: "primary" } }); - await secondary.fetchAgents({ subscribe: { subscriptionId: "secondary" } }); + await primary.fetchAgents({ subscribe: {} }); + await secondary.fetchAgents({ subscribe: {} }); const agent = await primary.createAgent({ cwd, @@ -101,7 +101,7 @@ describe("daemon E2E (real codex) - send while running recovery", () => { }); const initial = await reconnected.fetchAgents({ - subscribe: { subscriptionId: "reconnected" }, + subscribe: {}, }); const hydratedSnapshot = initial.entries.find( (candidate) => candidate.agent.id === agent.id, diff --git a/packages/server/src/server/daemon-e2e/streaming.e2e.test.ts b/packages/server/src/server/daemon-e2e/streaming.e2e.test.ts index df1a522018..3ef308701d 100644 --- a/packages/server/src/server/daemon-e2e/streaming.e2e.test.ts +++ b/packages/server/src/server/daemon-e2e/streaming.e2e.test.ts @@ -51,6 +51,7 @@ describe("daemon E2E - streaming", () => { title: "Streaming concat test", modeId: "bypassPermissions", }); + await ctx.client.subscribeAgentTimeline(agent.id, () => {}).ready; messages.length = 0; await ctx.client.sendMessage( @@ -78,6 +79,7 @@ describe("daemon E2E - streaming", () => { title: "Overlap stream test", modeId: "full-access", }); + await ctx.client.subscribeAgentTimeline(agent.id, () => {}).ready; messages.length = 0; await ctx.client.sendMessage(agent.id, "Run: sleep 30"); diff --git a/packages/server/src/server/daemon-e2e/terminal-byte-headless-parity.e2e.test.ts b/packages/server/src/server/daemon-e2e/terminal-byte-headless-parity.e2e.test.ts index f21f5f9b36..2b789788ad 100644 --- a/packages/server/src/server/daemon-e2e/terminal-byte-headless-parity.e2e.test.ts +++ b/packages/server/src/server/daemon-e2e/terminal-byte-headless-parity.e2e.test.ts @@ -8,7 +8,7 @@ import { expect, test } from "vitest"; import type { TerminalCell, TerminalState } from "@getpaseo/protocol/messages"; import { renderTerminalSnapshotToAnsi } from "@getpaseo/protocol/terminal-snapshot"; -import type { TerminalStreamEvent } from "@getpaseo/client/internal/terminal-stream-router"; +import type { TerminalStreamEvent } from "@getpaseo/client/internal/daemon-client"; import { DaemonClient } from "../test-utils/daemon-client.js"; import { createTestPaseoDaemon } from "../test-utils/paseo-daemon.js"; @@ -48,7 +48,7 @@ test("byte-stream headless terminal matches daemon state after high-output attac }); const initialSnapshot = waitForTerminalStreamEvent(client, terminal.id, "snapshot"); - await subscribeTerminal(client, terminal.id); + const liveSubscription = await subscribeTerminal(client, terminal.id); await initialSnapshot; client.sendTerminalInput(terminal.id, { type: "resize", ...BYTE_TEST_SIZE }); liveHeadless.resize(BYTE_TEST_SIZE); @@ -70,7 +70,7 @@ test("byte-stream headless terminal matches daemon state after high-output attac expect(liveHeadless.cursor()).toEqual(daemonSnapshot.cursor); expect(liveHeadless.visibleLines()).toEqual(capturedViewport.lines); - client.unsubscribeTerminal(terminal.id); + await liveSubscription.release(); stopLive(); const restoreHeadless = new ClientHeadlessTerminal(BYTE_TEST_SIZE); @@ -179,11 +179,12 @@ async function subscribeTerminal( client: DaemonClient, terminalId: string, options?: Parameters[1], -): Promise { +) { const response = await client.subscribeTerminal(terminalId, options); if (response.error) { throw new Error(response.error); } + return response.subscription; } async function readTerminalSnapshot(input: { diff --git a/packages/server/src/server/daemon-e2e/terminal.e2e.test.ts b/packages/server/src/server/daemon-e2e/terminal.e2e.test.ts index 6d22b4695e..633bd3bc1c 100644 --- a/packages/server/src/server/daemon-e2e/terminal.e2e.test.ts +++ b/packages/server/src/server/daemon-e2e/terminal.e2e.test.ts @@ -1048,6 +1048,9 @@ test("one client can stream two terminals concurrently", async () => { expect(firstSubscribe.error).toBeNull(); expect(secondSubscribe.error).toBeNull(); + if (firstSubscribe.error !== null || secondSubscribe.error !== null) { + throw new Error("Expected both terminal subscriptions to succeed"); + } expect(firstSubscribe.slot).not.toBe(secondSubscribe.slot); const firstOutput = waitForTerminalOutput(ctx.client, firstTerminalId, (text) => @@ -1077,8 +1080,8 @@ test("disconnect and reconnect both receive the current snapshot", async () => { const created = await createTerminalInWorkspace(ctx.client, { cwd }); const terminalId = created.terminal!.id; - await ctx.client.subscribeTerminal(terminalId); - ctx.client.unsubscribeTerminal(terminalId); + const observation = await ctx.client.subscribeTerminal(terminalId); + await observation.subscription.release(); ctx.client.sendTerminalInput(terminalId, { type: "input", diff --git a/packages/server/src/server/daemon-e2e/timeline-reconnect-contract.e2e.test.ts b/packages/server/src/server/daemon-e2e/timeline-reconnect-contract.e2e.test.ts index 8a794b54b5..510fb9db5e 100644 --- a/packages/server/src/server/daemon-e2e/timeline-reconnect-contract.e2e.test.ts +++ b/packages/server/src/server/daemon-e2e/timeline-reconnect-contract.e2e.test.ts @@ -110,7 +110,7 @@ test("reconnect catches up committed rows without replaying a provisional seed", try { await reconnectClient.fetchAgents({ - subscribe: { subscriptionId: "timeline-reconnect-a" }, + subscribe: {}, }); expect( @@ -197,7 +197,7 @@ test("reconnect with no new committed rows resumes from future live provisional try { await reconnectClient.fetchAgents({ - subscribe: { subscriptionId: "timeline-reconnect-b" }, + subscribe: {}, }); expect( diff --git a/packages/server/src/server/daemon-e2e/ui-action-stress.real.e2e.test.ts b/packages/server/src/server/daemon-e2e/ui-action-stress.real.e2e.test.ts index 925415a96e..7643138e0e 100644 --- a/packages/server/src/server/daemon-e2e/ui-action-stress.real.e2e.test.ts +++ b/packages/server/src/server/daemon-e2e/ui-action-stress.real.e2e.test.ts @@ -466,7 +466,7 @@ describe.each(realProviders)("daemon E2E (real %s) - UI action stress", (provide try { await client.connect(); await client.fetchAgents({ - subscribe: { subscriptionId: `ui-stress-normal-${provider}` }, + subscribe: {}, }); const agent = await client.createAgent({ cwd, @@ -504,7 +504,7 @@ describe.each(realProviders)("daemon E2E (real %s) - UI action stress", (provide try { await client.connect(); await client.fetchAgents({ - subscribe: { subscriptionId: `ui-stress-overlap-${provider}` }, + subscribe: {}, }); for (const scenario of scenarios) { diff --git a/packages/server/src/server/daemon-e2e/user-message-contract.real.e2e.test.ts b/packages/server/src/server/daemon-e2e/user-message-contract.real.e2e.test.ts index 41b40fed57..9e2d96f699 100644 --- a/packages/server/src/server/daemon-e2e/user-message-contract.real.e2e.test.ts +++ b/packages/server/src/server/daemon-e2e/user-message-contract.real.e2e.test.ts @@ -103,7 +103,7 @@ describe.each(CONTRACT_CASES)("daemon E2E (real $provider) - user_message contra appVersion: "0.1.80", }); await client.connect(); - await client.fetchAgents({ subscribe: { subscriptionId: `${entry.provider}-user-contract` } }); + await client.fetchAgents({ subscribe: {} }); }, 30_000); afterEach(async () => { diff --git a/packages/server/src/server/daemon-e2e/wait-for-idle.e2e.test.ts b/packages/server/src/server/daemon-e2e/wait-for-idle.e2e.test.ts index e2220a4109..ad2a62c804 100644 --- a/packages/server/src/server/daemon-e2e/wait-for-idle.e2e.test.ts +++ b/packages/server/src/server/daemon-e2e/wait-for-idle.e2e.test.ts @@ -130,7 +130,7 @@ test("waitForFinish resolves first idle edge even if a new run starts immediatel const secondary = new DaemonClient({ url: `ws://127.0.0.1:${ctx.daemon.port}/ws` }); await secondary.connect(); - await secondary.fetchAgents({ subscribe: { subscriptionId: "wait-for-idle-secondary" } }); + await secondary.fetchAgents({ subscribe: {} }); const agent = await ctx.client.createAgent({ provider: "codex", @@ -193,7 +193,7 @@ test("waitForFinish stays blocked when sendMessage transactionally replaces a ru try { await secondary.connect(); - await secondary.fetchAgents({ subscribe: { subscriptionId: "secondary" } }); + await secondary.fetchAgents({ subscribe: {} }); await ctx.client.sendMessage(agent.id, "Run: sleep 5"); await ctx.client.waitForAgentUpsert( diff --git a/packages/server/src/server/dictation/dictation-stream-manager.test.ts b/packages/server/src/server/dictation/dictation-stream-manager.test.ts index 6d389a17e8..975d6677b8 100644 --- a/packages/server/src/server/dictation/dictation-stream-manager.test.ts +++ b/packages/server/src/server/dictation/dictation-stream-manager.test.ts @@ -534,3 +534,54 @@ describe("DictationStreamManager (provider-agnostic provider)", () => { } }); }); + +it("cancellation during STT bootstrap closes the producer and never acknowledges a late connection", async () => { + let connected!: () => void; + const gate = new Promise((resolve) => { + connected = resolve; + }); + class ConnectingSession extends FakeRealtimeSession { + override async connect(): Promise { + await gate; + } + } + const session = new ConnectingSession(); + const messages: Array<{ type: string }> = []; + const manager = new DictationStreamManager({ + logger: pino({ level: "silent" }), + emit: (message) => messages.push(message), + sessionId: "cancel-bootstrap", + stt: new FakeSttProvider(session), + }); + const starting = manager.handleStart("dictation", "audio/pcm;rate=24000;bits=16"); + manager.handleCancel("dictation"); + expect(session.closed).toBe(true); + connected(); + await starting; + expect(messages).toEqual([]); + manager.cleanupAll(); +}); + +it("closes every dictation stream when one provider cleanup fails", async () => { + class FailingCloseSession extends FakeRealtimeSession { + override close(): void { + super.close(); + throw new Error("provider cleanup failed"); + } + } + const first = new FailingCloseSession(); + const second = new FakeRealtimeSession(); + const sessions = [first, second]; + const manager = new DictationStreamManager({ + logger: pino({ level: "silent" }), + sessionId: "cleanup-failure", + emit: () => {}, + stt: { id: "controlled", createSession: () => sessions.shift()! }, + }); + await manager.handleStart("first", "audio/pcm;rate=24000;bits=16"); + await manager.handleStart("second", "audio/pcm;rate=24000;bits=16"); + expect(() => manager.cleanupAll()).toThrow(); + expect(first.closed).toBe(true); + expect(second.closed).toBe(true); + expect(manager.hasDemand).toBe(false); +}); diff --git a/packages/server/src/server/dictation/dictation-stream-manager.ts b/packages/server/src/server/dictation/dictation-stream-manager.ts index e911db3d6e..b1b061501f 100644 --- a/packages/server/src/server/dictation/dictation-stream-manager.ts +++ b/packages/server/src/server/dictation/dictation-stream-manager.ts @@ -134,6 +134,7 @@ export class DictationStreamManager { private readonly language: string; private readonly finalTimeoutMs: number; private readonly autoCommitSeconds: number; + private readonly onIdle: (() => void) | undefined; private readonly streams = new Map(); constructor(params: { @@ -144,7 +145,9 @@ export class DictationStreamManager { language?: string; finalTimeoutMs?: number; autoCommitSeconds?: number; + onIdle?: () => void; }) { + this.onIdle = params.onIdle; this.logger = params.logger.child({ component: "dictation-stream-manager" }); this.emit = params.emit; this.sessionId = params.sessionId; @@ -157,10 +160,20 @@ export class DictationStreamManager { DEFAULT_DICTATION_AUTO_COMMIT_SECONDS; } + get hasDemand(): boolean { + return this.streams.size > 0; + } + public cleanupAll(): void { + const failures: unknown[] = []; for (const dictationId of this.streams.keys()) { - this.cleanupDictationStream(dictationId); + try { + this.cleanupDictationStream(dictationId); + } catch (error) { + failures.push(error); + } } + if (failures.length) throw new AggregateError(failures, "Dictation cleanup failed"); } public async handleStart(dictationId: string, format: string): Promise { @@ -189,79 +202,6 @@ export class DictationStreamManager { return; } - stt.on("committed", ({ segmentId }) => { - const state = this.streams.get(dictationId); - if (!state) { - return; - } - if (state.inFlightCommitCount > 0) { - state.inFlightCommitCount -= 1; - } - state.committedSegmentIds.push(segmentId); - - if (state.finishRequested && state.awaitingFinalCommit) { - state.awaitingFinalCommit = false; - } - - this.maybeFinalizeDictationStream(dictationId); - }); - - stt.on("transcript", ({ segmentId, transcript, isFinal }) => { - const state = this.streams.get(dictationId); - if (!state) { - return; - } - state.transcriptsBySegmentId.set(segmentId, transcript); - if (isFinal) { - state.finalTranscriptSegmentIds.add(segmentId); - } - - if (state.finishRequested && state.awaitingFinalCommit && isFinal) { - state.awaitingFinalCommit = false; - } - - const orderedIds = state.committedSegmentIds.includes(segmentId) - ? state.committedSegmentIds - : [...state.committedSegmentIds, segmentId]; - const partialText = orderedIds - .map((id) => state.transcriptsBySegmentId.get(id) ?? "") - .join(" ") - .trim(); - this.emitDictationPartial(dictationId, partialText); - - this.maybeSealDictationStreamFinish(dictationId); - this.maybeFinalizeDictationStream(dictationId); - }); - - stt.on("error", (err) => { - const message = err instanceof Error ? err.message : String(err); - const state = this.streams.get(dictationId); - if (state && state.finishRequested && isBufferTooSmallError(message)) { - if (state.inFlightCommitCount > 0) { - state.inFlightCommitCount -= 1; - } - if (state.awaitingFinalCommit) { - state.awaitingFinalCommit = false; - } - this.maybeFinalizeDictationStream(dictationId); - return; - } - void this.failAndCleanupDictationStream(dictationId, message, true); - }); - - try { - await stt.connect(); - } catch (error) { - const message = error instanceof Error ? error.message : String(error); - this.failDictationStream(dictationId, message, true); - try { - stt.close(); - } catch { - // no-op - } - return; - } - const inputRate = parsePcmRateFromFormat(format, 16000) ?? 16000; if (!Number.isFinite(inputRate) || inputRate <= 0) { this.failDictationStream( @@ -322,6 +262,78 @@ export class DictationStreamManager { finalTimeout: null, }); + stt.on("committed", ({ segmentId }) => { + const state = this.streams.get(dictationId); + if (state?.stt !== stt) { + return; + } + if (state.inFlightCommitCount > 0) { + state.inFlightCommitCount -= 1; + } + state.committedSegmentIds.push(segmentId); + + if (state.finishRequested && state.awaitingFinalCommit) { + state.awaitingFinalCommit = false; + } + + this.maybeFinalizeDictationStream(dictationId); + }); + + stt.on("transcript", ({ segmentId, transcript, isFinal }) => { + const state = this.streams.get(dictationId); + if (state?.stt !== stt) { + return; + } + state.transcriptsBySegmentId.set(segmentId, transcript); + if (isFinal) { + state.finalTranscriptSegmentIds.add(segmentId); + } + + if (state.finishRequested && state.awaitingFinalCommit && isFinal) { + state.awaitingFinalCommit = false; + } + + const orderedIds = state.committedSegmentIds.includes(segmentId) + ? state.committedSegmentIds + : [...state.committedSegmentIds, segmentId]; + const partialText = orderedIds + .map((id) => state.transcriptsBySegmentId.get(id) ?? "") + .join(" ") + .trim(); + this.emitDictationPartial(dictationId, partialText); + + this.maybeSealDictationStreamFinish(dictationId); + this.maybeFinalizeDictationStream(dictationId); + }); + + stt.on("error", (err) => { + const message = err instanceof Error ? err.message : String(err); + const state = this.streams.get(dictationId); + if (state?.stt !== stt) return; + if (state.finishRequested && isBufferTooSmallError(message)) { + if (state.inFlightCommitCount > 0) { + state.inFlightCommitCount -= 1; + } + if (state.awaitingFinalCommit) { + state.awaitingFinalCommit = false; + } + this.maybeFinalizeDictationStream(dictationId); + return; + } + void this.failAndCleanupDictationStream(dictationId, message, true); + }); + + try { + await stt.connect(); + } catch (error) { + if (this.streams.get(dictationId)?.stt !== stt) return; + const message = error instanceof Error ? error.message : String(error); + this.failDictationStream(dictationId, message, true); + this.cleanupDictationStream(dictationId); + return; + } + if (this.streams.get(dictationId)?.stt !== stt) return; + this.emitDictationAck(dictationId, -1); } @@ -528,7 +540,9 @@ export class DictationStreamManager { error: string, retryable: boolean, ): Promise { + const state = this.streams.get(dictationId); const debugRecordingPath = await this.maybePersistDictationStreamAudio(dictationId); + if (!state || this.streams.get(dictationId) !== state) return; this.emit({ type: "dictation_stream_error", payload: { @@ -561,12 +575,12 @@ export class DictationStreamManager { if (state.finalTimeout) { clearTimeout(state.finalTimeout); } + this.streams.delete(dictationId); try { state.stt.close(); - } catch { - // no-op + } finally { + if (this.streams.size === 0) this.onIdle?.(); } - this.streams.delete(dictationId); } private estimateFinalizationTimeout(state: DictationStreamState): { @@ -731,6 +745,7 @@ export class DictationStreamManager { if (orderedSegmentIds.length === 0) { void (async () => { const debugRecordingPath = await this.maybePersistDictationStreamAudio(dictationId); + if (this.streams.get(dictationId) !== state) return; this.emit({ type: "dictation_stream_final", payload: { @@ -770,6 +785,7 @@ export class DictationStreamManager { void (async () => { const debugRecordingPath = await this.maybePersistDictationStreamAudio(dictationId); + if (this.streams.get(dictationId) !== state) return; this.emit({ type: "dictation_stream_final", payload: { diff --git a/packages/server/src/server/file-upload/index.test.ts b/packages/server/src/server/file-upload/index.test.ts index b338d7dd1c..7eb97a07c3 100644 --- a/packages/server/src/server/file-upload/index.test.ts +++ b/packages/server/src/server/file-upload/index.test.ts @@ -1,6 +1,6 @@ -import { existsSync, mkdtempSync, readFileSync, realpathSync, rmSync } from "node:fs"; +import { existsSync, mkdtempSync, readFileSync, readdirSync, realpathSync, rmSync } from "node:fs"; import { tmpdir } from "node:os"; -import { join } from "node:path"; +import { dirname, join } from "node:path"; import { afterEach, describe, expect, it, vi } from "vitest"; import { @@ -37,14 +37,14 @@ describe("file uploads", () => { await expect(uploads.receiveFrame(uploadChunk("req-upload", "hello"))).resolves.toBeNull(); await expect(uploads.receiveFrame(uploadChunk("req-upload", " world"))).resolves.toBeNull(); - const path = join(paseoHome, "uploads", "upload_req-upload", "notes.txt"); + const path = uploadedPath(paseoHome, "notes.txt"); await expect(uploads.receiveFrame(uploadEnds("req-upload"))).resolves.toEqual({ type: "file.upload.response", payload: { requestId: "req-upload", file: { type: "uploaded_file", - id: "upload_req-upload", + id: expect.any(String), fileName: "notes.txt", mimeType: "text/plain", size: 11, @@ -70,8 +70,8 @@ describe("file uploads", () => { }); await expect(uploads.receiveFrame(uploadBegins("req-overflow"))).resolves.toBeNull(); - const uploadDir = join(paseoHome, "uploads", "upload_req-overflow"); - const path = join(uploadDir, "notes.txt"); + const path = uploadedPath(paseoHome, "notes.txt"); + const uploadDir = dirname(path); await expect(uploads.receiveFrame(uploadChunk("req-overflow", "hello!"))).resolves.toEqual({ type: "file.upload.response", payload: { @@ -106,9 +106,7 @@ describe("file uploads", () => { expect(results.slice(0, 3)).toEqual([null, null, null]); expect(results[3]?.payload.error).toBeNull(); - expect(readFileSync(join(paseoHome, "uploads", "upload_req-queued", "notes.txt"), "utf8")).toBe( - "hello world", - ); + expect(readFileSync(uploadedPath(paseoHome, "notes.txt"), "utf8")).toBe("hello world"); }); it("replaces duplicate upload starts without letting the old stale timeout evict the replacement", async () => { @@ -139,16 +137,16 @@ describe("file uploads", () => { }); await vi.advanceTimersByTimeAsync(30); - const path = join(paseoHome, "uploads", "upload_req-duplicate_2", "new.txt"); await expect(uploads.receiveFrame(uploadBegins("req-duplicate"))).resolves.toBeNull(); await expect(uploads.receiveFrame(uploadChunk("req-duplicate", "new"))).resolves.toBeNull(); + const path = uploadedPath(paseoHome, "new.txt"); await expect(uploads.receiveFrame(uploadEnds("req-duplicate"))).resolves.toEqual({ type: "file.upload.response", payload: { requestId: "req-duplicate", file: { type: "uploaded_file", - id: "upload_req-duplicate_2", + id: expect.any(String), fileName: "new.txt", mimeType: "text/plain", size: 3, @@ -184,14 +182,14 @@ describe("file uploads", () => { uploads.receiveFrame(uploadChunk("req-slow-active", " world")), ).resolves.toBeNull(); - const path = join(paseoHome, "uploads", "upload_req-slow-active", "notes.txt"); + const path = uploadedPath(paseoHome, "notes.txt"); await expect(uploads.receiveFrame(uploadEnds("req-slow-active"))).resolves.toEqual({ type: "file.upload.response", payload: { requestId: "req-slow-active", file: { type: "uploaded_file", - id: "upload_req-slow-active", + id: expect.any(String), fileName: "notes.txt", mimeType: "text/plain", size: 11, @@ -252,3 +250,12 @@ function decodeUploadFrame(bytes: Uint8Array): FileTransferFrame { } return frame; } + +function uploadedPath(paseoHome: string, fileName: string): string { + const root = join(paseoHome, "uploads"); + const file = readdirSync(root) + .map((id) => join(root, id, fileName)) + .find((candidate) => existsSync(candidate)); + if (!file) throw new Error(`Upload file ${fileName} is missing`); + return file; +} diff --git a/packages/server/src/server/file-upload/index.ts b/packages/server/src/server/file-upload/index.ts index 929e8e8a52..8b93e31f2f 100644 --- a/packages/server/src/server/file-upload/index.ts +++ b/packages/server/src/server/file-upload/index.ts @@ -1,3 +1,4 @@ +import { randomUUID } from "node:crypto"; import { appendFile, mkdir, rm, writeFile } from "node:fs/promises"; import { basename, join } from "node:path"; @@ -13,7 +14,9 @@ interface FileUploadStoreOptions { interface PendingUpload { requestId: string; id: string; - attempt: number; + source: object; + completed: boolean; + finished(response: FileUploadResponse | null): void; fileName: string; mimeType: string; size: number; @@ -22,6 +25,7 @@ interface PendingUpload { started: boolean; staleTimeout: ReturnType; queue: Promise; + cleanup?: Promise; } export class FileUploadStore { @@ -29,7 +33,8 @@ export class FileUploadStore { private readonly paseoHome: string; private readonly staleUploadTimeoutMs: number; - private readonly pending = new Map(); + private readonly defaultSource = {}; + private readonly pending = new Map>(); constructor(options: FileUploadStoreOptions) { this.paseoHome = options.paseoHome; @@ -37,41 +42,55 @@ export class FileUploadStore { options.staleUploadTimeoutMs ?? FileUploadStore.defaultStaleUploadTimeoutMs; } - beginUpload(request: FileUploadRequest): void { - const existingUpload = this.pending.get(request.requestId); - if (existingUpload) { - this.clearPendingUpload(existingUpload); - void existingUpload.queue.then(() => this.removeUploadDirectory(existingUpload)); - } - + beginUpload( + request: FileUploadRequest, + source: object = this.defaultSource, + finished: (response: FileUploadResponse | null) => void = () => {}, + ): () => Promise { + const existingUpload = this.pending.get(source)?.get(request.requestId); + if (existingUpload) void this.cancel(existingUpload).catch(() => {}); const fileName = sanitizeFileName(request.fileName); - const attempt = existingUpload ? existingUpload.attempt + 1 : 1; - const id = buildUploadId(request.requestId, attempt); + const id = `upload_${randomUUID()}`; const uploadDir = join(this.paseoHome, "uploads", id); const upload: PendingUpload = { requestId: request.requestId, id, - attempt, + source, + completed: false, + finished, fileName, mimeType: request.mimeType, size: request.size, path: join(uploadDir, fileName), receivedBytes: 0, started: false, - staleTimeout: this.createStaleUploadTimeout(request.requestId), + staleTimeout: this.createStaleUploadTimeout(source, request.requestId), queue: Promise.resolve(), }; - this.pending.set(request.requestId, upload); + const uploads = this.pending.get(source) ?? new Map(); + uploads.set(request.requestId, upload); + this.pending.set(source, uploads); + return () => this.cancel(upload); } - async receiveFrame(frame: FileTransferFrame): Promise { - const upload = this.pending.get(frame.requestId); + async receiveFrame( + frame: FileTransferFrame, + source: object = this.defaultSource, + ): Promise { + const upload = this.pending.get(source)?.get(frame.requestId); if (!upload) { return null; } this.refreshStaleUploadTimeout(upload); const operation = upload.queue.then(() => this.applyFrame(upload, frame)); + void operation.then( + (response) => { + if (response) upload.finished(response); + return undefined; + }, + () => upload.finished(null), + ); upload.queue = operation.then( () => undefined, () => undefined, @@ -83,7 +102,7 @@ export class FileUploadStore { upload: PendingUpload, frame: FileTransferFrame, ): Promise { - if (this.pending.get(upload.requestId) !== upload) { + if (this.pending.get(upload.source)?.get(upload.requestId) !== upload) { return null; } @@ -132,12 +151,17 @@ export class FileUploadStore { `Upload size mismatch: expected ${upload.size}, received ${upload.receivedBytes}.`, ); } + upload.completed = true; return buildUploadResponse(upload, null); } - private createStaleUploadTimeout(requestId: string): ReturnType { + private createStaleUploadTimeout( + source: object, + requestId: string, + ): ReturnType { const timeout = setTimeout(() => { - this.expireStaleUpload(requestId); + const upload = this.pending.get(source)?.get(requestId); + if (upload) void this.cancel(upload).catch(() => {}); }, this.staleUploadTimeoutMs); timeout.unref?.(); return timeout; @@ -145,30 +169,25 @@ export class FileUploadStore { private refreshStaleUploadTimeout(upload: PendingUpload): void { clearTimeout(upload.staleTimeout); - upload.staleTimeout = this.createStaleUploadTimeout(upload.requestId); + upload.staleTimeout = this.createStaleUploadTimeout(upload.source, upload.requestId); } - private expireStaleUpload(requestId: string): void { - const upload = this.pending.get(requestId); - if (!upload) { - return; - } + private cancel(upload: PendingUpload): Promise { + if (upload.cleanup) return upload.cleanup; this.clearPendingUpload(upload); - const cleanup = upload.queue.then( - () => this.removeUploadDirectory(upload), - () => this.removeUploadDirectory(upload), - ); - upload.queue = cleanup.then( - () => undefined, - () => undefined, - ); + upload.cleanup = upload.queue.then(async () => { + if (!upload.completed) await this.removeUploadDirectory(upload); + return undefined; + }); + upload.finished(null); + return upload.cleanup; } private clearPendingUpload(upload: PendingUpload): void { clearTimeout(upload.staleTimeout); - if (this.pending.get(upload.requestId) === upload) { - this.pending.delete(upload.requestId); - } + const uploads = this.pending.get(upload.source); + if (uploads?.get(upload.requestId) === upload) uploads.delete(upload.requestId); + if (uploads?.size === 0) this.pending.delete(upload.source); } private async removeFailedUpload(upload: PendingUpload): Promise { @@ -177,9 +196,7 @@ export class FileUploadStore { } private async removeUploadDirectory(upload: PendingUpload): Promise { - await rm(join(this.paseoHome, "uploads", upload.id), { recursive: true, force: true }).catch( - () => undefined, - ); + await rm(join(this.paseoHome, "uploads", upload.id), { recursive: true, force: true }); } } @@ -203,15 +220,6 @@ function buildUploadResponse(upload: PendingUpload, error: string | null): FileU }; } -function sanitizeUploadId(value: string): string { - return value.replace(/[^a-zA-Z0-9._-]/g, "_") || "file"; -} - -function buildUploadId(requestId: string, attempt: number): string { - const baseId = `upload_${sanitizeUploadId(requestId)}`; - return attempt === 1 ? baseId : `${baseId}_${attempt}`; -} - function sanitizeFileName(value: string): string { const name = basename(value) .replace(/[^a-zA-Z0-9._ -]/g, "_") diff --git a/packages/server/src/server/hub/execution-controller.test.ts b/packages/server/src/server/hub/execution-controller.test.ts index 4f2836d1ce..95c6555035 100644 --- a/packages/server/src/server/hub/execution-controller.test.ts +++ b/packages/server/src/server/hub/execution-controller.test.ts @@ -34,6 +34,7 @@ function deferred(): Deferred { } class ControlledHubExecutionAgents implements HubExecutionAgents { + observerCount = 0; private readonly createObserved = deferred(); private readonly createGate = deferred(); @@ -45,7 +46,10 @@ class ControlledHubExecutionAgents implements HubExecutionAgents { async control(): Promise {} subscribe(_listener: (event: OwnedAgentEvent) => void): () => void { - return () => undefined; + this.observerCount++; + return () => { + this.observerCount--; + }; } async invalidateAuthority(): Promise {} @@ -224,3 +228,28 @@ describe("HubExecutionController", () => { ]); }); }); + +test("Hub observation is acquired and released explicitly without closing its RPC controller", async () => { + const agents = new ControlledHubExecutionAgents(); + const messages: SessionOutboundMessage[] = []; + const controller = new HubExecutionController({ + agents, + validateAgentConfiguration: async () => [], + send: (message) => messages.push(message), + }); + expect(agents.observerCount).toBe(0); + controller.setObserving(true); + expect(agents.observerCount).toBe(1); + controller.setObserving(true); + expect(agents.observerCount).toBe(1); + controller.setObserving(false); + expect(agents.observerCount).toBe(0); + await controller.validateAgent({ + type: "hub.execution.agent.validate.request", + requestId: "plain-validation", + provider: "codex", + }); + expect(messages).toHaveLength(1); + expect(agents.observerCount).toBe(0); + await controller.cleanup(); +}); diff --git a/packages/server/src/server/hub/execution-controller.ts b/packages/server/src/server/hub/execution-controller.ts index 025b05a48b..4199433d3d 100644 --- a/packages/server/src/server/hub/execution-controller.ts +++ b/packages/server/src/server/hub/execution-controller.ts @@ -26,7 +26,7 @@ export class HubExecutionController { private readonly agents: HubExecutionAgents; private readonly send: (message: SessionOutboundMessage) => void; private readonly validateAgentConfiguration: HubExecutionControllerOptions["validateAgentConfiguration"]; - private readonly unsubscribe: () => void; + private unsubscribe: (() => void) | null = null; private readonly pendingCreates = new Set>(); private readonly pendingControls = new Set>(); private readonly pendingValidations = new Set>(); @@ -37,7 +37,19 @@ export class HubExecutionController { this.agents = options.agents; this.validateAgentConfiguration = options.validateAgentConfiguration; this.send = options.send; - this.unsubscribe = this.agents.subscribe((event) => this.sendOwnedEvent(event)); + } + + get isObserving(): boolean { + return this.unsubscribe !== null; + } + + setObserving(observing: boolean): void { + if (observing && !this.closed) + this.unsubscribe ??= this.agents.subscribe((event) => this.sendOwnedEvent(event)); + else { + this.unsubscribe?.(); + this.unsubscribe = null; + } } cleanup(): Promise { @@ -47,7 +59,7 @@ export class HubExecutionController { private async cleanupOnce(): Promise { this.closed = true; - this.unsubscribe(); + this.setObserving(false); await Promise.allSettled([ ...this.pendingCreates, ...this.pendingControls, diff --git a/packages/server/src/server/hub/test-utils/relationship-harness.ts b/packages/server/src/server/hub/test-utils/relationship-harness.ts index 3113b543ad..31fa1617f1 100644 --- a/packages/server/src/server/hub/test-utils/relationship-harness.ts +++ b/packages/server/src/server/hub/test-utils/relationship-harness.ts @@ -1582,7 +1582,7 @@ export class HubRelationshipHarness { appVersion: "0.1.106", }); await client.connect(); - await client.fetchAgents({ subscribe: { subscriptionId: "hub-relationship-trusted" } }); + await client.observeAgents().ready; return client; } diff --git a/packages/server/src/server/owned-subscriptions-client.e2e.test.ts b/packages/server/src/server/owned-subscriptions-client.e2e.test.ts new file mode 100644 index 0000000000..1502f39047 --- /dev/null +++ b/packages/server/src/server/owned-subscriptions-client.e2e.test.ts @@ -0,0 +1,167 @@ +import { WebSocket } from "ws"; +import { + DaemonClient as PublicDaemonClient, + type WebSocketLike, +} from "@getpaseo/client/internal/daemon-client"; +import { expect, test } from "vitest"; +import { createPaseoApi } from "@getpaseo/client"; +import { DaemonClient } from "./test-utils/daemon-client.js"; +import { createTestPaseoDaemon } from "./test-utils/paseo-daemon.js"; + +test("two SDK facades own combined agent lists and disposal preserves the other facade", async () => { + const daemon = await createTestPaseoDaemon({ mcpEnabled: false }); + const client = new DaemonClient({ + url: `ws://127.0.0.1:${daemon.port}/ws`, + appVersion: "0.8.0", + capabilities: { owned_subscriptions: true }, + }); + const admin = new DaemonClient({ url: `ws://127.0.0.1:${daemon.port}/ws`, appVersion: "0.8.0" }); + const app = createPaseoApi(client); + const plugin = createPaseoApi(client); + try { + await admin.connect(); + await client.connect(); + const agent = await admin.createAgent({ + provider: "codex", + cwd: daemon.staticDir, + title: "Before", + }); + const appUpdates: unknown[] = []; + const pluginUpdates: unknown[] = []; + app.agents.subscribe((update) => appUpdates.push(update)); + plugin.agents.subscribe((update) => pluginUpdates.push(update)); + const broad = await app.agents.list({ scope: "active", subscribe: {} }); + const filtered = await plugin.agents.list({ + scope: "active", + filter: { labels: { role: "orchestrator" } }, + page: { limit: 100 }, + subscribe: {}, + }); + expect(broad.subscriptionId).toEqual(expect.any(String)); + expect(filtered.subscriptionId).toEqual(expect.any(String)); + expect(broad.subscriptionId).not.toBe(filtered.subscriptionId); + expect(filtered.entries).toEqual([]); + await admin.updateAgent(agent.id, { name: "After" }); + await expect + .poll(() => appUpdates) + .toEqual( + expect.arrayContaining([ + expect.objectContaining({ + kind: "upsert", + subscriptionId: broad.subscriptionId, + agent: expect.objectContaining({ id: agent.id, title: "After" }), + }), + ]), + ); + expect(appUpdates).not.toEqual( + expect.arrayContaining([expect.objectContaining({ kind: "remove" })]), + ); + expect(pluginUpdates).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + kind: "remove", + subscriptionId: filtered.subscriptionId, + agentId: agent.id, + }), + ]), + ); + await plugin.dispose(); + const before = pluginUpdates.length; + await admin.updateAgent(agent.id, { name: "Still observed" }); + await expect + .poll(() => appUpdates) + .toEqual( + expect.arrayContaining([ + expect.objectContaining({ agent: expect.objectContaining({ title: "Still observed" }) }), + ]), + ); + expect(pluginUpdates).toHaveLength(before); + } finally { + await client.close(); + await admin.close(); + await daemon.close(); + } +}); + +test("public SDK scope cancellation during bootstrap releases the returned ID", async () => { + const daemon = await createTestPaseoDaemon({ mcpEnabled: false }); + const client = new DaemonClient({ url: `ws://127.0.0.1:${daemon.port}/ws` }); + const lifetime = new AbortController(); + const api = createPaseoApi(client, { signal: lifetime.signal }); + try { + await client.connect(); + const listing = api.agents.list({ subscribe: {} }); + lifetime.abort(); + await expect(listing).rejects.toThrow("released"); + await api.dispose(); + const { diagnostic } = await client.collectDiagnostics(); + expect(diagnostic).toContain("Source registrations: 0"); + expect(diagnostic).toContain("Producer listeners: 0"); + await expect(api.agents.list({ subscribe: {} })).rejects.toThrow("disposed"); + } finally { + await client.close(); + await daemon.close(); + } +}); + +test("a surviving public SDK timeline recovers persistent rows written during its socket outage", async () => { + const daemon = await createTestPaseoDaemon({ mcpEnabled: false }); + const sockets: WebSocket[] = []; + const makeClient = (observer: boolean) => + new PublicDaemonClient({ + url: `ws://127.0.0.1:${daemon.port}/ws`, + clientId: observer ? "r5-observer" : "r5-actor", + appVersion: "0.8.0", + reconnect: { enabled: false }, + webSocketFactory: (url) => { + const socket = new WebSocket(url); + if (observer) { + sockets.push(socket); + } + return socket as unknown as WebSocketLike; + }, + }); + const observer = makeClient(true); + const actor = makeClient(false); + const api = createPaseoApi(observer); + const received: unknown[] = []; + try { + await actor.connect(); + await observer.connect(); + const agent = await actor.createAgent({ + provider: "codex", + cwd: daemon.staticDir, + title: "Reconnect review", + }); + const handle = api.agents.ref(agent.id); + const subscription = handle.timeline.subscribe((event) => received.push(event)); + await subscription.ready; + await actor.sendMessage(agent.id, "before-outage-r5"); + await actor.waitForFinish(agent.id); + await expect.poll(() => JSON.stringify(received)).toContain("before-outage-r5"); + const oldId = subscription.subscriptionId; + sockets[0]!.terminate(); + await expect.poll(() => observer.getConnectionState().status).not.toBe("connected"); + await actor.sendMessage(agent.id, "during-outage-r5"); + await actor.waitForFinish(agent.id); + await observer.connect(); + await expect + .poll(() => subscription.subscriptionId) + .toSatisfy((id: unknown) => typeof id === "string" && id !== oldId); + await actor.sendMessage(agent.id, "after-outage-r5"); + await actor.waitForFinish(agent.id); + await expect.poll(() => JSON.stringify(received)).toContain("after-outage-r5"); + const history = await handle.timeline.refetch({ + direction: "before", + limit: 100, + projection: "projected", + }); + expect(JSON.stringify(history)).toContain("during-outage-r5"); + expect(JSON.stringify(received)).toContain("during-outage-r5"); + } finally { + await api.dispose(); + await observer.close(); + await actor.close(); + await daemon.close(); + } +}); diff --git a/packages/server/src/server/owned-subscriptions.e2e.test.ts b/packages/server/src/server/owned-subscriptions.e2e.test.ts new file mode 100644 index 0000000000..114ac5fdaa --- /dev/null +++ b/packages/server/src/server/owned-subscriptions.e2e.test.ts @@ -0,0 +1,1587 @@ +import { MockLoadTestAgentClient } from "./agent/providers/mock-load-test-agent.js"; +import { execFileSync } from "node:child_process"; +import { mkdir, writeFile } from "node:fs/promises"; +import path from "node:path"; +import { expect, test } from "vitest"; +import type { z } from "zod"; +import { WebSocket, WebSocketServer } from "ws"; +import { createServer } from "node:http"; +import { + decodeTerminalStreamFrame, + encodeFileTransferFrame, + FileTransferOpcode, + TerminalStreamOpcode, +} from "@getpaseo/protocol/binary-frames/index"; +import { DaemonClient } from "./test-utils/daemon-client.js"; +import { createTestPaseoDaemon } from "./test-utils/paseo-daemon.js"; +import { + SessionInboundMessageSchema, + type SessionInboundMessage, + type SessionOutboundMessage, + type WSOutboundMessage, +} from "./messages.js"; + +class SubscriptionPeer { + readonly frames: WSOutboundMessage[] = []; + readonly binaryFrames: Uint8Array[] = []; + + private constructor(private readonly socket: WebSocket) { + socket.on("message", (data, binary) => { + if (!binary) this.frames.push(JSON.parse(data.toString())); + else this.binaryFrames.push(new Uint8Array(data as Buffer)); + }); + } + + static async connect( + port: number, + clientId: string, + capabilities: Record = {}, + ): Promise { + const socket = new WebSocket(`ws://127.0.0.1:${port}/ws`); + const peer = new SubscriptionPeer(socket); + await new Promise((resolve, reject) => { + socket.once("open", resolve); + socket.once("error", reject); + }); + socket.send( + JSON.stringify({ + type: "hello", + clientType: "browser", + clientId, + protocolVersion: 1, + appVersion: "0.8.0", + capabilities: { owned_subscriptions: true, ...capabilities }, + }), + ); + await expect + .poll(() => + peer.frames.some( + (frame) => + frame.type === "session" && + frame.message.type === "status" && + frame.message.payload.status === "server_info", + ), + ) + .toBe(true); + peer.frames.length = 0; + return peer; + } + + async request(message: z.input): Promise { + this.socket.send(JSON.stringify({ type: "session", message })); + await expect + .poll(() => + this.frames.some( + (frame) => + frame.type === "session" && + "payload" in frame.message && + "requestId" in frame.message.payload && + "requestId" in message && + frame.message.payload.requestId === message.requestId, + ), + ) + .toBe(true); + } + + send(message: z.input): void { + this.socket.send( + JSON.stringify({ type: "session", message: SessionInboundMessageSchema.parse(message) }), + ); + } + + upload(requestId: string, text: string, fileName: string): void { + this.socket.send( + encodeFileTransferFrame({ + opcode: FileTransferOpcode.FileBegin, + requestId, + metadata: { + mime: "text/plain", + size: new TextEncoder().encode(text).byteLength, + encoding: "binary", + modifiedAt: "2026-09-09T00:00:00Z", + fileName, + }, + }), + ); + this.socket.send( + encodeFileTransferFrame({ + opcode: FileTransferOpcode.FileChunk, + requestId, + payload: new TextEncoder().encode(text), + }), + ); + this.socket.send(encodeFileTransferFrame({ opcode: FileTransferOpcode.FileEnd, requestId })); + } + + close(): void { + this.socket.terminate(); + } +} + +test("a pure list reply belongs only to its source socket in a shared logical session", async () => { + const daemon = await createTestPaseoDaemon({ mcpEnabled: false }); + const peers: SubscriptionPeer[] = []; + try { + const idle = await SubscriptionPeer.connect(daemon.port, "shared-owned-session"); + peers.push(idle); + const reader = await SubscriptionPeer.connect(daemon.port, "shared-owned-session"); + peers.push(reader); + await reader.request({ type: "fetch_agents_request", requestId: "reader-snapshot" }); + await idle.request({ type: "ping", requestId: "idle-barrier", clientSentAt: 1 }); + expect( + idle.frames.filter( + (frame) => frame.type === "session" && frame.message.type === "fetch_agents_response", + ), + ).toEqual([]); + } finally { + for (const peer of peers) peer.close(); + await daemon.close(); + } +}); + +test("combined filtered agent lists create independent server-owned observations", async () => { + const daemon = await createTestPaseoDaemon({ mcpEnabled: false }); + const admin = new DaemonClient({ url: `ws://127.0.0.1:${daemon.port}/ws`, appVersion: "0.8.0" }); + let peer: SubscriptionPeer | undefined; + try { + await admin.connect(); + const agent = await admin.createAgent({ + provider: "codex", + cwd: daemon.staticDir, + title: "Before", + }); + peer = await SubscriptionPeer.connect(daemon.port, "independent-agent-views"); + await peer.request({ type: "fetch_agents_request", requestId: "all", subscribe: {} }); + await peer.request({ + type: "fetch_agents_request", + requestId: "filtered", + filter: { labels: { role: "orchestrator" } }, + subscribe: {}, + }); + const snapshots = peer.frames.flatMap((frame) => + frame.type === "session" && frame.message.type === "fetch_agents_response" + ? [frame.message.payload] + : [], + ); + expect(snapshots).toHaveLength(2); + const [all, filtered] = snapshots; + expect(typeof all.subscriptionId).toBe("string"); + expect(typeof filtered.subscriptionId).toBe("string"); + expect(all.subscriptionId).not.toBe(filtered.subscriptionId); + await admin.updateAgent(agent.id, { name: "After" }); + await expect + .poll( + () => + peer!.frames.flatMap((frame) => + frame.type === "session" && frame.message.type === "agent_update" + ? [frame.message.payload] + : [], + ), + { timeout: 5000 }, + ) + .toEqual( + expect.arrayContaining([ + expect.objectContaining({ + subscriptionId: all.subscriptionId, + kind: "upsert", + agent: expect.objectContaining({ id: agent.id, title: "After" }), + }), + expect.objectContaining({ + subscriptionId: filtered.subscriptionId, + kind: "remove", + agentId: agent.id, + }), + ]), + ); + await peer.request({ + type: "subscription.release.request", + requestId: "release-filtered", + subscriptionId: filtered.subscriptionId!, + }); + peer.frames.length = 0; + await admin.updateAgent(agent.id, { name: "Still observed" }); + await expect + .poll( + () => + peer!.frames.flatMap((frame) => + frame.type === "session" && frame.message.type === "agent_update" + ? [frame.message.payload] + : [], + ), + { timeout: 5000 }, + ) + .toEqual([ + expect.objectContaining({ + subscriptionId: all.subscriptionId, + kind: "upsert", + agent: expect.objectContaining({ id: agent.id, title: "Still observed" }), + }), + ]); + } finally { + peer?.close(); + await admin.close(); + await daemon.close(); + } +}); + +test("workspace filters and identical queries coexist until their own release", async () => { + const daemon = await createTestPaseoDaemon({ mcpEnabled: false }); + const admin = new DaemonClient({ url: `ws://127.0.0.1:${daemon.port}/ws`, appVersion: "0.8.0" }); + let peer: SubscriptionPeer | undefined; + try { + await admin.connect(); + const aDir = path.join(daemon.staticDir, "a"); + const bDir = path.join(daemon.staticDir, "b"); + await mkdir(aDir); + await mkdir(bDir); + const a = (await admin.createWorkspace({ source: { kind: "directory", path: aDir } })) + .workspace!; + const b = (await admin.createWorkspace({ source: { kind: "directory", path: bDir } })) + .workspace!; + peer = await SubscriptionPeer.connect(daemon.port, "independent-workspace-views"); + await peer.request({ + type: "fetch_workspaces_request", + requestId: "a", + filter: { projectId: a.projectId }, + subscribe: {}, + }); + await peer.request({ + type: "fetch_workspaces_request", + requestId: "b", + filter: { projectId: b.projectId }, + subscribe: {}, + }); + await peer.request({ + type: "fetch_workspaces_request", + requestId: "b-copy", + filter: { projectId: b.projectId }, + subscribe: {}, + }); + const snapshots = peer.frames.flatMap((frame) => + frame.type === "session" && frame.message.type === "fetch_workspaces_response" + ? [frame.message.payload] + : [], + ); + expect(snapshots).toHaveLength(3); + const ids = snapshots.map((snapshot) => snapshot.subscriptionId!); + expect(new Set(ids).size).toBe(3); + await peer.request({ + type: "subscription.release.request", + requestId: "release-b", + subscriptionId: ids[1], + }); + await admin.setWorkspaceTitle(a.id, "A updated"); + await admin.setWorkspaceTitle(b.id, "B updated"); + await expect + .poll( + () => + peer!.frames.flatMap((frame) => + frame.type === "session" && frame.message.type === "workspace_update" + ? [frame.message.payload] + : [], + ), + { timeout: 5000 }, + ) + .toEqual( + expect.arrayContaining([ + expect.objectContaining({ + subscriptionId: ids[0], + kind: "upsert", + workspace: expect.objectContaining({ id: a.id, name: "A updated" }), + }), + expect.objectContaining({ + subscriptionId: ids[2], + kind: "upsert", + workspace: expect.objectContaining({ id: b.id, name: "B updated" }), + }), + ]), + ); + expect( + peer.frames.filter( + (frame) => + frame.type === "session" && + frame.message.type === "workspace_update" && + "subscriptionId" in frame.message.payload && + frame.message.payload.subscriptionId === ids[1], + ), + ).toEqual([]); + } finally { + peer?.close(); + await admin.close(); + await daemon.close(); + } +}); + +test("label lists are pure and label observers have independent lifetimes", async () => { + const daemon = await createTestPaseoDaemon({ mcpEnabled: false }); + const admin = new DaemonClient({ url: `ws://127.0.0.1:${daemon.port}/ws`, appVersion: "0.8.0" }); + let peer: SubscriptionPeer | undefined; + try { + await admin.connect(); + const workspace = ( + await admin.createWorkspace({ source: { kind: "directory", path: daemon.staticDir } }) + ).workspace!; + peer = await SubscriptionPeer.connect(daemon.port, "label-views"); + await peer.request({ type: "workspace.label.list.request", requestId: "plain" }); + await peer.request({ type: "workspace.label.list.request", requestId: "first", subscribe: {} }); + await peer.request({ + type: "workspace.label.list.request", + requestId: "second", + subscribe: {}, + }); + const snapshots = peer.frames.flatMap((frame) => + frame.type === "session" && frame.message.type === "workspace.label.list.response" + ? [frame.message.payload] + : [], + ); + expect(snapshots).toHaveLength(3); + expect(snapshots[0]).not.toHaveProperty("subscriptionId"); + const first = snapshots[1] as (typeof snapshots)[number] & { subscriptionId: string }; + const second = snapshots[2] as (typeof snapshots)[number] & { subscriptionId: string }; + expect(typeof first.subscriptionId).toBe("string"); + expect(first.subscriptionId).not.toBe(second.subscriptionId); + await peer.request({ + type: "subscription.release.request", + requestId: "release", + subscriptionId: first.subscriptionId, + }); + peer.frames.length = 0; + await peer.request({ + type: "workspace.label.assignment.set.request", + requestId: "assign", + workspaceId: workspace.id, + label: { name: "QA", color: "blue" }, + assigned: true, + }); + await expect + .poll(() => + peer!.frames.flatMap((frame) => + frame.type === "session" && frame.message.type === "workspace.label.update" + ? [frame.message.payload] + : [], + ), + ) + .toEqual([ + expect.objectContaining({ + kind: "upsert", + subscriptionId: second.subscriptionId, + label: { name: "QA", color: "blue" }, + }), + ]); + } finally { + peer?.close(); + await admin.close(); + await daemon.close(); + } +}); + +test("identical file observations get server IDs and release independently", async () => { + const daemon = await createTestPaseoDaemon({ mcpEnabled: false }); + let peer: SubscriptionPeer | undefined; + try { + await writeFile(path.join(daemon.staticDir, "observed.txt"), "before"); + peer = await SubscriptionPeer.connect(daemon.port, "file-observers"); + await peer.request({ + type: "fs.file.subscribe.request", + requestId: "file-a", + cwd: daemon.staticDir, + path: "observed.txt", + }); + await peer.request({ + type: "fs.file.subscribe.request", + requestId: "file-b", + cwd: daemon.staticDir, + path: "observed.txt", + }); + const snapshots = peer.frames.flatMap((frame) => + frame.type === "session" && frame.message.type === "fs.file.subscribe.response" + ? [frame.message.payload] + : [], + ); + expect(snapshots).toHaveLength(2); + expect(snapshots[0].subscriptionId).not.toBe(snapshots[1].subscriptionId); + await peer.request({ + type: "subscription.release.request", + requestId: "release-file-a", + subscriptionId: snapshots[0].subscriptionId, + }); + peer.frames.length = 0; + await writeFile(path.join(daemon.staticDir, "observed.txt"), "after"); + await expect + .poll( + () => + peer!.frames.flatMap((frame) => + frame.type === "session" && frame.message.type === "fs.file.update" + ? [frame.message.payload] + : [], + ), + { timeout: 5000 }, + ) + .toEqual([ + expect.objectContaining({ + subscriptionId: snapshots[1].subscriptionId, + version: expect.objectContaining({ status: "ready" }), + }), + ]); + } finally { + peer?.close(); + await daemon.close(); + } +}); + +test("checkout diff reads are snapshots and identical observers release independently", async () => { + const daemon = await createTestPaseoDaemon({ mcpEnabled: false }); + let peer: SubscriptionPeer | undefined; + try { + const cwd = daemon.staticDir; + execFileSync("git", ["init", "-q", "--initial-branch=main", cwd]); + await writeFile(path.join(cwd, "tracked.txt"), "before\n"); + execFileSync("git", ["-C", cwd, "add", "tracked.txt"]); + execFileSync("git", [ + "-C", + cwd, + "-c", + "user.name=QA", + "-c", + "user.email=qa@example.invalid", + "commit", + "-qm", + "Initial", + ]); + peer = await SubscriptionPeer.connect(daemon.port, "diff-observers"); + await peer.request({ + type: "checkout.diff.get.request", + requestId: "read", + cwd, + compare: { mode: "uncommitted" }, + }); + await peer.request({ + type: "subscribe_checkout_diff_request", + requestId: "diff-a", + cwd, + compare: { mode: "uncommitted" }, + }); + await peer.request({ + type: "subscribe_checkout_diff_request", + requestId: "diff-b", + cwd, + compare: { mode: "uncommitted" }, + }); + const snapshots = peer.frames.flatMap((frame) => + frame.type === "session" && frame.message.type === "subscribe_checkout_diff_response" + ? [frame.message.payload] + : [], + ); + expect(snapshots).toHaveLength(2); + expect(snapshots[0].subscriptionId).not.toBe(snapshots[1].subscriptionId); + await peer.request({ + type: "subscription.release.request", + requestId: "release-diff-a", + subscriptionId: snapshots[0].subscriptionId, + }); + peer.frames.length = 0; + await writeFile(path.join(cwd, "tracked.txt"), "after\n"); + await expect + .poll( + () => + peer!.frames.flatMap((frame) => + frame.type === "session" && + frame.message.type === "checkout_diff_update" && + frame.message.payload.files.length > 0 + ? [frame.message.payload] + : [], + ), + { timeout: 10000 }, + ) + .toEqual([ + expect.objectContaining({ + subscriptionId: snapshots[1].subscriptionId, + error: null, + files: [expect.objectContaining({ path: "tracked.txt" })], + }), + ]); + } finally { + peer?.close(); + await daemon.close(); + } +}); + +test("event-category observations use independent IDs without enabling other categories", async () => { + const daemon = await createTestPaseoDaemon({ mcpEnabled: false }); + const admin = new DaemonClient({ url: `ws://127.0.0.1:${daemon.port}/ws`, appVersion: "0.8.0" }); + let peer: SubscriptionPeer | undefined; + try { + await admin.connect(); + peer = await SubscriptionPeer.connect(daemon.port, "event-observers"); + await peer.request({ + type: "session.events.set_subscription.request", + requestId: "events-a", + events: ["project.update"], + }); + await peer.request({ + type: "session.events.set_subscription.request", + requestId: "events-b", + events: ["project.update"], + }); + const snapshots = peer.frames.flatMap((frame) => + frame.type === "session" && frame.message.type === "session.events.set_subscription.response" + ? [frame.message.payload] + : [], + ); + expect(snapshots).toHaveLength(2); + const ids = snapshots.map( + (snapshot) => (snapshot as typeof snapshot & { subscriptionId: string }).subscriptionId, + ); + expect(ids.every((id) => typeof id === "string")).toBe(true); + expect(ids[0]).not.toBe(ids[1]); + await peer.request({ + type: "subscription.release.request", + requestId: "release-events-a", + subscriptionId: ids[0], + }); + peer.frames.length = 0; + await admin.createWorkspace({ source: { kind: "directory", path: daemon.staticDir } }); + await expect + .poll( + () => + peer!.frames.flatMap((frame) => + frame.type === "session" && frame.message.type === "project.update" + ? [frame.message.payload] + : [], + ), + { timeout: 5000 }, + ) + .toEqual( + expect.arrayContaining([ + expect.objectContaining({ subscriptionId: ids[1], kind: "upsert" }), + ]), + ); + expect( + peer.frames.filter( + (frame) => frame.type === "session" && frame.message.type !== "project.update", + ), + ).toEqual([]); + } finally { + peer?.close(); + await admin.close(); + await daemon.close(); + } +}); + +test("timeline observers for the same agent survive an independent release", async () => { + const daemon = await createTestPaseoDaemon({ mcpEnabled: false }); + const admin = new DaemonClient({ url: `ws://127.0.0.1:${daemon.port}/ws`, appVersion: "0.8.0" }); + let peer: SubscriptionPeer | undefined; + try { + await admin.connect(); + const agent = await admin.createAgent({ + provider: "codex", + cwd: daemon.staticDir, + title: "Timeline", + }); + peer = await SubscriptionPeer.connect(daemon.port, "timeline-observers"); + await peer.request({ + type: "agent.timeline.set_subscription.request", + requestId: "timeline-a", + agentIds: [agent.id], + }); + await peer.request({ + type: "agent.timeline.set_subscription.request", + requestId: "timeline-b", + agentIds: [agent.id], + }); + const snapshots = peer.frames.flatMap((frame) => + frame.type === "session" && frame.message.type === "agent.timeline.set_subscription.response" + ? [frame.message.payload] + : [], + ); + expect(snapshots).toHaveLength(2); + const ids = snapshots.map( + (snapshot) => (snapshot as typeof snapshot & { subscriptionId: string }).subscriptionId, + ); + expect(ids.every((id) => typeof id === "string")).toBe(true); + expect(ids[0]).not.toBe(ids[1]); + await peer.request({ + type: "subscription.release.request", + requestId: "release-timeline-a", + subscriptionId: ids[0], + }); + peer.frames.length = 0; + await admin.sendMessage(agent.id, "An observed turn"); + await expect + .poll( + () => + peer!.frames.filter( + (frame) => frame.type === "session" && frame.message.type === "agent_stream", + ).length, + { timeout: 5000 }, + ) + .toBeGreaterThan(0); + const updates = peer.frames.flatMap((frame) => + frame.type === "session" && frame.message.type === "agent_stream" + ? [frame.message.payload] + : [], + ); + expect( + updates.every( + (update) => + "subscriptionId" in update && + update.subscriptionId === ids[1] && + update.agentId === agent.id, + ), + ).toBe(true); + } finally { + peer?.close(); + await admin.close(); + await daemon.close(); + } +}); + +test("same-workspace terminal directory observers do not share a teardown slot", async () => { + const daemon = await createTestPaseoDaemon({ mcpEnabled: false }); + const admin = new DaemonClient({ url: `ws://127.0.0.1:${daemon.port}/ws`, appVersion: "0.8.0" }); + let peer: SubscriptionPeer | undefined; + try { + await admin.connect(); + const workspace = ( + await admin.createWorkspace({ source: { kind: "directory", path: daemon.staticDir } }) + ).workspace!; + peer = await SubscriptionPeer.connect(daemon.port, "terminal-directory-observers"); + await peer.request({ + type: "subscribe_terminals_request", + requestId: "terminals-a", + cwd: daemon.staticDir, + workspaceId: workspace.id, + }); + await peer.request({ + type: "subscribe_terminals_request", + requestId: "terminals-b", + cwd: daemon.staticDir, + workspaceId: workspace.id, + }); + const snapshots = peer.frames.flatMap((frame) => + frame.type === "session" && frame.message.type === "terminals_changed" + ? [frame.message.payload] + : [], + ); + expect(snapshots).toHaveLength(2); + const ids = snapshots.map( + (snapshot) => (snapshot as typeof snapshot & { subscriptionId: string }).subscriptionId, + ); + expect(ids.every((id) => typeof id === "string")).toBe(true); + expect(ids[0]).not.toBe(ids[1]); + await peer.request({ + type: "subscription.release.request", + requestId: "release-terminals-a", + subscriptionId: ids[0], + }); + peer.frames.length = 0; + const result = await admin.createTerminal(daemon.staticDir, "Observed terminal", undefined, { + workspaceId: workspace.id, + command: "/bin/cat", + }); + expect(result.error).toBe(null); + await expect + .poll( + () => + peer!.frames.flatMap((frame) => + frame.type === "session" && frame.message.type === "terminals_changed" + ? [frame.message.payload] + : [], + ), + { timeout: 5000 }, + ) + .toEqual( + expect.arrayContaining([ + expect.objectContaining({ + subscriptionId: ids[1], + workspaceId: workspace.id, + terminals: [expect.objectContaining({ id: result.terminal!.id })], + }), + ]), + ); + const updates = peer.frames.flatMap((frame) => + frame.type === "session" && frame.message.type === "terminals_changed" + ? [frame.message.payload] + : [], + ); + expect( + updates.every((update) => "subscriptionId" in update && update.subscriptionId === ids[1]), + ).toBe(true); + await admin.killTerminal(result.terminal!.id); + } finally { + peer?.close(); + await admin.close(); + await daemon.close(); + } +}); + +test("terminal output has separate server IDs and binary slots, including in a shared session", async () => { + const daemon = await createTestPaseoDaemon({ mcpEnabled: false }); + const admin = new DaemonClient({ url: `ws://127.0.0.1:${daemon.port}/ws`, appVersion: "0.8.0" }); + const peers: SubscriptionPeer[] = []; + try { + await admin.connect(); + const workspace = ( + await admin.createWorkspace({ source: { kind: "directory", path: daemon.staticDir } }) + ).workspace!; + const result = await admin.createTerminal(daemon.staticDir, "Output ownership", undefined, { + workspaceId: workspace.id, + command: "/bin/cat", + }); + expect(result.error).toBe(null); + const idle = await SubscriptionPeer.connect(daemon.port, "terminal-output-session"); + peers.push(idle); + const peer = await SubscriptionPeer.connect(daemon.port, "terminal-output-session"); + peers.push(peer); + for (const requestId of ["output-a", "output-b"]) { + await peer.request({ + type: "subscribe_terminal_request", + requestId, + terminalId: result.terminal!.id, + }); + } + const replies = peer.frames.flatMap((frame) => + frame.type === "session" && + frame.message.type === "subscribe_terminal_response" && + frame.message.payload.error === null + ? [frame.message.payload] + : [], + ); + expect(replies).toHaveLength(2); + const ids = replies.map( + (reply) => (reply as typeof reply & { subscriptionId: string }).subscriptionId, + ); + expect(ids.every((id) => typeof id === "string")).toBe(true); + expect(ids[0]).not.toBe(ids[1]); + expect(replies[0].slot).not.toBe(replies[1].slot); + await expect + .poll(() => + peer.binaryFrames + .map(decodeTerminalStreamFrame) + .filter((frame) => frame?.opcode === TerminalStreamOpcode.Snapshot) + .map((frame) => frame!.slot), + ) + .toEqual(expect.arrayContaining(replies.map((reply) => reply.slot))); + expect(idle.binaryFrames).toEqual([]); + await peer.request({ + type: "subscription.release.request", + requestId: "release-output-a", + subscriptionId: ids[0], + }); + peer.binaryFrames.length = 0; + admin.sendTerminalInput(result.terminal!.id, { type: "input", data: "remaining-observer\n" }); + await expect + .poll( + () => + peer.binaryFrames + .map(decodeTerminalStreamFrame) + .filter((frame) => frame?.opcode === TerminalStreamOpcode.Output) + .map((frame) => Buffer.from(frame!.payload).toString()) + .join(""), + { timeout: 5000 }, + ) + .toContain("remaining-observer"); + expect( + peer.binaryFrames + .map(decodeTerminalStreamFrame) + .every((frame) => frame?.slot === replies[1].slot), + ).toBe(true); + expect(idle.binaryFrames).toEqual([]); + await admin.killTerminal(result.terminal!.id); + } finally { + for (const peer of peers) peer.close(); + await admin.close(); + await daemon.close(); + } +}); + +test("a modern connection and pure reads retain no client observation producers", async () => { + const daemon = await createTestPaseoDaemon({ mcpEnabled: false }); + let peer: SubscriptionPeer | undefined; + try { + peer = await SubscriptionPeer.connect(daemon.port, "no-demand-producers"); + await peer.request({ type: "fetch_agents_request", requestId: "plain-agents" }); + await peer.request({ type: "fetch_workspaces_request", requestId: "plain-workspaces" }); + await peer.request({ type: "workspace.label.list.request", requestId: "plain-labels" }); + await peer.request({ type: "list_terminals_request", requestId: "plain-terminals" }); + await peer.request({ type: "diagnostics.request", requestId: "producer-diagnostics" }); + const diagnostic = peer.frames.flatMap((frame) => + frame.type === "session" && frame.message.type === "diagnostics.response" + ? [frame.message.payload.diagnostic] + : [], + )[0]; + expect(diagnostic).toContain("Session observations"); + expect(diagnostic).toContain("Registrations: 0"); + expect(diagnostic).toContain("Producer listeners: 0"); + expect(diagnostic).toContain("Git observations: 0"); + } finally { + peer?.close(); + await daemon.close(); + } +}); + +test("config changes require their own event demand and cannot escape to an idle socket", async () => { + const daemon = await createTestPaseoDaemon({ mcpEnabled: false }); + const admin = new DaemonClient({ url: `ws://127.0.0.1:${daemon.port}/ws`, appVersion: "0.8.0" }); + const peers: SubscriptionPeer[] = []; + try { + await admin.connect(); + const idle = await SubscriptionPeer.connect(daemon.port, "config-session"); + peers.push(idle); + const observer = await SubscriptionPeer.connect(daemon.port, "config-session"); + peers.push(observer); + await observer.request({ + type: "session.events.set_subscription.request", + requestId: "config-feed", + events: ["status.daemon_config_changed"], + }); + await admin.patchDaemonConfig({ autoArchiveAfterMerge: true }); + await expect + .poll(() => + observer.frames.some( + (frame) => + frame.type === "session" && + frame.message.type === "status" && + frame.message.payload.status === "daemon_config_changed", + ), + ) + .toBe(true); + expect(idle.frames).toEqual([]); + const changes = observer.frames.flatMap((frame) => + frame.type === "session" && frame.message.type === "status" ? [frame.message.payload] : [], + ); + expect( + changes.every( + (change) => + change.status === "daemon_config_changed" && typeof change.subscriptionId === "string", + ), + ).toBe(true); + } finally { + for (const peer of peers) peer.close(); + await admin.close(); + await daemon.close(); + } +}); + +test("status-shaped operation replies use actual source request provenance", async () => { + const daemon = await createTestPaseoDaemon({ mcpEnabled: false }); + const peers: SubscriptionPeer[] = []; + try { + const idle = await SubscriptionPeer.connect(daemon.port, "status-reply-session"); + peers.push(idle); + const actor = await SubscriptionPeer.connect(daemon.port, "status-reply-session"); + peers.push(actor); + await actor.request({ + type: "create_agent_request", + requestId: "created-status", + config: { provider: "codex", cwd: daemon.staticDir }, + }); + expect(actor.frames).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + type: "session", + message: expect.objectContaining({ + type: "status", + payload: expect.objectContaining({ + status: "agent_created", + requestId: "created-status", + }), + }), + }), + ]), + ); + expect(idle.frames).toEqual([]); + await actor.request({ type: "schedule/list", requestId: "schedule-list" }); + expect(idle.frames).toEqual([]); + } finally { + for (const peer of peers) peer.close(); + await daemon.close(); + } +}); + +test("browser capability advertisement is passive and explicit hosts own command delivery", async () => { + const daemon = await createTestPaseoDaemon({ mcpEnabled: false }); + const peers: SubscriptionPeer[] = []; + try { + const capability = { hostKind: "QA browser", supportedCommands: ["list_tabs"] }; + const idle = await SubscriptionPeer.connect(daemon.port, "browser-host-session", { + browser_host: capability, + }); + peers.push(idle); + expect(daemon.daemon.browserToolsBroker.getRegisteredClientCount()).toBe(0); + const host = await SubscriptionPeer.connect(daemon.port, "browser-host-session", { + browser_host: capability, + }); + peers.push(host); + await host.request({ + type: "browser.host.register.request", + requestId: "host-register", + hostKind: capability.hostKind, + supportedCommands: ["list_tabs"], + }); + expect(daemon.daemon.browserToolsBroker.getRegisteredClientCount()).toBe(1); + const pending = daemon.daemon.browserToolsBroker.execute({ + command: { command: "list_tabs", args: {} }, + }); + await expect + .poll(() => + host.frames.some( + (frame) => + frame.type === "session" && frame.message.type === "browser.automation.execute.request", + ), + ) + .toBe(true); + expect(idle.frames).toEqual([]); + const registration = host.frames.flatMap((frame) => + frame.type === "session" && frame.message.type === "browser.host.register.response" + ? [frame.message.payload] + : [], + )[0]; + await host.request({ + type: "subscription.release.request", + requestId: "release-host", + subscriptionId: registration.subscriptionId, + }); + expect(daemon.daemon.browserToolsBroker.getRegisteredClientCount()).toBe(0); + expect(daemon.daemon.browserToolsBroker.getPendingRequestCount()).toBe(0); + await expect(pending).resolves.toMatchObject({ ok: false, error: { code: "browser_no_host" } }); + } finally { + for (const peer of peers) peer.close(); + await daemon.close(); + } +}); + +test("adding public SDK listeners does not create server observation demand", async () => { + const daemon = await createTestPaseoDaemon({ mcpEnabled: false }); + const client = new DaemonClient({ + url: `ws://127.0.0.1:${daemon.port}/ws`, + appVersion: "0.8.0", + capabilities: { owned_subscriptions: true }, + }); + try { + await client.connect(); + const off = client.on("project.update", () => {}); + const all = client.subscribe(() => {}); + await client.ping(); + const { diagnostic } = await client.collectDiagnostics(); + expect(diagnostic).toContain("Registrations: 0"); + expect(diagnostic).toContain("Producer listeners: 0"); + off(); + all(); + } finally { + await client.close(); + await daemon.close(); + } +}); + +test("upload operations with identical request IDs belong to their source sockets", async () => { + const daemon = await createTestPaseoDaemon({ mcpEnabled: false }); + const peers: SubscriptionPeer[] = []; + try { + const a = await SubscriptionPeer.connect(daemon.port, "upload-shared-session"); + peers.push(a); + const b = await SubscriptionPeer.connect(daemon.port, "upload-shared-session"); + peers.push(b); + const idle = await SubscriptionPeer.connect(daemon.port, "upload-shared-session"); + peers.push(idle); + a.send({ + type: "file.upload.request", + requestId: "same-upload", + fileName: "a.txt", + mimeType: "text/plain", + size: 5, + modifiedAt: "2026-09-09T00:00:00Z", + }); + b.send({ + type: "file.upload.request", + requestId: "same-upload", + fileName: "b.txt", + mimeType: "text/plain", + size: 5, + modifiedAt: "2026-09-09T00:00:00Z", + }); + await a.request({ type: "ping", requestId: "a-upload-begun", clientSentAt: 1 }); + await b.request({ type: "ping", requestId: "b-upload-begun", clientSentAt: 1 }); + a.upload("same-upload", "alpha", "a.txt"); + b.upload("same-upload", "bravo", "b.txt"); + const uploads = (peer: SubscriptionPeer) => + peer.frames.flatMap((frame) => + frame.type === "session" && frame.message.type === "file.upload.response" + ? [frame.message.payload] + : [], + ); + await expect + .poll(() => uploads(a)) + .toEqual([ + expect.objectContaining({ + error: null, + file: expect.objectContaining({ fileName: "a.txt" }), + }), + ]); + await expect + .poll(() => uploads(b)) + .toEqual([ + expect.objectContaining({ + error: null, + file: expect.objectContaining({ fileName: "b.txt" }), + }), + ]); + expect(uploads(a)[0]?.file?.id).not.toBe(uploads(b)[0]?.file?.id); + expect(uploads(idle)).toEqual([]); + } finally { + peers.forEach((peer) => peer.close()); + await daemon.close(); + } +}); + +test("notification ownership is separate from data demand and another socket's focus", async () => { + const daemon = await createTestPaseoDaemon({ mcpEnabled: false }); + const admin = new DaemonClient({ url: `ws://127.0.0.1:${daemon.port}/ws`, appVersion: "0.8.0" }); + const peers: SubscriptionPeer[] = []; + try { + await admin.connect(); + const workspace = ( + await admin.createWorkspace({ source: { kind: "directory", path: daemon.staticDir } }) + ).workspace; + if (!workspace) throw new Error("Workspace creation failed"); + const agent = await admin.createAgent({ + provider: "codex", + cwd: daemon.staticDir, + workspaceId: workspace.id, + title: "Attention", + }); + const observer = await SubscriptionPeer.connect(daemon.port, "attention-sources"); + const passive = await SubscriptionPeer.connect(daemon.port, "attention-sources"); + peers.push(observer, passive); + for (const requestId of ["data-a", "data-b", "notifications"]) { + await observer.request({ + type: "session.events.set_subscription.request", + requestId, + events: ["agent_attention_required"], + notifications: requestId === "notifications", + }); + } + const snapshots = observer.frames.flatMap((frame) => + frame.type === "session" && frame.message.type === "session.events.set_subscription.response" + ? [frame.message.payload] + : [], + ); + const notificationId = snapshots.find( + (snapshot) => snapshot.requestId === "notifications", + )!.subscriptionId; + observer.send({ + type: "client_heartbeat", + deviceType: "web", + focusedAgentId: null, + lastActivityAt: new Date().toISOString(), + appVisible: true, + }); + passive.send({ + type: "client_heartbeat", + deviceType: "web", + focusedAgentId: agent.id, + lastActivityAt: new Date().toISOString(), + appVisible: true, + }); + await passive.request({ type: "ping", requestId: "focused-barrier", clientSentAt: 1 }); + observer.frames.length = 0; + passive.frames.length = 0; + await admin.sendMessage(agent.id, "Complete a turn"); + const updates = () => + observer.frames.flatMap((frame) => + frame.type === "session" && frame.message.type === "agent_attention_required" + ? [frame.message.payload] + : [], + ); + await expect.poll(() => updates().length, { timeout: 10000 }).toBe(3); + expect( + updates() + .filter((update) => update.shouldNotify) + .map((update) => update.subscriptionId), + ).toEqual([notificationId]); + expect(new Set(updates().map((update) => update.subscriptionId))).toEqual( + new Set(snapshots.map((snapshot) => snapshot.subscriptionId)), + ); + expect( + passive.frames.filter( + (frame) => frame.type === "session" && frame.message.type === "agent_attention_required", + ), + ).toEqual([]); + } finally { + for (const peer of peers) peer.close(); + await admin.close(); + await daemon.close(); + } +}); + +test("protocol rejection is a source-only control reply on modern connections", async () => { + const daemon = await createTestPaseoDaemon({ mcpEnabled: false }); + const peers: SubscriptionPeer[] = []; + try { + const sender = await SubscriptionPeer.connect(daemon.port, "invalid-message-sources"); + const idle = await SubscriptionPeer.connect(daemon.port, "invalid-message-sources"); + peers.push(sender, idle); + await sender.request({ + type: "fetch_agents_request", + requestId: "invalid-query", + scope: 42, + } as unknown as SessionInboundMessage); + const replies = sender.frames.flatMap((frame) => + frame.type === "session" && frame.message.type === "rpc_error" ? [frame.message.payload] : [], + ); + expect(replies).toEqual([ + expect.objectContaining({ requestId: "invalid-query", requestType: "fetch_agents_request" }), + ]); + expect(idle.frames).toEqual([]); + } finally { + for (const peer of peers) peer.close(); + await daemon.close(); + } +}); + +test("dictation failure belongs only to its requesting source and releases speech demand", async () => { + const daemon = await createTestPaseoDaemon({ mcpEnabled: false }); + const peers: SubscriptionPeer[] = []; + try { + const requester = await SubscriptionPeer.connect(daemon.port, "speech-sources"); + const idle = await SubscriptionPeer.connect(daemon.port, "speech-sources"); + peers.push(requester, idle); + requester.send({ + type: "dictation_stream_start", + dictationId: "same-dictation", + format: "audio/pcm;rate=16000;bits=16", + }); + await expect + .poll(() => + requester.frames.some( + (frame) => frame.type === "session" && frame.message.type === "dictation_stream_error", + ), + ) + .toBe(true); + expect(idle.frames).toEqual([]); + await requester.request({ type: "diagnostics.request", requestId: "speech-producers" }); + const report = requester.frames.flatMap((frame) => + frame.type === "session" && frame.message.type === "diagnostics.response" + ? [frame.message.payload.diagnostic] + : [], + )[0]; + expect(report).toContain("Registrations: 0"); + expect(report).toContain("Producer listeners: 0"); + } finally { + for (const peer of peers) peer.close(); + await daemon.close(); + } +}); + +test("a legacy socket cannot borrow a modern sibling's directory observation", async () => { + const daemon = await createTestPaseoDaemon({ mcpEnabled: false }); + const admin = new DaemonClient({ url: `ws://127.0.0.1:${daemon.port}/ws`, appVersion: "0.8.0" }); + const peers: SubscriptionPeer[] = []; + try { + await admin.connect(); + const workspace = ( + await admin.createWorkspace({ source: { kind: "directory", path: daemon.staticDir } }) + ).workspace!; + if (!workspace) throw new Error("Workspace creation failed"); + const agent = await admin.createAgent({ + provider: "codex", + cwd: daemon.staticDir, + workspaceId: workspace.id, + }); + const modern = await SubscriptionPeer.connect(daemon.port, "mixed-notifications"); + const legacy = await SubscriptionPeer.connect(daemon.port, "mixed-notifications", { + owned_subscriptions: false, + selective_agent_timeline: true, + explicit_event_subscriptions: true, + }); + peers.push(modern, legacy); + await modern.request({ + type: "fetch_agents_request", + requestId: "modern-directory", + subscribe: {}, + }); + await modern.request({ + type: "session.events.set_subscription.request", + requestId: "modern-attention", + events: ["agent_attention_required"], + }); + await admin.sendMessage(agent.id, "Finish a turn"); + await expect + .poll(() => + modern.frames.some( + (frame) => frame.type === "session" && frame.message.type === "agent_attention_required", + ), + ) + .toBe(true); + expect( + legacy.frames.filter( + (frame) => + frame.type === "session" && + (frame.message.type === "agent_attention_required" || + (frame.message.type === "agent_stream" && + frame.message.payload.event.type === "attention_required")), + ), + ).toEqual([]); + } finally { + for (const peer of peers) peer.close(); + await admin.close(); + await daemon.close(); + } +}); + +test("archive and delete replies retain their historical names and reach only the requester", async () => { + const daemon = await createTestPaseoDaemon({ mcpEnabled: false }); + const admin = new DaemonClient({ url: `ws://127.0.0.1:${daemon.port}/ws`, appVersion: "0.8.0" }); + const peers: SubscriptionPeer[] = []; + try { + await admin.connect(); + const agent = await admin.createAgent({ provider: "codex", cwd: daemon.staticDir }); + const requester = await SubscriptionPeer.connect(daemon.port, "mutation-replies"); + const sibling = await SubscriptionPeer.connect(daemon.port, "mutation-replies"); + peers.push(requester, sibling); + await requester.request({ + type: "archive_agent_request", + requestId: "archive", + agentId: agent.id, + }); + await requester.request({ + type: "delete_agent_request", + requestId: "delete", + agentId: agent.id, + }); + expect( + requester.frames.flatMap((frame) => (frame.type === "session" ? [frame.message.type] : [])), + ).toEqual(["agent_archived", "agent_deleted"]); + expect(sibling.frames).toEqual([]); + } finally { + for (const peer of peers) peer.close(); + await admin.close(); + await daemon.close(); + } +}); + +test("Hub bootstrap sends its server ID before the producer's synchronous snapshot", async () => { + const daemon = await createTestPaseoDaemon({ mcpEnabled: false }); + const admin = new DaemonClient({ url: `ws://127.0.0.1:${daemon.port}/ws` }); + const frames: WSOutboundMessage[] = []; + const hub = createServer(async (request, response) => { + let body = ""; + for await (const chunk of request) body += chunk; + response.setHeader("content-type", "application/json"); + if (request.url !== "/api/daemons/enroll") { + response.end("{}"); + return; + } + const enrollment = JSON.parse(body); + const address = hub.address(); + if (!address || typeof address === "string") throw new Error("Missing local Hub address"); + response.end( + JSON.stringify({ + daemonId: enrollment.daemonId, + permissions: enrollment.permissions, + webSocketUrl: `ws://127.0.0.1:${address.port}/socket`, + }), + ); + }); + const wss = new WebSocketServer({ server: hub }); + wss.on("headers", (headers) => headers.push("x-paseo-session-protocol: 1")); + const connected = new Promise((resolve) => + wss.once("connection", (socket) => { + socket.on("message", (data) => frames.push(JSON.parse(data.toString()))); + resolve(socket); + }), + ); + let socket: WebSocket | undefined; + try { + await new Promise((resolve) => hub.listen(0, "127.0.0.1", resolve)); + const address = hub.address(); + if (!address || typeof address === "string") throw new Error("Missing local Hub address"); + await admin.connect(); + await admin.connectHub(`http://127.0.0.1:${address.port}`, "local-fixture-token", [ + "hub.execute", + ]); + socket = await connected; + socket.send( + JSON.stringify({ + type: "hello", + clientId: "local-hub", + clientType: "hub", + protocolVersion: 1, + capabilities: { owned_subscriptions: true }, + }), + ); + await expect.poll(() => frames.length).toBeGreaterThan(0); + socket.send( + JSON.stringify({ + type: "session", + message: { + type: "hub.execution.agent.create.request", + requestId: "create", + executionId: "bootstrap", + provider: "codex", + cwd: daemon.staticDir, + prompt: "hello", + }, + }), + ); + await expect + .poll(() => + frames.some( + (frame) => + frame.type === "session" && + frame.message.type === "hub.execution.agent.create.response" && + frame.message.payload.success, + ), + ) + .toBe(true); + frames.length = 0; + socket.send( + JSON.stringify({ + type: "session", + message: { + type: "session.events.set_subscription.request", + requestId: "observe", + events: ["hub.execution.agent.update"], + }, + }), + ); + await expect + .poll(() => + frames.some( + (frame) => + frame.type === "session" && frame.message.type === "hub.execution.agent.update", + ), + ) + .toBe(true); + expect(frames[0]).toMatchObject({ + type: "session", + message: { + type: "session.events.set_subscription.response", + payload: { requestId: "observe", subscriptionId: expect.any(String) }, + }, + }); + } finally { + await admin.disconnectHub(true).catch(() => {}); + socket?.terminate(); + await admin.close(); + await daemon.close(); + await new Promise((resolve) => wss.close(() => resolve())); + await new Promise((resolve) => hub.close(() => resolve())); + } +}); + +test("provider child changes require explicit event ownership and leave an idle peer quiet", async () => { + const daemon = await createTestPaseoDaemon({ mcpEnabled: false }); + const admin = new DaemonClient({ url: `ws://127.0.0.1:${daemon.port}/ws` }); + let idle: SubscriptionPeer | undefined; + let observer: SubscriptionPeer | undefined; + try { + await admin.connect(); + idle = await SubscriptionPeer.connect(daemon.port, "idle-provider-child"); + observer = await SubscriptionPeer.connect(daemon.port, "provider-child-observer"); + await observer.request({ + type: "session.events.set_subscription.request", + requestId: "children", + events: ["agent.provider_subagents.update"], + }); + const response = observer.frames.find( + (frame) => + frame.type === "session" && + frame.message.type === "session.events.set_subscription.response", + ); + expect(response).toBeDefined(); + const agent = await admin.createAgent({ provider: "codex", cwd: daemon.staticDir }); + await admin.sendMessage(agent.id, "Emit a provider child"); + await expect + .poll(() => + observer!.frames.some( + (frame) => + frame.type === "session" && + frame.message.type === "agent.provider_subagents.update" && + frame.message.payload.kind === "upsert" && + frame.message.payload.subagent.id === "fixture-child", + ), + ) + .toBe(true); + expect(idle.frames).toEqual([]); + expect(idle.binaryFrames).toEqual([]); + } finally { + idle?.close(); + observer?.close(); + await admin.close(); + await daemon.close(); + } +}); + +test("request outcomes for import and attention reach only their requesting socket", async () => { + const daemon = await createTestPaseoDaemon({ + mcpEnabled: false, + isDev: true, + agentClients: { mock: new MockLoadTestAgentClient() }, + }); + const requester = await SubscriptionPeer.connect(daemon.port, "reply-shared"); + const sibling = await SubscriptionPeer.connect(daemon.port, "reply-shared"); + const admin = new DaemonClient({ url: `ws://127.0.0.1:${daemon.port}/ws` }); + try { + await admin.connect(); + await requester.request({ + type: "import_agent_request", + requestId: "missing-import", + provider: "codex", + sessionId: "does-not-exist", + }); + expect(requester.frames).toContainEqual( + expect.objectContaining({ + message: expect.objectContaining({ + type: "status", + payload: expect.objectContaining({ + requestId: "missing-import", + status: "agent_create_failed", + }), + }), + }), + ); + await requester.request({ + type: "import_agent_request", + requestId: "successful-import", + provider: "mock", + sessionId: "saved-provider-session", + cwd: daemon.staticDir, + }); + expect(requester.frames).toContainEqual( + expect.objectContaining({ + message: expect.objectContaining({ + type: "status", + payload: expect.objectContaining({ + requestId: "successful-import", + status: "agent_resumed", + }), + }), + }), + ); + const agent = await admin.createAgent({ + provider: "codex", + cwd: daemon.staticDir, + title: "Attention", + }); + await requester.request({ + type: "clear_agent_attention", + requestId: "clear-attention", + agentId: agent.id, + }); + expect(requester.frames).toContainEqual( + expect.objectContaining({ + message: expect.objectContaining({ type: "clear_agent_attention_response" }), + }), + ); + expect(sibling.frames).toEqual([]); + expect(sibling.binaryFrames).toEqual([]); + } finally { + requester.close(); + sibling.close(); + await admin.close(); + await daemon.close(); + } +}); + +test("SDK import failure settles without waiting for the request timeout", async () => { + const daemon = await createTestPaseoDaemon({ mcpEnabled: false }); + const client = new DaemonClient({ url: `ws://127.0.0.1:${daemon.port}/ws` }); + try { + await client.connect(); + await expect( + client.importAgent({ provider: "codex", sessionId: "does-not-exist" }), + ).rejects.toThrow(); + } finally { + await client.close(); + await daemon.close(); + } +}, 5000); + +test("permission outcomes and domain observations keep independent source ownership", async () => { + const daemon = await createTestPaseoDaemon({ mcpEnabled: false }); + const clients = [ + "permission-shared", + "permission-shared", + "permission-control", + "permission-shared", + ].map((clientId) => new DaemonClient({ url: `ws://127.0.0.1:${daemon.port}/ws`, clientId })); + const legacy = new DaemonClient({ + url: `ws://127.0.0.1:${daemon.port}/ws`, + clientId: "permission-shared", + capabilities: { owned_subscriptions: false, explicit_event_subscriptions: false }, + }); + clients.push(legacy); + const actor = clients[0]; + const observed: SessionOutboundMessage[][] = clients.map(() => []); + const raw: SessionOutboundMessage[][] = clients.map(() => []); + try { + for (const [index, client] of clients.entries()) { + await client.connect(); + client.subscribeRawMessages((message) => raw[index].push(message)); + } + const owners = clients.slice(0, 3).map((client, index) => { + const owner = client.observeEvents(["agent_permission_request", "agent_permission_resolved"]); + owner.subscribe({ snapshot: () => {}, update: (message) => observed[index].push(message) }); + return owner; + }); + await Promise.all(owners.map((owner) => owner.ready)); + const agent = await actor.createAgent({ + provider: "codex", + cwd: daemon.staticDir, + modeId: "always-ask", + }); + for (let round = 0; round < 3; round += 1) { + if (round === 1) await owners[1].release(); + observed.forEach((messages) => { + messages.length = 0; + }); + raw.forEach((messages) => { + messages.length = 0; + }); + await actor.sendMessage( + agent.id, + 'Request approval to run the command `printf "ok" > permission.txt`. If denied, stop.', + ); + const state = await actor.waitForFinish(agent.id, 5000); + const permission = state.final?.pendingPermissions[0]; + expect(permission).toBeDefined(); + if (!permission) throw new Error("Expected a pending permission"); + await (round === 2 ? legacy : actor).respondToPermission(agent.id, permission.id, { + behavior: "deny", + message: "Owned observation regression", + }); + const resolutions = (messages: SessionOutboundMessage[]) => + messages.filter( + ( + message, + ): message is Extract => + message.type === "agent_permission_resolved" && + message.payload.requestId === permission.id, + ); + await expect.poll(() => resolutions(observed[2]).length).toBe(1); + await actor.waitForFinish(agent.id, 5000); + await expect.poll(() => resolutions(observed[0]).length).toBe(1); + for (const index of [0, 1, 2]) { + const expected = index === 1 && round >= 1 ? 0 : 1; + expect(resolutions(observed[index])).toHaveLength(expected); + for (const message of resolutions(observed[index])) { + expect(message.payload).toMatchObject({ + subscriptionId: owners[index].subscriptionId, + resolution: { behavior: "deny" }, + }); + } + const outcomes = resolutions(raw[index]).filter( + (message) => !message.payload.subscriptionId, + ); + expect(outcomes).toHaveLength(index === 0 && round < 2 ? 1 : 0); + } + expect(resolutions(raw[4])).toHaveLength(1); + expect(raw[3]).toEqual([]); + } + } finally { + await Promise.all(clients.map((client) => client.close())); + await daemon.close(); + } +}); diff --git a/packages/server/src/server/plugins/agent-configuration.e2e.test.ts b/packages/server/src/server/plugins/agent-configuration.e2e.test.ts index adb0c6d2da..3228306297 100644 --- a/packages/server/src/server/plugins/agent-configuration.e2e.test.ts +++ b/packages/server/src/server/plugins/agent-configuration.e2e.test.ts @@ -17,7 +17,7 @@ test("the configuration example adds MCP servers and overrides Codex options whi const client = new DaemonClient({ url: `ws://127.0.0.1:${daemon.port}/ws`, appVersion: "0.8.0" }); try { await client.connect(); - await client.fetchAgents({ subscribe: { subscriptionId: "configuration-example" } }); + await client.fetchAgents({ subscribe: {} }); await client.patchDaemonConfig({ pluginsEnabled: true }); await client.installDirectoryPlugin( fileURLToPath(new URL("../../../../../plugin-examples/agent-configuration", import.meta.url)), diff --git a/packages/server/src/server/plugins/lifecycle-archive.e2e.test.ts b/packages/server/src/server/plugins/lifecycle-archive.e2e.test.ts index 06ced0f347..8457c94711 100644 --- a/packages/server/src/server/plugins/lifecycle-archive.e2e.test.ts +++ b/packages/server/src/server/plugins/lifecycle-archive.e2e.test.ts @@ -12,7 +12,7 @@ test("workspace archive publishes agent archive hooks for both live and closed a const client = new DaemonClient({ url: `ws://127.0.0.1:${daemon.port}/ws`, appVersion: "0.8.0" }); try { await client.connect(); - await client.fetchAgents({ subscribe: { subscriptionId: "archive-hooks" } }); + await client.fetchAgents({ subscribe: {} }); await client.patchDaemonConfig({ pluginsEnabled: true }); await client.installDirectoryPlugin( fileURLToPath(new URL("../../../../../plugin-examples/lifecycle-logger", import.meta.url)), diff --git a/packages/server/src/server/plugins/lifecycle.e2e.test.ts b/packages/server/src/server/plugins/lifecycle.e2e.test.ts index f21d1b86e5..48af2cd80c 100644 --- a/packages/server/src/server/plugins/lifecycle.e2e.test.ts +++ b/packages/server/src/server/plugins/lifecycle.e2e.test.ts @@ -32,7 +32,7 @@ export default function contribute(server) { `, ); await client.connect(); - await client.fetchAgents({ subscribe: { subscriptionId: "lifecycle" } }); + await client.fetchAgents({ subscribe: {} }); await client.patchDaemonConfig({ pluginsEnabled: true }); await client.installDirectoryPlugin(directory); const created = await client.createWorkspace({ @@ -100,7 +100,7 @@ export default function contribute(server) { `, ); await client.connect(); - await client.fetchAgents({ subscribe: { subscriptionId: "turn-hooks" } }); + await client.fetchAgents({ subscribe: {} }); await client.patchDaemonConfig({ pluginsEnabled: true }); await client.installDirectoryPlugin(directory); const agent = await client.createAgent({ @@ -183,7 +183,7 @@ export default function contribute(server) { `, ); await client.connect(); - await client.fetchAgents({ subscribe: { subscriptionId: "agent-hooks" } }); + await client.fetchAgents({ subscribe: {} }); await client.patchDaemonConfig({ pluginsEnabled: true }); await client.installDirectoryPlugin(directory); const agent = await client.createAgent({ diff --git a/packages/server/src/server/plugins/lifecycle.real.e2e.test.ts b/packages/server/src/server/plugins/lifecycle.real.e2e.test.ts index 7dad32752e..bd71f0ce7e 100644 --- a/packages/server/src/server/plugins/lifecycle.real.e2e.test.ts +++ b/packages/server/src/server/plugins/lifecycle.real.e2e.test.ts @@ -130,7 +130,7 @@ test.skipIf(process.platform !== "linux")( }; try { await client.connect(); - await client.fetchAgents({ subscribe: { subscriptionId: "real-hooks" } }); + await client.fetchAgents({ subscribe: {} }); await client.patchDaemonConfig({ pluginsEnabled: true }); await client.installDirectoryPlugin(path.join(repoRoot, "plugin-examples/lifecycle-logger")); await client.installDirectoryPlugin(path.join(repoRoot, "plugin-examples/lifecycle-actions")); diff --git a/packages/server/src/server/plugins/plugin-process.ts b/packages/server/src/server/plugins/plugin-process.ts index d08bd6f669..ee8a598a64 100644 --- a/packages/server/src/server/plugins/plugin-process.ts +++ b/packages/server/src/server/plugins/plugin-process.ts @@ -282,6 +282,9 @@ async function initialize(message: Extract { if (stopping) return; stopping = true; + const releaseApi = paseo + ?.dispose() + .catch((error) => console.error("Plugin API cleanup failed", error)); hooks.close(); for (const pending of pendingProviderConnections.values()) pending.tombstoned = true; const currentCleanup = cleanup; @@ -292,6 +295,7 @@ async function shutdown(): Promise { console.error("Plugin cleanup failed", error); } await Promise.all([...providerConnections.keys()].map(closeProviderConnection)); + await releaseApi; await daemonClient?.close().catch(() => undefined); await sendAndWait({ type: "paseo_close" }); daemonClient = null; @@ -321,6 +325,9 @@ process.on("message", (rawMessage: unknown) => { if (message.type === "initialize") { void initialize(message).catch(async (error) => { send({ type: "fatal", error: describeError(error) }); + await paseo + ?.dispose() + .catch((failure) => console.error("Plugin API cleanup failed", failure)); await daemonClient?.close().catch(() => undefined); }); return; diff --git a/packages/server/src/server/plugins/settings.e2e.test.ts b/packages/server/src/server/plugins/settings.e2e.test.ts index 2823cd79ab..5828e84beb 100644 --- a/packages/server/src/server/plugins/settings.e2e.test.ts +++ b/packages/server/src/server/plugins/settings.e2e.test.ts @@ -33,6 +33,7 @@ export default function(server) { server.registerSettings(defineSettings({ id: " ); await first.connect(); await second.connect(); + await second.observeEvents(["status.plugin_settings_changed"]).ready; second.on("status", (message) => { if (message.payload.status === "plugin_settings_changed") changed.push(z.string().parse(message.payload.settingsId)); diff --git a/packages/server/src/server/schedule-run-lifecycle.e2e.test.ts b/packages/server/src/server/schedule-run-lifecycle.e2e.test.ts index 250d8ec583..a27de8825c 100644 --- a/packages/server/src/server/schedule-run-lifecycle.e2e.test.ts +++ b/packages/server/src/server/schedule-run-lifecycle.e2e.test.ts @@ -222,7 +222,7 @@ test("archiveOnFinish=false local scheduled run emits upserts and remains active }, runOnCreate: false, }); - await ctx.client.fetchWorkspaces({ subscribe: { subscriptionId: "schedule-local-workspaces" } }); + await ctx.client.fetchWorkspaces({ subscribe: {} }); const events = collectLifecycleUpdates(); const ran = await runScheduleOnce(schedule.id); @@ -256,7 +256,7 @@ test("archiveOnFinish=true scheduled run emits a workspace remove", async () => runOnCreate: false, }); await ctx.client.fetchWorkspaces({ - subscribe: { subscriptionId: "schedule-archive-workspaces" }, + subscribe: {}, }); const events = collectLifecycleUpdates(); @@ -293,7 +293,7 @@ test("worktree isolation creates a run worktree and archiveOnFinish removes it", runOnCreate: false, }); await ctx.client.fetchWorkspaces({ - subscribe: { subscriptionId: "schedule-worktree-workspaces" }, + subscribe: {}, }); const events = collectLifecycleUpdates(); diff --git a/packages/server/src/server/selective-timeline-delivery.e2e.test.ts b/packages/server/src/server/selective-timeline-delivery.e2e.test.ts index a06b7a32c8..f47b30ce85 100644 --- a/packages/server/src/server/selective-timeline-delivery.e2e.test.ts +++ b/packages/server/src/server/selective-timeline-delivery.e2e.test.ts @@ -6,7 +6,8 @@ import { createPersistedWorkspaceRecord } from "./workspace-registry.js"; import { afterEach, beforeEach, expect, test } from "vitest"; import { CLIENT_CAPS } from "@getpaseo/protocol/client-capabilities"; import type { SessionOutboundMessage } from "@getpaseo/protocol/messages"; -import { DaemonClient } from "./test-utils/daemon-client.js"; +import { DaemonClient, type WebSocketLike } from "@getpaseo/client/internal/daemon-client"; +import { WebSocket } from "ws"; import { createTestPaseoDaemon, type TestPaseoDaemon } from "./test-utils/paseo-daemon.js"; import { MockLoadTestAgentClient, @@ -25,7 +26,10 @@ class ConnectedClient { private readonly waiters: MessageWaiter[] = []; private readonly unsubscribe: () => void; - constructor(readonly client: DaemonClient) { + constructor( + readonly client: DaemonClient, + private readonly socket: WebSocket, + ) { this.unsubscribe = client.subscribeRawMessages((message) => { this.messages.push(message); for (let waiterIndex = this.waiters.length - 1; waiterIndex >= 0; waiterIndex -= 1) { @@ -38,6 +42,46 @@ class ConnectedClient { }); } + // These cases exercise the pre-owned-subscriptions selective timeline contract. + async setLegacyTimelineMembership(agentIds: string[]): Promise { + const requestId = crypto.randomUUID(); + this.socket.send( + JSON.stringify({ + type: "session", + message: { + type: "agent.timeline.set_subscription.request", + requestId, + agentIds, + }, + }), + ); + await this.next( + (message) => + message.type === "agent.timeline.set_subscription.response" && + message.payload.requestId === requestId, + "legacy timeline acknowledgement", + ); + } + + async observeLegacyDirectory(subscriptionId: string): Promise { + const requestId = crypto.randomUUID(); + this.socket.send( + JSON.stringify({ + type: "session", + message: { + type: "fetch_agents_request", + requestId, + subscribe: { subscriptionId }, + }, + }), + ); + await this.next( + (message) => + message.type === "fetch_agents_response" && message.payload.requestId === requestId, + "legacy directory acknowledgement", + ); + } + clear(): void { this.messages.length = 0; } @@ -141,24 +185,28 @@ async function connect(input: { pluginTimelineItems?: boolean; workspaceSetupBlocked?: boolean; }): Promise { + let socket!: WebSocket; const client = new DaemonClient({ url: `ws://127.0.0.1:${daemon.port}/ws`, clientId: input.clientId, + webSocketFactory: (url) => { + socket = new WebSocket(url); + return socket as unknown as WebSocketLike; + }, capabilities: { + [CLIENT_CAPS.ownedSubscriptions]: false, [CLIENT_CAPS.selectiveAgentTimeline]: input.selective, [CLIENT_CAPS.pluginTimelineItems]: input.pluginTimelineItems ?? false, [CLIENT_CAPS.workspaceSetupBlocked]: input.workspaceSetupBlocked ?? false, ...(input.timelineNotifications === undefined ? {} : { [CLIENT_CAPS.timelineNotifications]: input.timelineNotifications }), - ...(input.timelineReplacementInvalidation - ? { [CLIENT_CAPS.timelineReplacementInvalidation]: true } - : {}), + [CLIENT_CAPS.timelineReplacementInvalidation]: input.timelineReplacementInvalidation ?? false, }, reconnect: { enabled: false }, }); await client.connect(); - const connected = new ConnectedClient(client); + const connected = new ConnectedClient(client, socket); clients.push(connected); return connected; } @@ -358,9 +406,9 @@ test("rewind routes replacement completion by source capability and subscription }); await Promise.all([ - initiating.client.setAgentTimelineSubscription([agent.id]), - passive.client.setAgentTimelineSubscription([agent.id]), - unrelated.client.setAgentTimelineSubscription([]), + initiating.setLegacyTimelineMembership([agent.id]), + passive.setLegacyTimelineMembership([agent.id]), + unrelated.setLegacyTimelineMembership([]), ]); await initiating.client.sendMessage(agent.id, "Rewind this synthetic prompt"); await initiating.client.cancelAgent(agent.id); @@ -409,7 +457,7 @@ test("subscription acknowledgements stay on the requesting socket of a retained legacy.clear(); capable.clear(); - await capable.client.setAgentTimelineSubscription(["agent-a"]); + await capable.setLegacyTimelineMembership(["agent-a"]); await capable.barrier("targeted-subscription-ack"); expect( @@ -444,7 +492,7 @@ test("real WebSocket sessions enforce selective delivery, retained resets, downg await capable.barrier("before-membership"); expect(capable.hasTimeline(agentC.id)).toBe(false); - await capable.client.setAgentTimelineSubscription([agentA.id, agentB.id]); + await capable.setLegacyTimelineMembership([agentA.id, agentB.id]); legacy.clear(); capable.clear(); await daemon.daemon.agentManager.emitLiveTimelineItem(agentA.id, { @@ -467,7 +515,7 @@ test("real WebSocket sessions enforce selective delivery, retained resets, downg await capable.barrier("unviewed-c"); expect(capable.hasTimeline(agentC.id)).toBe(false); - await capable.client.setAgentTimelineSubscription([agentB.id]); + await capable.setLegacyTimelineMembership([agentB.id]); legacy.clear(); capable.clear(); await daemon.daemon.agentManager.emitLiveTimelineItem(agentA.id, { @@ -500,8 +548,8 @@ test("real WebSocket sessions enforce selective delivery, retained resets, downg expect(capable.hasTimeline(agentB.id)).toBe(false); await Promise.all([ - legacy.client.fetchAgents({ subscribe: { subscriptionId: "legacy-directory" } }), - capable.client.fetchAgents({ subscribe: { subscriptionId: "capable-directory" } }), + legacy.observeLegacyDirectory("legacy-directory"), + capable.observeLegacyDirectory("capable-directory"), ]); capable.client.sendHeartbeat({ deviceType: "mobile", diff --git a/packages/server/src/server/session.test.ts b/packages/server/src/server/session.test.ts index 04406b21f0..a32f5265c9 100644 --- a/packages/server/src/server/session.test.ts +++ b/packages/server/src/server/session.test.ts @@ -372,7 +372,10 @@ function createSessionForTest(options: SessionForTestOptions = {}): Session { onMessageToSource: (source: object, message: SessionOutboundMessage) => options.targetedMessages?.push({ source, message }), } - : {}), + : { + onMessageToSource: (_source: object, message: SessionOutboundMessage) => + messages.push(message), + }), onBinaryMessage: createBinaryMessageHandler(options.binaryMessages), logger, downloadTokenStore: options.downloadTokenStore ?? asDownloadTokenStore(), @@ -517,6 +520,12 @@ test("routes plugin requests and releases its owned catalog subscription on clea }; const session = createSessionForTest({ messages, pluginRuntime }); + await session.handleMessage({ + type: "session.events.set_subscription.request", + requestId: "catalog", + events: ["status.plugin_catalog_changed"], + }); + messages.length = 0; await session.handleMessage({ type: "plugin.list.request", requestId: "list" }); await session.handleMessage({ type: "plugin.logs.get.request", @@ -1308,43 +1317,56 @@ describe("workspace file access (behavior preservation)", () => { const messages: SessionOutboundMessage[] = []; const session = createSessionForTest({ messages, paseoHome }); - await session.handleMessage({ - type: "file.upload.request", - fileName: "notes.txt", - mimeType: "text/plain", - size: 11, - modifiedAt: "2026-05-02T00:00:00.000Z", - requestId: "req-upload", - }); - await session.handleBinaryFrame({ - kind: "file_transfer", - frame: uploadFrame({ - opcode: FileTransferOpcode.FileBegin, + const source = {}; + await session.handleMessage( + { + type: "file.upload.request", + fileName: "notes.txt", + mimeType: "text/plain", + size: 11, + modifiedAt: "2026-05-02T00:00:00.000Z", requestId: "req-upload", - metadata: { - mime: "text/plain", - size: 11, - encoding: "binary", - modifiedAt: "2026-05-02T00:00:00.000Z", - fileName: "notes.txt", - }, - }), - }); - await session.handleBinaryFrame({ - kind: "file_transfer", - frame: uploadFrame({ - opcode: FileTransferOpcode.FileChunk, - requestId: "req-upload", - payload: new TextEncoder().encode("hello world"), - }), - }); - await session.handleBinaryFrame({ - kind: "file_transfer", - frame: uploadFrame({ - opcode: FileTransferOpcode.FileEnd, - requestId: "req-upload", - }), - }); + }, + source, + ); + await session.handleBinaryFrame( + { + kind: "file_transfer", + frame: uploadFrame({ + opcode: FileTransferOpcode.FileBegin, + requestId: "req-upload", + metadata: { + mime: "text/plain", + size: 11, + encoding: "binary", + modifiedAt: "2026-05-02T00:00:00.000Z", + fileName: "notes.txt", + }, + }), + }, + source, + ); + await session.handleBinaryFrame( + { + kind: "file_transfer", + frame: uploadFrame({ + opcode: FileTransferOpcode.FileChunk, + requestId: "req-upload", + payload: new TextEncoder().encode("hello world"), + }), + }, + source, + ); + await session.handleBinaryFrame( + { + kind: "file_transfer", + frame: uploadFrame({ + opcode: FileTransferOpcode.FileEnd, + requestId: "req-upload", + }), + }, + source, + ); const response = messages.find((message) => message.type === "file.upload.response"); if (response?.type !== "file.upload.response") { @@ -5200,6 +5222,7 @@ test("acknowledges a timeline subscription only to its socket source", async () payload: { agentIds: ["agent-a"], requestId: "timeline-subscription-targeted", + subscriptionId: expect.any(String), }, }, }, @@ -5725,34 +5748,50 @@ test("provider snapshots preserve versionless visibility while capabilities upda { provider: "plugin-provider", status: "ready", enabled: true }, ]); const session = createSessionForTest({ messages, providerSnapshotManager: manager }); + const source = {}; + session.updateClientCapabilities(null, source); const read = async () => { messages.length = 0; - await session.handleMessage({ - type: "get_providers_snapshot_request", - requestId: "visibility", - }); + await session.handleMessage( + { + type: "get_providers_snapshot_request", + requestId: "visibility", + }, + source, + ); return findByType(messages, "get_providers_snapshot_response")!.payload; }; const versionless = await read(); expect(versionless.entries.map((entry) => entry.provider)).toEqual(["codex"]); expect(versionless.entries[0]!.modes![0]!.icon).toBe("ShieldCheck"); - session.updateClientCapabilities({ - [CLIENT_CAPS.customModeIcons]: true, - [CLIENT_CAPS.providerSnapshotReferences]: true, - }); + session.updateClientCapabilities( + { + [CLIENT_CAPS.customModeIcons]: true, + [CLIENT_CAPS.providerSnapshotReferences]: true, + }, + source, + ); const iconsOnly = await read(); expect(iconsOnly.entries.map((entry) => entry.provider)).toEqual(["codex"]); expect(iconsOnly.entries[0]!.modes![0]!.icon).toBe("Sparkles"); expect(iconsOnly.snapshotHash).toBeUndefined(); - session.updateAppVersion("0.1.45"); + session.updateClientCapabilities( + { [CLIENT_CAPS.customModeIcons]: true, [CLIENT_CAPS.providerSnapshotReferences]: true }, + source, + "0.1.45", + ); expect((await read()).entries.map((entry) => entry.provider)).toEqual([ "codex", "plugin-provider", ]); - session.updateClientCapabilities({ - [CLIENT_CAPS.compactProviderSnapshots]: true, - [CLIENT_CAPS.providerSnapshotReferences]: true, - }); + session.updateClientCapabilities( + { + [CLIENT_CAPS.compactProviderSnapshots]: true, + [CLIENT_CAPS.providerSnapshotReferences]: true, + }, + source, + "0.1.45", + ); const references = await read(); expect(references.entries).toEqual([]); expect(references.compactSnapshot!.entries.map((entry) => entry.provider)).toEqual([ diff --git a/packages/server/src/server/session.ts b/packages/server/src/server/session.ts index c1e149ee5e..20f4143edf 100644 --- a/packages/server/src/server/session.ts +++ b/packages/server/src/server/session.ts @@ -1,6 +1,9 @@ +import type { BrowserToolsBroker } from "./browser-tools/broker.js"; +import { BrowserAutomationHostCapabilitySchema } from "@getpaseo/protocol/browser-automation/capabilities"; import type { SessionEventSubscription } from "@getpaseo/protocol/messages"; import type { AgentRequests } from "./agent/requests/index.js"; import equal from "fast-deep-equal"; +import { SessionDelivery, type OwnedSubscription } from "./session/owned-subscriptions/index.js"; import { v4 as uuidv4 } from "uuid"; import { lstat, mkdir, mkdtemp, rename, rm, stat } from "node:fs/promises"; import { basename, resolve, sep } from "path"; @@ -164,7 +167,7 @@ import { removeProjectCustomIcon, setProjectCustomIcon, } from "../utils/project-custom-icon.js"; -import { VoiceSession } from "./session/voice/voice-session.js"; +import { VoiceSessions } from "./session/voice/index.js"; import { CheckoutSession } from "./session/checkout/checkout-session.js"; import { createWorkspaceGitObserverService, @@ -290,17 +293,6 @@ function errorToFriendlyMessage(error: unknown): string { return "Unknown error"; } -function resolveSubscriptionId( - subscribe: unknown, - requestedSubscriptionId: string | undefined, -): string | null { - if (!subscribe) return null; - if (requestedSubscriptionId && requestedSubscriptionId.length > 0) { - return requestedSubscriptionId; - } - return uuidv4(); -} - function isAppVersionAtLeast(appVersion: string | null, minVersion: string): boolean { if (!appVersion) return false; // Strip prerelease suffix: "0.1.45-beta.4" -> "0.1.45" @@ -413,6 +405,7 @@ type WorkspaceUpdatePayload = Extract< >["payload"]; interface WorkspaceUpdatesSubscriptionState { subscriptionId: string; + owner: OwnedSubscription; syncEnabled?: boolean; filter?: WorkspaceUpdatesFilter; isBootstrapping: boolean; @@ -446,6 +439,7 @@ const nodeSessionFileSystem: SessionFileSystem = { type AgentMcpTransportFactory = () => Promise; export interface SessionOptions { + browserToolsBroker?: BrowserToolsBroker | null; clientId: string; permissions: readonly DaemonPermission[]; appVersion?: string | null; @@ -454,7 +448,7 @@ export interface SessionOptions { onMessageToSource?: (source: object, msg: SessionOutboundMessage) => void; onBinaryMessage?: (frame: Uint8Array) => void; onBinaryMessageToSource?: (source: object, frame: Uint8Array) => Promise; - getTransportBufferedAmount?: () => number | null; + getTransportBufferedAmount?: (source?: object) => number | null; onLifecycleIntent?: (intent: SessionLifecycleIntent) => void; onWorkspaceRecovered?: (workspace: PersistedWorkspaceRecord) => Promise; logger: pino.Logger; @@ -616,7 +610,6 @@ interface ArchivedRecordSnapshot { } interface WorkspaceUpdateOptions { - dedupeGitState?: boolean; removedProjectId?: string; optimisticStatus?: WorkspaceDescriptorPayload["status"]; } @@ -654,7 +647,41 @@ function workspaceLabelErrorCode(error: unknown): string { return "workspace_label_failed"; } +interface ClientActivity { + deviceType: "web" | "mobile"; + focusedAgentId: string | null; + focusedTerminalId: string | null; + lastActivityAt: Date; + appVisible: boolean; + appVisibilityChangedAt: Date; +} + export class Session { + readonly delivery = new SessionDelivery( + (source, message) => { + if (!this.authorization.allowsOutbound(message)) return; + if (this.onMessageToSource) this.onMessageToSource(source, message); + else this.onMessage(message); + }, + (source, frame) => { + if (this.onBinaryMessageToSource) { + void this.onBinaryMessageToSource(source, frame).catch((error) => + this.sessionLogger.warn({ err: error }, "Failed to emit binary frame"), + ); + } else this.emitBinary(frame); + }, + (source, message) => this.workspaceSetupMessageForClient(message, source), + (request, message) => + this.sessionLogger.warn( + { + requestType: request.type, + messageType: message.type, + status: message.type === "status" ? message.payload.status : undefined, + }, + "Unrecognized reply contract; application delivery denied", + ), + ); + private readonly browserToolsBroker: SessionOptions["browserToolsBroker"]; private readonly clientId: string; private readonly authorization: SessionAuthorization; private appVersion: string | null; @@ -668,7 +695,7 @@ export class Session { private readonly onBinaryMessageToSource: | ((source: object, frame: Uint8Array) => Promise) | null; - private readonly getTransportBufferedAmount: () => number | null; + private readonly getTransportBufferedAmount: (source?: object) => number | null; private readonly onLifecycleIntent: ((intent: SessionLifecycleIntent) => void) | null; private readonly onWorkspaceRecovered: | ((workspace: PersistedWorkspaceRecord) => Promise) @@ -703,32 +730,31 @@ export class Session { private registryMutationQueue: Promise = Promise.resolve(); private projectUpdateQueue: Promise = Promise.resolve(); private isCleanedUp = false; - private viewedTimelineAgentIds = new Set(); - private readonly viewedTimelineAgentIdsBySource = new Map>(); - private readonly clientCapabilitiesBySource = new Map>(); - private readonly defaultTimelineSubscriptionSource = {}; + private readonly timelineSubscriptions = new Map< + string, + { owner: OwnedSubscription; agentIds: Set } + >(); + private readonly clientSources = new Map< + object, + { + capabilities: ReadonlySet; + appVersion: string | null; + activity: ClientActivity | null; + pushToken: string | null; + } + >(); private unsubscribeTerminalWorkspaceContributionEvents: (() => void) | null = null; private readonly agentUpdates: AgentUpdatesService; - private workspaceUpdatesSubscription: WorkspaceUpdatesSubscriptionState | null = null; + private readonly workspaceUpdatesSubscriptions = new Map< + string, + WorkspaceUpdatesSubscriptionState + >(); private readonly workspaceLabelService: WorkspaceLabelService | null; - private workspaceLabelSubscription: { - owner: object; - id: string; - unsubscribe: () => void; - } | null = null; - private projectSyncEnabled = false; - private readonly defaultEventSubscriptionSource = {}; - private readonly eventSubscriptions = new Map>(); + private readonly eventSubscriptions = new Map< + string, + { owner: OwnedSubscription; events: Set; notifications: boolean } + >(); private readonly workspaceUpdateTails = new Map>(); - private clientActivity: { - deviceType: "web" | "mobile"; - focusedAgentId: string | null; - focusedTerminalId: string | null; - lastActivityAt: Date; - appVisible: boolean; - appVisibilityChangedAt: Date; - } | null = null; - private registeredPushToken: string | null = null; private readonly terminalManager: TerminalManager | null; private readonly providerSnapshotManager: ProviderSnapshotManager; private readonly serviceProxy: ServiceProxySubsystem | null; @@ -744,7 +770,7 @@ export class Session { private readonly workspaceSetupRuntime: WorkspaceSetupRuntime; private readonly workspaceGitObserver: WorkspaceGitObserverService; private readonly workspaceDirectory: WorkspaceDirectory; - private readonly voiceSession: VoiceSession; + private readonly voiceSessions: VoiceSessions; private readonly checkoutSession: CheckoutSession; private readonly scheduleSession: ScheduleSession; private readonly providerCatalogSession: ProviderCatalogSession; @@ -814,6 +840,7 @@ export class Session { daemonRuntimeConfig, getWebSocketRuntimeMetrics, } = options; + this.browserToolsBroker = options.browserToolsBroker; this.clientId = clientId; this.authorization = new SessionAuthorization(permissions); this.appVersion = appVersion ?? null; @@ -833,7 +860,6 @@ export class Session { this.worktreesRoot = worktreesRoot; this.pluginRuntime = pluginRuntime; this.orchestrationSkills = orchestrationSkills; - this.unsubscribePluginChanges = this.subscribeToPluginChanges(pluginRuntime); this.sessionLogger = logger.child({ module: "session", clientId: this.clientId, @@ -909,11 +935,7 @@ export class Session { }); this.workspaceGitObserver = createWorkspaceGitObserverService({ workspaceGitService: this.workspaceGitService, - describeWorkspaceRecordWithGitData: (workspace) => - this.describeWorkspaceRecordWithGitData(workspace), emitWorkspaceUpdateForCwd: (cwd) => this.emitWorkspaceUpdateForCwd(cwd), - emitWorkspaceUpdateForWorkspaceId: (workspaceId) => - this.emitWorkspaceUpdateForWorkspaceId(workspaceId), emitStatusUpdate: (cwd, snapshot) => this.checkoutSession.emitStatusUpdate(cwd, snapshot), onBranchChanged, logger: this.sessionLogger, @@ -929,7 +951,26 @@ export class Session { isProviderVisibleToClient: (provider) => this.isProviderVisibleToClient(provider), supportsCustomModeIcons: () => this.supports(CLIENT_CAPS.customModeIcons), supportsCompactProviderSnapshots: () => this.supports(CLIENT_CAPS.compactProviderSnapshots), - wantsSnapshotChanges: () => this.wantsEvent("providers_snapshot_update"), + publishSnapshot: (project) => { + const delivered = new Set(); + for (const subscription of this.eventSubscriptions.values()) { + if (!subscription.events.has("providers_snapshot_update")) continue; + const message = this.delivery.forSource(subscription.owner.source, project); + if (message) subscription.owner.emit(message); + delivered.add(subscription.owner.source); + } + // COMPAT(ownedSubscriptions): added in v0.8.0, remove implicit provider delivery after 2027-03-09. + for (const source of this.clientSources.keys()) { + if ( + delivered.has(source) || + this.delivery.isModern(source) || + !this.wantsEvent("providers_snapshot_update", source) + ) + continue; + const message = this.delivery.forSource(source, project); + if (message) this.onMessageToSource?.(source, message); + } + }, supportsProviderSnapshotReferences: () => this.supports(CLIENT_CAPS.providerSnapshotReferences), listProviderAvailability: () => this.agentManager.listProviderAvailability(), @@ -979,6 +1020,22 @@ export class Session { daemonVersion, daemonRuntimeConfig, getWebSocketRuntimeMetrics, + getObservationMetrics: () => ({ + Registrations: this.delivery.registrationCount, + "Source registrations": this.delivery.sourceRegistrationCount(), + "Producer listeners": [ + this.unsubscribeAgentEvents, + this.unsubscribeProjectMutations, + this.unsubscribeWorkspaceMutations, + this.unsubscribePluginChanges, + this.unsubscribeTerminalWorkspaceContributionEvents, + this.providerCatalogSession.isObserving, + this.hubExecutionController?.isObserving, + ].filter(Boolean).length, + "Git observations": this.workspaceGitObserver.getMetrics().subscriptionCount, + "Terminal directories": this.terminalController.getMetrics().directorySubscriptionCount, + "Terminal output": this.terminalController.getMetrics().streamSubscriptionCount, + }), listProviderAvailability: () => this.agentManager.listProviderAvailability(), listAgents: () => this.agentManager.listAgents(), listProjects: () => this.projectRegistry.list(), @@ -1000,14 +1057,13 @@ export class Session { this.terminalController = new TerminalSessionController({ terminalManager, emit: (msg) => this.emit(msg), - emitBinary: (frame) => this.emitBinary(frame), hasBinaryChannel: () => this.onBinaryMessage !== null, isPathWithinRoot: (rootPath, candidatePath) => this.isPathWithinRoot(rootPath, candidatePath), sessionLogger: this.sessionLogger, listTerminalWorkspaceRefs: () => this.listActiveWorkspaceRefs(), - clientSupportsWrapReflow: () => - this.clientCapabilities.has(CLIENT_CAPS.terminalReflowableSnapshot), - getClientBufferedAmount: () => this.getTransportBufferedAmount(), + clientSupportsWrapReflow: (source) => + this.supportsForSource(CLIENT_CAPS.terminalReflowableSnapshot, source), + getClientBufferedAmount: (source) => this.getTransportBufferedAmount(source), }); this.agentUpdates = createAgentUpdatesService({ emit: (message) => this.emit(message), @@ -1074,11 +1130,11 @@ export class Session { logger: this.sessionLogger, emit: (message) => this.emit(message), spawnWorkspaceScript, + wantsStatusUpdates: () => this.wantsEvent("script_status_update"), assertAutomationAllowed: (workspaceId) => assertWorkspaceAutomationAllowedForWorkspace(this.workspaceRegistry, workspaceId), globalServicePorts: loadPersistedConfig(this.paseoHome).worktrees?.servicePorts, }); - this.subscribeToOptionalManagers(); this.workspaceDirectory = new WorkspaceDirectory({ logger: this.sessionLogger, projectRegistry: this.projectRegistry, @@ -1090,43 +1146,44 @@ export class Session { buildWorkspaceDescriptor: (input) => this.buildWorkspaceDescriptor(input), }); - this.voiceSession = new VoiceSession({ - host: { - emit: (msg) => this.emit(msg), - loadAgent: (agentId) => - ensureAgentLoaded(agentId, { - agentManager: this.agentManager, - agentStorage: this.agentStorage, - logger: this.sessionLogger, - }), - reloadAgentSession: (agentId, overrides) => - this.agentManager.reloadAgentSession(agentId, overrides), - sendSpokenInput: async (agentId, text) => { - await this.handleSendAgentMessage( - agentId, - text, - undefined, - undefined, - undefined, - undefined, - { spokenInput: true }, - ); + this.voiceSessions = new VoiceSessions( + { + host: { + emit: (msg) => this.emit(msg), + loadAgent: (agentId) => + ensureAgentLoaded(agentId, { + agentManager: this.agentManager, + agentStorage: this.agentStorage, + logger: this.sessionLogger, + }), + reloadAgentSession: (agentId, overrides) => + this.agentManager.reloadAgentSession(agentId, overrides), + sendSpokenInput: async (agentId, text) => { + await this.handleSendAgentMessage( + agentId, + text, + undefined, + undefined, + undefined, + undefined, + { spokenInput: true }, + ); + }, + interruptAgentIfRunning: (agentId) => this.interruptAgentIfRunning(agentId), + hasActiveAgentRun: (agentId) => this.hasActiveAgentRun(agentId), }, - interruptAgentIfRunning: (agentId) => this.interruptAgentIfRunning(agentId), - hasActiveAgentRun: (agentId) => this.hasActiveAgentRun(agentId), + logger: this.sessionLogger, + sessionId: this.sessionId, + sttLanguage, + tts, + stt, + voice, + voiceBridge, + dictation, }, - logger: this.sessionLogger, - sessionId: this.sessionId, - sttLanguage, - tts, - stt, - voice, - voiceBridge, - dictation, - }); - - this.subscribeToAgentEvents(); - this.subscribeToRegistryMutations(); + this.delivery, + () => this.refreshObservationProducers(), + ); this.sessionLogger.trace({}, "agent.session.lifecycle.created"); } @@ -1137,49 +1194,60 @@ export class Session { } } - updateClientCapabilities(capabilities: Record | null, source?: object): void { + updateClientCapabilities( + capabilities: Record | null, + source?: object, + appVersion = this.appVersion, + ): void { this.clientCapabilities = parseClientCapabilities(capabilities); if (source) { - this.eventSubscriptions.delete(source); - this.clientCapabilitiesBySource.set(source, this.clientCapabilities); - } - if (!source && !this.supports(CLIENT_CAPS.selectiveAgentTimeline)) { - this.viewedTimelineAgentIdsBySource.clear(); - this.viewedTimelineAgentIds.clear(); + this.delivery.attach(source, capabilities?.[CLIENT_CAPS.ownedSubscriptions] === true); + this.clientSources.set(source, { + capabilities: this.clientCapabilities, + appVersion, + activity: this.clientSources.get(source)?.activity ?? null, + pushToken: this.clientSources.get(source)?.pushToken ?? null, + }); + this.refreshObservationProducers(); + // COMPAT(ownedSubscriptions): added in v0.8.0, remove capability-driven legacy host registration after 2027-03-09. + if ( + !this.delivery.isModern(source) && + this.authorization.allowsPermission("workspace.write") + ) { + const host = BrowserAutomationHostCapabilitySchema.safeParse(capabilities?.browser_host); + if (host.success && this.browserToolsBroker) { + const request = { + type: "browser.host.register.request" as const, + requestId: uuidv4(), + hostKind: host.data.hostKind, + supportedCommands: host.data.supportedCommands, + }; + void this.delivery + .request(source, request, () => this.registerBrowserHost(request, false)) + .catch((error) => + this.sessionLogger.warn({ err: error }, "Failed to register legacy browser host"), + ); + } + } } } clearAgentTimelineSubscription(source: object): void { - this.clientCapabilitiesBySource.delete(source); - this.eventSubscriptions.delete(source); - if (this.viewedTimelineAgentIdsBySource.delete(source)) { - this.rebuildViewedTimelineAgentIds(); - } + void this.delivery + .detach(source) + .catch((err) => this.sessionLogger.error({ err }, "Failed to release source subscriptions")); + this.clientSources.delete(source); + this.refreshObservationProducers(); } - private replaceAgentTimelineSubscription(source: object | undefined, agentIds: string[]): void { - const subscriptionSource = source ?? this.defaultTimelineSubscriptionSource; - if (agentIds.length === 0) this.viewedTimelineAgentIdsBySource.delete(subscriptionSource); - else this.viewedTimelineAgentIdsBySource.set(subscriptionSource, new Set(agentIds)); - this.rebuildViewedTimelineAgentIds(); - } - - private rebuildViewedTimelineAgentIds(): void { - const viewedAgentIds = new Set(); - for (const agentIds of this.viewedTimelineAgentIdsBySource.values()) { - for (const agentId of agentIds) viewedAgentIds.add(agentId); - } - this.viewedTimelineAgentIds = viewedAgentIds; - } - - private usesSelectiveTimelineDelivery(): boolean { - if (this.clientCapabilitiesBySource.size === 0) { - return this.supports(CLIENT_CAPS.selectiveAgentTimeline); - } - for (const capabilities of this.clientCapabilitiesBySource.values()) { - if (!capabilities.has(CLIENT_CAPS.selectiveAgentTimeline)) return false; - } - return true; + private subscribeAgentTimelines(agentIds: string[]): OwnedSubscription { + const owner = this.delivery.begin("timelines", undefined, (id) => { + this.timelineSubscriptions.delete(id); + this.refreshObservationProducers(); + }); + this.timelineSubscriptions.set(owner.id, { owner, agentIds: new Set(agentIds) }); + this.refreshObservationProducers(); + return owner; } // COMPAT(timelineItemCapabilities): plugin items added in v0.8.0, notifications in v0.7.2. @@ -1196,77 +1264,78 @@ export class Session { event: Extract, serializedEvent: Extract["payload"]["event"], ): void { - if (this.clientCapabilitiesBySource.size === 0 || !this.onMessageToSource) { - if (serializedEvent.type === "timeline" && !this.supportsTimelineItem(serializedEvent.item)) - return; - if (this.usesSelectiveTimelineDelivery() && serializedEvent.type === "attention_required") { - this.emit({ - type: "agent_attention_required", - payload: { - agentId: event.agentId, - reason: serializedEvent.reason, - timestamp: serializedEvent.timestamp, - shouldNotify: serializedEvent.shouldNotify, - ...(serializedEvent.notification ? { notification: serializedEvent.notification } : {}), - }, - }); - } else if ( - !this.usesSelectiveTimelineDelivery() || - this.viewedTimelineAgentIds.has(event.agentId) - ) { - this.emit({ - type: "agent_stream", - payload: this.buildAgentStreamPayload(event, serializedEvent), - }); - } - return; + const attention = serializedEvent.type === "attention_required"; + if (attention) { + this.emit({ + type: "agent_attention_required", + payload: { + agentId: event.agentId, + reason: serializedEvent.reason, + timestamp: serializedEvent.timestamp, + shouldNotify: serializedEvent.shouldNotify, + ...(serializedEvent.notification ? { notification: serializedEvent.notification } : {}), + }, + }); } - - for (const [source, capabilities] of this.clientCapabilitiesBySource) { + const message: SessionOutboundMessage = { + type: "agent_stream", + payload: this.buildAgentStreamPayload(event, serializedEvent), + }; + for (const subscription of this.timelineSubscriptions.values()) { + const source = subscription.owner.source; + if (!subscription.agentIds.has(event.agentId)) continue; + if ( + attention && + (this.delivery.isModern(source) || + this.supportsForSource(CLIENT_CAPS.selectiveAgentTimeline, source)) + ) + continue; if ( serializedEvent.type === "timeline" && !this.supportsTimelineItem(serializedEvent.item, source) ) continue; - const supportsSelectiveDelivery = capabilities.has(CLIENT_CAPS.selectiveAgentTimeline); - if (supportsSelectiveDelivery && serializedEvent.type === "attention_required") { - if (!this.wantsEvent("agent_attention_required", source)) continue; - this.onMessageToSource(source, { - type: "agent_attention_required", - payload: { - agentId: event.agentId, - reason: serializedEvent.reason, - timestamp: serializedEvent.timestamp, - shouldNotify: serializedEvent.shouldNotify, - ...(serializedEvent.notification ? { notification: serializedEvent.notification } : {}), - }, - }); - continue; - } - if ( - supportsSelectiveDelivery && - !this.viewedTimelineAgentIdsBySource.get(source)?.has(event.agentId) - ) { - continue; - } - this.onMessageToSource(source, { - type: "agent_stream", - payload: this.buildAgentStreamPayload(event, serializedEvent), - }); + subscription.owner.emit(message); } + // COMPAT(ownedSubscriptions): added in v0.8.0, remove implicit timeline delivery after 2027-03-09. + for (const [source, { capabilities }] of this.clientSources) { + if (this.delivery.isModern(source) || capabilities.has(CLIENT_CAPS.selectiveAgentTimeline)) + continue; + if ( + serializedEvent.type === "timeline" && + !this.supportsTimelineItem(serializedEvent.item, source) + ) + continue; + const alreadyDelivered = [...this.timelineSubscriptions.values()].some( + (subscription) => + subscription.owner.source === source && subscription.agentIds.has(event.agentId), + ); + if (!alreadyDelivered) this.onMessageToSource?.(source, message); + } + if ( + this.clientSources.size === 0 && + !this.supports(CLIENT_CAPS.selectiveAgentTimeline) && + this.timelineSubscriptions.size === 0 + ) + this.emit(message); } supports(capability: ClientCapability): boolean { - return this.clientCapabilities.has(capability); + const source = this.delivery.currentSource; + return source + ? this.supportsForSource(capability, source) + : this.clientCapabilities.has(capability); } supportsForSource(capability: ClientCapability, source: object): boolean { return ( - this.clientCapabilitiesBySource.get(source)?.has(capability) ?? this.supports(capability) + this.clientSources.get(source)?.capabilities.has(capability) ?? + this.clientCapabilities.has(capability) ); } emitProjectUpdate(update: ProjectUpdate): Promise { + if (!this.wantsEvent("project.update")) return Promise.resolve(); const queued = this.projectUpdateQueue .catch(() => undefined) .then(() => this.publishProjectUpdate(update)); @@ -1280,15 +1349,23 @@ export class Session { update.kind === "upsert" ? { kind: "upsert" as const, project: await this.buildProjectDescriptor(update.project) } : update; + if (!this.wantsEvent("project.update")) return; const message: SessionOutboundMessage = { type: "project.update", - payload: this.directorySync.sequenceProjectUpdate(projectedPayload, this.projectSyncEnabled), + payload: this.directorySync.sequenceProjectUpdate(projectedPayload, true), }; this.emit(message); } async syncWorkspaceGitObserverForWorkspace(workspace: PersistedWorkspaceRecord): Promise { - await this.workspaceGitObserver.syncObserverForWorkspace(workspace); + if (this.workspaceUpdatesSubscriptions.size === 0 && !this.wantsEvent("checkout_status_update")) + return; + const descriptor = await this.describeWorkspaceRecord(workspace); + const demanded = [...this.workspaceUpdatesSubscriptions.values()].some((subscription) => + this.matchesWorkspaceFilter({ workspace: descriptor, filter: subscription.filter }), + ); + if (demanded || this.wantsEvent("checkout_status_update")) + this.workspaceGitObserver.syncObservers([descriptor]); } async emitWorkspaceUpdateForWorkspaceId(workspaceId: string): Promise { @@ -1299,7 +1376,7 @@ export class Session { workspace: WorkspaceDescriptorPayload, optimisticStatus?: WorkspaceDescriptorPayload["status"], ): Promise { - if (this.workspaceUpdatesSubscription) { + if (this.workspaceUpdatesSubscriptions.size > 0) { await this.emitWorkspaceUpdatesForWorkspaceIds( [workspace.id], optimisticStatus ? { optimisticStatus } : undefined, @@ -1335,25 +1412,29 @@ export class Session { async syncWorkspaceGitObserversForExternalWorkspaceIds( workspaceIds: Iterable, ): Promise { + if (!this.delivery.hasDemand("workspaces") && !this.wantsEvent("checkout_status_update")) + return; await Promise.all( Array.from(new Set(workspaceIds)).map(async (workspaceId) => { const workspace = await this.workspaceRegistry.get(workspaceId); if (workspace && !workspace.archivedAt) { - await this.workspaceGitObserver.syncObserverForWorkspace(workspace); + await this.syncWorkspaceGitObserverForWorkspace(workspace); } }), ); } async warmWorkspaceGitDataForWorkspace(workspace: PersistedWorkspaceRecord): Promise { - await this.workspaceGitObserver.warmGitData(workspace); + await this.syncWorkspaceGitObserverForWorkspace(workspace); + await this.emitWorkspaceUpdateForWorkspaceId(workspace.workspaceId); } async refreshRecoveredWorkspaceForExternalMutation( workspace: PersistedWorkspaceRecord, ): Promise { try { - await this.workspaceGitObserver.warmGitData(workspace); + await this.syncWorkspaceGitObserverForWorkspace(workspace); + await this.emitWorkspaceUpdateForWorkspaceId(workspace.workspaceId); } catch (error) { this.sessionLogger.warn( { err: error, workspaceId: workspace.workspaceId }, @@ -1373,15 +1454,25 @@ export class Session { /** * Get the client's current activity state */ - public getClientActivity(): { - deviceType: "web" | "mobile"; - focusedAgentId: string | null; - focusedTerminalId: string | null; - lastActivityAt: Date; - appVisible: boolean; - appVisibilityChangedAt: Date; - } | null { - return this.clientActivity; + private currentClientMetadata() { + const source = this.delivery.currentSource; + if (!source) throw new Error("Client control has no source"); + let metadata = this.clientSources.get(source); + if (!metadata) { + // COMPAT(ownedSubscriptions): added in v0.8.0, remove socketless legacy session adapter after 2027-03-09. + metadata = { + capabilities: this.clientCapabilities, + appVersion: this.appVersion, + activity: null, + pushToken: null, + }; + this.clientSources.set(source, metadata); + } + return metadata; + } + + public getClientActivity(source = this.delivery.currentSource): ClientActivity | null { + return source ? (this.clientSources.get(source)?.activity ?? null) : null; } private getFocusedAgentSelectionForCwd(cwd: string): @@ -1391,7 +1482,7 @@ export class Session { thinkingOptionId?: string | null; } | undefined { - const focusedAgentId = this.clientActivity?.focusedAgentId; + const focusedAgentId = this.getClientActivity()?.focusedAgentId; if (!focusedAgentId) { return undefined; } @@ -1509,33 +1600,75 @@ export class Session { /** * Subscribe to AgentManager events and forward them to the client */ - private subscribeToOptionalManagers(): void { - this.terminalController.start(); - if (this.terminalManager) { - this.unsubscribeTerminalWorkspaceContributionEvents = - this.terminalManager.subscribeTerminalWorkspaceContributionChanged((event) => { - void this.emitWorkspaceUpdateForTerminalContribution(event).catch((error) => { - this.sessionLogger.warn( - { err: error, terminalId: event.terminalId }, - "Failed to emit workspace update after terminal contribution changed", - ); - }); - }); + private refreshObservationProducers(): void { + const legacy = this.delivery.hasLegacySources(); + const workspaces = + legacy || this.delivery.hasDemand("workspaces") || this.wantsEvent("checkout_status_update"); + const agents = + workspaces || + this.delivery.hasDemand("agents") || + this.delivery.hasDemand("timelines") || + this.voiceSessions.hasDemand || + this.wantsEvent("agent_attention_required") || + this.wantsEvent("agent_permission_request") || + this.wantsEvent("agent_permission_resolved") || + this.wantsEvent("agent.provider_subagents.update"); + if (agents && !this.unsubscribeAgentEvents) this.subscribeToAgentEvents(); + if (!agents) { + this.unsubscribeAgentEvents?.(); + this.unsubscribeAgentEvents = null; } - this.providerCatalogSession.start(); + this.refreshWorkspaceProducers(workspaces); + this.refreshCatalogProducers(legacy); } - private subscribeToRegistryMutations(): void { - this.unsubscribeProjectMutations?.(); - this.unsubscribeProjectMutations = - this.projectRegistry.subscribeToMutations?.((mutation) => - this.enqueueRegistryMutation(() => this.handleProjectMutation(mutation)), - ) ?? null; - this.unsubscribeWorkspaceMutations?.(); - this.unsubscribeWorkspaceMutations = - this.workspaceRegistry.subscribeToMutations?.((mutation) => - this.enqueueRegistryMutation(() => this.handleWorkspaceMutation(mutation)), - ) ?? null; + private refreshWorkspaceProducers(observing: boolean): void { + if (observing) { + this.unsubscribeProjectMutations ??= + this.projectRegistry.subscribeToMutations?.((mutation) => + this.enqueueRegistryMutation(() => this.handleProjectMutation(mutation)), + ) ?? null; + this.unsubscribeWorkspaceMutations ??= + this.workspaceRegistry.subscribeToMutations?.((mutation) => + this.enqueueRegistryMutation(() => this.handleWorkspaceMutation(mutation)), + ) ?? null; + this.unsubscribeTerminalWorkspaceContributionEvents ??= + this.terminalManager?.subscribeTerminalWorkspaceContributionChanged((event) => { + void this.emitWorkspaceUpdateForTerminalContribution(event).catch((error) => + this.sessionLogger.warn( + { err: error, terminalId: event.terminalId }, + "Failed to emit workspace terminal contribution", + ), + ); + }) ?? null; + } else { + this.unsubscribeProjectMutations?.(); + this.unsubscribeProjectMutations = null; + this.unsubscribeWorkspaceMutations?.(); + this.unsubscribeWorkspaceMutations = null; + this.unsubscribeTerminalWorkspaceContributionEvents?.(); + this.unsubscribeTerminalWorkspaceContributionEvents = null; + } + } + + private refreshCatalogProducers(legacy: boolean): void { + this.hubExecutionController?.setObserving( + legacy || + this.wantsEvent("hub.execution.agent.update") || + this.wantsEvent("hub.execution.agent.stream"), + ); + if (this.wantsEvent("providers_snapshot_update")) this.providerCatalogSession.start(); + else this.providerCatalogSession.dispose(); + if ( + legacy || + this.wantsEvent("status.plugin_catalog_changed") || + this.wantsEvent("status.plugin_settings_changed") + ) + this.unsubscribePluginChanges ??= this.subscribeToPluginChanges(this.pluginRuntime); + else { + this.unsubscribePluginChanges?.(); + this.unsubscribePluginChanges = null; + } } private enqueueRegistryMutation(handleMutation: () => Promise): Promise { @@ -1574,10 +1707,11 @@ export class Session { } private async syncWorkspaceMutationObserver(mutation: WorkspaceMutation): Promise { - const subscription = this.workspaceUpdatesSubscription; - if (!mutation.workspace || !subscription) { + if ( + !mutation.workspace || + (this.workspaceUpdatesSubscriptions.size === 0 && !this.wantsEvent("checkout_status_update")) + ) return; - } const descriptorsByWorkspaceId = await this.buildWorkspaceDescriptorMap({ workspaceIds: [mutation.workspaceId], includeGitData: false, @@ -1585,7 +1719,10 @@ export class Session { const descriptor = descriptorsByWorkspaceId.get(mutation.workspaceId); if ( !descriptor || - !this.matchesWorkspaceFilter({ workspace: descriptor, filter: subscription.filter }) + (!this.wantsEvent("checkout_status_update") && + ![...this.workspaceUpdatesSubscriptions.values()].some((subscription) => + this.matchesWorkspaceFilter({ workspace: descriptor, filter: subscription.filter }), + )) ) { this.workspaceGitObserver.removeForWorkspaceId(mutation.workspaceId); return; @@ -1595,16 +1732,24 @@ export class Session { this.workspaceGitObserver.removeForWorkspaceId(mutation.workspaceId); return; } - await this.workspaceGitObserver.syncObserverForWorkspace(currentWorkspace); + await this.syncWorkspaceGitObserverForWorkspace(currentWorkspace); if (this.isCleanedUp) { this.workspaceGitObserver.removeForWorkspaceId(mutation.workspaceId); } } private async handleProjectMutation(mutation: ProjectMutation): Promise { + for (const subscription of this.workspaceUpdatesSubscriptions.values()) { + await this.handleProjectMutationForSubscription(mutation, subscription); + } + } + + private async handleProjectMutationForSubscription( + mutation: ProjectMutation, + subscription: WorkspaceUpdatesSubscriptionState, + ): Promise { try { - const subscription = this.workspaceUpdatesSubscription; - if (this.isCleanedUp || !subscription) { + if (this.isCleanedUp) { return; } const projectWorkspaceIds = (await this.workspaceRegistry.list()) @@ -1633,7 +1778,7 @@ export class Session { for (const workspaceId of projectWorkspaceIds) { this.workspaceGitObserver.removeForWorkspaceId(workspaceId); } - await this.emitWorkspaceUpdatesForWorkspaceIds(updateIds, { + await this.enqueueWorkspaceUpdates(new Set(updateIds), subscription, { removedProjectId: mutation.projectId, }); return; @@ -1644,7 +1789,7 @@ export class Session { this.workspaceGitObserver.removeForWorkspaceId(workspaceId); } } - await this.emitWorkspaceUpdatesForWorkspaceIds(projectWorkspaceIds); + await this.enqueueWorkspaceUpdates(new Set(projectWorkspaceIds), subscription, undefined); } catch (error) { this.sessionLogger.warn( { err: error, projectId: mutation.projectId, mutationKind: mutation.kind }, @@ -1687,7 +1832,18 @@ export class Session { }; } - if (this.clientCapabilitiesBySource.size === 0 || !this.onMessageToSource) { + const delivered = new Set(); + for (const subscription of this.eventSubscriptions.values()) { + if (!subscription.events.has("agent.provider_subagents.update")) continue; + if ( + update.type === "timeline" && + !this.supportsTimelineItem(update.row.item, subscription.owner.source) + ) + continue; + subscription.owner.emit(message); + delivered.add(subscription.owner.source); + } + if (this.clientSources.size === 0 || !this.onMessageToSource) { if ( this.supports(CLIENT_CAPS.providerSubagents) && (update.type !== "timeline" || this.supportsTimelineItem(update.row.item)) @@ -1696,8 +1852,13 @@ export class Session { } return; } - for (const [source, capabilities] of this.clientCapabilitiesBySource) { - if (!capabilities.has(CLIENT_CAPS.providerSubagents)) continue; + for (const [source, { capabilities }] of this.clientSources) { + if ( + delivered.has(source) || + this.delivery.isModern(source) || + !capabilities.has(CLIENT_CAPS.providerSubagents) + ) + continue; if (update.type === "timeline" && !this.supportsTimelineItem(update.row.item, source)) continue; this.onMessageToSource(source, message); @@ -1738,7 +1899,7 @@ export class Session { } if ( - this.voiceSession.isActiveForAgent(event.agentId) && + this.voiceSessions.isActiveForAgent(event.agentId) && event.event.type === "permission_requested" && isVoicePermissionAllowed(event.event.request) ) { @@ -1786,7 +1947,8 @@ export class Session { }, }); } else if (event.event.type === "permission_resolved") { - this.emit({ + // A provider event is not the outcome of whichever request is currently running. + this.emitSubscribedEvent({ type: "agent_permission_resolved", payload: { agentId: event.agentId, @@ -1852,7 +2014,14 @@ export class Session { } private isProviderVisibleToClient(provider: string): boolean { - if (this.supports(CLIENT_CAPS.allProviders) || clientSupportsAllProviders(this.appVersion)) { + if ( + this.supports(CLIENT_CAPS.allProviders) || + clientSupportsAllProviders( + this.delivery.currentSource + ? (this.clientSources.get(this.delivery.currentSource)?.appVersion ?? null) + : this.appVersion, + ) + ) { return true; } return LEGACY_PROVIDER_IDS.has(provider); @@ -1898,6 +2067,10 @@ export class Session { * Main entry point for processing session messages */ public async handleMessage(msg: SessionInboundMessage, source?: object): Promise { + return this.delivery.request(source, msg, () => this.handleRequest(msg, source)); + } + + private async handleRequest(msg: SessionInboundMessage, source?: object): Promise { this.inflightRequests++; if (this.inflightRequests > this.peakInflightRequests) { this.peakInflightRequests = this.inflightRequests; @@ -1966,6 +2139,13 @@ export class Session { public setPermissions(permissions: readonly DaemonPermission[]): void { this.authorization.replacePermissions(permissions); + if (!this.authorization.allowsPermission("workspace.write")) { + void this.delivery + .releaseFamily("browser-host") + .catch((error) => + this.sessionLogger.error({ err: error }, "Failed to release revoked browser hosting"), + ); + } } public getPermissions(): DaemonPermission[] { @@ -1980,23 +2160,100 @@ export class Session { return this.authorization.allowsPermission(permission); } - public subscribesToAgent(agent: ManagedAgent): Promise { - return this.agentUpdates.includesLiveAgent(agent); + public subscribesToAgent(agent: ManagedAgent, source: object): Promise { + return this.agentUpdates.includesLiveAgent( + agent, + new Set(this.delivery.subscriptionIds(source, "agents")), + ); } - public subscribesToTerminalDirectory(input: { - cwd: string; - workspaceId?: string; - }): Promise { - return this.terminalController.hasDirectorySubscription(input); + public subscribesToTerminalDirectory( + input: { + cwd: string; + workspaceId?: string; + }, + source: object, + ): Promise { + return this.terminalController.hasDirectorySubscription(input, source); + } + + public publishToSource(source: object, message: SessionOutboundMessage): void { + if (!this.authorization.allowsOutbound(message)) return; + this.emitSubscribedEvent(message, source); + } + + public wantsSourceEvent(source: object, event: SessionEventSubscription): boolean { + return this.wantsEvent(event, source); + } + + public wantsSourceNotification(source: object, event: SessionEventSubscription): boolean { + // COMPAT(ownedSubscriptions): added in v0.8.0, remove legacy notification selection after 2027-03-09. + if (!this.delivery.isModern(source)) return true; + return [...this.eventSubscriptions.values()].some( + (subscription) => + subscription.owner.source === source && + subscription.notifications && + subscription.events.has(event), + ); } public publish(message: SessionOutboundMessage): void { this.emit(message); } + private async registerBrowserHost( + request: Extract, + respond = true, + ): Promise { + if (!this.browserToolsBroker) throw new Error("Browser hosting is unavailable"); + let unregister: (() => void) | undefined; + const owner = this.delivery.begin("browser-host", undefined, () => unregister?.()); + try { + unregister = this.browserToolsBroker.registerClient({ + id: owner.id, + hostKind: request.hostKind, + supportedCommands: request.supportedCommands, + sendBrowserAutomationRequest: (message) => owner.emit(message), + }); + if (respond) + this.emit({ + type: "browser.host.register.response", + payload: { requestId: request.requestId, subscriptionId: owner.responseId }, + }); + } catch (error) { + await owner.release(); + throw error; + } + } + + private dispatchSubscriptionMessage( + msg: SessionInboundMessage, + source?: object, + ): Promise | undefined { + if (msg.type === "browser.host.register.request") return this.registerBrowserHost(msg); + if (msg.type === "browser.automation.execute.response") { + if (source) + this.browserToolsBroker?.receiveResponse( + msg, + this.delivery.subscriptionIds(source, "browser-host"), + ); + return Promise.resolve(); + } + if (msg.type === "subscription.release.request") { + return this.delivery.release(msg.subscriptionId).then(() => { + this.emit({ + type: "subscription.release.response", + payload: { requestId: msg.requestId, subscriptionId: msg.subscriptionId }, + }); + return undefined; + }); + } + return undefined; + } + private async dispatchInboundMessage(msg: SessionInboundMessage, source?: object): Promise { const promise = + this.dispatchSubscriptionMessage(msg, source) ?? this.dispatchVoiceAndControlMessage(msg) ?? this.dispatchAgentRewindMessage(msg, source) ?? this.dispatchAgentRelationshipMessage(msg) ?? @@ -2251,28 +2508,14 @@ export class Session { private dispatchVoiceAndControlMessage(msg: SessionInboundMessage): Promise | undefined { switch (msg.type) { case "voice_audio_chunk": - return this.voiceSession.handleAudioChunk(msg); case "abort_request": - return this.voiceSession.handleAbort(); case "audio_played": - this.voiceSession.handleAudioPlayed(msg.id); - return undefined; case "set_voice_mode": - return this.voiceSession.handleSetVoiceMode(msg.enabled, msg.agentId, msg.requestId); case "dictation_stream_start": - return this.voiceSession.handleDictationStreamStart(msg); case "dictation_stream_chunk": - return this.voiceSession.handleDictationChunk({ - dictationId: msg.dictationId, - seq: msg.seq, - audioBase64: msg.audio, - format: msg.format, - }); case "dictation_stream_finish": - return this.voiceSession.handleDictationFinish(msg.dictationId, msg.finalSeq); case "dictation_stream_cancel": - this.voiceSession.handleDictationCancel(msg.dictationId); - return undefined; + return this.voiceSessions.handleMessage(msg); case "restart_server_request": return this.handleRestartServerRequest(msg.requestId, msg.reason); case "shutdown_server_request": @@ -2335,34 +2578,41 @@ export class Session { case "agent.provider_subagents.timeline.get.request": return this.handleProviderSubagentTimelineRequest(msg, source); case "session.events.set_subscription.request": { - this.eventSubscriptions.set( - source ?? this.defaultEventSubscriptionSource, - new Set(msg.events), - ); + const owner = this.delivery.begin("events", undefined, async (id) => { + this.eventSubscriptions.delete(id); + this.refreshObservationProducers(); + if (msg.events.includes("checkout_status_update")) + await this.reconcileWorkspaceGitObservers(); + }); + this.eventSubscriptions.set(owner.id, { + owner, + events: new Set(msg.events), + notifications: msg.notifications === true, + }); this.emitForSource( { type: "session.events.set_subscription.response", - payload: { requestId: msg.requestId }, + payload: { requestId: msg.requestId, subscriptionId: owner.responseId }, }, source, ); - return undefined; + this.refreshObservationProducers(); + if (!msg.events.includes("checkout_status_update")) return undefined; + return this.reconcileWorkspaceGitObservers().catch(async (error) => { + await owner.release(); + throw error; + }); } case "agent.timeline.set_subscription.request": { const agentIds = [...new Set(msg.agentIds)].sort(); - if ( - source - ? this.supportsForSource(CLIENT_CAPS.selectiveAgentTimeline, source) - : this.supports(CLIENT_CAPS.selectiveAgentTimeline) - ) { - this.replaceAgentTimelineSubscription(source, agentIds); - } - const response: SessionOutboundMessage = { - type: "agent.timeline.set_subscription.response", - payload: { agentIds, requestId: msg.requestId }, - }; - if (source && this.onMessageToSource) this.onMessageToSource(source, response); - else this.emit(response); + const owner = this.subscribeAgentTimelines(agentIds); + this.emitForSource( + { + type: "agent.timeline.set_subscription.response", + payload: { agentIds, requestId: msg.requestId, subscriptionId: owner.responseId }, + }, + source, + ); return undefined; } case "agent.fork_context.request": @@ -2497,11 +2747,12 @@ export class Session { return this.checkoutSession.handleBranchSuggestionsRequest(msg); case "directory_suggestions_request": return this.handleDirectorySuggestionsRequest(msg); + case "checkout.diff.get.request": + return this.checkoutSession.handleReadDiffRequest(msg); case "subscribe_checkout_diff_request": - return this.checkoutSession.handleSubscribeDiffRequest(msg); + return this.checkoutSession.handleSubscribeDiffRequest(msg, this.delivery); case "unsubscribe_checkout_diff_request": - this.checkoutSession.handleUnsubscribeDiffRequest(msg); - return undefined; + return this.checkoutSession.handleUnsubscribeDiffRequest(msg, this.delivery); case "checkout_switch_branch_request": return this.checkoutSession.handleCheckoutSwitchBranchRequest(msg); case "checkout.rename_branch.request": @@ -2627,10 +2878,9 @@ export class Session { case "file_explorer_request": return this.workspaceFilesSession.handleFileExplorerRequest(msg, source); case "fs.file.subscribe.request": - return this.workspaceFilesSession.handleFileSubscribeRequest(msg); + return this.workspaceFilesSession.handleFileSubscribeRequest(msg, this.delivery); case "fs.file.unsubscribe.request": - this.workspaceFilesSession.handleFileUnsubscribeRequest(msg); - return undefined; + return this.workspaceFilesSession.handleFileUnsubscribeRequest(msg, this.delivery); case "fs.file.write.request": return this.workspaceFilesSession.handleFileWriteRequest(msg); case "fs.entry.create.request": @@ -2648,7 +2898,7 @@ export class Session { case "file_download_token_request": return this.workspaceFilesSession.handleFileDownloadTokenRequest(msg); case "file.upload.request": - this.workspaceFilesSession.handleFileUploadRequest(msg); + this.workspaceFilesSession.handleFileUploadRequest(msg, this.delivery); return undefined; default: return undefined; @@ -2704,7 +2954,7 @@ export class Session { case "workspace.script.stop.request": return this.handleWorkspaceScriptStopRequest(msg); default: - return this.terminalController.dispatch(msg); + return this.terminalController.dispatch(msg, this.delivery); } } @@ -2743,8 +2993,8 @@ export class Session { return; case "push.unregister.request": this.pushNotifications.revoke(msg.token); - if (this.registeredPushToken?.trim() === msg.token.trim()) { - this.registeredPushToken = null; + if (this.currentClientMetadata().pushToken?.trim() === msg.token.trim()) { + this.currentClientMetadata().pushToken = null; } this.emit({ type: "push.unregister.response", @@ -2762,15 +3012,15 @@ export class Session { return this.sessionId; } - public async handleBinaryFrame(binaryFrame: BinaryFrame): Promise { + public async handleBinaryFrame(binaryFrame: BinaryFrame, source: object): Promise { if (!this.authorization.allowsPermission("workspace.write")) { return; } if (binaryFrame.kind === "file_transfer") { - await this.workspaceFilesSession.handleFileTransferFrame(binaryFrame.frame); + await this.workspaceFilesSession.handleFileTransferFrame(binaryFrame.frame, source); return; } - this.terminalController.handleBinaryFrame(binaryFrame.frame); + this.terminalController.handleBinaryFrame(binaryFrame.frame, source); } private async handleRestartServerRequest(requestId: string, reason?: string): Promise { @@ -4118,29 +4368,36 @@ export class Session { const timeline = this.agentManager.fetchTimeline(agentId, { limit: 0 }); const epoch = timeline.epoch; - if (this.clientCapabilitiesBySource.size === 0 || !this.onMessageToSource) { + if (this.clientSources.size === 0 || !this.onMessageToSource) { if (!this.supports(CLIENT_CAPS.timelineReplacementInvalidation)) { this.emitReconstructedTimelineRows(agentId, agent.provider, timeline.rows, epoch); } return; } - for (const [source, capabilities] of this.clientCapabilitiesBySource) { - const isInitiator = source === initiatingSource; - const supportsReplacement = capabilities.has(CLIENT_CAPS.timelineReplacementInvalidation); - const isSubscribed = this.viewedTimelineAgentIdsBySource.get(source)?.has(agentId) === true; - if (supportsReplacement) { - if (isSubscribed && !isInitiator) { - this.onMessageToSource(source, { - type: "agent.timeline.replacement", - payload: { agentId, epoch }, - }); - } - continue; + for (const subscription of this.timelineSubscriptions.values()) { + const source = subscription.owner.source; + if (!subscription.agentIds.has(agentId)) continue; + const modern = this.delivery.isModern(source); + if ( + modern || + (source !== initiatingSource && + this.supportsForSource(CLIENT_CAPS.timelineReplacementInvalidation, source)) + ) { + subscription.owner.emit({ + type: "agent.timeline.replacement", + payload: { agentId, epoch }, + }); + } + } + // COMPAT(timelineReplacementInvalidation): added in v0.5.0, remove reconstructed replay after 2027-02-21. + for (const [source, { capabilities }] of this.clientSources) { + if ( + !this.delivery.isModern(source) && + !capabilities.has(CLIENT_CAPS.timelineReplacementInvalidation) + ) { + this.emitReconstructedTimelineRows(agentId, agent.provider, timeline.rows, epoch, source); } - // COMPAT(timelineReplacementInvalidation): added in v0.5.0, replay reconstructed - // rows to legacy clients until the supported client floor is >= v0.5.0 after 2027-02-21. - this.emitReconstructedTimelineRows(agentId, agent.provider, timeline.rows, epoch, source); } } @@ -4289,7 +4546,8 @@ export class Session { const appVisibilityChangedAt = msg.appVisibilityChangedAt ? new Date(msg.appVisibilityChangedAt) : new Date(msg.lastActivityAt); - this.clientActivity = { + const metadata = this.currentClientMetadata(); + metadata.activity = { deviceType: msg.deviceType, focusedAgentId: msg.focusedAgentId, focusedTerminalId, @@ -4300,8 +4558,8 @@ export class Session { if (msg.appVisible && focusedTerminalId) { void this.clearFocusedTerminalAttention(focusedTerminalId); } - if (this.registeredPushToken) { - this.pushNotifications.renew(this.registeredPushToken); + if (metadata.pushToken) { + this.pushNotifications.renew(metadata.pushToken); } } @@ -4321,7 +4579,7 @@ export class Session { * Handle push token registration */ private handleRegisterPushToken(token: string): void { - this.registeredPushToken = token; + this.currentClientMetadata().pushToken = token; this.pushNotifications.renew(token); this.sessionLogger.info("Registered push token"); } @@ -4427,6 +4685,15 @@ export class Session { response, logger: this.sessionLogger, }); + // COMPAT(ownedSubscriptions): added in v0.8.0, remove after 2027-03-09. + // Legacy clients consume the single domain resolution; modern request outcomes + // are independent of whether this socket (or its logical Session) observes it. + if (this.delivery.isModern(this.delivery.currentSource)) { + this.delivery.reply({ + type: "agent_permission_resolved", + payload: { agentId, requestId, resolution: response }, + }); + } } catch (error) { this.sessionLogger.error( { err: error, agentId, requestId }, @@ -5182,6 +5449,8 @@ export class Session { subscription: WorkspaceUpdatesSubscriptionState, payload: WorkspaceUpdatePayload, ): void { + if (this.workspaceUpdatesSubscriptions.get(subscription.subscriptionId) !== subscription) + return; if (payload.kind === "upsert") { subscription.visibleEmptyProjectIds?.delete(payload.workspace.projectId); } else { @@ -5199,20 +5468,22 @@ export class Session { } const workspaceId = payload.kind === "upsert" ? payload.workspace.id : payload.id; subscription.lastEmittedByWorkspaceId.set(workspaceId, payload); - this.emit({ - type: "workspace_update", - payload, - }); + subscription.owner.emit({ type: "workspace_update", payload }); } - private flushBootstrappedWorkspaceUpdates(options?: { - snapshotByWorkspaceId?: Map< - string, - { status: string; statusEnteredAt: string | null; activityAtMs: number | null } - >; - }): void { - const subscription = this.workspaceUpdatesSubscription; - if (!subscription || !subscription.isBootstrapping) { + private flushBootstrappedWorkspaceUpdates( + subscription: WorkspaceUpdatesSubscriptionState, + options?: { + snapshotByWorkspaceId?: Map< + string, + { status: string; statusEnteredAt: string | null; activityAtMs: number | null } + >; + }, + ): void { + if ( + this.workspaceUpdatesSubscriptions.get(subscription.subscriptionId) !== subscription || + !subscription.isBootstrapping + ) { return; } @@ -5246,10 +5517,7 @@ export class Session { } const workspaceId = payload.kind === "upsert" ? payload.workspace.id : payload.id; subscription.lastEmittedByWorkspaceId.set(workspaceId, payload); - this.emit({ - type: "workspace_update", - payload, - }); + subscription.owner.emit({ type: "workspace_update", payload }); } } @@ -5387,17 +5655,11 @@ export class Session { workspaceIds: Iterable, options?: WorkspaceUpdateOptions, ): Promise { - const subscription = this.workspaceUpdatesSubscription; - if (!subscription) { - return; + const uniqueWorkspaceIds = new Set(workspaceIds); + if (uniqueWorkspaceIds.size === 0) return; + for (const subscription of this.workspaceUpdatesSubscriptions.values()) { + await this.enqueueWorkspaceUpdates(uniqueWorkspaceIds, subscription, options); } - - const uniqueWorkspaceIds = new Set(Array.from(workspaceIds)); - if (uniqueWorkspaceIds.size === 0) { - return; - } - - await this.enqueueWorkspaceUpdates(uniqueWorkspaceIds, subscription, options); } private enqueueWorkspaceUpdates( @@ -5409,7 +5671,9 @@ export class Session { (this.workspaceUpdateTails.get(workspaceId) ?? Promise.resolve()).catch(() => undefined), ); const next = Promise.all(previous).then(() => - this.emitWorkspaceUpdateBatch(workspaceIds, subscription, options), + this.delivery.forSource(subscription.owner.source, () => + this.emitWorkspaceUpdateBatch(workspaceIds, subscription, options), + ), ); for (const workspaceId of workspaceIds) { this.workspaceUpdateTails.set(workspaceId, next); @@ -5431,7 +5695,7 @@ export class Session { subscription: WorkspaceUpdatesSubscriptionState, options: WorkspaceUpdateOptions | undefined, ): Promise { - if (this.workspaceUpdatesSubscription !== subscription) { + if (this.workspaceUpdatesSubscriptions.get(subscription.subscriptionId) !== subscription) { return; } @@ -5441,7 +5705,7 @@ export class Session { }); for (const workspaceId of workspaceIds) { - if (this.workspaceUpdatesSubscription !== subscription) { + if (this.workspaceUpdatesSubscriptions.get(subscription.subscriptionId) !== subscription) { return; } const workspace = descriptorsByWorkspaceId.get(workspaceId); @@ -5454,18 +5718,12 @@ export class Session { options?.optimisticStatus, ); const lastEmitted = subscription.lastEmittedByWorkspaceId.get(workspaceId); - if ( - options?.dedupeGitState && - this.workspaceGitObserver.shouldSkipUpdate(workspaceId, nextWorkspace) - ) { - continue; - } this.workspaceGitObserver.recordDescriptorState(workspaceId, nextWorkspace); if (!nextWorkspace) { if (this.shouldSkipWorkspaceRemoval(lastEmitted, options?.removedProjectId)) { continue; } - if (this.workspaceUpdatesSubscription !== subscription) { + if (this.workspaceUpdatesSubscriptions.get(subscription.subscriptionId) !== subscription) { return; } subscription.lastEmittedByWorkspaceId.delete(workspaceId); @@ -5570,12 +5828,8 @@ export class Session { // and emits its own per-id descriptor. This is a deliberate same-folder fan, // not a cwd → id ownership lookup: git never resolves which workspace owns a // path. See `workspaceIdsOnCheckout`. - private async emitWorkspaceUpdateForCwd( - cwd: string, - options?: { - dedupeGitState?: boolean; - }, - ): Promise { + private async emitWorkspaceUpdateForCwd(cwd: string, options?: {}): Promise { + if (this.workspaceUpdatesSubscriptions.size === 0) return; const workspaceIds = workspaceIdsOnCheckout(await this.workspaceRegistry.list(), cwd); if (workspaceIds.length === 0) { return; @@ -5586,16 +5840,26 @@ export class Session { private async handleFetchAgents( request: Extract, ): Promise { - const requestedSubscriptionId = request.subscribe?.subscriptionId?.trim(); - const subscriptionId = resolveSubscriptionId(request.subscribe, requestedSubscriptionId); - + const owner = request.subscribe + ? this.delivery.begin("agents", request.subscribe.subscriptionId, (id) => { + this.agentUpdates.clearSubscription(id); + this.refreshObservationProducers(); + }) + : null; + const subscriptionId = owner?.responseId; try { - if (subscriptionId) { + if (owner) { this.agentUpdates.beginSubscription({ - subscriptionId, + subscriptionId: owner.id, + isProviderVisible: (provider) => + this.delivery.forSource(owner.source, () => this.isProviderVisibleToClient(provider)), filter: request.filter, syncEnabled: Boolean(request.sync), + emit: (message) => { + if (message.type === "agent_update") owner.emit(message); + }, }); + this.refreshObservationProducers(); } const payload = request.sync @@ -5619,11 +5883,11 @@ export class Session { }); if (subscriptionId) { - this.agentUpdates.flushBootstrapped(subscriptionId, { snapshotUpdatedAtByAgentId }); + this.agentUpdates.flushBootstrapped(owner!.id, { snapshotUpdatedAtByAgentId }); } } catch (error) { if (subscriptionId) { - this.agentUpdates.clearSubscription(subscriptionId); + await owner?.release(); } const code = error instanceof SessionRequestError ? error.code : "fetch_agents_failed"; const message = error instanceof Error ? error.message : "Failed to fetch agents"; @@ -5715,9 +5979,30 @@ export class Session { private async handleFetchWorkspacesRequest( request: Extract, ): Promise { - const requestedSubscriptionId = request.subscribe?.subscriptionId?.trim(); - const subscriptionId = resolveSubscriptionId(request.subscribe, requestedSubscriptionId); - + const owner = request.subscribe + ? this.delivery.begin("workspaces", request.subscribe.subscriptionId, async (id) => { + this.workspaceUpdatesSubscriptions.delete(id); + this.refreshObservationProducers(); + await this.reconcileWorkspaceGitObservers(); + }) + : null; + const subscriptionId = owner?.responseId; + const subscription: WorkspaceUpdatesSubscriptionState | null = owner + ? { + subscriptionId: owner.id, + owner, + syncEnabled: Boolean(request.sync), + filter: request.filter, + isBootstrapping: true, + pendingUpdatesByWorkspaceId: new Map(), + lastEmittedByWorkspaceId: new Map(), + visibleEmptyProjectIds: new Set(), + } + : null; + if (subscription) { + this.workspaceUpdatesSubscriptions.set(subscription.subscriptionId, subscription); + this.refreshObservationProducers(); + } try { this.sessionLogger.debug( { @@ -5729,22 +6014,10 @@ export class Session { }, "fetch_workspaces_request_received", ); - if (subscriptionId) { - this.workspaceUpdatesSubscription = { - subscriptionId, - syncEnabled: Boolean(request.sync), - filter: request.filter, - isBootstrapping: true, - pendingUpdatesByWorkspaceId: new Map(), - lastEmittedByWorkspaceId: new Map(), - visibleEmptyProjectIds: new Set(), - }; - } - const payload = request.sync ? await this.readWorkspaceDirectorySync(request) : await this.listFetchWorkspacesEntries(request); - this.workspaceGitObserver.syncObservers(payload.entries); + this.sessionLogger.debug( { requestId: request.requestId, @@ -5755,12 +6028,13 @@ export class Session { "fetch_workspaces_response_ready", ); const snapshot = this.buildBootstrapSnapshot(payload.entries); - this.seedWorkspaceSubscriptionSnapshot( - subscriptionId, - request.filter, - payload.entries, - payload.emptyProjects, - ); + if (subscription) { + this.seedWorkspaceSubscriptionSnapshot( + subscription, + payload.entries, + payload.emptyProjects, + ); + } this.emit({ type: "fetch_workspaces_response", @@ -5771,13 +6045,12 @@ export class Session { }, }); - if (subscriptionId && this.workspaceUpdatesSubscription?.subscriptionId === subscriptionId) { - this.flushBootstrappedWorkspaceUpdates(snapshot); + if (subscription) { + await this.reconcileWorkspaceGitObservers(); + this.flushBootstrappedWorkspaceUpdates(subscription, snapshot); } } catch (error) { - if (subscriptionId && this.workspaceUpdatesSubscription?.subscriptionId === subscriptionId) { - this.workspaceUpdatesSubscription = null; - } + await owner?.release(); const code = error instanceof SessionRequestError ? error.code : "fetch_workspaces_failed"; const message = error instanceof Error ? error.message : "Failed to fetch workspaces"; this.sessionLogger.error({ err: error }, "Failed to handle fetch_workspaces_request"); @@ -5805,7 +6078,6 @@ export class Session { const synchronized = request.sync ? this.directorySync.synchronizeProjects(projects, request.sync) : null; - if (synchronized) this.projectSyncEnabled = true; this.emit({ type: "project.list.response", payload: { @@ -5852,70 +6124,57 @@ export class Session { private async handleWorkspaceLabelList( request: Extract, ): Promise { - const owner = {}; + const service = this.requireWorkspaceLabels(); + if (!request.subscribe) { + this.emit({ + type: "workspace.label.list.response", + payload: { requestId: request.requestId, ...(await service.list(request.sync)) }, + }); + return; + } + let bootstrap: ReturnType | undefined; + const owner = this.delivery.begin("labels", request.subscribe.subscriptionId, async () => { + await bootstrap?.then( + (subscription) => subscription.unsubscribe(), + () => undefined, + ); + }); try { - this.workspaceLabelSubscription?.unsubscribe(); - this.workspaceLabelSubscription = { - owner, - id: request.subscribe.subscriptionId, - unsubscribe: () => undefined, - }; - const service = this.requireWorkspaceLabels(); type LiveChange = Parameters[0]["onChange"]>[0]; - const pending: LiveChange[] = []; + const pending = new Map(); let ready = false; const emitChange = (change: LiveChange): void => { - this.emit({ + owner.emit({ type: "workspace.label.update", - payload: - change.kind === "upsert" - ? { - kind: "upsert", - label: change.label, - ...(change.previousName ? { previousName: change.previousName } : {}), - generation: change.generation, - seq: change.seq, - } - : { - kind: "remove", - name: change.name, - generation: change.generation, - seq: change.seq, - }, + payload: change, }); }; - const subscription = await service.subscribe({ + bootstrap = service.subscribe({ cursor: request.sync, onChange: (change) => { - if (this.workspaceLabelSubscription?.owner !== owner) return; - if (!ready) pending.push(change); + if (owner.signal.aborted) return; + if (!ready) + pending.set(change.kind === "upsert" ? change.label.name : change.name, change); else emitChange(change); }, }); - const ownsSubscription = this.workspaceLabelSubscription?.owner === owner; - if (ownsSubscription) { - this.workspaceLabelSubscription = { - owner, - id: request.subscribe.subscriptionId, - unsubscribe: subscription.unsubscribe, - }; - } else { - subscription.unsubscribe(); - } + const subscription = await bootstrap; + if (owner.signal.aborted) return; this.emit({ type: "workspace.label.list.response", - payload: { requestId: request.requestId, ...subscription.snapshot }, + payload: { + requestId: request.requestId, + subscriptionId: owner.responseId, + ...subscription.snapshot, + }, }); ready = true; - if (ownsSubscription) { - for (const change of pending) { - if (change.seq > subscription.snapshot.sync.headSeq) emitChange(change); - } + for (const change of pending.values()) { + if (change.seq > subscription.snapshot.sync.headSeq) emitChange(change); } + pending.clear(); } catch (error) { - if (this.workspaceLabelSubscription?.owner === owner) { - this.workspaceLabelSubscription = null; - } + await owner.release(); this.emitWorkspaceLabelError(request, error); } } @@ -6036,16 +6295,27 @@ export class Session { return { snapshotByWorkspaceId }; } + private async reconcileWorkspaceGitObservers(): Promise { + const all = this.wantsEvent("checkout_status_update") + ? await this.workspaceDirectory.listObservationTargets() + : []; + const entries = [...this.workspaceUpdatesSubscriptions.values()].flatMap((subscription) => + [...subscription.lastEmittedByWorkspaceId.values()].flatMap((update) => + update.kind === "upsert" ? [update.workspace] : [], + ), + ); + this.workspaceGitObserver.reconcileObservers( + this.wantsEvent("checkout_status_update") ? [...entries, ...all] : entries, + ); + } + private seedWorkspaceSubscriptionSnapshot( - subscriptionId: string | null, - filter: FetchWorkspacesRequestFilter | undefined, + subscription: WorkspaceUpdatesSubscriptionState, entries: FetchWorkspacesResponseEntry[], emptyProjects: WorkspaceProjectDescriptorPayload[], ): void { - const subscription = this.workspaceUpdatesSubscription; - if (!subscription) return; - if (subscriptionId && subscription.subscriptionId !== subscriptionId) return; - if (!subscriptionId && !equal(subscription.filter, filter)) return; + if (this.workspaceUpdatesSubscriptions.get(subscription.subscriptionId) !== subscription) + return; for (const entry of entries) { subscription.lastEmittedByWorkspaceId.set(entry.id, { kind: "upsert", @@ -7609,7 +7879,11 @@ export class Session { logger: this.sessionLogger, }); if (result.disposition === "turn_started") { - await waitForAgentRunStartWithTimeout(this.agentManager, agentId); + await waitForAgentRunStartWithTimeout( + this.agentManager, + agentId, + this.delivery.requestSignal, + ); } }; if (msg.messageId) { @@ -7640,6 +7914,7 @@ export class Session { }, }); } catch (error) { + if (this.delivery.requestSignal.aborted) return; this.handleAgentRunError(resolved.agentId, error, "Failed to send agent message"); this.emit({ type: "send_agent_message_response", @@ -7658,6 +7933,8 @@ export class Session { requestId: string, timeoutMs?: number, ): Promise { + const sourceSignal = this.delivery.requestSignal; + sourceSignal.throwIfAborted(); const resolved = await this.resolveAgentIdentifier(agentIdOrIdentifier); if (!resolved.ok) { this.emit({ @@ -7717,7 +7994,7 @@ export class Session { try { let result = await this.agentManager.waitForAgentEvent(agentId, { - signal: abortController.signal, + signal: AbortSignal.any([abortController.signal, sourceSignal]), waitForActive: true, }); let final = await this.getAgentPayloadById(agentId); @@ -7740,6 +8017,7 @@ export class Session { payload: { requestId, status, final, error, lastMessage: result.lastMessage }, }); } catch (error) { + if (sourceSignal.aborted) return; const isAbort = error instanceof Error && (error.name === "AbortError" || error.message.toLowerCase().includes("aborted")); @@ -7780,41 +8058,80 @@ export class Session { */ // COMPAT(explicitEventSubscriptions): added in v0.8.0, remove legacy broadcasts after 2027-03-08. private wantsEvent(event: SessionEventSubscription, source?: object): boolean { - if (!source && this.clientCapabilitiesBySource.size > 0) { - return [...this.clientCapabilitiesBySource.keys()].some((candidate) => - this.wantsEvent(event, candidate), - ); + for (const subscription of this.eventSubscriptions.values()) { + if ((!source || subscription.owner.source === source) && subscription.events.has(event)) + return true; } + if (!source && this.clientSources.size > 0) { + return [...this.clientSources.keys()].some((candidate) => this.wantsEvent(event, candidate)); + } + if (source && this.delivery.isModern(source)) return false; + if (!source && !this.delivery.hasLegacySources()) return false; + // COMPAT(ownedSubscriptions): added in v0.8.0, remove implicit event delivery after 2027-03-09. const capabilities = source - ? this.clientCapabilitiesBySource.get(source)! + ? (this.clientSources.get(source)?.capabilities ?? this.clientCapabilities) : this.clientCapabilities; - if (event === "project.update" && !capabilities.has(CLIENT_CAPS.projectUpdates)) return false; - return ( - !capabilities.has(CLIENT_CAPS.explicitEventSubscriptions) || - this.eventSubscriptions.get(source ?? this.defaultEventSubscriptionSource)?.has(event) === - true + return legacyWantsEvent(event, capabilities); + } + + private emitEventToOwner( + subscription: { owner: OwnedSubscription; notifications: boolean }, + message: SessionOutboundMessage, + notified: Set, + ): void { + if ( + message.type !== "agent_attention_required" && + message.type !== "terminal_attention_required" + ) { + subscription.owner.emit(message); + return; + } + const shouldNotify = + message.payload.shouldNotify && + subscription.notifications && + !notified.has(subscription.owner.source); + subscription.owner.emit( + message.type === "agent_attention_required" + ? { ...message, payload: { ...message.payload, shouldNotify } } + : { ...message, payload: { ...message.payload, shouldNotify } }, ); + if (shouldNotify) notified.add(subscription.owner.source); + } + + private emitSubscribedEvent(message: SessionOutboundMessage, onlySource?: object): boolean { + const event = sessionEventCategory(message); + if (!event) return false; + if (!this.authorization.allowsOutbound(message)) return true; + const delivered = new Set(); + const notified = new Set(); + for (const subscription of this.eventSubscriptions.values()) { + if ( + !subscription.events.has(event) || + (onlySource && subscription.owner.source !== onlySource) + ) + continue; + this.emitEventToOwner(subscription, message, notified); + delivered.add(subscription.owner.source); + } + // COMPAT(ownedSubscriptions): added in v0.8.0, remove implicit event delivery after 2027-03-09. + if (this.onMessageToSource && this.clientSources.size > 0) { + for (const source of this.clientSources.keys()) { + if ( + (!onlySource || source === onlySource) && + !delivered.has(source) && + !this.delivery.isModern(source) && + this.wantsEvent(event, source) + ) + this.onMessageToSource(source, this.workspaceSetupMessageForClient(message, source)); + } + } else if (delivered.size === 0 && this.wantsEvent(event)) this.onMessage(message); + return true; } private emit(msg: SessionOutboundMessage): void { - if (!this.authorization.allowsOutbound(msg)) { - return; - } - if ( - msg.type === "project.update" || - msg.type === "providers_snapshot_update" || - msg.type === "agent_attention_required" || - msg.type === "agent_permission_request" || - msg.type === "agent_permission_resolved" - ) { - if (this.clientCapabilitiesBySource.size > 0 && this.onMessageToSource) { - for (const source of this.clientCapabilitiesBySource.keys()) { - if (this.wantsEvent(msg.type, source)) this.onMessageToSource(source, msg); - } - return; - } - if (!this.wantsEvent(msg.type)) return; - } + if (!this.authorization.allowsOutbound(msg)) return; + if (this.delivery.reply(msg)) return; + if (this.emitSubscribedEvent(msg)) return; // JSON.stringify(msg) is only computed when trace is enabled — it runs for // every outbound message otherwise, and trace is disabled by default. // Optional-chained because test logger stubs don't implement isLevelEnabled. @@ -7828,8 +8145,8 @@ export class Session { ); } if (msg.type === "workspace_setup_progress" || msg.type === "workspace_setup_status_response") { - if (this.clientCapabilitiesBySource.size > 0 && this.onMessageToSource) { - for (const [source] of this.clientCapabilitiesBySource) { + if (this.clientSources.size > 0 && this.onMessageToSource) { + for (const [source] of this.clientSources) { this.onMessageToSource(source, this.workspaceSetupMessageForClient(msg, source)); } return; @@ -7841,12 +8158,14 @@ export class Session { // COMPAT(workspaceSetupBlocked): added in v0.8.0, remove after 2027-03-07 once client floor >= v0.8.0. private workspaceSetupMessageForClient( - message: Extract< - SessionOutboundMessage, - { type: "workspace_setup_progress" | "workspace_setup_status_response" } - >, + message: SessionOutboundMessage, source?: object, ): SessionOutboundMessage { + if ( + message.type !== "workspace_setup_progress" && + message.type !== "workspace_setup_status_response" + ) + return message; const supportsBlocked = source ? this.supportsForSource(CLIENT_CAPS.workspaceSetupBlocked, source) : this.supports(CLIENT_CAPS.workspaceSetupBlocked); @@ -7877,6 +8196,7 @@ export class Session { private async emitBinaryForFileTransfer(frame: Uint8Array, source?: object): Promise { if (source && this.onBinaryMessageToSource) { + this.delivery.authorizeFileReply(frame, source); await this.onBinaryMessageToSource(source, frame); return; } @@ -7885,6 +8205,7 @@ export class Session { private emitForSource(msg: SessionOutboundMessage, source?: object): void { if (source && this.onMessageToSource) { + msg = this.delivery.authorizeReply(msg, source); this.onMessageToSource(source, msg); return; } @@ -7897,6 +8218,7 @@ export class Session { public async cleanup(): Promise { this.sessionLogger.trace({}, "agent.session.lifecycle.cleanup"); this.isCleanedUp = true; + await this.delivery.close(); if (this.unsubscribeAgentEvents) { this.unsubscribeAgentEvents(); @@ -7908,8 +8230,6 @@ export class Session { this.unsubscribePluginChanges = null; this.unsubscribeWorkspaceMutations?.(); this.unsubscribeWorkspaceMutations = null; - this.workspaceLabelSubscription?.unsubscribe(); - this.workspaceLabelSubscription = null; this.agentUpdates.dispose(); await this.hubExecutionController?.cleanup(); if (this.unsubscribeTerminalWorkspaceContributionEvents) { @@ -7918,14 +8238,11 @@ export class Session { } this.providerCatalogSession.dispose(); - await this.voiceSession.cleanup(); - this.terminalController.dispose(); this.checkoutSession.cleanup(); this.workspaceGitObserver.dispose(); - this.workspaceFilesSession.dispose(); } } @@ -7979,3 +8296,57 @@ function normalizeCloneRepository(input: { function isValidGitHubRepoSegment(value: string): boolean { return /^[A-Za-z0-9._-]+$/u.test(value); } + +function sessionEventCategory(message: SessionOutboundMessage): SessionEventSubscription | null { + switch (message.type) { + case "project.update": + case "providers_snapshot_update": + case "agent_attention_required": + case "agent_permission_request": + case "agent_permission_resolved": + case "checkout_status_update": + case "script_status_update": + case "workspace_setup_progress": + case "agent.provider_subagents.update": + case "terminal_attention_required": + case "activity_log": + case "hub.execution.agent.update": + case "hub.execution.agent.stream": + return message.type; + case "status": + switch (message.payload.status) { + case "server_info": + return "status.server_info"; + case "daemon_config_changed": + return "status.daemon_config_changed"; + case "plugin_catalog_changed": + return "status.plugin_catalog_changed"; + case "plugin_settings_changed": + return "status.plugin_settings_changed"; + default: + return null; + } + default: + return null; + } +} + +// COMPAT(ownedSubscriptions): added in v0.8.0, remove implicit event delivery after 2027-03-09. +function legacyWantsEvent( + event: SessionEventSubscription, + capabilities: ReadonlySet, +): boolean { + if (event === "project.update" && !capabilities.has(CLIENT_CAPS.projectUpdates)) return false; + switch (event) { + case "project.update": + case "providers_snapshot_update": + case "agent_attention_required": + case "agent_permission_request": + case "agent_permission_resolved": + return !capabilities.has(CLIENT_CAPS.explicitEventSubscriptions); + case "agent.provider_subagents.update": + return capabilities.has(CLIENT_CAPS.providerSubagents); + default: + return true; + } +} diff --git a/packages/server/src/server/session.workspace-git-watch.test.ts b/packages/server/src/server/session.workspace-git-watch.test.ts index 69c548b922..581a142572 100644 --- a/packages/server/src/server/session.workspace-git-watch.test.ts +++ b/packages/server/src/server/session.workspace-git-watch.test.ts @@ -24,13 +24,6 @@ import { createNoopWorkspaceGitService } from "./test-utils/workspace-git-servic import type { WorkspaceGitObserverService } from "./session/workspace-git-observer/workspace-git-observer-service.js"; interface SessionInternals { - workspaceUpdatesSubscription: { - subscriptionId: string; - filter: undefined; - isBootstrapping: boolean; - pendingUpdatesByWorkspaceId: Map; - lastEmittedByWorkspaceId: Map; - }; buildWorkspaceDescriptorMap: () => Promise>; workspaceGitObserver: WorkspaceGitObserverService; listAgentPayloads: () => Promise; @@ -203,6 +196,7 @@ function createSessionForWorkspaceGitWatchTests(options?: { agentManager: createStub({ subscribe: () => () => {}, listAgents: () => [], + listProviderSubagentActivity: () => [], getAgent: () => null, }), agentStorage: createStub({ @@ -331,13 +325,11 @@ describe("workspace git watch targets", () => { cwd: REPO_CWD, name: "main", }); - sessionAny.workspaceUpdatesSubscription = { - subscriptionId: "sub-1", - filter: undefined, - isBootstrapping: false, - pendingUpdatesByWorkspaceId: new Map(), - lastEmittedByWorkspaceId: new Map(), - }; + await session.handleMessage({ + type: "fetch_workspaces_request", + requestId: "workspace-demand", + subscribe: {}, + }); let descriptor = { id: "ws-10", @@ -394,7 +386,6 @@ describe("workspace git watch targets", () => { test("emits checkout_status_update to a client subscribed to the workspace git target", async () => { const { session, emitted, projects, workspaces, workspaceGitService, subscriptions } = createSessionForWorkspaceGitWatchTests(); - const sessionAny = asInternals(session); seedGitWorkspace({ projects, workspaces, @@ -403,13 +394,11 @@ describe("workspace git watch targets", () => { cwd: REPO_CWD, name: "main", }); - sessionAny.workspaceUpdatesSubscription = { - subscriptionId: "sub-1", - filter: undefined, - isBootstrapping: false, - pendingUpdatesByWorkspaceId: new Map(), - lastEmittedByWorkspaceId: new Map(), - }; + await session.handleMessage({ + type: "fetch_workspaces_request", + requestId: "workspace-demand", + subscribe: {}, + }); syncGitObserver(session, REPO_CWD, "ws-10"); emitted.length = 0; @@ -493,6 +482,11 @@ describe("workspace git watch targets", () => { name: "old-branch", }); + await session.handleMessage({ + type: "fetch_workspaces_request", + requestId: "branch-demand", + subscribe: {}, + }); await session.syncWorkspaceGitObserversForExternalWorkspaceIds(["ws-10"]); subscriptions[0]?.listener( @@ -575,7 +569,6 @@ describe("workspace git watch targets", () => { test("embeds PR status in checkout_status_update for GitHub-inclusive snapshot pushes", async () => { const { session, emitted, projects, workspaces, subscriptions } = createSessionForWorkspaceGitWatchTests(); - const sessionAny = asInternals(session); seedGitWorkspace({ projects, workspaces, @@ -584,13 +577,11 @@ describe("workspace git watch targets", () => { cwd: REPO_CWD, name: "main", }); - sessionAny.workspaceUpdatesSubscription = { - subscriptionId: "sub-1", - filter: undefined, - isBootstrapping: false, - pendingUpdatesByWorkspaceId: new Map(), - lastEmittedByWorkspaceId: new Map(), - }; + await session.handleMessage({ + type: "fetch_workspaces_request", + requestId: "workspace-demand", + subscribe: {}, + }); syncGitObserver(session, REPO_CWD, "ws-10"); emitted.length = 0; diff --git a/packages/server/src/server/session.workspaces.test.ts b/packages/server/src/server/session.workspaces.test.ts index 2914e58215..e8df2788cf 100644 --- a/packages/server/src/server/session.workspaces.test.ts +++ b/packages/server/src/server/session.workspaces.test.ts @@ -146,7 +146,6 @@ interface SessionTestAccess { upsert(record: unknown): Promise; }; agentUpdates: AgentUpdatesService; - workspaceUpdatesSubscription: unknown; interruptAgentIfRunning(agentId: string): unknown; reconcileWorkspaceRecord(workspaceId: string): Promise<{ changed: boolean; @@ -155,7 +154,7 @@ interface SessionTestAccess { [key: string]: unknown; }>; handleArchiveAgentRequest(agentId: string, requestId: string): Promise; - handleMessage(message: unknown): Promise; + handleMessage(message: unknown, source?: object): Promise; handleCreatePaseoWorktreeRequest(params: unknown): Promise; listAgentPayloads(...args: unknown[]): Promise; listFetchWorkspacesEntries(params: unknown): Promise; @@ -172,7 +171,7 @@ interface SessionTestAccess { emitWorkspaceUpdateForCwd(...args: unknown[]): Promise; emitWorkspaceUpdatesForWorkspaceIds(...args: unknown[]): Promise; emitWorkspaceUpdatesForExternalWorkspaceIds(workspaceIds: Iterable): Promise; - updateClientCapabilities(capabilities: Record | null): void; + updateClientCapabilities(capabilities: Record | null, source?: object): void; emit(message: unknown): void; onMessage(message: unknown): void; paseoHome: string; @@ -209,16 +208,17 @@ type AgentUpdatesSubscriptionFilter = Parameters< AgentUpdatesService["beginSubscription"] >[0]["filter"]; -// Drives the agent-updates module to a live (non-bootstrapping) subscription — -// the post-extraction equivalent of assigning a subscription with -// `isBootstrapping: false`. begin → flush leaves an empty buffer and emits nothing. -function activateAgentUpdatesSubscription( +async function activateAgentUpdatesSubscription( session: TestSession, subscriptionId: string, filter?: AgentUpdatesSubscriptionFilter, -): void { - session.agentUpdates.beginSubscription({ subscriptionId, filter }); - session.agentUpdates.flushBootstrapped(subscriptionId); +): Promise { + await session.handleMessage({ + type: "fetch_agents_request", + requestId: subscriptionId, + filter, + subscribe: { subscriptionId }, + }); } const AgentIdEntrySchema = z.object({ agent: z.object({ id: z.string() }) }); @@ -865,7 +865,7 @@ test("agent updates preserve queued live transitions across stored metadata read createdAt: "2026-07-31T10:00:00.000Z", updatedAt: "2026-07-31T10:00:00.000Z", }); - activateAgentUpdatesSubscription(session, "sub-coherent"); + await activateAgentUpdatesSubscription(session, "sub-coherent"); if (!forwardAgentEvent) throw new Error("Agent event listener was not installed"); forwardAgentEvent({ type: "agent_state", agent: running }); @@ -915,17 +915,21 @@ test("client heartbeat clears attention for the focused terminal", async () => { }), }); - await session.handleMessage({ - type: "client_heartbeat", - deviceType: "web", - focusedAgentId: null, - focusedTerminalId: "terminal-1", - lastActivityAt: "2026-06-13T12:00:00.000Z", - appVisible: true, - }); + const source = {}; + await session.handleMessage( + { + type: "client_heartbeat", + deviceType: "web", + focusedAgentId: null, + focusedTerminalId: "terminal-1", + lastActivityAt: "2026-06-13T12:00:00.000Z", + appVisible: true, + }, + source, + ); expect(clearedTerminalIds).toEqual(["terminal-1"]); - expect(session.getClientActivity()).toMatchObject({ + expect(session.getClientActivity(source)).toMatchObject({ focusedAgentId: null, focusedTerminalId: "terminal-1", appVisible: true, @@ -1462,7 +1466,7 @@ test("agent_update placement does not refresh git snapshots", async () => { session.workspaceRegistry.list = async () => [workspace]; session.workspaceRegistry.get = async (id: string) => id === workspace.workspaceId ? workspace : null; - activateAgentUpdatesSubscription(session, "sub-agents", {}); + await activateAgentUpdatesSubscription(session, "sub-agents", {}); await session.agentUpdates.forwardLiveAgent( makeManagedAgent({ @@ -1504,7 +1508,7 @@ test("agent_update emits remove when the agent has no workspaceId", async () => }), ); - activateAgentUpdatesSubscription(session, "sub-agents", {}); + await activateAgentUpdatesSubscription(session, "sub-agents", {}); await session.agentUpdates.forwardLiveAgent( makeManagedAgent({ @@ -1664,7 +1668,7 @@ test("archive emits an authoritative agent_update upsert for subscribed clients" }), ); - activateAgentUpdatesSubscription(session, "sub-agents", { includeArchived: true }); + await activateAgentUpdatesSubscription(session, "sub-agents", { includeArchived: true }); await session.handleArchiveAgentRequest("agent-1", "req-archive"); @@ -2145,7 +2149,7 @@ test("close_items_request archives agents and kills terminals in one batch", asy }), ); - activateAgentUpdatesSubscription(session, "sub-agents", { includeArchived: true }); + await activateAgentUpdatesSubscription(session, "sub-agents", { includeArchived: true }); await session.handleMessage({ type: "close_items_request", @@ -2328,7 +2332,7 @@ test("close_items_request archives stored agents that are not currently loaded", }), ); - activateAgentUpdatesSubscription(session, "sub-agents", { includeArchived: true }); + await activateAgentUpdatesSubscription(session, "sub-agents", { includeArchived: true }); await session.handleMessage({ type: "close_items_request", @@ -2479,7 +2483,7 @@ test("close_items_request continues after an archive failure", async () => { }), ); - activateAgentUpdatesSubscription(session, "sub-agents", { includeArchived: true }); + await activateAgentUpdatesSubscription(session, "sub-agents", { includeArchived: true }); await session.handleMessage({ type: "close_items_request", @@ -3731,13 +3735,13 @@ test("workspace update stream keeps persisted workspace visible after agents sto }), ); - session.workspaceUpdatesSubscription = { - subscriptionId: "sub-1", + await session.handleMessage({ + type: "fetch_workspaces_request", + requestId: "sub-1", filter: undefined, - isBootstrapping: false, - pendingUpdatesByWorkspaceId: new Map(), - lastEmittedByWorkspaceId: new Map(), - }; + subscribe: { subscriptionId: "sub-1" }, + }); + expect(filterByType(emitted, "rpc_error")).toEqual([]); session.buildWorkspaceDescriptorMap = async () => new Map([ [ @@ -3825,33 +3829,13 @@ test("archiving the last workspace emits a remove carrying the now-empty project session.workspaceRegistry.get = async (workspaceId: string) => workspaceId === archivedWorkspace.workspaceId ? archivedWorkspace : null; - session.workspaceUpdatesSubscription = { - subscriptionId: "sub-1", - filter: undefined, - isBootstrapping: false, - pendingUpdatesByWorkspaceId: new Map(), - lastEmittedByWorkspaceId: new Map([ - [ - archivedWorkspace.workspaceId, - { - kind: "upsert", - workspace: { - id: archivedWorkspace.workspaceId, - projectId: project.projectId, - projectDisplayName: project.displayName, - projectRootPath: project.rootPath, - workspaceDirectory: archivedWorkspace.cwd, - projectKind: project.kind, - workspaceKind: archivedWorkspace.kind, - name: archivedWorkspace.displayName, - status: "done", - activityAt: null, - diffStat: null, - }, - }, - ], - ]), - }; + archivedWorkspace.archivedAt = null; + await session.handleMessage({ + type: "fetch_workspaces_request", + requestId: "sub-1", + subscribe: { subscriptionId: "sub-1" }, + }); + archivedWorkspace.archivedAt = "2026-03-02T12:00:00.000Z"; // The archived workspace no longer resolves to an active descriptor. session.buildWorkspaceDescriptorMap = async () => new Map(); @@ -3915,13 +3899,12 @@ test("project.remove.request archives active workspaces and removes the project if (!existing) return; workspaces.set(workspaceId, { ...existing, updatedAt: archivedAt, archivedAt }); }; - session.workspaceUpdatesSubscription = { - subscriptionId: "sub-project-remove", + await session.handleMessage({ + type: "fetch_workspaces_request", + requestId: "sub-project-remove", filter: undefined, - isBootstrapping: false, - pendingUpdatesByWorkspaceId: new Map(), - lastEmittedByWorkspaceId: new Map(), - }; + subscribe: { subscriptionId: "sub-project-remove" }, + }); session.listAgentPayloads = async () => []; session.buildWorkspaceDescriptorMap = async (options: { workspaceIds?: Iterable }) => { const workspaceIds = Array.from(options.workspaceIds ?? workspaces.keys()); @@ -4012,15 +3995,11 @@ test("project.remove.request removes an already-empty project", async () => { if (!existing) return; workspaces.set(workspaceId, { ...existing, updatedAt: archivedAt, archivedAt }); }; - session.workspaceUpdatesSubscription = { - subscriptionId: "sub-empty-project-remove", - filter: undefined, - isBootstrapping: false, - pendingUpdatesByWorkspaceId: new Map(), - lastEmittedByWorkspaceId: new Map([ - [archivedWorkspace.workspaceId, { kind: "remove", id: archivedWorkspace.workspaceId }], - ]), - }; + await session.handleMessage({ + type: "fetch_workspaces_request", + requestId: "sub-empty-project-remove", + subscribe: { subscriptionId: "sub-empty-project-remove" }, + }); session.listAgentPayloads = async () => []; session.buildWorkspaceDescriptorMap = async () => new Map(); @@ -4229,13 +4208,12 @@ test("workspace updates stay scoped to the matching cwd", async () => { session.workspaceRegistry.archive = async (workspaceId) => { archivedWorkspaceIds.push(workspaceId); }; - session.workspaceUpdatesSubscription = { - subscriptionId: "sub-dedup", + await session.handleMessage({ + type: "fetch_workspaces_request", + requestId: "sub-dedup", filter: undefined, - isBootstrapping: false, - pendingUpdatesByWorkspaceId: new Map(), - lastEmittedByWorkspaceId: new Map(), - }; + subscribe: { subscriptionId: "sub-dedup" }, + }); session.buildWorkspaceDescriptorMap = async () => new Map([ [ @@ -4392,13 +4370,12 @@ test("import_agent_request registers a workspace for a never-seen cwd", async () session.agentStorage.get = async () => null; session.agentUpdates.forwardLiveAgent = async () => undefined; - session.workspaceUpdatesSubscription = { - subscriptionId: "sub-import", + await session.handleMessage({ + type: "fetch_workspaces_request", + requestId: "sub-import", filter: undefined, - isBootstrapping: false, - pendingUpdatesByWorkspaceId: new Map(), - lastEmittedByWorkspaceId: new Map(), - }; + subscribe: { subscriptionId: "sub-import" }, + }); session.buildWorkspaceDescriptorMap = async () => { const workspace = Array.from(workspaces.values()).find( (candidate) => candidate.cwd === importedCwd, @@ -4595,7 +4572,7 @@ test("open_project_response returns immediately even when the GitHub fetch is sl test("open_project_request emits a workspace_update with githubRuntime once the snapshot resolves", async () => { const emitted: SessionOutboundMessage[] = []; - const session = createSessionForWorkspaceTests(); + const session = createSessionForWorkspaceTests({ onMessage: (message) => emitted.push(message) }); const projects = new Map>(); const workspaces = new Map>(); const cwd = path.resolve("/tmp/github-runtime-repo"); @@ -4604,9 +4581,6 @@ test("open_project_request emits a workspace_update with githubRuntime once the let listener: ((snapshot: WorkspaceGitRuntimeSnapshot) => void) | null = null; const peeked = { value: null as WorkspaceGitRuntimeSnapshot | null }; - session.emit = (message) => { - if (isSessionOutboundMessage(message)) emitted.push(message); - }; session.projectRegistry.get = async (projectId: string) => projects.get(projectId) ?? null; session.projectRegistry.getOrCreateActiveByRoot = async (allocation) => { const project = createPersistedProjectRecord({ @@ -4656,13 +4630,12 @@ test("open_project_request emits a workspace_update with githubRuntime once the listener?.(snapshot); return snapshot; }; - session.workspaceUpdatesSubscription = { - subscriptionId: "sub-open-project", + await session.handleMessage({ + type: "fetch_workspaces_request", + requestId: "sub-open-project", filter: undefined, - isBootstrapping: false, - pendingUpdatesByWorkspaceId: new Map(), - lastEmittedByWorkspaceId: new Map(), - }; + subscribe: { subscriptionId: "sub-open-project" }, + }); await session.handleMessage({ type: "open_project_request", cwd, @@ -5303,13 +5276,12 @@ test("workspace recovery stays accepted when git observer warming fails", async session.workspaceRegistry.upsert = async (record: PersistedWorkspaceRecord) => { workspace = record; }; - session.workspaceUpdatesSubscription = { - subscriptionId: "sub-recovery-warm-failure", + await session.handleMessage({ + type: "fetch_workspaces_request", + requestId: "sub-recovery-warm-failure", filter: undefined, - isBootstrapping: false, - pendingUpdatesByWorkspaceId: new Map(), - lastEmittedByWorkspaceId: new Map(), - }; + subscribe: { subscriptionId: "sub-recovery-warm-failure" }, + }); session.listAgentPayloads = async () => []; await session.handleMessage({ @@ -6105,13 +6077,12 @@ test.skip("opening a new worktree reconciles older local workspaces into the rem session.emit = (message) => { if (isSessionOutboundMessage(message)) emitted.push(message); }; - session.workspaceUpdatesSubscription = { - subscriptionId: "sub-reconcile", + await session.handleMessage({ + type: "fetch_workspaces_request", + requestId: "sub-reconcile", filter: undefined, - isBootstrapping: false, - pendingUpdatesByWorkspaceId: new Map(), - lastEmittedByWorkspaceId: new Map(), - }; + subscribe: { subscriptionId: "sub-reconcile" }, + }); session.listAgentPayloads = async () => []; session.projectRegistry.get = async (projectId: string) => projects.get(projectId) ?? null; session.projectRegistry.list = async () => Array.from(projects.values()); @@ -6845,7 +6816,7 @@ test("buildWorkspaceDescriptorMap stamps workspace archiving state", async () => test("emitWorkspaceUpdatesForWorkspaceIds includes archiving state and dedupes unchanged emits", async () => { const emitted: SessionOutboundMessage[] = []; - const session = createSessionForWorkspaceTests(); + const session = createSessionForWorkspaceTests({ onMessage: (message) => emitted.push(message) }); const archivingAt = "2026-04-30T20:45:00.000Z"; const project = createPersistedProjectRecord({ projectId: "proj-archiving-emit", @@ -6865,20 +6836,17 @@ test("emitWorkspaceUpdatesForWorkspaceIds includes archiving state and dedupes u updatedAt: "2026-03-01T12:00:00.000Z", }); - session.emit = (message) => { - if (isSessionOutboundMessage(message)) emitted.push(message); - }; - session.workspaceUpdatesSubscription = { - subscriptionId: "sub-archiving", + await session.handleMessage({ + type: "fetch_workspaces_request", + requestId: "sub-archiving", filter: undefined, - isBootstrapping: false, - pendingUpdatesByWorkspaceId: new Map(), - lastEmittedByWorkspaceId: new Map(), - }; + subscribe: { subscriptionId: "sub-archiving" }, + }); session.listAgentPayloads = async () => []; session.projectRegistry.list = async () => [project]; session.workspaceRegistry.list = async () => [workspace]; + emitted.length = 0; session.markWorkspaceArchiving([workspace.workspaceId], archivingAt); await session.emitWorkspaceUpdatesForWorkspaceIds([workspace.workspaceId]); @@ -6937,13 +6905,16 @@ test("external workspace updates emit one deduplicated batch", async () => { return [main, feature]; }; session.listAgentPayloads = async () => []; - session.workspaceUpdatesSubscription = { - subscriptionId: "sub-observer-batch", + await session.handleMessage({ + type: "fetch_workspaces_request", + requestId: "sub-observer-batch", filter: undefined, - isBootstrapping: false, - pendingUpdatesByWorkspaceId: new Map(), - lastEmittedByWorkspaceId: new Map(), - }; + subscribe: { subscriptionId: "sub-observer-batch" }, + }); + main.displayName = "Updated main"; + feature.displayName = "Updated feature"; + snapshotReads.projects = 0; + snapshotReads.workspaces = 0; session.onMessage = (message) => { if (isSessionOutboundMessage(message)) emitted.push(message); }; @@ -7112,6 +7083,10 @@ test("fetch_workspaces_response emits before cold registration-triggered git wor const session = asTestSession( createSessionForWorkspaceTests({ workspaceGitService, + onMessage: (message) => { + if (message.type === "fetch_workspaces_response") events.push("response"); + emitted.push(message); + }, }), ); const project = createPersistedProjectRecord({ @@ -7132,13 +7107,6 @@ test("fetch_workspaces_response emits before cold registration-triggered git wor updatedAt: "2026-03-01T12:00:00.000Z", }); - session.emit = (message: unknown) => { - if (!isSessionOutboundMessage(message)) return; - if (message.type === "fetch_workspaces_response") { - events.push("response"); - } - emitted.push(message); - }; session.listAgentPayloads = async () => []; session.projectRegistry.list = async () => [project]; session.workspaceRegistry.list = async () => [workspace]; @@ -7178,6 +7146,7 @@ test("workspace_update includes updated runtime fields", async () => { const session = asTestSession( createSessionForWorkspaceTests({ workspaceGitService, + onMessage: (message) => emitted.push(message), }), ); const project = createPersistedProjectRecord({ @@ -7198,16 +7167,12 @@ test("workspace_update includes updated runtime fields", async () => { updatedAt: "2026-03-01T12:00:00.000Z", }); - session.emit = (message) => { - if (isSessionOutboundMessage(message)) emitted.push(message); - }; - session.workspaceUpdatesSubscription = { - subscriptionId: "sub-runtime", + await session.handleMessage({ + type: "fetch_workspaces_request", + requestId: "sub-runtime", filter: undefined, - isBootstrapping: false, - pendingUpdatesByWorkspaceId: new Map(), - lastEmittedByWorkspaceId: new Map(), - }; + subscribe: { subscriptionId: "sub-runtime" }, + }); session.listAgentPayloads = async () => []; session.projectRegistry.list = async () => [project]; session.workspaceRegistry.list = async () => [workspace]; @@ -7380,19 +7345,6 @@ test("workspace mutation handling does not let a delayed upsert recreate an arch createdAt: "2026-03-01T12:00:00.000Z", updatedAt: "2026-03-01T12:00:00.000Z", }); - const descriptor = { - id: workspace.workspaceId, - projectId: project.projectId, - projectDisplayName: project.displayName, - projectRootPath: project.rootPath, - workspaceDirectory: workspace.cwd, - projectKind: project.kind, - workspaceKind: workspace.kind, - name: workspace.displayName, - status: "done", - activityAt: null, - diffStat: null, - } as WorkspaceDescriptorPayload; let listedWorkspaces: PersistedWorkspaceRecord[] = []; const workspaceRegistry: SessionOptions["workspaceRegistry"] = { initialize: async () => {}, @@ -7444,12 +7396,12 @@ test("workspace mutation handling does not let a delayed upsert recreate an arch let resumeDescribe!: () => void; const describeStarted = new Promise((resolveStarted) => { - session.describeWorkspaceRecordWithGitData = async () => { + workspaceRegistry.list = async () => { resolveStarted(); await new Promise((resolveResume) => { resumeDescribe = resolveResume; }); - return descriptor; + return listedWorkspaces; }; }); @@ -7559,12 +7511,17 @@ test("workspace mutations outside a filtered subscription neither watch nor emit }); session.buildWorkspaceDescriptorMap = async () => new Map([[descriptor.id, descriptor]]); - await session.handleMessage({ - type: "fetch_workspaces_request", - requestId: "req-filtered-mutation", - filter: { projectId: "some-other-project" }, - subscribe: {}, - }); + const source = {}; + session.updateClientCapabilities({ [CLIENT_CAPS.ownedSubscriptions]: true }, source); + await session.handleMessage( + { + type: "fetch_workspaces_request", + requestId: "req-filtered-mutation", + filter: { projectId: "some-other-project" }, + subscribe: {}, + }, + source, + ); emitted.length = 0; await mutationListener?.({ @@ -7638,28 +7595,19 @@ test("project removal mutation broadcasts the final delta to another subscribed remove: async () => {}, }, }); - session.workspaceUpdatesSubscription = { - subscriptionId: "sub-global-remove", - filter: undefined, - isBootstrapping: false, - pendingUpdatesByWorkspaceId: new Map(), - lastEmittedByWorkspaceId: new Map([ - [ - workspace.workspaceId, - { - kind: "remove", - id: workspace.workspaceId, - emptyProject: { - projectId: project.projectId, - projectDisplayName: project.displayName, - projectCustomName: null, - projectRootPath: project.rootPath, - projectKind: project.kind, - }, - }, - ], - ]), - }; + workspace.archivedAt = null; + projectRegistry.list = async () => [project]; + projectRegistry.get = async () => project; + await session.handleMessage({ + type: "fetch_workspaces_request", + requestId: "sub-global-remove", + subscribe: { subscriptionId: "sub-global-remove" }, + }); + workspace.archivedAt = "2026-03-02T12:00:00.000Z"; + await session.emitWorkspaceUpdatesForWorkspaceIds([workspace.workspaceId]); + emitted.length = 0; + projectRegistry.list = async () => []; + projectRegistry.get = async () => null; await projectRegistry.remove(project.projectId); @@ -8072,13 +8020,11 @@ test("project.rename.request stores customName and emits an updated workspace de session.workspaceRegistry.get = async (id: string) => id === workspace.workspaceId ? workspace : null; - session.workspaceUpdatesSubscription = { - subscriptionId: "sub-workspaces", - filter: {}, - isBootstrapping: false, - lastEmittedByWorkspaceId: new Map(), - pendingUpdatesByWorkspaceId: new Map(), - }; + await session.handleMessage({ + type: "fetch_workspaces_request", + requestId: "sub-workspaces", + subscribe: { subscriptionId: "sub-workspaces" }, + }); await session.handleMessage({ type: "project.rename.request", @@ -8249,13 +8195,11 @@ test("workspace.title.set.request stores the title and emits an updated descript return updated; }; - session.workspaceUpdatesSubscription = { - subscriptionId: "sub-workspaces", - filter: {}, - isBootstrapping: false, - lastEmittedByWorkspaceId: new Map(), - pendingUpdatesByWorkspaceId: new Map(), - }; + await session.handleMessage({ + type: "fetch_workspaces_request", + requestId: "sub-workspaces", + subscribe: { subscriptionId: "sub-workspaces" }, + }); await session.handleMessage({ type: "workspace.title.set.request", @@ -8319,13 +8263,11 @@ test("workspace.pin.set.request stores the pin timestamp and emits an updated de workspaces.set(id, updated); return updated; }; - session.workspaceUpdatesSubscription = { - subscriptionId: "sub-workspaces", - filter: {}, - isBootstrapping: false, - lastEmittedByWorkspaceId: new Map(), - pendingUpdatesByWorkspaceId: new Map(), - }; + await session.handleMessage({ + type: "fetch_workspaces_request", + requestId: "sub-workspaces", + subscribe: { subscriptionId: "sub-workspaces" }, + }); await session.handleMessage({ type: "workspace.pin.set.request", @@ -8422,11 +8364,11 @@ test("workspace.title.set.request returns accepted=false when workspace is not f expect(response?.payload.error).toBeTruthy(); }); -function createSessionWithTerminalManager(options: { +async function createSessionWithTerminalManager(options: { workspaces: PersistedWorkspaceRecord[]; projects: PersistedProjectRecord[]; onMessage?: (message: SessionOutboundMessage) => void; -}): { session: TestSession; terminalManager: TerminalManager } { +}): Promise<{ session: TestSession; terminalManager: TerminalManager }> { const terminalManager = createTerminalManager(); terminalManagers.push(terminalManager); @@ -8459,13 +8401,11 @@ function createSessionWithTerminalManager(options: { workspaceRegistry, }); - session.workspaceUpdatesSubscription = { - subscriptionId: "sub-workspaces", - filter: undefined, - isBootstrapping: false, - lastEmittedByWorkspaceId: new Map(), - pendingUpdatesByWorkspaceId: new Map(), - }; + await session.handleMessage({ + type: "fetch_workspaces_request", + requestId: "sub-workspaces", + subscribe: { subscriptionId: "sub-workspaces" }, + }); return { session, terminalManager }; } @@ -8595,7 +8535,7 @@ test("title-only terminal change does not build workspace descriptors or emit wo createdAt: "2026-03-01T12:00:00.000Z", updatedAt: "2026-03-01T12:00:00.000Z", }); - const { session, terminalManager } = createSessionWithTerminalManager({ + const { session, terminalManager } = await createSessionWithTerminalManager({ workspaces: [workspace], projects: [project], onMessage: (message) => emitted.push(message), @@ -8639,7 +8579,7 @@ test("terminal activity contribution change updates the correct workspace", asyn createdAt: "2026-03-01T12:00:00.000Z", updatedAt: "2026-03-01T12:00:00.000Z", }); - const { terminalManager } = createSessionWithTerminalManager({ + const { terminalManager } = await createSessionWithTerminalManager({ workspaces: [workspace], projects: [project], onMessage: (message) => emitted.push(message), @@ -8697,7 +8637,7 @@ test("same-cwd terminal activity updates only the workspace that owns the termin createdAt: "2026-03-01T12:00:00.000Z", updatedAt: "2026-03-01T12:00:00.000Z", }); - const { terminalManager } = createSessionWithTerminalManager({ + const { terminalManager } = await createSessionWithTerminalManager({ workspaces: [workspaceA, workspaceB], projects: [project], onMessage: (message) => emitted.push(message), @@ -8761,7 +8701,7 @@ test("a worktree terminal updates only the workspace that owns it", async () => createdAt: "2026-03-01T12:00:00.000Z", updatedAt: "2026-03-01T12:00:00.000Z", }); - const { terminalManager } = createSessionWithTerminalManager({ + const { terminalManager } = await createSessionWithTerminalManager({ workspaces: [workspaceRoot, workspaceWorktree], projects: [project], onMessage: (message) => emitted.push(message), @@ -8814,7 +8754,7 @@ test("removing an idle terminal does not update workspace status", async () => { createdAt: "2026-03-01T12:00:00.000Z", updatedAt: "2026-03-01T12:00:00.000Z", }); - const { session, terminalManager } = createSessionWithTerminalManager({ + const { session, terminalManager } = await createSessionWithTerminalManager({ workspaces: [workspace], projects: [project], onMessage: (message) => emitted.push(message), @@ -8853,7 +8793,7 @@ test("removing a contributing terminal clears workspace status", async () => { createdAt: "2026-03-01T12:00:00.000Z", updatedAt: "2026-03-01T12:00:00.000Z", }); - const { terminalManager } = createSessionWithTerminalManager({ + const { terminalManager } = await createSessionWithTerminalManager({ workspaces: [workspace], projects: [project], onMessage: (message) => emitted.push(message), diff --git a/packages/server/src/server/session/agent-updates/agent-updates-service.ts b/packages/server/src/server/session/agent-updates/agent-updates-service.ts index a4b1579688..58521f336f 100644 --- a/packages/server/src/server/session/agent-updates/agent-updates-service.ts +++ b/packages/server/src/server/session/agent-updates/agent-updates-service.ts @@ -16,30 +16,20 @@ type AgentUpdatesFilter = NonNullable< interface AgentUpdatesSubscriptionState { subscriptionId: string; - syncEnabled?: boolean; + emit: (message: SessionOutboundMessage) => void; + syncEnabled: boolean; filter?: AgentUpdatesFilter; + isProviderVisible: (provider: string) => boolean; isBootstrapping: boolean; pendingUpdatesByAgentId: Map; } -/** - * Owns the single per-client `agent_update` subscription: when a client subscribes - * via `fetch_agents_request`, every later agent lifecycle change (live forward, - * stored-record archive/detach, delete) is filtered against the subscription's - * filter and either emitted or — while the initial snapshot is still being built — - * buffered and replayed on flush. Keeping the mutable subscription state, the - * bootstrap buffer, the provider-visibility gate, and the filter predicate behind - * one interface stops the rest of session.ts from poking the subscription shape or - * hand-rolling `agent_update` payloads, and the (previously untested) filter/buffer/ - * flush branches become exercisable through injected fakes. - * - * The snapshot listing path applies the SAME filter via the pure - * `matchesAgentUpdatesFilter` so a subscription's initial page and its live updates - * stay consistent. - */ +/** Owns filtered agent snapshots and live updates for independent observers. */ export interface AgentUpdatesService { beginSubscription(input: { subscriptionId: string; + isProviderVisible?: (provider: string) => boolean; + emit?: (message: SessionOutboundMessage) => void; filter?: AgentUpdatesFilter; syncEnabled?: boolean; }): void; @@ -49,7 +39,7 @@ export interface AgentUpdatesService { ): void; clearSubscription(subscriptionId: string): void; hasSubscription(): boolean; - includesLiveAgent(agent: ManagedAgent): Promise; + includesLiveAgent(agent: ManagedAgent, subscriptionIds?: ReadonlySet): Promise; forwardLiveAgent(agent: ManagedAgent): Promise; emitStoredRecord(record: StoredAgentRecord): Promise; removeAgent(agentId: string): Promise; @@ -162,7 +152,7 @@ function agentUpdateTargetId(update: AgentUpdatePayload): string { } export function createAgentUpdatesService(deps: AgentUpdatesServiceDeps): AgentUpdatesService { - let subscription: AgentUpdatesSubscriptionState | null = null; + const subscriptions = new Map(); const liveAgentUpdateTails = new Map>(); const sequence = ( sub: AgentUpdatesSubscriptionState, @@ -173,7 +163,8 @@ export function createAgentUpdatesService(deps: AgentUpdatesServiceDeps): AgentU ) => deps.sequenceAgentUpdate(payload, agent, project, agentId, sub.syncEnabled === true); function bufferOrEmit(sub: AgentUpdatesSubscriptionState, payload: AgentUpdatePayload): void { - if (payload.kind === "upsert" && !deps.isProviderVisibleToClient(payload.agent.provider)) { + if (subscriptions.get(sub.subscriptionId) !== sub) return; + if (payload.kind === "upsert" && !sub.isProviderVisible(payload.agent.provider)) { return; } if (sub.isBootstrapping) { @@ -181,194 +172,112 @@ export function createAgentUpdatesService(deps: AgentUpdatesServiceDeps): AgentU return; } - deps.emit({ - type: "agent_update", - payload, - }); + sub.emit({ type: "agent_update", payload }); } function beginSubscription(input: { subscriptionId: string; + isProviderVisible?: (provider: string) => boolean; + emit?: (message: SessionOutboundMessage) => void; filter?: AgentUpdatesFilter; syncEnabled?: boolean; }): void { - subscription = { - subscriptionId: input.subscriptionId, - syncEnabled: input.syncEnabled, - filter: input.filter, + subscriptions.set(input.subscriptionId, { + ...input, + emit: input.emit ?? deps.emit, + syncEnabled: input.syncEnabled ?? false, + isProviderVisible: input.isProviderVisible ?? deps.isProviderVisibleToClient, isBootstrapping: true, pendingUpdatesByAgentId: new Map(), - }; + }); } function flushBootstrapped( subscriptionId: string, options?: { snapshotUpdatedAtByAgentId?: Map }, ): void { - if (!subscription || subscription.subscriptionId !== subscriptionId) { - return; - } - if (!subscription.isBootstrapping) { - return; - } - - subscription.isBootstrapping = false; - const pending = Array.from(subscription.pendingUpdatesByAgentId.values()); - subscription.pendingUpdatesByAgentId.clear(); - + const sub = subscriptions.get(subscriptionId); + if (!sub?.isBootstrapping) return; + sub.isBootstrapping = false; + const pending = Array.from(sub.pendingUpdatesByAgentId.values()); + sub.pendingUpdatesByAgentId.clear(); for (const payload of pending) { if (payload.kind === "upsert") { const snapshotUpdatedAt = options?.snapshotUpdatedAtByAgentId?.get(payload.agent.id); - if (typeof snapshotUpdatedAt === "number") { - const updateUpdatedAt = Date.parse(payload.agent.updatedAt); - if (!Number.isNaN(updateUpdatedAt) && updateUpdatedAt < snapshotUpdatedAt) { - continue; - } - } + if ( + snapshotUpdatedAt !== undefined && + Date.parse(payload.agent.updatedAt) < snapshotUpdatedAt + ) + continue; } - - deps.emit({ - type: "agent_update", - payload, - }); + bufferOrEmit(sub, payload); } } function clearSubscription(subscriptionId: string): void { - if (subscription && subscription.subscriptionId === subscriptionId) { - subscription = null; - } + subscriptions.delete(subscriptionId); } function hasSubscription(): boolean { - return subscription !== null; + return subscriptions.size > 0; } - async function includesLiveAgent(agent: ManagedAgent): Promise { - const activeSubscription = subscription; - if (!activeSubscription) return false; - + async function includesLiveAgent( + agent: ManagedAgent, + subscriptionIds?: ReadonlySet, + ): Promise { + const observers = [...subscriptions.values()].filter( + (sub) => !subscriptionIds || subscriptionIds.has(sub.subscriptionId), + ); + if (observers.length === 0) return false; const payload = await deps.enrichAgentPayload(toAgentPayload(agent)); - if (subscription !== activeSubscription || !deps.isProviderVisibleToClient(payload.provider)) { - return false; - } const project = payload.workspaceId ? await deps.buildProjectPlacementForWorkspaceId(payload.workspaceId) : null; return ( - subscription === activeSubscription && project !== null && - matchesAgentUpdatesFilter({ - agent: payload, - project, - filter: activeSubscription.filter, - }) + observers.some( + (sub) => + sub.isProviderVisible(payload.provider) && + matchesAgentUpdatesFilter({ agent: payload, project, filter: sub.filter }), + ) ); } + async function publishPayload(payload: AgentSnapshotPayload): Promise { + const observers = [...subscriptions.values()]; + if (observers.length === 0) return; + const project = payload.workspaceId + ? await deps.buildProjectPlacementForWorkspaceId(payload.workspaceId) + : null; + for (const sub of observers) { + const matches = + project && matchesAgentUpdatesFilter({ agent: payload, project, filter: sub.filter }); + bufferOrEmit( + sub, + sequence( + sub, + matches + ? { kind: "upsert", agent: payload, project } + : { kind: "remove", agentId: payload.id }, + payload, + project, + payload.id, + ), + ); + } + } + async function emitStoredRecord(record: StoredAgentRecord): Promise { const payload = deps.buildStoredAgentPayload(record); - const sub = subscription; - if (!sub) { - return payload; - } - - const project = payload.workspaceId - ? await deps.buildProjectPlacementForWorkspaceId(payload.workspaceId) - : null; - if (!project) { - bufferOrEmit( - sub, - sequence(sub, { kind: "remove", agentId: payload.id }, null, null, payload.id), - ); - return payload; - } - - const matches = matchesAgentUpdatesFilter({ - agent: payload, - project, - filter: sub.filter, - }); - bufferOrEmit( - sub, - sequence( - sub, - matches - ? { - kind: "upsert", - agent: payload, - project, - } - : { - kind: "remove", - agentId: payload.id, - }, - payload, - project, - payload.id, - ), - ); + await publishPayload(payload); return payload; } async function emitLiveAgentUpdate(payload: AgentSnapshotPayload): Promise { try { - const sub = subscription; - payload = await deps.enrichAgentPayload(payload); - if (sub) { - const project = payload.workspaceId - ? await deps.buildProjectPlacementForWorkspaceId(payload.workspaceId) - : null; - if (!project) { - bufferOrEmit( - sub, - sequence(sub, { kind: "remove", agentId: payload.id }, null, null, payload.id), - ); - } else { - const matches = matchesAgentUpdatesFilter({ - agent: payload, - project, - filter: sub.filter, - }); - - if (matches) { - bufferOrEmit( - sub, - sequence( - sub, - { - kind: "upsert", - agent: payload, - project, - }, - payload, - project, - payload.id, - ), - ); - } else { - bufferOrEmit( - sub, - sequence( - sub, - { - kind: "remove", - agentId: payload.id, - }, - payload, - project, - payload.id, - ), - ); - } - } - } - - // A lifecycle change updates exactly the agent's owning workspace, never - // every workspace sharing its cwd. Ownership is the agent's workspaceId. - if (payload.workspaceId) { - await deps.emitWorkspaceUpdateForWorkspaceId(payload.workspaceId); - } + if (hasSubscription()) await publishPayload(await deps.enrichAgentPayload(payload)); + if (payload.workspaceId) await deps.emitWorkspaceUpdateForWorkspaceId(payload.workspaceId); } catch (error) { deps.logger.error({ err: error }, "Failed to emit agent update"); } @@ -390,7 +299,7 @@ export function createAgentUpdatesService(deps: AgentUpdatesServiceDeps): AgentU } function forwardLiveAgent(agent: ManagedAgent): Promise { - if (!subscription) { + if (!hasSubscription()) { const workspaceId = agent.workspaceId; return workspaceId ? enqueueAgentUpdate(agent.id, async () => { @@ -408,17 +317,14 @@ export function createAgentUpdatesService(deps: AgentUpdatesServiceDeps): AgentU function removeAgent(agentId: string): Promise { return enqueueAgentUpdate(agentId, () => { - if (subscription) { - bufferOrEmit( - subscription, - sequence(subscription, { kind: "remove", agentId }, null, null, agentId), - ); + for (const sub of subscriptions.values()) { + bufferOrEmit(sub, sequence(sub, { kind: "remove", agentId }, null, null, agentId)); } }); } function dispose(): void { - subscription = null; + subscriptions.clear(); } return { diff --git a/packages/server/src/server/session/checkout/checkout-session.test.ts b/packages/server/src/server/session/checkout/checkout-session.test.ts index d29418a9a5..98185ddc08 100644 --- a/packages/server/src/server/session/checkout/checkout-session.test.ts +++ b/packages/server/src/server/session/checkout/checkout-session.test.ts @@ -1,3 +1,4 @@ +import { SessionDelivery } from "../owned-subscriptions/index.js"; import { execFileSync } from "node:child_process"; import { mkdtempSync, readFileSync, realpathSync, rmSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; @@ -48,6 +49,7 @@ function createFakeDiffSubscriber(initial: CheckoutDiffSnapshotPayload) { const subscriptions: FakeDiffSubscription[] = []; const refreshedCwds: string[] = []; const subscriber: CheckoutDiffSubscriber = { + read: async (params) => ({ ...initial, cwd: params.cwd }), subscribe: async (params, listener) => { let isSubscribed = true; const subscription: FakeDiffSubscription = { @@ -173,7 +175,23 @@ function makeCheckoutSession(options?: { worktreesRoot: undefined, logger: pino({ level: "silent" }), }); - return { checkout, emitted, hostCalls, gitMutationCalls, generatorCalls }; + const delivery = new SessionDelivery((_source, message) => emitted.push(message)); + return { + checkout, + emitted, + hostCalls, + gitMutationCalls, + generatorCalls, + subscribe: (request: Parameters[0]) => + delivery.request(undefined, request, () => + checkout.handleSubscribeDiffRequest(request, delivery), + ), + unsubscribe: (request: Parameters[0]) => + delivery.request(undefined, request, () => + checkout.handleUnsubscribeDiffRequest(request, delivery), + ), + close: () => delivery.close(), + }; } function createGitSnapshot( @@ -532,9 +550,9 @@ describe("CheckoutSession", () => { files: [], error: null, }); - const { checkout, emitted } = makeCheckoutSession({ diff: subscriber }); + const { subscribe, unsubscribe, emitted } = makeCheckoutSession({ diff: subscriber }); - await checkout.handleSubscribeDiffRequest({ + await subscribe({ type: "subscribe_checkout_diff_request", subscriptionId: "s1", cwd: "/repo", @@ -566,7 +584,7 @@ describe("CheckoutSession", () => { }, }); - checkout.handleUnsubscribeDiffRequest({ + await unsubscribe({ type: "unsubscribe_checkout_diff_request", subscriptionId: "s1", }); @@ -574,22 +592,22 @@ describe("CheckoutSession", () => { expect(subscriptions[0].unsubscribeCalls).toBe(1); }); - it("replaces an existing subscription when the same id subscribes again", async () => { + it("preserves legacy replacement when an existing id when the same id subscribes again", async () => { const { subscriber, subscriptions } = createFakeDiffSubscriber({ cwd: "/repo", files: [], error: null, }); - const { checkout } = makeCheckoutSession({ diff: subscriber }); + const { subscribe } = makeCheckoutSession({ diff: subscriber }); - await checkout.handleSubscribeDiffRequest({ + await subscribe({ type: "subscribe_checkout_diff_request", subscriptionId: "s1", cwd: "/repo", compare: { mode: "uncommitted" }, requestId: "first", }); - await checkout.handleSubscribeDiffRequest({ + await subscribe({ type: "subscribe_checkout_diff_request", subscriptionId: "s1", cwd: "/repo", @@ -608,16 +626,16 @@ describe("CheckoutSession", () => { files: [], error: null, }); - const { checkout } = makeCheckoutSession({ diff: subscriber }); + const { subscribe, close } = makeCheckoutSession({ diff: subscriber }); - await checkout.handleSubscribeDiffRequest({ + await subscribe({ type: "subscribe_checkout_diff_request", subscriptionId: "s1", cwd: "/repo", compare: { mode: "uncommitted" }, requestId: "r", }); - await checkout.handleSubscribeDiffRequest({ + await subscribe({ type: "subscribe_checkout_diff_request", subscriptionId: "s2", cwd: "/repo", @@ -625,7 +643,7 @@ describe("CheckoutSession", () => { requestId: "r", }); - checkout.cleanup(); + await close(); expect(subscriptions[0].unsubscribeCalls).toBe(1); expect(subscriptions[1].unsubscribeCalls).toBe(1); diff --git a/packages/server/src/server/session/checkout/checkout-session.ts b/packages/server/src/server/session/checkout/checkout-session.ts index 789f1f0449..152734603c 100644 --- a/packages/server/src/server/session/checkout/checkout-session.ts +++ b/packages/server/src/server/session/checkout/checkout-session.ts @@ -1,4 +1,5 @@ import type pino from "pino"; +import type { SessionDelivery } from "../owned-subscriptions/index.js"; import { isAbsolute } from "node:path"; import { getErrorMessage } from "@getpaseo/protocol/error-utils"; import { getForgeDefinitionOrNeutral } from "@getpaseo/protocol/forge-manifest"; @@ -108,6 +109,9 @@ function toLegacyGithubSearchItems(items: ForgeSearchResultItem[]): LegacyGithub * real CheckoutDiffManager satisfies this structurally; tests supply a fake. */ export interface CheckoutDiffSubscriber { + read( + params: Omit, + ): Promise; subscribe( params: CheckoutDiffSubscriptionRequest, listener: (snapshot: CheckoutDiffSnapshotPayload) => void, @@ -152,7 +156,6 @@ export class CheckoutSession { private readonly paseoHome: string; private readonly worktreesRoot: string | undefined; private readonly logger: pino.Logger; - private readonly diffSubscriptions = new Map void>(); private readonly statusUpdateFingerprints = new Map(); constructor(options: CheckoutSessionOptions) { @@ -400,48 +403,77 @@ export class CheckoutSession { } } - async handleSubscribeDiffRequest(msg: SubscribeCheckoutDiffRequest): Promise { - const cwd = expandTilde(msg.cwd); - this.diffSubscriptions.get(msg.subscriptionId)?.(); - const abort = new AbortController(); - const unsubscribe = () => abort.abort(); - this.diffSubscriptions.set(msg.subscriptionId, unsubscribe); + async handleReadDiffRequest( + msg: Extract, + ): Promise { + const snapshot = await this.checkoutDiffManager.read({ + cwd: expandTilde(msg.cwd), + compare: msg.compare, + }); + this.host.emit({ + type: "checkout.diff.get.response", + payload: { ...snapshot, requestId: msg.requestId }, + }); + } + async handleSubscribeDiffRequest( + msg: SubscribeCheckoutDiffRequest, + ownership: SessionDelivery, + ): Promise { + let bootstrap: Promise | undefined; + const owner = ownership.begin( + "diffs", + msg.subscriptionId, + async () => { + await bootstrap?.then( + (subscription) => subscription.unsubscribe(), + () => undefined, + ); + }, + `diff:${msg.subscriptionId}`, + ); + let ready = false; + let pending: CheckoutDiffSnapshotPayload | null = null; + const emitSnapshot = (snapshot: CheckoutDiffSnapshotPayload) => + owner.emit({ + type: "checkout_diff_update", + payload: { ...snapshot, subscriptionId: owner.responseId }, + }); try { - const subscription = await this.checkoutDiffManager.subscribe( - { cwd, compare: msg.compare, signal: abort.signal }, + bootstrap = this.checkoutDiffManager.subscribe( + { cwd: expandTilde(msg.cwd), compare: msg.compare, signal: owner.signal }, (snapshot) => { - this.host.emit({ - type: "checkout_diff_update", - payload: { - subscriptionId: msg.subscriptionId, - ...snapshot, - }, - }); + if (owner.signal.aborted) return; + if (ready) emitSnapshot(snapshot); + else pending = snapshot; }, ); - + const subscription = await bootstrap; + if (owner.signal.aborted) { + subscription.unsubscribe(); + return; + } this.host.emit({ type: "subscribe_checkout_diff_response", payload: { - subscriptionId: msg.subscriptionId, ...subscription.initial, + subscriptionId: owner.responseId, requestId: msg.requestId, }, }); + ready = true; + if (pending) emitSnapshot(pending); } catch (error) { - if (this.diffSubscriptions.get(msg.subscriptionId) === unsubscribe) { - this.diffSubscriptions.delete(msg.subscriptionId); - } - unsubscribe(); + await owner.release(); throw error; } } - handleUnsubscribeDiffRequest(msg: UnsubscribeCheckoutDiffRequest): void { - const unsubscribe = this.diffSubscriptions.get(msg.subscriptionId); - this.diffSubscriptions.delete(msg.subscriptionId); - unsubscribe?.(); + async handleUnsubscribeDiffRequest( + msg: UnsubscribeCheckoutDiffRequest, + ownership: SessionDelivery, + ): Promise { + await ownership.release(msg.subscriptionId); } async handleRefreshRequest(msg: CheckoutRefreshRequest): Promise { @@ -1413,10 +1445,6 @@ export class CheckoutSession { } cleanup(): void { - for (const unsubscribe of this.diffSubscriptions.values()) { - unsubscribe(); - } - this.diffSubscriptions.clear(); this.statusUpdateFingerprints.clear(); } } diff --git a/packages/server/src/server/session/daemon/daemon-self-update-session-controller.test.ts b/packages/server/src/server/session/daemon/daemon-self-update-session-controller.test.ts index ff274d9019..81c41f3421 100644 --- a/packages/server/src/server/session/daemon/daemon-self-update-session-controller.test.ts +++ b/packages/server/src/server/session/daemon/daemon-self-update-session-controller.test.ts @@ -1,3 +1,4 @@ +import { SessionDelivery } from "../owned-subscriptions/index.js"; import { describe, expect, test } from "vitest"; import { createTestLogger } from "../../../test-utils/test-logger.js"; import type { SessionInboundMessage, SessionOutboundMessage } from "../../messages.js"; @@ -176,3 +177,48 @@ describe("DaemonSelfUpdateSessionController", () => { expect(restartIntents).toEqual([]); }); }); + +test("self-update progress is request-owned through the final delivery boundary", async () => { + const source = {}, + sibling = {}; + const frames: SessionOutboundMessage[] = []; + const delivery = new SessionDelivery((socket, message) => { + expect(socket).toBe(source); + expect(delivery.permits(source, message)).toBe(true); + expect(delivery.permits(sibling, message)).toBe(false); + frames.push(message); + }); + delivery.attach(source, true); + delivery.attach(sibling, true); + let lateProgress: (() => void) | undefined; + const controller = new DaemonSelfUpdateSessionController({ + clientId: "safe-update", + daemonVersion: "0.8.0", + sessionLogger: createTestLogger(), + emit: (message) => { + delivery.reply(message); + }, + emitLifecycleIntent: () => { + throw new Error("No restart is allowed in this test"); + }, + updater: { + async update(input) { + input.onProgress("starting"); + await Promise.resolve(); + input.onProgress("installing"); + lateProgress = () => input.onProgress("starting"); + return { success: false, error: "Safe injected update stopped", newVersion: null }; + }, + }, + }); + await delivery.request(source, updateRequest, async () => { + await controller.dispatch(updateRequest); + }); + expect(frames.map((message) => message.type)).toEqual([ + "daemon.update.progress", + "daemon.update.progress", + "daemon.update.response", + ]); + lateProgress?.(); + expect(frames).toHaveLength(3); +}); diff --git a/packages/server/src/server/session/daemon/daemon-session.ts b/packages/server/src/server/session/daemon/daemon-session.ts index 8c3aa2f167..95caafc58b 100644 --- a/packages/server/src/server/session/daemon/daemon-session.ts +++ b/packages/server/src/server/session/daemon/daemon-session.ts @@ -49,6 +49,7 @@ export interface DaemonSessionOptions { listWorkspaces: () => Promise; listProviderAvailability: () => Promise; getWebSocketRuntimeMetrics?: () => DaemonWebSocketRuntimeDiagnosticSnapshot | null; + getObservationMetrics?: () => Record; logger: pino.Logger; hubRelationships?: HubRelationshipManagement; reloadConfig: () => DaemonConfigReloadResult; @@ -73,6 +74,7 @@ export class DaemonSession { private readonly listWorkspaces: () => Promise; private readonly listProviderAvailability: () => Promise; private readonly getWebSocketRuntimeMetrics: () => DaemonWebSocketRuntimeDiagnosticSnapshot | null; + private readonly getObservationMetrics: DaemonSessionOptions["getObservationMetrics"]; private readonly logger: pino.Logger; private readonly selfUpdate: DaemonSelfUpdateSessionController; private readonly hubRelationships: HubRelationshipManagement | null; @@ -90,6 +92,7 @@ export class DaemonSession { this.listWorkspaces = options.listWorkspaces; this.listProviderAvailability = options.listProviderAvailability; this.getWebSocketRuntimeMetrics = options.getWebSocketRuntimeMetrics ?? (() => null); + this.getObservationMetrics = options.getObservationMetrics; this.logger = options.logger; this.hubRelationships = options.hubRelationships ?? null; this.reloadConfig = options.reloadConfig; @@ -283,6 +286,7 @@ export class DaemonSession { listWorkspaces: this.listWorkspaces, listProviderAvailability: this.listProviderAvailability, getWebSocketRuntimeMetrics: this.getWebSocketRuntimeMetrics, + getObservationMetrics: this.getObservationMetrics, logger: this.logger, }); this.host.emit({ diff --git a/packages/server/src/server/session/daemon/diagnostics.ts b/packages/server/src/server/session/daemon/diagnostics.ts index 87ea2b2faa..9cff35cb3b 100644 --- a/packages/server/src/server/session/daemon/diagnostics.ts +++ b/packages/server/src/server/session/daemon/diagnostics.ts @@ -25,6 +25,7 @@ export interface DaemonDiagnosticsOptions { listWorkspaces: () => Promise; listProviderAvailability: () => Promise; getWebSocketRuntimeMetrics: () => DaemonWebSocketRuntimeDiagnosticSnapshot | null; + getObservationMetrics?: () => Record; logger: pino.Logger; } @@ -68,6 +69,16 @@ export async function collectDaemonDiagnostics(options: DaemonDiagnosticsOptions ]), ]; + if (options.getObservationMetrics) + sections.push( + formatSection( + "Session observations", + Object.entries(options.getObservationMetrics()).map(([label, value]) => ({ + label, + value: String(value), + })), + ), + ); sections.push( await safeSection("Daemon process", () => collectProcessEntries(options), options.logger), ); diff --git a/packages/server/src/server/session/files/workspace-files-session.test.ts b/packages/server/src/server/session/files/workspace-files-session.test.ts index 6d27509fbe..43c517453b 100644 --- a/packages/server/src/server/session/files/workspace-files-session.test.ts +++ b/packages/server/src/server/session/files/workspace-files-session.test.ts @@ -1,3 +1,4 @@ +import { SessionDelivery } from "../owned-subscriptions/index.js"; import { existsSync, mkdtempSync, @@ -555,14 +556,22 @@ describe("WorkspaceFilesSession", () => { test("round-trips an upload through transfer frames", async () => { const { subsystem, emitted, paseoHome } = makeSubsystem(); - subsystem.handleFileUploadRequest({ + const source = {}; + const ownership = new SessionDelivery((_source, message) => { + emitted.push(message); + }); + ownership.attach(source, true); + const request = { type: "file.upload.request", fileName: "notes.txt", mimeType: "text/plain", size: 11, modifiedAt: "2026-05-02T00:00:00.000Z", requestId: "req-upload", - }); + } as const; + await ownership.request(source, request, async () => + subsystem.handleFileUploadRequest(request, ownership), + ); await subsystem.handleFileTransferFrame( uploadFrame({ opcode: FileTransferOpcode.FileBegin, @@ -575,6 +584,7 @@ describe("WorkspaceFilesSession", () => { fileName: "notes.txt", }, }), + source, ); await subsystem.handleFileTransferFrame( uploadFrame({ @@ -582,9 +592,11 @@ describe("WorkspaceFilesSession", () => { requestId: "req-upload", payload: new TextEncoder().encode("hello world"), }), + source, ); await subsystem.handleFileTransferFrame( uploadFrame({ opcode: FileTransferOpcode.FileEnd, requestId: "req-upload" }), + source, ); const message = emitted.find((entry) => entry.type === "file.upload.response"); @@ -593,8 +605,10 @@ describe("WorkspaceFilesSession", () => { } expect(message.payload.error).toBeNull(); expect(message.payload.file?.fileName).toBe("notes.txt"); - expect(readFileSync(join(paseoHome, "uploads", "upload_req-upload", "notes.txt"), "utf8")).toBe( - "hello world", - ); + const file = message.payload.file; + if (!file) throw new Error("Expected uploaded file"); + expect(file.path.startsWith(join(paseoHome, "uploads"))).toBe(true); + expect(readFileSync(file.path, "utf8")).toBe("hello world"); + await ownership.close(); }); }); diff --git a/packages/server/src/server/session/files/workspace-files-session.ts b/packages/server/src/server/session/files/workspace-files-session.ts index 0f833479c2..a0bcb6ed2d 100644 --- a/packages/server/src/server/session/files/workspace-files-session.ts +++ b/packages/server/src/server/session/files/workspace-files-session.ts @@ -1,4 +1,6 @@ import type pino from "pino"; +import type { SessionDelivery } from "../owned-subscriptions/index.js"; +import type { FileVersion } from "@getpaseo/protocol/messages"; import { getErrorMessage } from "@getpaseo/protocol/error-utils"; import { encodeFileTransferFrame, @@ -68,7 +70,6 @@ export class WorkspaceFilesSession { private readonly logger: pino.Logger; private readonly fileUploads: FileUploadStore; private readonly fileObserver: FileObserver; - private readonly fileSubscriptions = new Map void>(); constructor(options: WorkspaceFilesSessionOptions) { this.host = options.host; @@ -78,32 +79,59 @@ export class WorkspaceFilesSession { this.fileObserver = options.fileObserver ?? workspaceFileObserver; } - async handleFileSubscribeRequest(request: FileSubscribeRequest): Promise { - this.fileSubscriptions.get(request.subscriptionId)?.(); + async handleFileSubscribeRequest( + request: FileSubscribeRequest, + ownership: SessionDelivery, + ): Promise { + let bootstrap: ReturnType | undefined; + const owner = ownership.begin( + "files", + request.subscriptionId, + async () => { + await bootstrap?.then( + (subscription) => subscription.unsubscribe(), + () => undefined, + ); + }, + `file:${request.subscriptionId}`, + ); + let ready = false; + let pending: FileVersion | null = null; + const emitVersion = (version: FileVersion) => + owner.emit({ + type: "fs.file.update", + payload: { subscriptionId: owner.responseId, version }, + }); try { - const subscription = await this.fileObserver.subscribe( + bootstrap = this.fileObserver.subscribe( { cwd: request.cwd, path: request.path }, (version) => { - this.host.emit({ - type: "fs.file.update", - payload: { subscriptionId: request.subscriptionId, version }, - }); + if (owner.signal.aborted) return; + if (ready) emitVersion(version); + else pending = version; }, ); - this.fileSubscriptions.set(request.subscriptionId, subscription.unsubscribe); + const subscription = await bootstrap; + if (owner.signal.aborted) { + subscription.unsubscribe(); + return; + } this.host.emit({ type: "fs.file.subscribe.response", payload: { - subscriptionId: request.subscriptionId, + subscriptionId: owner.responseId, initial: subscription.initial, requestId: request.requestId, }, }); + ready = true; + if (pending) emitVersion(pending); } catch (error) { + await owner.release(); this.host.emit({ type: "fs.file.subscribe.response", payload: { - subscriptionId: request.subscriptionId, + subscriptionId: owner.responseId, initial: { status: "error", cwd: request.cwd, @@ -116,9 +144,11 @@ export class WorkspaceFilesSession { } } - handleFileUnsubscribeRequest(request: FileUnsubscribeRequest): void { - this.fileSubscriptions.get(request.subscriptionId)?.(); - this.fileSubscriptions.delete(request.subscriptionId); + async handleFileUnsubscribeRequest( + request: FileUnsubscribeRequest, + ownership: SessionDelivery, + ): Promise { + await ownership.release(request.subscriptionId); this.host.emit({ type: "fs.file.unsubscribe.response", payload: { subscriptionId: request.subscriptionId, requestId: request.requestId }, @@ -213,11 +243,6 @@ export class WorkspaceFilesSession { }); } - dispose(): void { - for (const unsubscribe of this.fileSubscriptions.values()) unsubscribe(); - this.fileSubscriptions.clear(); - } - async handleFileExplorerRequest(request: FileExplorerRequest, source?: object): Promise { const { cwd: workspaceCwd, path: requestedPath = ".", mode, requestId } = request; const cwd = workspaceCwd.trim(); @@ -349,15 +374,23 @@ export class WorkspaceFilesSession { } } - handleFileUploadRequest(request: FileUploadRequest): void { - this.fileUploads.beginUpload(request); + handleFileUploadRequest(request: FileUploadRequest, ownership: SessionDelivery): void { + let cancel: (() => Promise) | undefined; + const operation = ownership.operation( + (message) => + message.type === "file.upload.response" && message.payload.requestId === request.requestId, + () => cancel?.(), + ); + cancel = this.fileUploads.beginUpload(request, operation.source, (response) => { + if (response) operation.emit(response); + void operation + .release() + .catch((error) => this.logger.error({ err: error }, "Upload cleanup failed")); + }); } - async handleFileTransferFrame(frame: FileTransferFrame): Promise { - const response = await this.fileUploads.receiveFrame(frame); - if (response) { - this.host.emit(response); - } + async handleFileTransferFrame(frame: FileTransferFrame, source: object): Promise { + await this.fileUploads.receiveFrame(frame, source); } async handleProjectIconRequest( diff --git a/packages/server/src/server/session/owned-subscriptions/index.ts b/packages/server/src/server/session/owned-subscriptions/index.ts new file mode 100644 index 0000000000..9765d9c064 --- /dev/null +++ b/packages/server/src/server/session/owned-subscriptions/index.ts @@ -0,0 +1,433 @@ +import { isReply } from "./replies.js"; +import { AsyncLocalStorage } from "node:async_hooks"; +import { randomUUID } from "node:crypto"; +import type { SessionInboundMessage, SessionOutboundMessage } from "../../messages.js"; + +interface Source { + socket: object; + modern: boolean; + active: boolean; + handshakePending: boolean; + cancellation: AbortController; + subscriptions: Map; + operations: Set; +} +interface DeliveryOwner { + source: Source; + active: boolean; +} +interface RequestOwner extends DeliveryOwner { + request: SessionInboundMessage; +} +interface RetainedOwner extends DeliveryOwner { + cancellation: AbortController; + stop(): void | Promise; + forget(): void; + release?: Promise; +} +interface SubscriptionOwner extends RetainedOwner { + id: string; + family: string; + legacySlot: string; + responseId: string; +} + +export interface OwnedOperation { + readonly source: object; + readonly signal: AbortSignal; + emit(message: SessionOutboundMessage): void; + release(): Promise; +} + +export interface OwnedSubscription { + readonly id: string; + readonly responseId: string; + readonly source: object; + readonly signal: AbortSignal; + emit(message: SessionOutboundMessage): void; + emitBinary(frame: Uint8Array): void; + release(): Promise; +} + +/** Source provenance and lifetimes. Domain producers own snapshots, filters and buffering. */ +export class SessionDelivery { + private readonly requests = new AsyncLocalStorage(); + private readonly projectionSource = new AsyncLocalStorage(); + private readonly sources = new Map(); + private readonly proofs = new WeakMap(); + private readonly defaultSource = {}; + + constructor( + private readonly send: (source: object, message: SessionOutboundMessage) => void, + private readonly sendBinary: (source: object, frame: Uint8Array) => void = () => {}, + private readonly project: ( + source: object, + message: SessionOutboundMessage, + ) => SessionOutboundMessage = (_source, message) => message, + private readonly unexpectedReply: ( + request: SessionInboundMessage, + message: SessionOutboundMessage, + ) => void = () => {}, + ) {} + + attach(socket: object, modern: boolean): void { + const existing = this.sources.get(socket); + if (existing) { + if (existing.modern !== modern) + throw new Error("Cannot renegotiate subscription ownership on an active source"); + return; + } + this.sources.set(socket, { + socket, + modern, + active: true, + handshakePending: true, + cancellation: new AbortController(), + subscriptions: new Map(), + operations: new Set(), + }); + } + + get currentSource(): object | undefined { + return this.projectionSource.getStore() ?? this.requests.getStore()?.source.socket; + } + + get requestSignal(): AbortSignal { + const request = this.requests.getStore(); + if (!request) throw new Error("Operation has no requesting source"); + return request.source.cancellation.signal; + } + + forSource(source: object, project: () => T): T { + return this.projectionSource.run(source, project); + } + + hasLegacySources(): boolean { + return [...this.sources.values()].some((source) => source.active && !source.modern); + } + + hasDemand(family: string): boolean { + return [...this.sources.values()].some( + (source) => + source.active && + [...source.subscriptions.values()].some((owner) => owner.active && owner.family === family), + ); + } + + subscriptionIds(source: object, family: string): string[] { + return [...(this.sources.get(source)?.subscriptions.values() ?? [])] + .filter((owner) => owner.active && owner.family === family) + .map((owner) => owner.id); + } + + get registrationCount(): number { + return [...this.sources.values()].reduce( + (count, source) => count + source.subscriptions.size + source.operations.size, + 0, + ); + } + + sourceRegistrationCount(socket = this.currentSource): number { + const source = socket ? this.sources.get(socket) : undefined; + return source ? source.subscriptions.size + source.operations.size : 0; + } + + async releaseFamily(family: string): Promise { + await Promise.all( + [...this.sources.values()].flatMap((source) => + [...source.subscriptions.values()] + .filter((owner) => owner.family === family) + .map((owner) => this.releaseOwner(owner)), + ), + ); + } + + isModern(socket: object | undefined): boolean { + return socket ? this.sources.get(socket)?.modern === true : false; + } + + async detach(socket: object): Promise { + const source = this.sources.get(socket); + if (!source) return; + source.active = false; + source.cancellation.abort(); + await Promise.all( + [...source.subscriptions.values(), ...source.operations].map((owner) => + this.releaseOwner(owner), + ), + ); + this.sources.delete(socket); + } + + async close(): Promise { + await Promise.all([...this.sources.keys()].map((socket) => this.detach(socket))); + } + + async request( + socket: object | undefined, + request: SessionInboundMessage, + run: () => Promise, + ): Promise { + socket ??= this.defaultSource; + if (!this.sources.has(socket)) this.attach(socket, false); + const source = this.sources.get(socket)!; + const owner: RequestOwner = { source, request, active: true }; + try { + await this.requests.run(owner, run); + } finally { + owner.active = false; + } + } + + protocolFailure( + socket: object, + failure: { + requestId?: string; + requestType?: string; + error: string; + code: "invalid_message" | "unknown_schema"; + }, + ): void { + const source = this.sources.get(socket); + if (!source?.active) return; + const message: SessionOutboundMessage = + failure.requestId !== undefined + ? { type: "rpc_error", payload: { ...failure, requestId: failure.requestId } } + : { type: "status", payload: { status: "error", message: failure.error } }; + const owner: DeliveryOwner = { source, active: true }; + try { + this.sendOwned(owner, message); + } finally { + owner.active = false; + } + } + + authorizeReply(message: SessionOutboundMessage, socket: object): SessionOutboundMessage { + message = this.project(socket, message); + const owner = this.requests.getStore(); + if (owner?.active && owner.source.socket === socket && isReply(owner.request, message)) + this.proofs.set(message, owner); + return message; + } + + reply(message: SessionOutboundMessage): boolean { + const owner = this.requests.getStore(); + if (!owner?.active) return false; + if (!isReply(owner.request, message)) { + if ( + "requestId" in owner.request && + "payload" in message && + "requestId" in message.payload && + message.payload.requestId === owner.request.requestId + ) { + this.unexpectedReply(owner.request, message); + } + return false; + } + const reply = owner.source.modern ? message : legacyMessage(message); + this.sendOwned(owner, reply); + return true; + } + + begin( + family: string, + requestedId: string | undefined, + stop: (id: string) => void | Promise, + legacySlot = family, + ): OwnedSubscription { + const request = this.requests.getStore(); + if (!request?.active || !request.source.active) + throw new Error("Subscription source is closed"); + const source = request.source; + if (source.modern && (!("requestId" in request.request) || !request.request.requestId)) + throw new Error("Owned subscriptions require a requestId"); + if (source.modern && requestedId !== undefined) + throw new Error("Subscription IDs are assigned by the host"); + // COMPAT(ownedSubscriptions): added in v0.8.0, remove after 2027-03-09 once client floor >= v0.8.0. + if (!source.modern) { + for (const prior of source.subscriptions.values()) { + // Keep failed teardown registered so disconnect/close can report it. + if (prior.legacySlot === legacySlot) void this.releaseOwner(prior).catch(() => {}); + } + } + const id = randomUUID(); + const responseId = source.modern ? id : requestedId?.trim() || id; + const owner: SubscriptionOwner = { + id, + responseId, + source, + family, + legacySlot, + active: true, + cancellation: new AbortController(), + stop: () => stop(id), + forget: () => { + source.subscriptions.delete(id); + }, + }; + source.subscriptions.set(id, owner); + return { + id, + responseId, + source: source.socket, + signal: owner.cancellation.signal, + emit: (message) => { + if (!owner.active || !source.active) return; + const tagged = + source.modern || + message.type === "fs.file.update" || + message.type === "checkout_diff_update" + ? withSubscriptionId(message, responseId) + : legacyMessage(message); + this.sendOwned(owner, tagged); + }, + emitBinary: (frame) => { + if (!owner.active || !source.active) return; + this.proofs.set(frame, owner); + this.sendBinary(source.socket, frame); + }, + release: () => this.releaseOwner(owner), + }; + } + + /** A domain extends the originating request until its asynchronous operation ends. */ + operation( + accepts: (message: SessionOutboundMessage) => boolean, + stop: () => void | Promise, + ): OwnedOperation { + const request = this.requests.getStore(); + if (!request?.active || !request.source.active) throw new Error("Operation source is closed"); + const source = request.source; + const owner: RetainedOwner = { + source, + active: true, + cancellation: new AbortController(), + stop, + forget: () => { + source.operations.delete(owner); + }, + }; + source.operations.add(owner); + return { + source: source.socket, + signal: owner.cancellation.signal, + emit: (message) => { + if (!owner.active || !source.active || !accepts(message)) return; + this.sendOwned(owner, message); + }, + release: () => this.releaseOwner(owner), + }; + } + + async release(id: string): Promise { + const source = this.requests.getStore()?.source; + const owner = + source?.subscriptions.get(id) ?? + (source && !source.modern + ? [...source.subscriptions.values()].find((candidate) => candidate.responseId === id) + : undefined); + if (owner) await this.releaseOwner(owner); + } + + async releaseLegacySlot(slot: string): Promise { + const source = this.requests.getStore()?.source; + if (!source) return; + if (source.modern) + throw new Error("Release subscriptions using the server-assigned subscription ID"); + const owner = [...source.subscriptions.values()].find( + (candidate) => candidate.legacySlot === slot, + ); + if (owner) await this.releaseOwner(owner); + } + + authorizeFileReply(frame: Uint8Array, socket: object): void { + const owner = this.requests.getStore(); + if ( + owner?.active && + owner.source.socket === socket && + owner.request.type === "file_explorer_request" + ) + this.proofs.set(frame, owner); + } + + permitsBinary(socket: object, frame: Uint8Array): boolean { + const source = this.sources.get(socket); + if (!source?.active) return false; + // COMPAT(ownedSubscriptions): added in v0.8.0, remove after 2027-03-09 once client floor >= v0.8.0. + if (!source.modern) return true; + const owner = this.proofs.get(frame); + return owner?.active === true && owner.source === source; + } + + permits(socket: object, message: SessionOutboundMessage): boolean { + const source = this.sources.get(socket); + if (!source?.active) return false; + // COMPAT(ownedSubscriptions): added in v0.8.0, remove after 2027-03-09 once client floor >= v0.8.0. + if (!source.modern) return true; + if ( + message.type === "status" && + message.payload.status === "server_info" && + source.handshakePending + ) { + source.handshakePending = false; + return true; + } + const owner = this.proofs.get(message); + return owner?.active === true && owner.source === source; + } + + private sendOwned(owner: DeliveryOwner, message: SessionOutboundMessage): void { + const projected = this.project(owner.source.socket, message); + this.proofs.set(projected, owner); + this.send(owner.source.socket, projected); + } + + private releaseOwner(owner: RetainedOwner): Promise { + if (owner.release) return owner.release; + owner.active = false; + let resolve!: () => void; + let reject!: (error: unknown) => void; + owner.release = new Promise((accept, fail) => { + resolve = accept; + reject = fail; + }); + owner.cancellation.abort(); + try { + void Promise.resolve(owner.stop()).then(() => { + owner.forget(); + resolve(); + return undefined; + }, reject); + } catch (error) { + reject(error); + } + return owner.release; + } +} + +function withSubscriptionId( + message: T, + subscriptionId: string, +): T { + return "payload" in message + ? { ...message, payload: { ...message.payload, subscriptionId } } + : { ...message, subscriptionId }; +} + +// COMPAT(ownedSubscriptions): added in v0.8.0, remove after 2027-03-09 once client floor >= v0.8.0. +function legacyMessage(message: SessionOutboundMessage): SessionOutboundMessage { + if (message.type === "terminals_changed") { + const { workspaceId: _workspaceId, ...payload } = message.payload; + return { ...message, payload }; + } + if ( + message.type !== "subscribe_terminal_response" && + message.type !== "session.events.set_subscription.response" && + message.type !== "agent.timeline.set_subscription.response" && + message.type !== "workspace.label.list.response" + ) + return message; + if (!("subscriptionId" in message.payload)) return message; + const { subscriptionId: _subscriptionId, ...payload } = message.payload; + return { ...message, payload } as SessionOutboundMessage; +} diff --git a/packages/server/src/server/session/owned-subscriptions/replies.ts b/packages/server/src/server/session/owned-subscriptions/replies.ts new file mode 100644 index 0000000000..2fcfc1b97e --- /dev/null +++ b/packages/server/src/server/session/owned-subscriptions/replies.ts @@ -0,0 +1,69 @@ +import type { + KnownStatusPayload, + SessionInboundMessage, + SessionOutboundMessage, +} from "../../messages.js"; + +type Request = Extract; +type ConventionalReply = T extends `${infer P}.request` + ? `${P}.response` + : T extends `${infer P}_request` + ? `${P}_response` + : T extends `${string}/${string}` + ? `${T}/response` + : `${T}_response`; +type CorrelatedType = M extends { type: infer T; payload: infer P } + ? "requestId" extends keyof P + ? T + : never + : never; +type StatusReply = Extract["status"]; +type ExceptionalReply = Exclude< + CorrelatedType>, + ConventionalReply | "rpc_error" +>; + +// Naming is a protocol convention, not proof. Every nonconforming correlated output +// must declare its originating request; additions to the wire union fail typecheck here. +const exceptions = { + pong: ["ping"], + agent_archived: ["archive_agent_request"], + agent_deleted: ["delete_agent_request"], + agent_permission_resolved: ["agent_permission_response"], + terminals_changed: ["subscribe_terminals_request"], + "daemon.update.progress": ["daemon.update.request"], + // Transcription belongs to the explicitly retained voice operation, not a request ID. + transcription_result: [], + // This legacy requestId is a producer marker; checkout updates require event demand. + checkout_status_update: [], +} satisfies Record; +const statuses = { + agent_created: ["create_agent_request"], + agent_create_failed: ["create_agent_request", "import_agent_request"], + agent_resumed: ["resume_agent_request", "import_agent_request"], + agent_refreshed: ["refresh_agent_request"], + restart_requested: ["restart_server_request"], + shutdown_requested: ["shutdown_server_request"], +} satisfies Record; + +export function isReply(request: SessionInboundMessage, message: SessionOutboundMessage): boolean { + if ( + !("requestId" in request) || + !("payload" in message) || + !("requestId" in message.payload) || + message.payload.requestId !== request.requestId + ) + return false; + if (message.type === "rpc_error") return message.payload.requestType === request.type; + const declared: Readonly> = + message.type === "status" ? statuses : exceptions; + const key = message.type === "status" ? message.payload.status : message.type; + if (key in declared) return declared[key].includes(request.type); + if (message.type === "status") return false; + if (request.type.endsWith(".request")) + return message.type === request.type.replace(/\.request$/, ".response"); + if (request.type.endsWith("_request")) + return message.type === request.type.replace(/_request$/, "_response"); + if (request.type.includes("/")) return message.type === `${request.type}/response`; + return message.type === `${request.type}_response`; +} diff --git a/packages/server/src/server/session/provider/provider-catalog-session.test.ts b/packages/server/src/server/session/provider/provider-catalog-session.test.ts index b72a5142d9..a174f1db5a 100644 --- a/packages/server/src/server/session/provider/provider-catalog-session.test.ts +++ b/packages/server/src/server/session/provider/provider-catalog-session.test.ts @@ -54,7 +54,10 @@ function makeSubsystem(options: MakeOptions = {}) { isProviderVisibleToClient: (provider) => visible.has(provider), supportsCustomModeIcons: () => options.supportsCustomModeIcons ?? false, supportsProviderSnapshotReferences: () => false, - wantsSnapshotChanges: () => true, + publishSnapshot: (project) => { + const message = project(); + if (message) emitted.push(message); + }, supportsCompactProviderSnapshots: () => options.supportsCompactProviderSnapshots ?? false, listProviderAvailability: async () => [], listDraftFeatures: async () => [], @@ -396,7 +399,10 @@ it("announces shared content without retransmitting models or hashing discovery isProviderVisibleToClient: () => true, supportsCustomModeIcons: () => true, supportsCompactProviderSnapshots: () => true, - wantsSnapshotChanges: () => true, + publishSnapshot: (project) => { + const message = project(); + if (message) emitted.push(message); + }, supportsProviderSnapshotReferences: () => references, listProviderAvailability: async () => [], listDraftFeatures: async () => [], diff --git a/packages/server/src/server/session/provider/provider-catalog-session.ts b/packages/server/src/server/session/provider/provider-catalog-session.ts index 7175cabbc9..dd87543c19 100644 --- a/packages/server/src/server/session/provider/provider-catalog-session.ts +++ b/packages/server/src/server/session/provider/provider-catalog-session.ts @@ -48,7 +48,7 @@ export interface ProviderCatalogSessionHost { supportsCustomModeIcons(): boolean; supportsCompactProviderSnapshots(): boolean; supportsProviderSnapshotReferences(): boolean; - wantsSnapshotChanges(): boolean; + publishSnapshot(project: () => SessionOutboundMessage | null): void; listProviderAvailability(): Promise; listDraftFeatures(config: AgentSessionConfig): Promise; } @@ -81,15 +81,18 @@ export class ProviderCatalogSession { this.logger = options.logger; } + get isObserving(): boolean { + return this.unsubscribeSnapshotEvents !== null; + } + start(): void { + if (this.unsubscribeSnapshotEvents) return; const handleProviderSnapshotChange = (transition: ProviderSnapshotTransition) => { - if (!this.host.wantsSnapshotChanges()) return; - const previous = this.visibleSnapshot(transition.previous); - const current = this.visibleSnapshot(transition.current); - if (sameSnapshotRecords(previous.records, current.records)) return; - this.host.emit({ - type: "providers_snapshot_update", - payload: this.snapshotPayload(current), + this.host.publishSnapshot(() => { + const previous = this.visibleSnapshot(transition.previous); + const current = this.visibleSnapshot(transition.current); + if (sameSnapshotRecords(previous.records, current.records)) return null; + return { type: "providers_snapshot_update", payload: this.snapshotPayload(current) }; }); }; this.providerSnapshotManager.on("change", handleProviderSnapshotChange); diff --git a/packages/server/src/server/session/voice/index.test.ts b/packages/server/src/server/session/voice/index.test.ts new file mode 100644 index 0000000000..043b759d1e --- /dev/null +++ b/packages/server/src/server/session/voice/index.test.ts @@ -0,0 +1,58 @@ +import { EventEmitter } from "node:events"; +import pino from "pino"; +import { expect, test } from "vitest"; +import { SessionDelivery } from "../owned-subscriptions/index.js"; +import { VoiceSessions } from "./index.js"; +import type { StreamingTranscriptionSession } from "../../speech/speech-provider.js"; + +test("a failed speech close reports the failure without retaining observation demand", async () => { + class BrokenCloseStream extends EventEmitter implements StreamingTranscriptionSession { + readonly requiredSampleRate = 16000; + async connect() {} + appendPcm16() {} + commit() {} + clear() {} + close() { + throw new Error("Speech worker close failed"); + } + } + const source = {}; + const delivery = new SessionDelivery(() => {}); + delivery.attach(source, true); + const demands: boolean[] = []; + const voice = new VoiceSessions( + { + sessionId: "failed-speech-close", + logger: pino({ level: "silent" }), + host: { + emit() {}, + async loadAgent() { + throw new Error("Unexpected agent load"); + }, + async reloadAgentSession() { + throw new Error("Unexpected agent reload"); + }, + async sendSpokenInput() { + throw new Error("Unexpected spoken input"); + }, + async interruptAgentIfRunning() {}, + hasActiveAgentRun: () => false, + }, + stt: null, + tts: null, + dictation: { stt: { id: "local", createSession: () => new BrokenCloseStream() } }, + }, + delivery, + () => demands.push(voice.hasDemand), + ); + const request = { + type: "dictation_stream_start" as const, + dictationId: "recording", + format: "audio/pcm;rate=16000;bits=16", + }; + await delivery.request(source, request, () => voice.handleMessage(request)); + expect(voice.hasDemand).toBe(true); + await expect(delivery.detach(source)).rejects.toThrow(); + expect(voice.hasDemand).toBe(false); + expect(demands.at(-1)).toBe(false); +}); diff --git a/packages/server/src/server/session/voice/index.ts b/packages/server/src/server/session/voice/index.ts new file mode 100644 index 0000000000..1832da1c65 --- /dev/null +++ b/packages/server/src/server/session/voice/index.ts @@ -0,0 +1,146 @@ +import type { SessionInboundMessage, SessionOutboundMessage } from "../../messages.js"; +import type { OwnedOperation, SessionDelivery } from "../owned-subscriptions/index.js"; +import { VoiceSession, type VoiceSessionOptions } from "./voice-session.js"; + +type VoiceMessage = Extract< + SessionInboundMessage, + { + type: + | "voice_audio_chunk" + | "abort_request" + | "audio_played" + | "set_voice_mode" + | "dictation_stream_start" + | "dictation_stream_chunk" + | "dictation_stream_finish" + | "dictation_stream_cancel"; + } +>; + +interface ActiveVoiceSource { + voice: VoiceSession; + owner: OwnedOperation; + pending: Set>; + closing: boolean; +} + +/** Audio conversations belong to their requesting socket, including bootstrap and finalization. */ +export class VoiceSessions { + private readonly sources = new Map(); + + constructor( + private readonly options: VoiceSessionOptions, + private readonly delivery: SessionDelivery, + private readonly demandChanged: () => void, + ) {} + + get hasDemand(): boolean { + return [...this.sources.values()].some((source) => !source.closing); + } + + isActiveForAgent(agentId: string): boolean { + return [...this.sources.values()].some( + (source) => !source.closing && source.voice.isActiveForAgent(agentId), + ); + } + + handleMessage(message: VoiceMessage): Promise { + const source = this.delivery.currentSource; + if (!source) throw new Error("Voice request has no source"); + const existing = this.sources.get(source); + if (existing?.closing) return existing.owner.release().then(() => this.handleMessage(message)); + const active = existing ?? this.startSource(source); + const pending = Promise.resolve().then(() => { + return active.closing ? undefined : this.dispatch(active.voice, message); + }); + active.pending.add(pending); + const finished = () => { + active.pending.delete(pending); + this.releaseIdle(active); + return undefined; + }; + void pending.then(finished, finished); + return pending; + } + + private startSource(source: object): ActiveVoiceSource { + const owner = this.delivery.operation(isVoiceOutput, async () => { + active.closing = true; + this.demandChanged(); + // Cancel input immediately; restore agent configuration after bootstrap has settled. + try { + active.voice.cancel(); + } finally { + await Promise.allSettled(active.pending); + await active.voice.cleanup(); + } + if (this.sources.get(source) === active) this.sources.delete(source); + }); + const active: ActiveVoiceSource = { + owner, + pending: new Set(), + closing: false, + voice: new VoiceSession({ + ...this.options, + host: { ...this.options.host, emit: (message) => owner.emit(message) }, + onIdle: () => this.releaseIdle(active), + }), + }; + this.sources.set(source, active); + this.demandChanged(); + return active; + } + + private releaseIdle(source: ActiveVoiceSource): void { + if (source.closing || source.pending.size > 0 || source.voice.hasDemand) return; + void source.owner + .release() + .catch((error) => + this.options.logger.error({ err: error }, "Failed to release voice source"), + ); + } + + private async dispatch(voice: VoiceSession, message: VoiceMessage): Promise { + switch (message.type) { + case "voice_audio_chunk": + return voice.handleAudioChunk(message); + case "abort_request": + return voice.handleAbort(); + case "audio_played": + return voice.handleAudioPlayed(message.id); + case "set_voice_mode": + return voice.handleSetVoiceMode(message.enabled, message.agentId, message.requestId); + case "dictation_stream_start": + return voice.handleDictationStreamStart(message); + case "dictation_stream_chunk": + return voice.handleDictationChunk({ + dictationId: message.dictationId, + seq: message.seq, + audioBase64: message.audio, + format: message.format, + }); + case "dictation_stream_finish": + return voice.handleDictationFinish(message.dictationId, message.finalSeq); + case "dictation_stream_cancel": + return voice.handleDictationCancel(message.dictationId); + } + } +} + +function isVoiceOutput(message: SessionOutboundMessage): boolean { + switch (message.type) { + case "audio_output": + case "transcription_result": + case "voice_input_state": + case "set_voice_mode_response": + case "dictation_stream_ack": + case "dictation_stream_partial": + case "dictation_stream_final": + case "dictation_stream_error": + case "dictation_stream_finish_accepted": + case "activity_log": + return true; + default: + return false; + } +} diff --git a/packages/server/src/server/session/voice/voice-session.ts b/packages/server/src/server/session/voice/voice-session.ts index f6331dba3f..b374e759ae 100644 --- a/packages/server/src/server/session/voice/voice-session.ts +++ b/packages/server/src/server/session/voice/voice-session.ts @@ -135,6 +135,7 @@ export interface VoiceSessionOptions { host: VoiceSessionHost; logger: pino.Logger; sessionId: string; + onIdle?: () => void; sttLanguage?: string; tts: Resolvable; stt: Resolvable; @@ -169,6 +170,8 @@ export class VoiceSession { private readonly sttLanguage: string; private abortController: AbortController; + private closed = false; + private readonly onIdle: (() => void) | undefined; private processingPhase: ProcessingPhase = "idle"; private isVoiceMode = false; @@ -211,6 +214,7 @@ export class VoiceSession { const { host, logger, sessionId, sttLanguage, tts, stt, voice, voiceBridge, dictation } = options; this.host = host; + this.onIdle = options.onIdle; this.sessionLogger = logger; this.sessionId = sessionId; this.sttLanguage = sttLanguage ?? "en"; @@ -234,9 +238,20 @@ export class VoiceSession { stt: dictation?.stt ?? null, language: dictation?.sttLanguage, finalTimeoutMs: dictation?.finalTimeoutMs, + onIdle: this.onIdle, }); } + get hasDemand(): boolean { + return ( + this.isVoiceMode || + this.dictationStreamManager.hasDemand || + this.processingPhase !== "idle" || + this.audioBuffer !== null || + this.pendingAudioSegments.length > 0 + ); + } + isActiveForAgent(agentId: string): boolean { return this.isVoiceMode && this.voiceModeAgentId === agentId; } @@ -374,6 +389,10 @@ export class VoiceSession { ); const refreshedAgentId = await this.enableVoiceModeForAgent(normalizedAgentId); this.voiceModeAgentId = refreshedAgentId; + if (this.closed) { + await this.disableVoiceModeForActiveAgent(true); + return; + } this.sessionLogger.info( { agentId: refreshedAgentId, elapsedMs: Date.now() - startedAt }, "set_voice_mode agent enable complete", @@ -389,7 +408,7 @@ export class VoiceSession { { agentId: this.voiceModeAgentId, elapsedMs: Date.now() - startedAt }, "set_voice_mode voice turn controller started", ); - this.isVoiceMode = true; + this.isVoiceMode = !this.closed; this.sessionLogger.info( { agentId: this.voiceModeAgentId, @@ -473,6 +492,7 @@ export class VoiceSession { const startedAt = Date.now(); this.sessionLogger.info({ agentId }, "enableVoiceModeForAgent.ensureAgentLoaded.start"); const existing = await this.host.loadAgent(agentId); + if (this.closed) throw new Error("Voice source is closed"); this.sessionLogger.info( { agentId, elapsedMs: Date.now() - startedAt }, "enableVoiceModeForAgent.ensureAgentLoaded.done", @@ -516,9 +536,6 @@ export class VoiceSession { return; } - this.unregisterVoiceSpeakHandler?.(agentId); - this.unregisterVoiceCallerContext?.(agentId); - if (restoreAgentConfig && this.voiceModeBaseConfig) { const baseConfig = this.voiceModeBaseConfig; try { @@ -533,6 +550,8 @@ export class VoiceSession { } } + this.unregisterVoiceSpeakHandler?.(agentId); + this.unregisterVoiceCallerContext?.(agentId); this.voiceModeBaseConfig = null; this.voiceModeAgentId = null; } @@ -636,8 +655,9 @@ export class VoiceSession { }); this.sessionLogger.info("startVoiceTurnController connecting controller"); - await controller.start(); this.voiceTurnController = controller; + await controller.start(); + if (this.closed) await controller.stop(); this.sessionLogger.info("startVoiceTurnController connected"); } @@ -1191,6 +1211,7 @@ export class VoiceSession { */ private setPhase(phase: ProcessingPhase): void { this.processingPhase = phase; + if (phase === "idle") this.onIdle?.(); this.sessionLogger.debug({ phase }, `Phase: ${phase}`); } @@ -1238,6 +1259,7 @@ export class VoiceSession { * debug persistence before forwarding to the session emitter. */ private emit(msg: SessionOutboundMessage): void { + if (this.closed) return; if ( msg.type === "audio_output" && (process.env.TTS_DEBUG_AUDIO_DIR || isPaseoDictationDebugEnabled()) && @@ -1289,21 +1311,40 @@ export class VoiceSession { this.host.emit(msg); } - /** - * Tear down all voice resources. - */ - async cleanup(): Promise { + /** Stop input synchronously, including a source disconnected during bootstrap. */ + cancel(): void { + if (this.closed) return; + this.closed = true; this.abortController.abort(); this.clearBufferTimeout(); this.pendingAudioSegments = []; this.audioBuffer = null; - await this.stopVoiceTurnController(); + const failures: unknown[] = []; + for (const cleanup of [ + () => this.ttsManager.cleanup(), + () => this.sttManager.cleanup(), + () => this.dictationStreamManager.cleanupAll(), + ]) { + try { + cleanup(); + } catch (error) { + failures.push(error); + } + } + if (failures.length) throw new AggregateError(failures, "Voice input cleanup failed"); + } - this.ttsManager.cleanup(); - this.sttManager.cleanup(); - this.dictationStreamManager.cleanupAll(); - - await this.disableVoiceModeForActiveAgent(true); - this.isVoiceMode = false; + /** Restore the agent only after the caller has drained its in-flight requests. */ + async cleanup(): Promise { + try { + this.cancel(); + } finally { + try { + await this.stopVoiceTurnController(); + } finally { + await this.disableVoiceModeForActiveAgent(true); + this.isVoiceMode = false; + } + } } } diff --git a/packages/server/src/server/session/workspace-git-observer/workspace-git-observer-service.test.ts b/packages/server/src/server/session/workspace-git-observer/workspace-git-observer-service.test.ts index 1eb08a7022..04bcb75996 100644 --- a/packages/server/src/server/session/workspace-git-observer/workspace-git-observer-service.test.ts +++ b/packages/server/src/server/session/workspace-git-observer/workspace-git-observer-service.test.ts @@ -7,7 +7,6 @@ import type { WorkspaceGitRuntimeSnapshot, WorkspaceGitService, } from "../../workspace-git-service.js"; -import type { PersistedWorkspaceRecord } from "../../workspace-registry.js"; import { createWorkspaceGitObserverService } from "./workspace-git-observer-service.js"; // Watch targets are keyed by resolve(cwd), which is platform-dependent (POSIX vs Windows @@ -46,10 +45,6 @@ function makeSnapshot(cwd: string, currentBranch: string | null): WorkspaceGitRu return { cwd, git: { currentBranch } } as unknown as WorkspaceGitRuntimeSnapshot; } -function makeRecord(workspaceId: string): PersistedWorkspaceRecord { - return { workspaceId } as unknown as PersistedWorkspaceRecord; -} - function flushMicrotasks(): Promise { return new Promise((done) => setImmediate(done)); } @@ -59,12 +54,9 @@ function buildHarness(opts: { emitCwdRejects?: boolean } = {}) { const registerCalls: string[] = []; const unsubscribeCalls: string[] = []; const emitCwdCalls: string[] = []; - const emitWorkspaceIdCalls: string[] = []; const statusCalls: Array<{ cwd: string; branch: string | null }> = []; const branchChanges: Array<[string, string | null, string | null]> = []; const warnCalls: unknown[][] = []; - const describeCalls: PersistedWorkspaceRecord[] = []; - let describeResult: WorkspaceDescriptorPayload | null = null; const workspaceGitService: Pick = { registerWorkspace({ cwd }, listener) { @@ -81,22 +73,12 @@ function buildHarness(opts: { emitCwdRejects?: boolean } = {}) { const service = createWorkspaceGitObserverService({ workspaceGitService, - describeWorkspaceRecordWithGitData: async (workspace) => { - describeCalls.push(workspace); - if (!describeResult) { - throw new Error("describeResult not set"); - } - return describeResult; - }, emitWorkspaceUpdateForCwd: async (cwd) => { emitCwdCalls.push(cwd); if (opts.emitCwdRejects) { throw new Error("emit boom"); } }, - emitWorkspaceUpdateForWorkspaceId: async (workspaceId) => { - emitWorkspaceIdCalls.push(workspaceId); - }, emitStatusUpdate: (cwd, snapshot) => { statusCalls.push({ cwd, branch: snapshot.git.currentBranch ?? null }); }, @@ -120,14 +102,9 @@ function buildHarness(opts: { emitCwdRejects?: boolean } = {}) { registerCalls, unsubscribeCalls, emitCwdCalls, - emitWorkspaceIdCalls, statusCalls, branchChanges, warnCalls, - describeCalls, - setDescribeResult: (descriptor: WorkspaceDescriptorPayload) => { - describeResult = descriptor; - }, }; } @@ -267,30 +244,6 @@ describe("git snapshot listener", () => { }); }); -describe("shouldSkipUpdate", () => { - test("returns false when no observer exists for the workspace", () => { - const h = buildHarness(); - expect(h.service.shouldSkipUpdate("unknown", null)).toBe(false); - }); - - test("skips a repeat descriptor state and re-emits when it changes", () => { - const h = buildHarness(); - h.service.syncObservers([makeDescriptor({ id: "ws1", workspaceDirectory: WS1 })]); - const a = makeDescriptor({ id: "ws1", workspaceDirectory: WS1, name: "main" }); - const b = makeDescriptor({ id: "ws1", workspaceDirectory: WS1, name: "feature" }); - expect(h.service.shouldSkipUpdate("ws1", a)).toBe(false); - expect(h.service.shouldSkipUpdate("ws1", a)).toBe(true); - expect(h.service.shouldSkipUpdate("ws1", b)).toBe(false); - }); - - test("starts from the descriptor state recorded during observer sync", () => { - const h = buildHarness(); - const descriptor = makeDescriptor({ id: "ws1", workspaceDirectory: WS1, name: "main" }); - h.service.syncObservers([descriptor]); - expect(h.service.shouldSkipUpdate("ws1", descriptor)).toBe(true); - }); -}); - describe("recordDescriptorState", () => { test("does not treat a workspace title as a branch change", () => { const h = buildHarness(); @@ -385,26 +338,7 @@ describe("teardown", () => { h.service.syncObservers([makeDescriptor({ id: "ws1", workspaceDirectory: WS1 })]); h.service.dispose(); const descriptor = makeDescriptor({ id: "ws1", workspaceDirectory: WS1, name: "main" }); - expect(h.service.shouldSkipUpdate("ws1", descriptor)).toBe(false); h.service.recordDescriptorState("ws1", descriptor); expect(h.branchChanges).toEqual([]); }); }); - -describe("syncObserverForWorkspace / warmGitData", () => { - test("describes the record then registers the observer", async () => { - const h = buildHarness(); - h.setDescribeResult(makeDescriptor({ id: "ws1", workspaceDirectory: WS1 })); - await h.service.syncObserverForWorkspace(makeRecord("ws1")); - expect(h.describeCalls).toEqual([makeRecord("ws1")]); - expect(h.registerCalls).toEqual([WS1]); - }); - - test("warmGitData registers the observer and emits a workspace update", async () => { - const h = buildHarness(); - h.setDescribeResult(makeDescriptor({ id: "ws1", workspaceDirectory: WS1 })); - await h.service.warmGitData(makeRecord("ws1")); - expect(h.registerCalls).toEqual([WS1]); - expect(h.emitWorkspaceIdCalls).toEqual(["ws1"]); - }); -}); diff --git a/packages/server/src/server/session/workspace-git-observer/workspace-git-observer-service.ts b/packages/server/src/server/session/workspace-git-observer/workspace-git-observer-service.ts index dfc87b312b..d657c63957 100644 --- a/packages/server/src/server/session/workspace-git-observer/workspace-git-observer-service.ts +++ b/packages/server/src/server/session/workspace-git-observer/workspace-git-observer-service.ts @@ -5,9 +5,6 @@ import type { WorkspaceGitRuntimeSnapshot, WorkspaceGitService, } from "../../workspace-git-service.js"; -import type { PersistedWorkspaceRecord } from "../../workspace-registry.js"; - -const WORKSPACE_GIT_WATCH_REMOVED_STATE_KEY = "__removed__"; interface WorkspaceGitWatchTarget { workspaceIds: Set; @@ -15,7 +12,6 @@ interface WorkspaceGitWatchTarget { interface WorkspaceGitWatchState { cwd: string; - latestDescriptorStateKey: string | null; lastBranchName: string | null; } @@ -39,11 +35,11 @@ export interface WorkspaceGitObserverMetrics { */ export interface WorkspaceGitObserverService { syncObservers(workspaces: Iterable): void; - syncObserverForWorkspace(workspace: PersistedWorkspaceRecord): Promise; - warmGitData(workspace: PersistedWorkspaceRecord): Promise; - // Check-and-record dedupe gate: returns true when the descriptor state is unchanged - // for this workspace, and otherwise advances the recorded state key as a side effect. - shouldSkipUpdate(workspaceId: string, workspace: WorkspaceDescriptorPayload | null): boolean; + reconcileObservers( + workspaces: Iterable< + Pick + >, + ): void; recordDescriptorState(workspaceId: string, workspace: WorkspaceDescriptorPayload | null): void; handleBranchSnapshot(cwd: string, branchName: string | null): void; getMetrics(): WorkspaceGitObserverMetrics; @@ -53,11 +49,7 @@ export interface WorkspaceGitObserverService { export function createWorkspaceGitObserverService(deps: { workspaceGitService: Pick; - describeWorkspaceRecordWithGitData: ( - workspace: PersistedWorkspaceRecord, - ) => Promise; emitWorkspaceUpdateForCwd: (cwd: string) => Promise; - emitWorkspaceUpdateForWorkspaceId: (workspaceId: string) => Promise; emitStatusUpdate: (cwd: string, snapshot: WorkspaceGitRuntimeSnapshot) => void; onBranchChanged?: ( workspaceId: string, @@ -68,9 +60,7 @@ export function createWorkspaceGitObserverService(deps: { }): WorkspaceGitObserverService { const { workspaceGitService, - describeWorkspaceRecordWithGitData, emitWorkspaceUpdateForCwd, - emitWorkspaceUpdateForWorkspaceId, emitStatusUpdate, onBranchChanged, logger, @@ -80,16 +70,6 @@ export function createWorkspaceGitObserverService(deps: { const workspaceStates = new Map(); const subscriptions = new Map void>(); - function descriptorStateKey(workspace: WorkspaceDescriptorPayload | null): string { - if (!workspace) { - return WORKSPACE_GIT_WATCH_REMOVED_STATE_KEY; - } - return JSON.stringify([ - workspace.name, - workspace.diffStat ? [workspace.diffStat.additions, workspace.diffStat.deletions] : null, - ]); - } - function rememberDescriptorState( workspaceId: string, workspace: WorkspaceDescriptorPayload | null, @@ -98,7 +78,6 @@ export function createWorkspaceGitObserverService(deps: { if (!state) { return; } - state.latestDescriptorStateKey = descriptorStateKey(workspace); const currentBranch = workspace?.gitRuntime?.currentBranch; if (currentBranch !== undefined) { state.lastBranchName = currentBranch; @@ -168,7 +147,6 @@ export function createWorkspaceGitObserverService(deps: { if (!workspaceStates.has(options.workspaceId)) { workspaceStates.set(options.workspaceId, { cwd: normalizedCwd, - latestDescriptorStateKey: null, lastBranchName: null, }); } @@ -206,33 +184,20 @@ export function createWorkspaceGitObserverService(deps: { } } - async function syncObserverForWorkspace(workspace: PersistedWorkspaceRecord): Promise { - const descriptor = await describeWorkspaceRecordWithGitData(workspace); - syncObservers([descriptor]); - } - return { + reconcileObservers(workspaces) { + const retained = new Map([...workspaces].map((workspace) => [workspace.id, workspace])); + for (const workspaceId of workspaceStates.keys()) { + if (!retained.has(workspaceId)) removeForWorkspaceId(workspaceId); + } + for (const workspace of retained.values()) { + syncObserver(workspace.workspaceDirectory, { + isGit: workspace.workspaceKind !== "directory", + workspaceId: workspace.id, + }); + } + }, syncObservers, - syncObserverForWorkspace, - - async warmGitData(workspace) { - await syncObserverForWorkspace(workspace); - await emitWorkspaceUpdateForWorkspaceId(workspace.workspaceId); - }, - - shouldSkipUpdate(workspaceId, workspace) { - const state = workspaceStates.get(workspaceId); - if (!state) { - return false; - } - const nextStateKey = descriptorStateKey(workspace); - if (state.latestDescriptorStateKey === nextStateKey) { - return true; - } - state.latestDescriptorStateKey = nextStateKey; - return false; - }, - recordDescriptorState(workspaceId, nextWorkspace) { const state = workspaceStates.get(workspaceId); const newBranchName = nextWorkspace?.gitRuntime?.currentBranch; diff --git a/packages/server/src/server/session/workspace-scripts/workspace-scripts-service.ts b/packages/server/src/server/session/workspace-scripts/workspace-scripts-service.ts index 7dee9364f9..2116e82b11 100644 --- a/packages/server/src/server/session/workspace-scripts/workspace-scripts-service.ts +++ b/packages/server/src/server/session/workspace-scripts/workspace-scripts-service.ts @@ -66,6 +66,7 @@ export function createWorkspaceScriptsService(deps: { globalServicePorts?: PaseoServicePortAllocation; logger: pino.Logger; emit: (message: SessionOutboundMessage) => void; + wantsStatusUpdates?: () => boolean; spawnWorkspaceScript: (options: SpawnWorkspaceScriptOptions) => Promise; assertAutomationAllowed: (workspaceId: string) => Promise; }): WorkspaceScriptsService { @@ -129,6 +130,7 @@ export function createWorkspaceScriptsService(deps: { } async function emitStatusUpdate(workspaceId: string, _workspaceDirectory: string): Promise { + if (deps.wantsStatusUpdates && !deps.wantsStatusUpdates()) return; try { const workspace = await workspaceRegistry.get(workspaceId); if (!workspace) return; diff --git a/packages/server/src/server/test-utils/daemon-test-context.ts b/packages/server/src/server/test-utils/daemon-test-context.ts index 44c6f28457..c872a686dd 100644 --- a/packages/server/src/server/test-utils/daemon-test-context.ts +++ b/packages/server/src/server/test-utils/daemon-test-context.ts @@ -43,7 +43,7 @@ export async function createDaemonTestContext( url: `ws://127.0.0.1:${daemon.port}/ws`, }); await client.connect(); - await client.fetchAgents({ subscribe: { subscriptionId: "test" } }); + await client.fetchAgents({ subscribe: {} }); return { daemon, diff --git a/packages/server/src/server/test-utils/fake-agent-client.ts b/packages/server/src/server/test-utils/fake-agent-client.ts index 4d16f0302e..323e9944c7 100644 --- a/packages/server/src/server/test-utils/fake-agent-client.ts +++ b/packages/server/src/server/test-utils/fake-agent-client.ts @@ -737,6 +737,16 @@ class FakeAgentSession implements AgentSession { await this.appendHistoryEvent(turnStarted); this.notifySubscribers(turnStarted); + if (textPrompt === "Emit a provider child") { + const child: AgentStreamEvent = { + type: "provider_subagent", + provider: this.providerName, + event: { type: "upsert", id: "fixture-child", title: "Fixture child", status: "running" }, + }; + await this.appendHistoryEvent(child); + this.notifySubscribers(child); + } + if (textPrompt.toLowerCase().includes("emit a turn failure")) { const failed: AgentStreamEvent = { type: "turn_failed", diff --git a/packages/server/src/server/test-utils/session-stubs.ts b/packages/server/src/server/test-utils/session-stubs.ts index 15ed7ecc09..a6f404e62a 100644 --- a/packages/server/src/server/test-utils/session-stubs.ts +++ b/packages/server/src/server/test-utils/session-stubs.ts @@ -28,7 +28,10 @@ export function asSessionLogger(stub: { export function asAgentManager(stub: { [K in keyof SessionOptions["agentManager"]]?: unknown; }): SessionOptions["agentManager"] { - return createStub(stub); + return createStub({ + listProviderSubagentActivity: () => [], + ...stub, + }); } export function asAgentStorage(stub: { diff --git a/packages/server/src/server/voice-local-agent.e2e.test.ts b/packages/server/src/server/voice-local-agent.e2e.test.ts index 29841b7938..c78759459a 100644 --- a/packages/server/src/server/voice-local-agent.e2e.test.ts +++ b/packages/server/src/server/voice-local-agent.e2e.test.ts @@ -31,8 +31,10 @@ function makeErrorHandler(reject: (error: Error) => void) { }; } -function makeSpeakToolHandler(resolve: (value: string) => void) { - return (msg: SessionMessage<"agent_stream" | "agent.timeline.replacement">) => { +function makeSpeakToolHandler( + resolve: (value: string) => void, +): Parameters[1] { + return (msg) => { if (msg.type !== "agent_stream") return; if (msg.payload.event.type !== "timeline") return; const item = msg.payload.event.item; diff --git a/packages/server/src/server/websocket-server.browser-tools.test.ts b/packages/server/src/server/websocket-server.browser-tools.test.ts index ba83e979d1..df0b02ad43 100644 --- a/packages/server/src/server/websocket-server.browser-tools.test.ts +++ b/packages/server/src/server/websocket-server.browser-tools.test.ts @@ -139,10 +139,11 @@ describe("WebSocketServer browser tools wiring", () => { it("keeps browser automation registered when a browser host client resumes", async () => { const harness = await startBrowserToolsDaemonHarness(); const clientId = "browser-host-client-1"; - await harness.connectBrowserHostClient({ + const originalBrowserHost = await harness.connectBrowserHostClient({ clientId, capabilities: browserHostCapabilities(), }); + await originalBrowserHost.disconnect(); const resumedBrowserHost = await harness.connectBrowserHostClient({ clientId, @@ -182,6 +183,7 @@ describe("WebSocketServer browser tools wiring", () => { await browserHost.nextBrowserRequest(); expect(harness.broker.getPendingRequestCount()).toBe(1); + await browserHost.disconnect(); await harness.connectBrowserHostClient({ clientId, capabilities: browserHostCapabilities(["list_tabs"]), @@ -225,6 +227,11 @@ async function startBrowserToolsDaemonHarness(): Promise client.sendBrowserAutomationExecuteResponse(response), async disconnect() { + await observation.release(); requests.close(); clients.delete(client); await client.close(); diff --git a/packages/server/src/server/websocket-server.notifications.test.ts b/packages/server/src/server/websocket-server.notifications.test.ts index 15f4aa38d2..8ff692d580 100644 --- a/packages/server/src/server/websocket-server.notifications.test.ts +++ b/packages/server/src/server/websocket-server.notifications.test.ts @@ -1,3 +1,4 @@ +import { SessionDelivery } from "./session/owned-subscriptions/index.js"; import { afterEach, describe, expect, it, vi } from "vitest"; import type { Server as HTTPServer } from "http"; import type pino from "pino"; @@ -191,9 +192,15 @@ function connectClient( options: { subscribed?: boolean } = {}, ) { const ws = createOpenSocket(); + const delivery = new SessionDelivery(() => {}); + delivery.attach(ws, false); asInternals(server).sessions.set(ws, { kind: "trusted", - session: createSessionWithActivity(activity, options.subscribed ?? true), + session: { + ...createSessionWithActivity(activity, options.subscribed ?? true), + delivery, + wantsSourceNotification: () => true, + }, clientId: "client-test", appVersion: null, connectionLogger: createLogger(), diff --git a/packages/server/src/server/websocket-server.relay-reconnect.test.ts b/packages/server/src/server/websocket-server.relay-reconnect.test.ts index c470476913..79577d6889 100644 --- a/packages/server/src/server/websocket-server.relay-reconnect.test.ts +++ b/packages/server/src/server/websocket-server.relay-reconnect.test.ts @@ -1,3 +1,4 @@ +import { SessionDelivery } from "./session/owned-subscriptions/index.js"; import { afterEach, beforeEach, describe, expect, test, vi } from "vitest"; import type { Server as HTTPServer } from "http"; import type pino from "pino"; @@ -46,15 +47,27 @@ const sessionMock = vi.hoisted(() => { const instances: MockSession[] = []; class MockSession { - cleanup = vi.fn(async () => {}); + readonly delivery = new SessionDelivery((source, message) => { + const send = this.args.onMessageToSource as (source: object, message: unknown) => void; + send(source, message); + }); + cleanup = vi.fn(async () => { + await this.delivery.close(); + }); handleMessage = vi.fn(async () => {}); handleBinaryFrame = vi.fn((_frame: unknown) => {}); supports = vi.fn((capability: string) => this.args.clientCapabilities?.[capability] === true); - updateClientCapabilities = vi.fn((capabilities: Record | null) => { - this.args.clientCapabilities = capabilities; + updateClientCapabilities = vi.fn( + (capabilities: Record | null, source: object) => { + this.args.clientCapabilities = capabilities; + this.delivery.attach(source, capabilities?.owned_subscriptions === true); + }, + ); + clearAgentTimelineSubscription = vi.fn((source: object) => { + void this.delivery.detach(source); }); - clearAgentTimelineSubscription = vi.fn(); getClientActivity = vi.fn(() => null); + wantsSourceEvent = (source: object) => !this.delivery.isModern(source); getSessionId = vi.fn(() => "mock-session-id"); getPermissions = vi.fn(() => this.args.permissions as string[]); allowsInbound = vi.fn(() => true); @@ -889,7 +902,7 @@ describe("relay external socket reconnect behavior", () => { payload: { requestId: "failing-provider-diagnostic", requestType: "provider_diagnostic_request", - error: "Invalid message", + error: "Invalid message: handler exploded", code: "invalid_message", }, }, diff --git a/packages/server/src/server/websocket-server.terminal-notifications.test.ts b/packages/server/src/server/websocket-server.terminal-notifications.test.ts index 0f4e7f25ff..3f77a0482e 100644 --- a/packages/server/src/server/websocket-server.terminal-notifications.test.ts +++ b/packages/server/src/server/websocket-server.terminal-notifications.test.ts @@ -1,3 +1,4 @@ +import { SessionDelivery } from "./session/owned-subscriptions/index.js"; import { afterEach, describe, expect, it, vi } from "vitest"; import type { Server as HTTPServer } from "http"; import type pino from "pino"; @@ -194,9 +195,13 @@ function createOpenSocket() { function connectClient(server: VoiceAssistantWebSocketServer, subscribed = true) { const ws = createOpenSocket(); + const delivery = new SessionDelivery(() => {}); + delivery.attach(ws, false); asInternals<{ sessions: Map }>(server).sessions.set(ws, { kind: "trusted", session: { + delivery, + wantsSourceNotification: () => true, getClientActivity: vi.fn(() => null), subscribesToTerminalDirectory: vi.fn(async () => subscribed), }, diff --git a/packages/server/src/server/websocket-server.ts b/packages/server/src/server/websocket-server.ts index 30fce35b13..8b0ab3879f 100644 --- a/packages/server/src/server/websocket-server.ts +++ b/packages/server/src/server/websocket-server.ts @@ -92,11 +92,7 @@ import { normalizeClientRestartRpcReason, } from "./lifecycle-reasons.js"; import { CLIENT_CAPS } from "@getpaseo/protocol/client-capabilities"; -import type { BrowserAutomationExecuteResponse } from "@getpaseo/protocol/browser-automation/rpc-schemas"; -import { - BrowserAutomationHostCapabilitySchema, - type BrowserAutomationHostCapability, -} from "@getpaseo/protocol/browser-automation/capabilities"; + import type { BrowserToolsBroker } from "./browser-tools/broker.js"; import type { DaemonRuntimeConfig } from "./session/daemon/daemon-session.js"; import { DirectorySyncService } from "./directory-sync/index.js"; @@ -418,15 +414,6 @@ function bufferFromWsData(data: Buffer | ArrayBuffer | Buffer[] | string): Buffe return Buffer.from(data); } -function getBrowserHostCapability( - capabilities: Record | null, -): BrowserAutomationHostCapability | null { - const parsed = BrowserAutomationHostCapabilitySchema.safeParse( - capabilities?.[CLIENT_CAPS.browserHost], - ); - return parsed.success ? parsed.data : null; -} - export interface WebSocketLike { readyState: number; bufferedAmount?: number; @@ -463,11 +450,6 @@ interface PluginSessionConnection extends SessionConnectionBase { type SessionConnection = ReconnectableSessionConnection | PluginSessionConnection; -interface BrowserToolsRegistration { - capabilitySignature: string; - unregister: () => void; -} - interface SocketSessionOptions { clientId: string; appVersion: string | null; @@ -478,7 +460,7 @@ interface SocketSessionOptions { onMessageToSource?: (source: object, message: SessionOutboundMessage) => void; onBinaryMessage?: (frame: Uint8Array) => void; onBinaryMessageToSource?: (source: object, frame: Uint8Array) => Promise; - getTransportBufferedAmount?: () => number | null; + getTransportBufferedAmount?: (source?: object) => number | null; onLifecycleIntent?: (intent: SessionLifecycleIntent) => void; hubExecutionAgents?: HubExecutionAgents; hubRelationships?: HubRelationshipManagement; @@ -596,7 +578,6 @@ export class VoiceAssistantWebSocketServer { private unsubscribeTerminalActivity: (() => void) | null = null; private readonly browserToolsBroker: BrowserToolsBroker | null; private readonly hubRelationships: HubRelationshipManagement | null; - private readonly browserToolsRegistrations = new Map(); private connectionLifecycle: "starting" | "accepting" | "stopping" = "accepting"; private readonly advertiseDaemonStatusRpc: boolean; private readonly advertiseRelayConfig: boolean; @@ -1010,7 +991,6 @@ export class VoiceAssistantWebSocketServer { for (const connection of new Set(this.externalSessionsByKey.values())) { if (connection.principalId === principalId) { connection.session.setPermissions(permissions); - this.syncBrowserToolsClientRegistration(connection); } } } @@ -1103,18 +1083,38 @@ export class VoiceAssistantWebSocketServer { this.sessions.clear(); this.socketIdentities.clear(); this.externalSessionsByKey.clear(); - for (const clientId of this.browserToolsRegistrations.keys()) { - this.unregisterBrowserToolsClient(clientId); - } this.wss.close(); } private sendToClient(ws: WebSocketLike, message: WSOutboundMessage): void { - this.sendMessageToSockets([ws], message); + this.sendMessageToSockets([ws], message, true); } - private sendMessageToSockets(sockets: Iterable, message: WSOutboundMessage): void { - const writableSockets = [...sockets].filter((ws) => this.ensureOutboundCapacity(ws, 0)); + private sendMessageToSockets( + sockets: Iterable, + message: WSOutboundMessage, + reportRejection = false, + ): void { + const writableSockets = [...sockets].filter((ws) => { + if ( + message.type === "session" && + this.sessions.get(ws)?.session.delivery.permits(ws, message.message) !== true + ) { + this.incrementRuntimeCounter("applicationJsonRejected"); + // Legacy broadcasts intentionally exclude modern sockets. Targeted failures are bugs. + if (reportRejection) + this.logger.warn( + { + messageType: message.message.type, + status: + message.message.type === "status" ? message.message.payload.status : undefined, + }, + "ws_application_delivery_denied", + ); + return false; + } + return this.ensureOutboundCapacity(ws, 0); + }); if (writableSockets.length === 0) { return; } @@ -1135,13 +1135,24 @@ export class VoiceAssistantWebSocketServer { } } - private sendBinaryToClient(ws: WebSocketLike, frame: Uint8Array): void { + private sendBinaryToClient(ws: WebSocketLike, frame: Uint8Array, reportRejection = true): void { + if (!this.sessions.get(ws)?.session.delivery.permitsBinary(ws, frame)) { + this.incrementRuntimeCounter("applicationBinaryRejected"); + if (reportRejection) + this.logger.warn({ bytes: frame.byteLength }, "ws_application_binary_delivery_denied"); + return; + } this.sendFrameToClient(ws, frame, outboundFrameByteLength(frame), () => { this.runtimeMetrics.recordOutboundBinaryFrame(ws.bufferedAmount); }); } private async sendBinaryToClientAndWait(ws: WebSocketLike, frame: Uint8Array): Promise { + if (!this.sessions.get(ws)?.session.delivery.permitsBinary(ws, frame)) { + this.incrementRuntimeCounter("applicationBinaryRejected"); + this.logger.warn({ bytes: frame.byteLength }, "ws_application_binary_delivery_denied"); + return; + } try { const sent = await sendBoundedPhysicalFrameAndWait({ socket: ws, @@ -1232,7 +1243,7 @@ export class VoiceAssistantWebSocketServer { private sendBinaryToConnection(connection: SessionConnection, frame: Uint8Array): void { for (const ws of connection.sockets) { - this.sendBinaryToClient(ws, frame); + this.sendBinaryToClient(ws, frame, false); } } @@ -1345,10 +1356,11 @@ export class VoiceAssistantWebSocketServer { } await this.sendBinaryToClientAndWait(source as WebSocketLike, frame); }, - getTransportBufferedAmount: () => { + getTransportBufferedAmount: (source) => { if (!connection) { return null; } + if (source) return (source as WebSocketLike).bufferedAmount ?? null; // Relay-attached sockets are a WebSocketLike that doesn't expose // bufferedAmount. Return null when no socket gives a signal so the // terminal fallback can't mistake "no signal" for "client keeping up"; @@ -1382,12 +1394,13 @@ export class VoiceAssistantWebSocketServer { lifecycle.kind === "ephemeral-plugin" ? { ...base, lifecycle: "ephemeral-plugin", pluginId: lifecycle.pluginId } : { ...base, lifecycle: "reconnectable", externalDisconnectCleanupTimeout: null }; - session.updateClientCapabilities(clientCapabilities, ws); + session.updateClientCapabilities(clientCapabilities, ws, appVersion); return connection; } private createSocketSession(options: SocketSessionOptions): Session { return new Session({ + browserToolsBroker: this.browserToolsBroker, clientId: options.clientId, appVersion: options.appVersion, clientCapabilities: options.clientCapabilities, @@ -1448,6 +1461,8 @@ export class VoiceAssistantWebSocketServer { }, voiceBridge: { registerVoiceSpeakHandler: (agentId, handler) => { + if (this.voiceSpeakHandlers.has(agentId)) + throw new Error("Voice mode is already active for this agent"); this.voiceSpeakHandlers.set(agentId, handler); }, unregisterVoiceSpeakHandler: (agentId) => { @@ -1565,7 +1580,6 @@ export class VoiceAssistantWebSocketServer { this.externalSessionsByKey.set(sessionKey, connection); } pending.identity.sessionId = connection.session.getSessionId(); - this.syncBrowserToolsClientRegistration(connection); this.sendToClient(ws, this.createServerInfoMessage(connection.session)); connection.connectionLogger.info( { @@ -1598,18 +1612,16 @@ export class VoiceAssistantWebSocketServer { // COMPAT(selectiveAgentTimeline): added in v0.1.106. Every capable resumed // hello resets membership before server_info so stale retained-session // state cannot leak. Remove after 2027-01-12. - existing.session.updateClientCapabilities(newClientCapabilities, ws); + existing.session.updateClientCapabilities(newClientCapabilities, ws, newAppVersion); if ( JSON.stringify(existing.clientCapabilities ?? null) !== JSON.stringify(newClientCapabilities ?? null) ) { existing.clientCapabilities = newClientCapabilities; - this.syncBrowserToolsClientRegistration(existing); } existing.sockets.add(ws); this.sessions.set(ws, existing); pending.identity.sessionId = existing.session.getSessionId(); - this.syncBrowserToolsClientRegistration(existing); this.sendToClient(ws, this.createServerInfoMessage(existing.session)); pending.connectionLogger.info( { @@ -1632,6 +1644,7 @@ export class VoiceAssistantWebSocketServer { desktopManaged: this.daemonRuntimeConfig?.desktopManaged === true, ...(this.serverCapabilities ? { capabilities: this.serverCapabilities } : {}), features: { + ownedSubscriptions: true, agentRequestReceipts: true, hubAgentRpc: true, // COMPAT(directorySync): added in v0.3.x, remove gate after 2027-02-12. @@ -1802,7 +1815,16 @@ export class VoiceAssistantWebSocketServer { private broadcastCapabilitiesUpdate(): void { for (const connection of new Set(this.sessions.values())) { - this.sendToConnection(connection, this.createServerInfoMessage(connection.session)); + if ( + ![...connection.sockets].some((socket) => + connection.session.wantsSourceEvent(socket, "status.server_info"), + ) + ) + continue; + connection.session.publish({ + type: "status", + payload: this.buildServerInfoStatusPayload(connection.session), + }); } } @@ -1894,7 +1916,6 @@ export class VoiceAssistantWebSocketServer { this.socketIdentities.delete(ws); if (connection.sockets.size === 0) { - this.unregisterBrowserToolsClient(connection); if (connection.lifecycle === "ephemeral-plugin") { this.pluginSocketIds.delete(ws); await this.cleanupConnection(connection, "Plugin session disconnected"); @@ -1972,7 +1993,6 @@ export class VoiceAssistantWebSocketServer { this.externalSessionsByKey.delete(connection.sessionKey); } } - this.unregisterBrowserToolsClient(connection); connection.connectionLogger.trace( { clientId: connection.clientId, totalSessions: this.sessions.size }, @@ -1981,54 +2001,6 @@ export class VoiceAssistantWebSocketServer { await connection.session.cleanup(); } - private syncBrowserToolsClientRegistration(connection: SessionConnection): void { - if (!this.browserToolsBroker) { - return; - } - const registrationKey = connection.sessionKey; - if (!connection.session.allowsPermission("workspace.write")) { - this.unregisterBrowserToolsClient(registrationKey); - return; - } - const browserHostCapability = getBrowserHostCapability(connection.clientCapabilities); - if (!browserHostCapability) { - this.unregisterBrowserToolsClient(registrationKey); - return; - } - const capabilitySignature = JSON.stringify(browserHostCapability); - const existing = this.browserToolsRegistrations.get(registrationKey); - if (existing?.capabilitySignature === capabilitySignature) { - return; - } - if (existing) { - this.browserToolsRegistrations.delete(registrationKey); - existing.unregister(); - } - - const unregister = this.browserToolsBroker.registerClient({ - id: connection.principalId === "owner" ? connection.clientId : registrationKey, - hostKind: browserHostCapability.hostKind, - supportedCommands: browserHostCapability.supportedCommands, - sendBrowserAutomationRequest: (request) => { - this.sendToConnection(connection, wrapSessionMessage(request)); - }, - }); - this.browserToolsRegistrations.set(registrationKey, { - capabilitySignature, - unregister, - }); - } - - private unregisterBrowserToolsClient(connection: SessionConnection | string): void { - const registrationKey = typeof connection === "string" ? connection : connection.sessionKey; - const registration = this.browserToolsRegistrations.get(registrationKey); - if (!registration) { - return; - } - this.browserToolsRegistrations.delete(registrationKey); - registration.unregister(); - } - private handleInvalidInboundMessage(args: { ws: WebSocketLike; parsed: unknown; @@ -2071,35 +2043,13 @@ export class VoiceAssistantWebSocketServer { "WS inbound message validation failed", ); - if (requestInfo) { - this.sendToClient( - ws, - wrapSessionMessage({ - type: "rpc_error", - payload: { - requestId: requestInfo.requestId, - requestType: requestInfo.requestType, - error: isUnknownSchema - ? `Unknown request, try upgrading the daemon (currently v${this.daemonVersion})` - : "Invalid message", - code: isUnknownSchema ? "unknown_schema" : "invalid_message", - }, - }), - ); - return; - } - - const errorMessage = `Invalid message: ${parsedMessage.error.message}`; - this.sendToClient( - ws, - wrapSessionMessage({ - type: "status", - payload: { - status: "error", - message: errorMessage, - }, - }), - ); + activeConnection?.session.delivery.protocolFailure(ws, { + ...requestInfo, + error: isUnknownSchema + ? `Unknown request, try upgrading the daemon (currently v${this.daemonVersion})` + : `Invalid message: ${parsedMessage.error.message}`, + code: isUnknownSchema ? "unknown_schema" : "invalid_message", + }); } private maybeHandleBinaryFrame(params: { @@ -2128,7 +2078,7 @@ export class VoiceAssistantWebSocketServer { } return true; } - void Promise.resolve(activeConnection.session.handleBinaryFrame(decodedFrame)).catch( + void Promise.resolve(activeConnection.session.handleBinaryFrame(decodedFrame, ws)).catch( (error: unknown) => { this.handleRawMessageError({ ws, @@ -2287,15 +2237,6 @@ export class VoiceAssistantWebSocketServer { "ws_control_rpc_received", ); } - if (message.message.type === "browser.automation.execute.response") { - if (!activeConnection.session.allowsInbound(message.message)) { - await activeConnection.session.handleMessage(message.message, ws); - return; - } - this.browserToolsBroker?.receiveResponse(message.message as BrowserAutomationExecuteResponse); - return; - } - const startMs = performance.now(); await activeConnection.session.handleMessage(message.message, ws); const durationMs = performance.now() - startMs; @@ -2348,32 +2289,11 @@ export class VoiceAssistantWebSocketServer { } const requestInfo = extractRequestInfoFromUnknownWsInbound(parsedPayload); - if (requestInfo) { - this.sendToClient( - ws, - wrapSessionMessage({ - type: "rpc_error", - payload: { - requestId: requestInfo.requestId, - requestType: requestInfo.requestType, - error: "Invalid message", - code: "invalid_message", - }, - }), - ); - return; - } - - this.sendToClient( - ws, - wrapSessionMessage({ - type: "status", - payload: { - status: "error", - message: `Invalid message: ${err.message}`, - }, - }), - ); + this.sessions.get(ws)?.session.delivery.protocolFailure(ws, { + ...requestInfo, + error: `Invalid message: ${err.message}`, + code: "invalid_message", + }); } private decodeRawMessagePayloadForError(data: Buffer | ArrayBuffer | Buffer[] | string): { @@ -2498,8 +2418,8 @@ export class VoiceAssistantWebSocketServer { this.logger.info(loggedMetrics, "ws_runtime_metrics"); } - private getClientActivityState(session: Session): ClientPresenceState { - const activity = session.getClientActivity(); + private getClientActivityState(session: Session, source: object): ClientPresenceState { + const activity = session.getClientActivity(source); if (!activity) { return { appVisible: false, @@ -2532,14 +2452,22 @@ export class VoiceAssistantWebSocketServer { }> = []; for (const [ws, connection] of this.sessions) { - if (!(await connection.session.subscribesToAgent(agent))) continue; + if ( + connection.session.delivery.isModern(ws) + ? !connection.session.wantsSourceEvent(ws, "agent_attention_required") + : !(await connection.session.subscribesToAgent(agent, ws)) + ) + continue; clientEntries.push({ ws, - state: this.getClientActivityState(connection.session), + state: this.getClientActivityState(connection.session, ws), }); } - const allStates = clientEntries.map((e) => e.state); + const notificationEntries = clientEntries.filter(({ ws }) => + this.sessions.get(ws)?.session.wantsSourceNotification(ws, "agent_attention_required"), + ); + const allStates = notificationEntries.map((e) => e.state); const nowMs = Date.now(); const assistantMessage = await this.agentManager.getLastAssistantMessage(params.agentId); const notification = buildAgentAttentionNotificationPayload({ @@ -2564,8 +2492,10 @@ export class VoiceAssistantWebSocketServer { }); } - for (const [clientIndex, { ws }] of clientEntries.entries()) { - const shouldNotify = clientIndex === plan.inAppRecipientIndex; + for (const { ws } of clientEntries) { + const shouldNotify = + plan.inAppRecipientIndex !== null && + notificationEntries[plan.inAppRecipientIndex]?.ws === ws; const timestamp = new Date().toISOString(); const connection = this.sessions.get(ws); const attentionPayload = { @@ -2576,7 +2506,8 @@ export class VoiceAssistantWebSocketServer { notification, }; const message = wrapSessionMessage( - connection?.session.supportsForSource(CLIENT_CAPS.selectiveAgentTimeline, ws) + connection?.session.delivery.isModern(ws) || + connection?.session.supportsForSource(CLIENT_CAPS.selectiveAgentTimeline, ws) ? { type: "agent_attention_required", payload: attentionPayload, @@ -2598,7 +2529,9 @@ export class VoiceAssistantWebSocketServer { }, ); - this.sendToClient(ws, message); + if (message.type === "session" && this.sessions.get(ws)?.session.delivery.isModern(ws)) + this.sessions.get(ws)!.session.publishToSource(ws, message.message); + else this.sendToClient(ws, message); } } @@ -2616,20 +2549,28 @@ export class VoiceAssistantWebSocketServer { for (const [ws, connection] of this.sessions) { if ( - !(await connection.session.subscribesToTerminalDirectory({ - cwd: params.cwd, - ...(params.workspaceId ? { workspaceId: params.workspaceId } : {}), - })) + connection.session.delivery.isModern(ws) + ? !connection.session.wantsSourceEvent(ws, "terminal_attention_required") + : !(await connection.session.subscribesToTerminalDirectory( + { + cwd: params.cwd, + ...(params.workspaceId ? { workspaceId: params.workspaceId } : {}), + }, + ws, + )) ) { continue; } clientEntries.push({ ws, - state: this.getClientActivityState(connection.session), + state: this.getClientActivityState(connection.session, ws), }); } - const allStates = clientEntries.map((e) => e.state); + const notificationEntries = clientEntries.filter(({ ws }) => + this.sessions.get(ws)?.session.wantsSourceNotification(ws, "terminal_attention_required"), + ); + const allStates = notificationEntries.map((e) => e.state); const nowMs = Date.now(); const workspaceId = params.workspaceId; @@ -2663,8 +2604,10 @@ export class VoiceAssistantWebSocketServer { }); } - for (const [clientIndex, { ws }] of clientEntries.entries()) { - const shouldNotify = clientIndex === plan.inAppRecipientIndex; + for (const { ws } of clientEntries) { + const shouldNotify = + plan.inAppRecipientIndex !== null && + notificationEntries[plan.inAppRecipientIndex]?.ws === ws; const message = wrapSessionMessage({ type: "terminal_attention_required", payload: { @@ -2678,7 +2621,9 @@ export class VoiceAssistantWebSocketServer { shouldNotify, }, }); - this.sendToClient(ws, message); + if (message.type === "session" && this.sessions.get(ws)?.session.delivery.isModern(ws)) + this.sessions.get(ws)!.session.publishToSource(ws, message.message); + else this.sendToClient(ws, message); } } } diff --git a/packages/server/src/server/websocket/runtime-metrics.ts b/packages/server/src/server/websocket/runtime-metrics.ts index 8e7c5c6ffd..0031ad69bb 100644 --- a/packages/server/src/server/websocket/runtime-metrics.ts +++ b/packages/server/src/server/websocket/runtime-metrics.ts @@ -17,6 +17,8 @@ export interface WebSocketRuntimeCounters { relayExternalSocketAttached: number; originRejected: number; hostRejected: number; + applicationJsonRejected: number; + applicationBinaryRejected: number; } export interface WebSocketRuntimeMetricsSnapshot { @@ -232,6 +234,8 @@ function createRuntimeCounters(): WebSocketRuntimeCounters { relayExternalSocketAttached: 0, originRejected: 0, hostRejected: 0, + applicationJsonRejected: 0, + applicationBinaryRejected: 0, }; } diff --git a/packages/server/src/server/wire-compat.test.ts b/packages/server/src/server/wire-compat.test.ts index baf720b51b..aca7046728 100644 --- a/packages/server/src/server/wire-compat.test.ts +++ b/packages/server/src/server/wire-compat.test.ts @@ -213,7 +213,7 @@ function createSessionForWireCompatTest(options?: { permissions: OWNER_PERMISSIONS, clientCapabilities: options?.clientCapabilities ?? null, onMessage: (message) => messages.push(message), - onMessageToSource: options?.onMessageToSource, + onMessageToSource: options?.onMessageToSource ?? ((_source, message) => messages.push(message)), logger: pino({ level: "silent" }), downloadTokenStore: {} as SessionOptions["downloadTokenStore"], pushNotifications: {} as SessionOptions["pushNotifications"], @@ -277,6 +277,7 @@ function createSessionForWireCompatTest(options?: { terminalManager: null, }); + session.updateClientCapabilities(options?.clientCapabilities ?? null, {}); return session; } @@ -342,6 +343,8 @@ describe("wire compatibility", () => { { type: "project.update", payload: { + generation: expect.any(String), + seq: 1, kind: "upsert", project: { projectId: "project-1", @@ -356,7 +359,7 @@ describe("wire compatibility", () => { }, { type: "project.update", - payload: { kind: "remove", projectId: "project-1" }, + payload: { kind: "remove", projectId: "project-1", generation: expect.any(String), seq: 2 }, }, ]); }); @@ -549,13 +552,40 @@ describe("wire compatibility", () => { test("setup progress is adapted per socket without changing the canonical snapshot", async () => { const legacy = {}; const capable = {}; + const modern = {}; + const modernCapable = {}; const delivered = new Map(); const session = createSessionForWireCompatTest({ onMessageToSource: (source, message) => delivered.set(source, [...(delivered.get(source) ?? []), message]), }); - session.updateClientCapabilities({}, legacy); - session.updateClientCapabilities({ workspace_setup_blocked: true }, capable); + for (const [source, blocked, owned] of [ + [legacy, false, false], + [capable, true, false], + [modern, false, true], + [modernCapable, true, true], + ] as const) { + session.updateClientCapabilities( + { + explicit_event_subscriptions: true, + owned_subscriptions: owned, + workspace_setup_blocked: blocked, + }, + source, + ); + await session.handleMessage( + { + type: "session.events.set_subscription.request", + requestId: "setup", + events: ["workspace_setup_progress"], + }, + source, + ); + expect(delivered.get(source)).toContainEqual( + expect.objectContaining({ type: "session.events.set_subscription.response" }), + ); + } + delivered.clear(); const message = { type: "workspace_setup_progress" as const, payload: { @@ -590,6 +620,20 @@ test("setup progress is adapted per socket without changing the canonical snapsh }, }, ]); + expect(delivered.get(modernCapable)).toEqual([ + { ...message, payload: { ...message.payload, subscriptionId: expect.any(String) } }, + ]); + expect(delivered.get(modern)).toEqual([ + { + ...message, + payload: { + ...message.payload, + status: "failed", + error: expect.stringContaining("Update Paseo"), + subscriptionId: expect.any(String), + }, + }, + ]); expect(message.payload.status).toBe("blocked"); await session.cleanup(); }); diff --git a/packages/server/src/server/workspace-archive-record-scoped.e2e.test.ts b/packages/server/src/server/workspace-archive-record-scoped.e2e.test.ts index 7e2b0c2064..cc05d496ba 100644 --- a/packages/server/src/server/workspace-archive-record-scoped.e2e.test.ts +++ b/packages/server/src/server/workspace-archive-record-scoped.e2e.test.ts @@ -205,8 +205,8 @@ test("archiving a workspace removes it from every subscribed client", async () = const removals = collectWorkspaceRemovals(observer); try { - await ctx.client.fetchWorkspaces({ subscribe: { subscriptionId: "workspace-initiator" } }); - await observer.fetchWorkspaces({ subscribe: { subscriptionId: "workspace-observer" } }); + await ctx.client.fetchWorkspaces({ subscribe: {} }); + await observer.fetchWorkspaces({ subscribe: {} }); const archive = await ctx.client.archiveWorkspace(workspaceId); await observer.ping({ requestId: "archive-observer-barrier" }); @@ -233,7 +233,7 @@ test("renaming a workspace updates every subscribed client", async () => { const titles = collectWorkspaceTitles(observer); try { - await observer.fetchWorkspaces({ subscribe: { subscriptionId: "workspace-observer" } }); + await observer.fetchWorkspaces({ subscribe: {} }); const renamed = await ctx.client.setWorkspaceTitle(workspaceId, "Renamed workspace"); await observer.ping({ requestId: "rename-observer-barrier" }); diff --git a/packages/server/src/server/workspace-create-git-fanout.local.e2e.test.ts b/packages/server/src/server/workspace-create-git-fanout.local.e2e.test.ts index 82f580ef1b..30e7043b64 100644 --- a/packages/server/src/server/workspace-create-git-fanout.local.e2e.test.ts +++ b/packages/server/src/server/workspace-create-git-fanout.local.e2e.test.ts @@ -145,10 +145,10 @@ async function startObservedFixture(siblingCount: number): Promise file.path)).toEqual(["README.md"]); startGitCommandMetrics(); await waitForGitCommandMetricsIdle({ quietMs: 1_500, timeoutMs: 30_000 }); @@ -460,7 +459,7 @@ test("refreshes every workspace that depends on a changed shared base ref", asyn status: 2, "symbolic-ref": 3, }); - client?.unsubscribeCheckoutDiff(subscriptionId); + await subscription.release(); }, 120_000); test("records the Git command ledger for repository metadata business rules", async () => { @@ -627,7 +626,7 @@ test("workspace archive is admitted while 52 sibling observations hydrate", asyn appVersion: "0.3.0-beta.2", }); await client.connect(); - await client.fetchAgents({ subscribe: { subscriptionId: "agents" } }); + await client.fetchAgents({ subscribe: {} }); const created = await client.createWorkspace({ source: { kind: "worktree", @@ -644,7 +643,7 @@ test("workspace archive is admitted while 52 sibling observations hydrate", asyn await client.fetchWorkspaces({ page: { limit: 200 }, - subscribe: { subscriptionId: "workspaces" }, + subscribe: {}, }); expect(snapshotGitCommandRuntimeMetrics().pending).toBeGreaterThan(0); const archiveStartedAt = Date.now(); @@ -670,10 +669,10 @@ test("workspace create is admitted while 100 sibling observations hydrate", asyn appVersion: "0.3.0-beta.2", }); await client.connect(); - await client.fetchAgents({ subscribe: { subscriptionId: "agents" } }); + await client.fetchAgents({ subscribe: {} }); const snapshot = await client.fetchWorkspaces({ page: { limit: 200 }, - subscribe: { subscriptionId: "workspaces" }, + subscribe: {}, }); expect(snapshot.entries).toHaveLength(SIBLING_COUNT); expect(snapshotGitCommandRuntimeMetrics().pending).toBeGreaterThan(0); diff --git a/packages/server/src/server/workspace-directory.test.ts b/packages/server/src/server/workspace-directory.test.ts index 3f2a150b99..50c226cfb4 100644 --- a/packages/server/src/server/workspace-directory.test.ts +++ b/packages/server/src/server/workspace-directory.test.ts @@ -645,3 +645,52 @@ describe("WorkspaceDirectory empty projects", () => { expect(result.emptyProjects.map((p) => p.projectId)).toEqual(["empty"]); }); }); + +test("Git observation targets exclude archived records without hydrating app descriptors", async () => { + const workspace = ( + id: string, + projectId: string, + archivedAt: string | null = null, + ): PersistedWorkspaceRecord => ({ + workspaceId: id, + projectId, + cwd: `/workspace/${id}`, + kind: "local_checkout", + displayName: id, + createdAt: NOW, + updatedAt: NOW, + archivedAt, + }); + const project = (id: string, archivedAt: string | null = null): PersistedProjectRecord => ({ + projectId: id, + rootPath: `/workspace/${id}`, + kind: "git", + displayName: id, + customName: null, + createdAt: NOW, + updatedAt: NOW, + archivedAt, + }); + const unexpectedHydration = async (): Promise => { + throw new Error("Watcher reconciliation hydrated app data"); + }; + const directory = new WorkspaceDirectory({ + logger: createTestLogger(), + projectRegistry: { list: async () => [project("active"), project("archived", NOW)] }, + workspaceRegistry: { + list: async () => [ + workspace("observed", "active"), + workspace("hidden", "active", NOW), + workspace("hidden-project", "archived"), + ], + }, + listAgentPayloads: unexpectedHydration, + listProviderSubagentActivity: unexpectedHydration, + listTerminalActivityContributions: unexpectedHydration, + buildWorkspaceDescriptor: unexpectedHydration, + isProviderVisibleToClient: () => true, + }); + expect(await directory.listObservationTargets()).toEqual([ + { id: "observed", workspaceDirectory: "/workspace/observed", workspaceKind: "local_checkout" }, + ]); +}); diff --git a/packages/server/src/server/workspace-directory.ts b/packages/server/src/server/workspace-directory.ts index 8817576885..13b9996489 100644 --- a/packages/server/src/server/workspace-directory.ts +++ b/packages/server/src/server/workspace-directory.ts @@ -153,6 +153,18 @@ export function workspaceIdsForProjects( return Array.from(workspaceIds); } +function activeWorkspaceRecords( + workspaces: PersistedWorkspaceRecord[], + projects: PersistedProjectRecord[], +): PersistedWorkspaceRecord[] { + const archivedProjects = new Set( + projects.filter((project) => project.archivedAt).map((project) => project.projectId), + ); + return workspaces.filter( + (workspace) => !workspace.archivedAt && !archivedProjects.has(workspace.projectId), + ); +} + export class WorkspaceDirectory { private readonly archivingByWorkspaceId = new Map(); /** @@ -223,12 +235,7 @@ export class WorkspaceDirectory { .filter((project) => !project.archivedAt) .map((project) => [project.projectId, project] as const), ); - const archivedProjectIds = new Set( - persistedProjects.filter((project) => project.archivedAt).map((project) => project.projectId), - ); - const activeRecords = persistedWorkspaces.filter( - (workspace) => !workspace.archivedAt && !archivedProjectIds.has(workspace.projectId), - ); + const activeRecords = activeWorkspaceRecords(persistedWorkspaces, persistedProjects); const descriptorsByWorkspaceId = new Map(); const workspaceIds = options.workspaceIds ? new Set(options.workspaceIds) : null; const activeWorkspaceIds = new Set(activeRecords.map((workspace) => workspace.workspaceId)); @@ -575,6 +582,20 @@ export class WorkspaceDirectory { })); } + async listObservationTargets(): Promise< + Pick[] + > { + const [workspaces, projects] = await Promise.all([ + this.deps.workspaceRegistry.list(), + this.deps.projectRegistry.list(), + ]); + return activeWorkspaceRecords(workspaces, projects).map((workspace) => ({ + id: workspace.workspaceId, + workspaceDirectory: workspace.cwd, + workspaceKind: workspace.kind, + })); + } + async listDescriptors(): Promise { return Array.from( ( diff --git a/packages/server/src/server/workspace-labels/internal/service.ts b/packages/server/src/server/workspace-labels/internal/service.ts index f560959833..aad0ea2047 100644 --- a/packages/server/src/server/workspace-labels/internal/service.ts +++ b/packages/server/src/server/workspace-labels/internal/service.ts @@ -55,6 +55,12 @@ export class WorkspaceLabelService { await this.catalog.initialize(); } + async list( + cursor?: WorkspaceLabelCursor, + ): Promise> { + return this.exclusive(async () => this.sequence.synchronize(await this.catalog.list(), cursor)); + } + async subscribe(input: { cursor?: WorkspaceLabelCursor; onChange: (change: WorkspaceLabelChange & { generation: string; seq: number }) => void; diff --git a/packages/server/src/server/workspace-same-cwd-isolation.e2e.test.ts b/packages/server/src/server/workspace-same-cwd-isolation.e2e.test.ts index 58bf79c66b..139f8a3795 100644 --- a/packages/server/src/server/workspace-same-cwd-isolation.e2e.test.ts +++ b/packages/server/src/server/workspace-same-cwd-isolation.e2e.test.ts @@ -374,6 +374,7 @@ test("local workspace auto-title does not broadcast provider snapshot warm-up to try { await client.connect(); + await client.observeEvents(["providers_snapshot_update"]).ready; await client.getProvidersSnapshot({ cwd }); await expect .poll( diff --git a/packages/server/src/terminal/terminal-session-controller.resize.test.ts b/packages/server/src/terminal/terminal-session-controller.resize.test.ts index 63181b2482..c84b03cab7 100644 --- a/packages/server/src/terminal/terminal-session-controller.resize.test.ts +++ b/packages/server/src/terminal/terminal-session-controller.resize.test.ts @@ -5,7 +5,12 @@ import { encodeTerminalResizePayload, type TerminalStreamFrame, } from "@getpaseo/protocol/binary-frames/index"; -import type { SessionOutboundMessage, TerminalState } from "@getpaseo/protocol/messages"; +import { SessionDelivery } from "../server/session/owned-subscriptions/index.js"; +import type { + SessionInboundMessage, + SessionOutboundMessage, + TerminalState, +} from "@getpaseo/protocol/messages"; import type pino from "pino"; import { TerminalSessionController } from "./terminal-session-controller.js"; @@ -80,44 +85,57 @@ describe("terminal session controller size ownership", () => { const { terminalManager, appliedSizes } = createFixture(); const controllerA = createController(terminalManager); const controllerB = createController(terminalManager); + const sourceA = {}; + const sourceB = {}; + const ownership = new SessionDelivery(() => {}); + // Legacy binary resize and attach sizing remain source scoped. + const dispatch = ( + controller: TerminalSessionController, + source: object, + message: SessionInboundMessage, + ) => + ownership.request(source, message, async () => { + await controller.dispatch(message, ownership); + }); - controllerA.dispatch({ + await dispatch(controllerA, sourceA, { type: "terminal_input", terminalId: "terminal-1", message: { type: "resize", rows: 30, cols: 100, intent: "claim" }, }); - controllerA.dispatch({ + await dispatch(controllerA, sourceA, { type: "terminal_input", terminalId: "terminal-1", message: { type: "resize", rows: 31, cols: 101, intent: "update" }, }); - controllerB.dispatch({ + await dispatch(controllerB, sourceB, { type: "terminal_input", terminalId: "terminal-1", message: { type: "resize", rows: 32, cols: 102, intent: "update" }, }); - await controllerB.dispatch({ + await dispatch(controllerB, sourceB, { type: "subscribe_terminal_request", terminalId: "terminal-1", requestId: "subscribe-b", restore: { mode: "live" }, }); - controllerB.handleBinaryFrame(resizeFrame({ rows: 33, cols: 103, intent: "claim" })); - controllerA.dispatch({ + controllerB.handleBinaryFrame(resizeFrame({ rows: 33, cols: 103, intent: "claim" }), sourceB); + await dispatch(controllerA, sourceA, { type: "terminal_input", terminalId: "terminal-1", message: { type: "resize", rows: 34, cols: 104, intent: "update" }, }); - await controllerA.dispatch({ + await dispatch(controllerA, sourceA, { type: "subscribe_terminal_request", terminalId: "terminal-1", requestId: "subscribe-a", restore: { mode: "live", size: { rows: 35, cols: 105 } }, }); - controllerB.handleBinaryFrame(resizeFrame({ rows: 36, cols: 106, intent: "update" })); + controllerB.handleBinaryFrame(resizeFrame({ rows: 36, cols: 106, intent: "update" }), sourceB); + await ownership.close(); expect(appliedSizes).toEqual(["100x30", "101x31", "103x33", "105x35"]); }); }); diff --git a/packages/server/src/terminal/terminal-session-controller.test.ts b/packages/server/src/terminal/terminal-session-controller.test.ts index 9fbd7e3d2a..301292edeb 100644 --- a/packages/server/src/terminal/terminal-session-controller.test.ts +++ b/packages/server/src/terminal/terminal-session-controller.test.ts @@ -1,7 +1,7 @@ import { describe, expect, test, vi } from "vitest"; -import type pino from "pino"; +import pino from "pino"; -import type { SessionOutboundMessage } from "../server/messages.js"; +import type { SessionInboundMessage, SessionOutboundMessage } from "../server/messages.js"; import { TerminalStreamOpcode, decodeTerminalStreamFrame, @@ -9,11 +9,41 @@ import { } from "@getpaseo/protocol/binary-frames/index"; import type { TerminalCell, TerminalState } from "@getpaseo/protocol/messages"; import type { ServerMessage, TerminalSession, TerminalStateSnapshot } from "./terminal.js"; -import { TerminalSessionController } from "./terminal-session-controller.js"; +import { + TerminalSessionController, + type TerminalSessionControllerOptions, +} from "./terminal-session-controller.js"; +import { SessionDelivery } from "../server/session/owned-subscriptions/index.js"; import type { TerminalManager, TerminalsChangedEvent } from "./terminal-manager.js"; import { isSameOrDescendantPath } from "../server/path-utils.js"; import { PluginSessionSocket } from "../server/plugins/session-socket.js"; +function createController( + options: TerminalSessionControllerOptions & { emitBinary(frame: Uint8Array): void }, +) { + const source = {}; + const ownership = new SessionDelivery( + (_source, message) => options.emit(message), + (_source, frame) => options.emitBinary(frame), + ); + ownership.attach(source, false); + const controller = new TerminalSessionController({ + ...options, + emit: (message) => { + if (!ownership.reply(message)) options.emit(message); + }, + }); + return { + start: () => controller.start(), + dispatch: (message: SessionInboundMessage) => + ownership.request(source, message, async () => { + await controller.dispatch(message, ownership); + }), + hasDirectorySubscription: (input: { cwd: string; workspaceId?: string }) => + controller.hasDirectorySubscription(input), + }; +} + function deferred(): { promise: Promise; resolve: (value: T) => void; @@ -102,7 +132,7 @@ describe("terminal-session-controller restore", () => { subscribeTerminalActivity: vi.fn(() => vi.fn()), subscribeTerminalWorkspaceContributionChanged: vi.fn(() => vi.fn()), }; - const controller = new TerminalSessionController({ + const controller = createController({ terminalManager, emit: (message) => outboundMessages.push(message), emitBinary: (bytes) => { @@ -218,7 +248,7 @@ describe("terminal-session-controller legacy terminal creation", () => { subscribeTerminalActivity: vi.fn(() => vi.fn()), subscribeTerminalWorkspaceContributionChanged: vi.fn(() => vi.fn()), }; - const controller = new TerminalSessionController({ + const controller = createController({ terminalManager, emit: (message) => outboundMessages.push(message), emitBinary: vi.fn(), @@ -293,7 +323,7 @@ describe("terminal-session-controller legacy terminal creation", () => { subscribeTerminalActivity: vi.fn(() => vi.fn()), subscribeTerminalWorkspaceContributionChanged: vi.fn(() => vi.fn()), }; - const controller = new TerminalSessionController({ + const controller = createController({ terminalManager, emit: (message) => outboundMessages.push(message), emitBinary: vi.fn(), @@ -329,7 +359,7 @@ async function waitForCoalescerFlush(): Promise { describe("terminal-session-controller wrap-flag gating", () => { function setup(clientSupportsWrapReflow?: () => boolean): { - controller: TerminalSessionController; + controller: ReturnType; getTerminalState: ReturnType; } { const terminal: TerminalSession = { @@ -379,7 +409,7 @@ describe("terminal-session-controller wrap-flag gating", () => { subscribeTerminalActivity: vi.fn(() => vi.fn()), subscribeTerminalWorkspaceContributionChanged: vi.fn(() => vi.fn()), } as unknown as TerminalManager; - const controller = new TerminalSessionController({ + const controller = createController({ terminalManager, emit: vi.fn(), emitBinary: vi.fn(), @@ -391,7 +421,7 @@ describe("terminal-session-controller wrap-flag gating", () => { return { controller, getTerminalState }; } - async function subscribe(controller: TerminalSessionController): Promise { + async function subscribe(controller: ReturnType): Promise { await controller.dispatch({ type: "subscribe_terminal_request", terminalId: "term-1", @@ -457,7 +487,7 @@ describe("terminal-session-controller subdirectory aggregation", () => { }; const outboundMessages: SessionOutboundMessage[] = []; - const controller = new TerminalSessionController({ + const controller = createController({ terminalManager, emit: (message) => outboundMessages.push(message), emitBinary: vi.fn(), @@ -521,7 +551,7 @@ describe("terminal-session-controller subdirectory aggregation", () => { subscribeTerminalWorkspaceContributionChanged: vi.fn(() => vi.fn()), }; const outboundMessages: SessionOutboundMessage[] = []; - const controller = new TerminalSessionController({ + const controller = createController({ terminalManager, emit: (message) => outboundMessages.push(message), emitBinary: vi.fn(), @@ -617,7 +647,7 @@ describe("terminal-session-controller workspace-scoped subscriptions", () => { }; const outboundMessages: SessionOutboundMessage[] = []; - const controller = new TerminalSessionController({ + const controller = createController({ terminalManager, emit: (message) => outboundMessages.push(message), emitBinary: vi.fn(), @@ -714,7 +744,7 @@ describe("terminal-session-controller backpressure snapshot fallback", () => { } as unknown as TerminalManager; const frames: TerminalStreamFrame[] = []; - const controller = new TerminalSessionController({ + const controller = createController({ terminalManager, emit: vi.fn(), emitBinary: (bytes) => { @@ -807,3 +837,404 @@ describe("terminal-session-controller backpressure snapshot fallback", () => { expect(frames.some((frame) => frame.opcode === TerminalStreamOpcode.Snapshot)).toBe(true); }); }); + +function exitFixture(readSnapshot: () => Promise, backedUp = false) { + const outputs = new Set<(message: ServerMessage) => void>(); + const exits = new Set[0]>(); + const terminal: TerminalSession = { + id: "exit-terminal", + name: "Terminal", + cwd: "/unused", + workspaceId: "workspace", + send() {}, + subscribe(listener) { + outputs.add(listener); + return () => { + outputs.delete(listener); + }; + }, + onExit(listener) { + exits.add(listener); + return () => { + exits.delete(listener); + }; + }, + onCommandFinished: () => () => {}, + onTitleChange: () => () => {}, + onActivityChange: () => () => {}, + getSize: () => ({ rows: 1, cols: 80 }), + getState: () => terminalState("snapshot"), + getStateSnapshot: () => ({ state: terminalState("snapshot"), revision: 0 }), + getReplayPreamble: () => "", + getTitle: () => undefined, + getActivity: () => null, + setActivity() {}, + clearActivityAttention: () => false, + setTitle() {}, + getExitInfo: () => null, + kill() {}, + async killAndWait() {}, + }; + const manager: TerminalManager = { + getTerminal: () => terminal, + getTerminalState: readSnapshot, + getTerminals: async () => [terminal], + createTerminal: async () => terminal, + registerCwdEnv() {}, + validateTerminalActivityToken: () => "unknown", + setTerminalTitle: () => true, + setTerminalActivity: async () => true, + clearTerminalAttention: async () => false, + killTerminal() {}, + async killTerminalAndWait() {}, + captureTerminal: async () => ({ lines: [], totalLines: 0 }), + listDirectories: () => [], + killAll() {}, + subscribeTerminalsChanged: () => () => {}, + subscribeTerminalActivity: () => () => {}, + subscribeTerminalWorkspaceContributionChanged: () => () => {}, + }; + const frames: Array<{ + source: object; + message?: SessionOutboundMessage; + binary?: TerminalStreamFrame; + }> = []; + const delivery = new SessionDelivery( + (source, message) => frames.push({ source, message }), + (source, bytes) => { + const binary = decodeTerminalStreamFrame(bytes); + if (binary) frames.push({ source, binary }); + }, + ); + const controller = new TerminalSessionController({ + terminalManager: manager, + emit: (message) => { + delivery.reply(message); + }, + hasBinaryChannel: () => true, + isPathWithinRoot: () => false, + sessionLogger: pino({ level: "silent" }), + getClientBufferedAmount: () => (backedUp ? 8 * 1024 * 1024 : 0), + }); + return { + frames, + delivery, + controller, + outputs, + exits, + subscribe: async (source: object, modern: boolean, requestId: string) => { + delivery.attach(source, modern); + const request = { + type: "subscribe_terminal_request" as const, + terminalId: terminal.id, + requestId, + }; + await delivery.request(source, request, async () => { + await controller.dispatch(request, delivery); + }); + const reply = frames.find( + (row) => + row.message?.type === "subscribe_terminal_response" && + row.message.payload.requestId === requestId, + )?.message; + if (reply?.type !== "subscribe_terminal_response" || reply.payload.error !== null) + throw new Error("Missing stream acknowledgement"); + return { source, slot: reply.payload.slot, id: reply.payload.subscriptionId }; + }, + output: (data: string, revision: number) => { + for (const listener of outputs) listener({ type: "output", data, revision }); + }, + exit: () => { + for (const listener of exits) listener({ exitCode: 0, signal: null, lastOutputLines: [] }); + }, + }; +} + +describe("terminal output before natural exit", () => { + test.each([true, false])( + "flushes each observer's final bytes before exit (modern=%s)", + async (modern) => { + vi.useFakeTimers(); + const f = exitFixture(async () => ({ state: terminalState("snapshot"), revision: 0 })); + try { + const source = {}; + const owners = [ + await f.subscribe(source, modern, "first"), + await f.subscribe(modern ? source : {}, modern, "second"), + ]; + await vi.advanceTimersByTimeAsync(0); + f.output("FIRST-OUTPUT", 1); + await vi.advanceTimersByTimeAsync(5); + f.output("FINAL-BYTES", 2); + f.exit(); + await vi.advanceTimersByTimeAsync(0); + for (const owner of owners) { + const ordered = f.frames.filter( + (row) => + row.source === owner.source && + (row.binary?.slot === owner.slot || + (row.message?.type === "terminal_stream_exit" && + row.message.payload.subscriptionId === owner.id)), + ); + expect(ordered.map((row) => row.binary?.opcode ?? row.message?.type)).toEqual([ + TerminalStreamOpcode.Snapshot, + TerminalStreamOpcode.Output, + TerminalStreamOpcode.Output, + "terminal_stream_exit", + ]); + expect(Buffer.from(ordered[2].binary!.payload).toString()).toBe("FINAL-BYTES"); + } + if (modern) expect(new Set(owners.map((owner) => owner.id)).size).toBe(2); + expect(f.delivery.registrationCount).toBe(0); + expect(f.controller.getMetrics().streamSubscriptionCount).toBe(0); + expect(f.outputs.size + f.exits.size).toBe(0); + } finally { + await f.delivery.close(); + vi.useRealTimers(); + } + }, + ); + + test("waits for bootstrap snapshot and flushes buffered bytes without starting backpressure work", async () => { + vi.useFakeTimers(); + const snapshot = deferred(); + let reads = 0; + const f = exitFixture(() => { + reads++; + return snapshot.promise; + }, true); + try { + await f.subscribe({}, true, "bootstrap"); + f.output("x".repeat(300 * 1024), 1); + f.output("FINAL-BYTES", 2); + f.exit(); + await vi.advanceTimersByTimeAsync(0); + expect(f.frames.map((row) => row.message?.type)).toEqual(["subscribe_terminal_response"]); + snapshot.resolve({ state: terminalState("snapshot"), revision: 0 }); + await vi.advanceTimersByTimeAsync(0); + expect(f.frames.slice(1).map((row) => row.binary?.opcode ?? row.message?.type)).toEqual([ + TerminalStreamOpcode.Snapshot, + TerminalStreamOpcode.Output, + "terminal_stream_exit", + ]); + expect(Buffer.from(f.frames[2].binary!.payload).toString()).toBe( + "x".repeat(300 * 1024) + "FINAL-BYTES", + ); + expect(reads).toBe(1); + expect(f.delivery.registrationCount).toBe(0); + } finally { + snapshot.resolve(null); + await f.delivery.close(); + vi.useRealTimers(); + } + }); + + test.each([true, false, "rejected"])( + "finishes an in-flight backpressure snapshot (available=%s)", + async (available) => { + vi.useFakeTimers(); + const snapshot = deferred(); + let reads = 0; + const f = exitFixture( + async () => + ++reads === 1 ? { state: terminalState("initial"), revision: 0 } : snapshot.promise, + true, + ); + try { + await f.subscribe({}, true, "pressure"); + await vi.advanceTimersByTimeAsync(0); + f.output("x".repeat(300 * 1024), 1); + await vi.advanceTimersByTimeAsync(5); + expect(reads).toBe(2); + f.output("FINAL-BYTES", 2); + f.exit(); + await vi.advanceTimersByTimeAsync(0); + expect(f.frames.some((row) => row.message?.type === "terminal_stream_exit")).toBe(false); + if (available === "rejected") snapshot.reject(new Error("Snapshot failed during exit")); + else + snapshot.resolve( + available ? { state: terminalState("pressure-snapshot"), revision: 1 } : null, + ); + await vi.advanceTimersByTimeAsync(0); + const output = f.frames + .filter((row) => row.binary?.opcode === TerminalStreamOpcode.Output) + .map((row) => Buffer.from(row.binary!.payload).toString()) + .join(""); + expect(output).toBe((available === true ? "" : "x".repeat(300 * 1024)) + "FINAL-BYTES"); + expect(f.frames.at(-1)?.message?.type).toBe("terminal_stream_exit"); + expect(f.delivery.registrationCount).toBe(0); + expect(reads).toBe(2); + } finally { + snapshot.resolve(null); + await f.delivery.close(); + vi.useRealTimers(); + } + }, + ); + + test("release during exit's pending snapshot suppresses late delivery", async () => { + vi.useFakeTimers(); + const snapshot = deferred(); + const f = exitFixture(() => snapshot.promise); + const source = {}; + try { + const owner = await f.subscribe(source, true, "pending"); + f.output("FINAL-BYTES", 1); + f.exit(); + const request = { + type: "subscription.release.request" as const, + requestId: "release", + subscriptionId: owner.id!, + }; + const release = f.delivery.request(source, request, () => f.delivery.release(owner.id!)); + snapshot.resolve({ state: terminalState("snapshot"), revision: 0 }); + await release; + await vi.advanceTimersByTimeAsync(0); + expect(f.frames.map((row) => row.message?.type)).toEqual(["subscribe_terminal_response"]); + expect(f.delivery.registrationCount).toBe(0); + expect(f.outputs.size + f.exits.size).toBe(0); + } finally { + snapshot.resolve(null); + await f.delivery.close(); + vi.useRealTimers(); + } + }); + + test("explicit release discards only that observer's trailing output", async () => { + vi.useFakeTimers(); + const f = exitFixture(async () => ({ state: terminalState("snapshot"), revision: 0 })); + const source = {}; + try { + const a = await f.subscribe(source, true, "a"); + const b = await f.subscribe(source, true, "b"); + await vi.advanceTimersByTimeAsync(0); + f.output("FINAL-BYTES", 1); + const request = { + type: "subscription.release.request" as const, + requestId: "release", + subscriptionId: a.id!, + }; + await f.delivery.request(source, request, () => f.delivery.release(a.id!)); + f.exit(); + await vi.advanceTimersByTimeAsync(0); + expect( + f.frames.some( + (row) => + row.binary && + row.binary.slot === a.slot && + row.binary.opcode === TerminalStreamOpcode.Output, + ), + ).toBe(false); + expect( + f.frames.some( + (row) => + row.binary && + row.binary.slot === b.slot && + row.binary.opcode === TerminalStreamOpcode.Output && + Buffer.from(row.binary.payload).toString() === "FINAL-BYTES", + ), + ).toBe(true); + expect(f.delivery.registrationCount).toBe(0); + } finally { + await f.delivery.close(); + vi.useRealTimers(); + } + }); +}); + +describe("terminal snapshot failure", () => { + test.each(["initial", "legacy initial", "backpressure"])( + "releases only the failed observer after %s rejection", + async (phase) => { + vi.useFakeTimers(); + let reads = 0; + const f = exitFixture(async () => { + if (++reads === (phase === "backpressure" ? 2 : 1)) + throw new Error("Terminal worker request timed out: getTerminalState"); + return { state: terminalState("healthy"), revision: 0 }; + }, phase === "backpressure"); + try { + const source = {}; + const failed = await f.subscribe(source, phase !== "legacy initial", "failed"); + await vi.advanceTimersByTimeAsync(0); + if (phase === "backpressure") { + f.output("x".repeat(300 * 1024), 1); + await vi.advanceTimersByTimeAsync(5); + } + const healthy = await f.subscribe( + phase === "legacy initial" ? {} : source, + phase !== "legacy initial", + "healthy", + ); + await vi.advanceTimersByTimeAsync(0); + f.output("SIBLING-ALIVE", 2); + await vi.advanceTimersByTimeAsync(5); + expect(f.delivery.registrationCount).toBe(1); + expect(f.controller.getMetrics().streamSubscriptionCount).toBe(1); + expect(f.outputs.size).toBe(1); + expect(f.exits.size).toBe(1); + expect( + f.frames + .filter((row) => row.message?.type === "terminal_stream_exit") + .map((row) => row.message), + ).toEqual([ + { + type: "terminal_stream_exit", + payload: { + terminalId: "exit-terminal", + ...(phase === "legacy initial" ? {} : { subscriptionId: failed.id }), + error: "Terminal worker request timed out: getTerminalState", + }, + }, + ]); + expect( + f.frames.some( + (row) => + row.binary?.slot === healthy.slot && + row.binary?.opcode === TerminalStreamOpcode.Output && + Buffer.from(row.binary.payload).toString() === "SIBLING-ALIVE", + ), + ).toBe(true); + f.exit(); + await vi.advanceTimersByTimeAsync(0); + expect(f.frames.at(-1)?.message).toEqual({ + type: "terminal_stream_exit", + payload: { + terminalId: "exit-terminal", + ...(phase === "legacy initial" ? {} : { subscriptionId: healthy.id }), + }, + }); + expect(f.delivery.registrationCount).toBe(0); + expect(f.outputs.size + f.exits.size).toBe(0); + } finally { + await f.delivery.close(); + vi.useRealTimers(); + } + }, + ); + + test("release during pending rejection suppresses the failure and completes cleanup", async () => { + const snapshot = deferred(); + const f = exitFixture(() => snapshot.promise); + const source = {}; + try { + const owner = await f.subscribe(source, true, "pending"); + f.output("BUFFERED", 1); + const request = { + type: "subscription.release.request" as const, + requestId: "release", + subscriptionId: owner.id!, + }; + const release = f.delivery.request(source, request, () => f.delivery.release(owner.id!)); + snapshot.reject(new Error("Terminal worker request timed out: getTerminalState")); + await release; + expect(f.frames.map((row) => row.message?.type)).toEqual(["subscribe_terminal_response"]); + expect(f.delivery.registrationCount).toBe(0); + expect(f.controller.getMetrics().streamSubscriptionCount).toBe(0); + expect(f.outputs.size + f.exits.size).toBe(0); + } finally { + snapshot.resolve(null); + await f.delivery.close(); + } + }); +}); diff --git a/packages/server/src/terminal/terminal-session-controller.ts b/packages/server/src/terminal/terminal-session-controller.ts index 998c5c93e8..9925ed1846 100644 --- a/packages/server/src/terminal/terminal-session-controller.ts +++ b/packages/server/src/terminal/terminal-session-controller.ts @@ -1,4 +1,8 @@ import type pino from "pino"; +import type { + OwnedSubscription, + SessionDelivery, +} from "../server/session/owned-subscriptions/index.js"; import type { CaptureTerminalRequest, CreateTerminalRequest, @@ -39,6 +43,14 @@ import { terminalSubscriptionKey } from "@getpaseo/protocol/terminal-subscriptio const MAX_TERMINAL_STREAM_SLOTS = 256; +interface TerminalDirectorySubscription { + cwd: string; + workspaceId: string | undefined; + owner: OwnedSubscription; + refresh: Promise | null; + pending: boolean; +} + interface BufferedTerminalOutput { data: string; revision?: number; @@ -47,6 +59,10 @@ interface BufferedTerminalOutput { interface ActiveTerminalStream { terminalId: string; slot: number; + owner: OwnedSubscription; + snapshotTask?: Promise; + snapshotOutput?: Buffer; + exiting: boolean; unsubscribe: () => void; needsSnapshot: boolean; snapshotInFlight: boolean; @@ -65,7 +81,6 @@ interface SnapshotSendResult { export interface TerminalSessionControllerOptions { terminalManager: TerminalManager | null; emit: (msg: SessionOutboundMessage) => void; - emitBinary: (frame: Uint8Array) => void; hasBinaryChannel: () => boolean; isPathWithinRoot: (rootPath: string, candidatePath: string) => boolean; sessionLogger: pino.Logger; @@ -74,7 +89,7 @@ export interface TerminalSessionControllerOptions { // Whether the connected client can reflow restored snapshots. When true the // daemon attaches per-row soft-wrap flags to snapshots; otherwise it omits them // so old (strict-schema) clients still parse the snapshot. - clientSupportsWrapReflow?: () => boolean; + clientSupportsWrapReflow?: (source: object) => boolean; // Current max bytes queued on the client's transport(s) but not yet sent. // Drives the snapshot catch-up fallback: a keeping-up client reports ~0 and // keeps streaming; a backed-up client trips the snapshot path. Defaults to a @@ -82,7 +97,7 @@ export interface TerminalSessionControllerOptions { // stream. // Bytes queued on the client transport but not yet sent, or null when the // transport exposes no backpressure signal (e.g. the multiplexed relay socket). - getClientBufferedAmount?: () => number | null; + getClientBufferedAmount?: (source: object) => number | null; } interface TerminalWorkspaceRef { @@ -123,34 +138,22 @@ const TERMINAL_MESSAGE_TYPES: ReadonlySet = export class TerminalSessionController { private readonly terminalManager: TerminalManager | null; private readonly emit: (msg: SessionOutboundMessage) => void; - private readonly emitBinary: (frame: Uint8Array) => void; private readonly hasBinaryChannel: () => boolean; private readonly isPathWithinRoot: (rootPath: string, candidatePath: string) => boolean; private readonly sessionLogger: pino.Logger; private readonly listTerminalWorkspaceRefs: () => Promise; private readonly listTerminalWorkspaceRoots: () => Promise; - private readonly clientSupportsWrapReflow: () => boolean; - private readonly getClientBufferedAmount: () => number | null; - private readonly terminalSizeOwner = {}; + private readonly clientSupportsWrapReflow: (source: object) => boolean; + private readonly getClientBufferedAmount: (source: object) => number | null; - // A subscription is scoped to a (cwd, workspaceId) pair, keyed by - // terminalSubscriptionKey: two workspaces sharing a cwd subscribe and unsub - // independently, and each only receives its own workspace's terminals. The - // workspaceId is absent for old clients, which key to the cwd alone. - private readonly subscribedDirectories = new Map< - string, - { cwd: string; workspaceId: string | undefined } - >(); + private readonly subscribedDirectories = new Map(); private unsubscribeTerminalsChanged: (() => void) | null = null; - private readonly exitSubscriptions = new Map void>(); private readonly activeStreams = new Map(); - private readonly idToSlot = new Map(); private nextSlot = 0; constructor(options: TerminalSessionControllerOptions) { this.terminalManager = options.terminalManager; this.emit = options.emit; - this.emitBinary = options.emitBinary; this.hasBinaryChannel = options.hasBinaryChannel; this.isPathWithinRoot = options.isPathWithinRoot; this.sessionLogger = options.sessionLogger; @@ -163,9 +166,7 @@ export class TerminalSessionController { } start(): void { - if (!this.terminalManager) { - return; - } + if (!this.terminalManager || this.unsubscribeTerminalsChanged) return; this.unsubscribeTerminalsChanged = this.terminalManager.subscribeTerminalsChanged((event) => { void this.handleTerminalsChanged(event); }); @@ -178,9 +179,16 @@ export class TerminalSessionController { }; } - async hasDirectorySubscription(input: { cwd: string; workspaceId?: string }): Promise { + async hasDirectorySubscription( + input: { cwd: string; workspaceId?: string }, + source?: object, + ): Promise { + const subscriptions = [...this.subscribedDirectories.values()].filter( + (subscription) => !source || subscription.owner.source === source, + ); + if (subscriptions.length === 0) return false; const workspaceRoots = await this.listTerminalWorkspaceRoots(); - return Array.from(this.subscribedDirectories.values()).some((subscription) => { + return subscriptions.some((subscription) => { if ( subscription.workspaceId !== undefined && subscription.workspaceId !== input.workspaceId @@ -191,28 +199,28 @@ export class TerminalSessionController { }); } - dispatch(msg: SessionInboundMessage): Promise | undefined { + dispatch(msg: SessionInboundMessage, ownership: SessionDelivery): Promise | undefined { if (!isTerminalMessage(msg)) { return undefined; } switch (msg.type) { case "subscribe_terminals_request": - this.handleSubscribeTerminalsRequest(msg); - return undefined; + return this.handleSubscribeTerminalsRequest(msg, ownership); case "unsubscribe_terminals_request": - this.handleUnsubscribeTerminalsRequest(msg); - return undefined; + return ownership.releaseLegacySlot( + `terminal-directory:${terminalSubscriptionKey(msg.cwd, msg.workspaceId)}`, + ); case "list_terminals_request": return this.handleListTerminalsRequest(msg); case "create_terminal_request": return this.handleCreateTerminalRequest(msg); case "subscribe_terminal_request": - return this.handleSubscribeTerminalRequest(msg); + return this.handleSubscribeTerminalRequest(msg, ownership); case "unsubscribe_terminal_request": - this.handleUnsubscribeTerminalRequest(msg); - return undefined; + return ownership.releaseLegacySlot(`terminal-output:${msg.terminalId}`); case "terminal_input": - this.handleTerminalInput(msg); + if (!ownership.currentSource) throw new Error("Terminal input requires a source"); + this.handleTerminalInput(msg, ownership.currentSource); return undefined; case "kill_terminal_request": return this.handleKillTerminalRequest(msg); @@ -225,9 +233,9 @@ export class TerminalSessionController { } } - handleBinaryFrame(frame: TerminalStreamFrame): void { + handleBinaryFrame(frame: TerminalStreamFrame, source: object): void { const activeStream = this.activeStreams.get(frame.slot); - if (!activeStream || !this.terminalManager) { + if (!activeStream || activeStream.owner.source !== source || !this.terminalManager) { return; } const terminal = this.terminalManager.getTerminal(activeStream.terminalId); @@ -254,7 +262,7 @@ export class TerminalSessionController { if (!resize) { return; } - applyTerminalSize(terminal, this.terminalSizeOwner, resize); + applyTerminalSize(terminal, source, resize); return; } @@ -289,52 +297,13 @@ export class TerminalSessionController { } this.subscribedDirectories.clear(); - for (const unsubscribeExit of this.exitSubscriptions.values()) { - unsubscribeExit(); + for (const stream of this.activeStreams.values()) { + void stream.owner + .release() + .catch((error) => + this.sessionLogger.warn({ err: error }, "Failed to release terminal stream"), + ); } - this.exitSubscriptions.clear(); - - for (const terminalId of Array.from(this.idToSlot.keys())) { - this.detachStream(terminalId, { emitExit: false }); - } - } - - private ensureExitSubscription(terminal: TerminalSession): void { - if (this.exitSubscriptions.has(terminal.id)) { - return; - } - const unsubscribeExit = terminal.onExit(() => { - this.handleTerminalExited(terminal.id); - }); - this.exitSubscriptions.set(terminal.id, unsubscribeExit); - } - - private handleTerminalExited(terminalId: string): void { - const unsubscribeExit = this.exitSubscriptions.get(terminalId); - if (unsubscribeExit) { - unsubscribeExit(); - this.exitSubscriptions.delete(terminalId); - } - this.detachStream(terminalId, { emitExit: true }); - } - - private emitTerminalsChangedSnapshot(input: { - cwd: string; - terminals: Array<{ - id: string; - name: string; - workspaceId: string; - title?: string; - activity: TerminalActivity | null; - }>; - }): void { - this.emit({ - type: "terminals_changed", - payload: { - cwd: input.cwd, - terminals: input.terminals, - }, - }); } private toTerminalInfo( @@ -371,47 +340,78 @@ export class TerminalSessionController { } } - private handleSubscribeTerminalsRequest(msg: SubscribeTerminalsRequest): void { - const subscription = { cwd: msg.cwd, workspaceId: msg.workspaceId }; - this.subscribedDirectories.set(terminalSubscriptionKey(msg.cwd, msg.workspaceId), subscription); - void this.emitTerminalsSnapshotForSubscription(subscription); - } - - private handleUnsubscribeTerminalsRequest(msg: UnsubscribeTerminalsRequest): void { - this.subscribedDirectories.delete(terminalSubscriptionKey(msg.cwd, msg.workspaceId)); - } - - private async emitTerminalsSnapshotForSubscription(subscription: { - cwd: string; - workspaceId: string | undefined; - }): Promise { - const key = terminalSubscriptionKey(subscription.cwd, subscription.workspaceId); - if (!this.terminalManager || !this.subscribedDirectories.has(key)) { - return; - } + private async handleSubscribeTerminalsRequest( + msg: SubscribeTerminalsRequest, + ownership: SessionDelivery, + ): Promise { + if (!this.terminalManager) throw new Error("Terminal manager not available"); + let subscription: TerminalDirectorySubscription | undefined; + const owner = ownership.begin( + "terminal-directories", + undefined, + async (id) => { + this.subscribedDirectories.delete(id); + if (this.subscribedDirectories.size === 0) { + this.unsubscribeTerminalsChanged?.(); + this.unsubscribeTerminalsChanged = null; + } + await subscription?.refresh?.catch(() => undefined); + }, + `terminal-directory:${terminalSubscriptionKey(msg.cwd, msg.workspaceId)}`, + ); + subscription = { + cwd: msg.cwd, + workspaceId: msg.workspaceId, + owner, + refresh: null, + pending: false, + }; + this.subscribedDirectories.set(owner.id, subscription); + this.start(); try { - const terminals = await this.getTerminalsForWorkspaceRoot( - subscription.cwd, - subscription.workspaceId, - ); - for (const terminal of terminals) { - this.ensureExitSubscription(terminal); - } - if (!this.subscribedDirectories.has(key)) { - return; - } - this.emitTerminalsChangedSnapshot({ - cwd: subscription.cwd, - terminals: terminals.map((terminal) => this.toTerminalInfo(terminal)), - }); + await this.emitTerminalsSnapshotForSubscription(subscription, msg.requestId); } catch (error) { - this.sessionLogger.warn( - { err: error, cwd: subscription.cwd }, - "Failed to emit initial terminal snapshot", - ); + await owner.release(); + throw error; } } + private emitTerminalsSnapshotForSubscription( + subscription: TerminalDirectorySubscription, + requestId?: string, + ): Promise { + if (subscription.refresh) { + subscription.pending = true; + return subscription.refresh; + } + const refresh = (async () => { + do { + subscription.pending = false; + const terminals = await this.getTerminalsForWorkspaceRoot( + subscription.cwd, + subscription.workspaceId, + ); + if (subscription.owner.signal.aborted) return; + subscription.owner.emit({ + type: "terminals_changed", + payload: { + cwd: subscription.cwd, + workspaceId: subscription.workspaceId, + terminals: terminals.map((terminal) => this.toTerminalInfo(terminal)), + ...(requestId ? { requestId } : {}), + }, + }); + requestId = undefined; + } while (subscription.pending); + })(); + subscription.refresh = refresh; + const settled = () => { + subscription.refresh = null; + }; + void refresh.then(settled, settled); + return refresh; + } + private async handleListTerminalsRequest(msg: ListTerminalsRequest): Promise { if (!this.terminalManager) { this.emit({ @@ -436,9 +436,6 @@ export class TerminalSessionController { } else { terminals = await this.getAllTerminalSessions(); } - for (const terminal of terminals) { - this.ensureExitSubscription(terminal); - } this.emit({ type: "list_terminals_response", payload: { @@ -582,7 +579,6 @@ export class TerminalSessionController { rows: msg.size?.rows, cols: msg.size?.cols, }); - this.ensureExitSubscription(session); this.emit({ type: "create_terminal_response", payload: { @@ -662,7 +658,10 @@ export class TerminalSessionController { respond(renamed, renamed ? null : "Terminal not found"); } - private async handleSubscribeTerminalRequest(msg: SubscribeTerminalRequest): Promise { + private async handleSubscribeTerminalRequest( + msg: SubscribeTerminalRequest, + ownership: SessionDelivery, + ): Promise { if (!this.terminalManager) { this.emit({ type: "subscribe_terminal_response", @@ -687,17 +686,29 @@ export class TerminalSessionController { }); return; } - this.ensureExitSubscription(session); - if (msg.restore?.size) { - applyTerminalSize(session, this.terminalSizeOwner, { + let stream: ActiveTerminalStream | undefined; + const owner = ownership.begin( + "terminal-output", + undefined, + async () => { + if (!stream) return; + this.detachRegistration(stream); + await stream.snapshotTask; + }, + `terminal-output:${msg.terminalId}`, + ); + // COMPAT(ownedSubscriptions): added in v0.8.0, remove after 2027-03-09 once client floor >= v0.8.0. + if (!ownership.isModern(owner.source) && msg.restore?.size) { + applyTerminalSize(session, owner.source, { ...msg.restore.size, intent: "claim", }); } - const slot = this.bindActiveStream(session, { restore: msg.restore }); + const slot = this.bindActiveStream(session, owner, { restore: msg.restore }); if (slot === null) { + await owner.release(); this.sessionLogger.warn( { terminalId: msg.terminalId, @@ -721,22 +732,17 @@ export class TerminalSessionController { payload: { terminalId: msg.terminalId, slot, + subscriptionId: owner.responseId, error: null, requestId: msg.requestId, }, }); - const activeStream = this.activeStreams.get(slot); - if (activeStream) { - void this.trySendSnapshot(activeStream); - } + stream = this.activeStreams.get(slot); + if (stream) void this.trySendSnapshot(stream); } - private handleUnsubscribeTerminalRequest(msg: UnsubscribeTerminalRequest): void { - this.detachStream(msg.terminalId, { emitExit: false }); - } - - private handleTerminalInput(msg: TerminalInput): void { + private handleTerminalInput(msg: TerminalInput, source: object): void { if (!this.terminalManager) { return; } @@ -745,10 +751,9 @@ export class TerminalSessionController { this.sessionLogger.warn({ terminalId: msg.terminalId }, "Terminal not found for input"); return; } - this.ensureExitSubscription(session); if (msg.message.type === "resize") { - applyTerminalSize(session, this.terminalSizeOwner, msg.message); + applyTerminalSize(session, source, msg.message); return; } @@ -812,8 +817,6 @@ export class TerminalSessionController { return; } - this.ensureExitSubscription(session); - try { const capture = await this.terminalManager.captureTerminal(msg.terminalId, { start: msg.start, @@ -848,23 +851,13 @@ export class TerminalSessionController { private bindActiveStream( terminal: TerminalSession, + owner: OwnedSubscription, options?: { restore?: TerminalRestoreOptions }, ): number | null { if (!this.hasBinaryChannel()) { return null; } - const existingSlot = this.idToSlot.get(terminal.id); - if (typeof existingSlot === "number") { - const existingStream = this.activeStreams.get(existingSlot); - if (existingStream) { - existingStream.needsSnapshot = true; - existingStream.restore = options?.restore; - return existingSlot; - } - this.idToSlot.delete(terminal.id); - } - const slot = this.allocateSlot(); if (slot === null) { return null; @@ -873,9 +866,11 @@ export class TerminalSessionController { const activeStream: ActiveTerminalStream = { terminalId: terminal.id, slot, + owner, unsubscribe: () => {}, needsSnapshot: true, snapshotInFlight: false, + exiting: false, readyRevision: undefined, restore: options?.restore, bufferedOutputs: [], @@ -897,17 +892,21 @@ export class TerminalSessionController { // (e.g. the multiplexed relay socket); there we can't tell a slow client // from a fast one, so fall back unconditionally at the byte threshold to // keep a slow relay client from falling unboundedly behind. - const clientBufferedAmount = this.getClientBufferedAmount(); + const clientBufferedAmount = this.getClientBufferedAmount(owner.source); if ( + !activeStream.exiting && activeStream.outputBytesSinceSnapshot > MAX_TERMINAL_OUTPUT_FRAME_BYTES && (clientBufferedAmount === null || clientBufferedAmount > MAX_CLIENT_BUFFERED_BYTES) ) { + // The snapshot replaces this batch only after it succeeds. If the + // terminal disappears during the read, completion still owns these bytes. + activeStream.snapshotOutput = payload; activeStream.restore = resolveRestoreAfterOutputOverflow(activeStream.restore); activeStream.needsSnapshot = true; void this.trySendSnapshot(activeStream); return; } - this.emitBinary( + activeStream.owner.emitBinary( encodeTerminalStreamFrame({ opcode: TerminalStreamOpcode.Output, slot, @@ -919,9 +918,8 @@ export class TerminalSessionController { }; this.activeStreams.set(slot, activeStream); - this.idToSlot.set(terminal.id, slot); - activeStream.unsubscribe = terminal.subscribe( + const unsubscribeOutput = terminal.subscribe( (message) => { if (this.activeStreams.get(slot) !== activeStream) { return; @@ -950,10 +948,24 @@ export class TerminalSessionController { }, { initialSnapshot: resolveTerminalSubscriptionSnapshotMode(options?.restore) }, ); + const unsubscribeExit = terminal.onExit(() => + this.detachStream(terminal.id, { emitExit: true }), + ); + activeStream.unsubscribe = () => { + unsubscribeOutput(); + unsubscribeExit(); + }; return slot; } - private async trySendSnapshot(activeStream: ActiveTerminalStream): Promise { + private trySendSnapshot(activeStream: ActiveTerminalStream): Promise { + if (activeStream.snapshotInFlight) return activeStream.snapshotTask ?? Promise.resolve(); + const task = this.sendSnapshot(activeStream); + activeStream.snapshotTask = task; + return task; + } + + private async sendSnapshot(activeStream: ActiveTerminalStream): Promise { if ( this.activeStreams.get(activeStream.slot) !== activeStream || !activeStream.needsSnapshot || @@ -986,15 +998,32 @@ export class TerminalSessionController { if (!snapshotResult.shouldContinue) { return; } + activeStream.snapshotOutput = undefined; this.replayTerminalOutputAfterSnapshot(activeStream, terminal, snapshotResult.replayRevision); activeStream.needsSnapshot = false; activeStream.outputBytesSinceSnapshot = 0; } catch (error) { + if (this.activeStreams.get(activeStream.slot) !== activeStream) return; this.sessionLogger.warn( { err: error, terminalId: activeStream.terminalId }, "Failed to pull terminal snapshot", ); - activeStream.needsSnapshot = true; + // Natural completion owns the buffered final bytes and waits for this read. + if (activeStream.exiting) return; + activeStream.owner.emit({ + type: "terminal_stream_exit", + payload: { + terminalId: activeStream.terminalId, + error: error instanceof Error ? error.message : "Unable to read terminal snapshot", + }, + }); + // Cleanup removes this slot/listeners synchronously, then awaits this task. + // Awaiting release here would make the snapshot task wait for itself. + void activeStream.owner + .release() + .catch((releaseError) => + this.sessionLogger.warn({ err: releaseError }, "Failed to release terminal stream"), + ); } finally { activeStream.snapshotInFlight = false; } @@ -1005,7 +1034,7 @@ export class TerminalSessionController { terminalManager: TerminalManager, ): Promise { const snapshot = await terminalManager.getTerminalState(activeStream.terminalId, { - includeWrapFlags: this.clientSupportsWrapReflow(), + includeWrapFlags: this.clientSupportsWrapReflow(activeStream.owner.source), }); if (this.activeStreams.get(activeStream.slot) !== activeStream) { return { shouldContinue: false }; @@ -1015,7 +1044,7 @@ export class TerminalSessionController { return { shouldContinue: false }; } - this.emitBinary( + activeStream.owner.emitBinary( encodeLegacyTerminalSnapshotFrame({ slot: activeStream.slot, snapshot, @@ -1039,7 +1068,7 @@ export class TerminalSessionController { const snapshot = await terminalManager.getTerminalState(activeStream.terminalId, { ...snapshotOptions, - includeWrapFlags: this.clientSupportsWrapReflow(), + includeWrapFlags: this.clientSupportsWrapReflow(activeStream.owner.source), }); if (this.activeStreams.get(activeStream.slot) !== activeStream) { return { shouldContinue: false }; @@ -1049,7 +1078,7 @@ export class TerminalSessionController { return { shouldContinue: false }; } - this.emitBinary( + activeStream.owner.emitBinary( encodeTerminalRestoreFrame({ slot: activeStream.slot, snapshot, @@ -1100,33 +1129,44 @@ export class TerminalSessionController { } private detachStream(terminalId: string, options?: { emitExit: boolean }): boolean { - const slot = this.idToSlot.get(terminalId); - if (typeof slot !== "number") { - return false; + let detached = false; + for (const stream of this.activeStreams.values()) { + if (stream.terminalId !== terminalId) continue; + detached = true; + if (stream.exiting) continue; + stream.exiting = true; + const completion = options?.emitExit ? this.completeStream(stream) : stream.owner.release(); + void completion.catch((error) => + this.sessionLogger.warn({ err: error }, "Failed to release terminal stream"), + ); } - const activeStream = this.activeStreams.get(slot); - if (!activeStream) { - this.idToSlot.delete(terminalId); - return false; + return detached; + } + + private async completeStream(stream: ActiveTerminalStream): Promise { + // Preserve bootstrap/snapshot ordering. Explicit release can remove this + // registration while the read is pending, in which case it discards delivery. + await stream.snapshotTask; + if (this.activeStreams.get(stream.slot) !== stream) return; + if (stream.snapshotOutput) { + stream.outputCoalescer.handle(stream.snapshotOutput.toString("utf8")); + stream.snapshotOutput = undefined; } - activeStream.outputCoalescer.flush(); - activeStream.bufferedOutputs.length = 0; - this.activeStreams.delete(slot); - this.idToSlot.delete(terminalId); - try { - activeStream.unsubscribe(); - } catch (error) { - this.sessionLogger.warn({ err: error }, "Failed to unsubscribe terminal stream"); + for (const output of stream.bufferedOutputs.splice(0)) { + stream.outputCoalescer.handle(output.data); } - if (options?.emitExit) { - this.emit({ - type: "terminal_stream_exit", - payload: { - terminalId: activeStream.terminalId, - }, - }); - } - return true; + // Completion must not turn this final flush into another backpressure read. + stream.outputCoalescer.flush(); + stream.owner.emit({ type: "terminal_stream_exit", payload: { terminalId: stream.terminalId } }); + await stream.owner.release(); + } + + private detachRegistration(stream: ActiveTerminalStream): void { + this.activeStreams.delete(stream.slot); + stream.outputCoalescer.dispose(); + stream.snapshotOutput = undefined; + stream.bufferedOutputs.length = 0; + stream.unsubscribe(); } } diff --git a/packages/server/src/terminal/terminal-size-ownership.ts b/packages/server/src/terminal/terminal-size-ownership.ts index 626ae8a634..788a771a0c 100644 --- a/packages/server/src/terminal/terminal-size-ownership.ts +++ b/packages/server/src/terminal/terminal-size-ownership.ts @@ -6,7 +6,7 @@ interface TerminalSizeRequest { intent?: "claim" | "update"; } -const terminalSizeOwners = new WeakMap(); +const terminalSizeOwners = new WeakMap>(); export function applyTerminalSize( terminal: TerminalSession, @@ -14,12 +14,12 @@ export function applyTerminalSize( request: TerminalSizeRequest, ): boolean { const intent = resolveTerminalSizeIntent(request.intent); - if (intent === "update" && terminalSizeOwners.get(terminal) !== owner) { + if (intent === "update" && terminalSizeOwners.get(terminal)?.deref() !== owner) { return false; } if (intent === "claim") { - terminalSizeOwners.set(terminal, owner); + terminalSizeOwners.set(terminal, new WeakRef(owner)); } const currentSize = terminal.getSize(); diff --git a/plugin-examples/local-plugin/client/main.tsx b/plugin-examples/local-plugin/client/main.tsx index 172639e183..2bd574dc26 100644 --- a/plugin-examples/local-plugin/client/main.tsx +++ b/plugin-examples/local-plugin/client/main.tsx @@ -13,6 +13,7 @@ import { incrementRpc } from "../shared/increment"; export function contributeClient(client: PluginClientContext) { const pills = new Map(); let stopped = false; + const lifetime = new AbortController(); const register = (agent: { id: string; workspaceId?: string | null }) => { if (stopped || !agent.workspaceId) return; pills.get(agent.id)?.remove(); @@ -39,20 +40,30 @@ export function contributeClient(client: PluginClientContext) { pills.get(agentId)?.remove(); pills.delete(agentId); }; - const unsubscribe = client.paseo.agents.subscribe((update) => { - if (update.kind === "remove") remove(update.agentId); - else register(update.agent); - }); void client.paseo.agents - .list() - .then(({ entries }) => { - for (const { agent } of entries) register(agent); + .list({ subscribe: {}, signal: lifetime.signal }) + .then(({ subscription }) => { + subscription.subscribe({ + snapshot: ({ entries }) => { + for (const pill of pills.values()) pill.remove(); + pills.clear(); + for (const { agent } of entries) register(agent); + }, + update: (message) => { + if (message.type !== "agent_update") return; + const update = message.payload; + if (update.kind === "remove") remove(update.agentId); + else register(update.agent); + }, + }); return undefined; }) - .catch(() => undefined); + .catch((error) => { + if (!stopped) console.error("Agent observation failed", error); + }); return () => { stopped = true; - unsubscribe(); + lifetime.abort(); for (const pill of pills.values()) pill.remove(); pills.clear(); }; diff --git a/public-docs/plugins/v0.8/reference.md b/public-docs/plugins/v0.8/reference.md index 3677956a75..7f0d98319a 100644 --- a/public-docs/plugins/v0.8/reference.md +++ b/public-docs/plugins/v0.8/reference.md @@ -1357,6 +1357,10 @@ const pill = client.addComposerPill({ }); ``` +For pills that follow the agent directory, use an explicit [owned list subscription](/docs/sdk/events#agent-directory). +The [local plugin example](https://github.com/getpaseo/paseo/blob/main/plugin-examples/local-plugin/client/main.tsx) +replaces registrations on each snapshot and aborts the observation during entry cleanup, including pending bootstrap. + ## Button descriptor These contracts are exported from `@getpaseo/plugin/client`. diff --git a/public-docs/sdk/agents.md b/public-docs/sdk/agents.md index 565bedbef8..fa3ed80c00 100644 --- a/public-docs/sdk/agents.md +++ b/public-docs/sdk/agents.md @@ -156,12 +156,17 @@ The handle exposes `status`, `capabilities`, `availableModes`, `pendingPermissio A handle from `ref()` has observed nothing, so every one of them is `null` until `refresh()`, `run()`, `waitForFinish()`, a timeline refetch, or `subscribe()` delivers a snapshot. Optional values in an observed snapshot also read as `null`. Use `current()` when you need the whole snapshot or need to distinguish those states. -`subscribe()` keeps the properties current, so a long-lived handle can poll them without another RPC: +`subscribe()` is a local listener. An owned agent-directory observation supplies its updates: ```ts const unsubscribe = agent.subscribe(() => { if (agent.status === "error") console.error(agent.lastError); }); +const directory = await client.agents.list({ subscribe: {} }); + +// When this view closes: +unsubscribe(); +await directory.subscription.release(); ``` ## List the commands a session loaded diff --git a/public-docs/sdk/events.md b/public-docs/sdk/events.md index dafba6bcb3..03f69907fa 100644 --- a/public-docs/sdk/events.md +++ b/public-docs/sdk/events.md @@ -8,39 +8,55 @@ category: TypeScript SDK # SDK events -Subscriptions report changes after they happen. Fetch an initial snapshot first, then apply updates to it. - -Every `subscribe()` method returns an unsubscribe function. Timeline, project, and provider subscriptions establish network demand and restore it after reconnect. Unsubscribing releases that demand when its last listener leaves. Agent and workspace directories use the explicit `list({ subscribe })` bootstrap shown below. Unsubscribing never stops or archives the underlying resource. +Use an owned subscription to fetch a snapshot and follow its changes. Connecting, plain reads, and local listeners do not start observation. Each observation gets a new server-issued ID, even when its filter matches another observation. ## Follow one agent's status -The daemon sends agent-directory updates only after the connection opens an agent-list subscription: - ```ts -await client.agents.list({ +const directory = await client.agents.list({ filter: { includeArchived: false }, - subscribe: { subscriptionId: "issue-board-agents" }, + subscribe: {}, }); -const agent = client.agents.ref(agentId); -await agent.refresh(); - -const unsubscribe = agent.subscribe((update) => { - if (update.kind === "upsert") { - console.log(update.agent.status); - } else { - console.log("Agent removed from the active directory"); - } +directory.subscription.subscribe({ + snapshot({ entries }) { + const entry = entries.find(({ agent }) => agent.id === agentId); + console.log(entry?.agent.status); + }, + update(message) { + if (message.type !== "agent_update") return; + const update = message.payload; + if (update.kind === "upsert" && update.agent.id === agentId) { + console.log(update.agent.status); + } else if (update.kind === "remove" && update.agentId === agentId) { + console.log("Agent removed from this directory"); + } + }, }); + +// When this view closes: +await directory.subscription.release(); ``` -The handle updates its properties and `current()` value before it calls your handler. +`list({ subscribe: {} })` returns the snapshot, `subscriptionId`, and `subscription`. Omitting `subscribe` returns only a snapshot. Do not supply a subscription ID. The subscription delivers its snapshot before updates and receives a new ID and snapshot after reconnect. Releasing it leaves other observations and the underlying agents intact. + +`client.agents.subscribe()` and agent-handle `subscribe()` add local listeners to observations owned by that API instance. They do not request data. Use the returned subscription's callbacks when multiple filtered views need separate updates. ## Follow timeline events ```ts const unsubscribe = agent.timeline.subscribe((update) => { const { event } = update; + if (event.type === "snapshot") { + // Replace your recent history view; these are complete projected entries, + // not additional live message fragments. + console.log("Reconnected", event.page.epoch, event.page.entries); + return; + } + if (event.type === "error") { + console.error("Timeline observation stopped:", event.error); + return; + } if (event.type === "replacement") { // Previously fetched history belongs to an old epoch. Fetch the page your UI needs. void agent.timeline.refetch().then((page) => console.log(page.entries)); @@ -56,7 +72,11 @@ const unsubscribe = agent.timeline.subscribe((update) => { }); ``` -Await `unsubscribe.ready` before starting work whose events you need to observe. It resolves when the daemon acknowledges the subscription and rejects if establishment fails. Call `unsubscribe()` to release it. +Await `unsubscribe.ready` before starting work whose events you need to observe. It acknowledges the initial live subscription; initial history is a separate [read](#fetch-timeline-history). Call `unsubscribe()` to release demand, or await `unsubscribe.release()` for teardown. + +After reconnect, the same handle receives `{ agentId, subscriptionId, event: { type: "snapshot", reason: "reconnect", page } }` before subsequent updates. The host assigns a fresh subscription ID. `page` is the latest 100 projected history entries, with the same epoch, cursors and paging flags as `timeline.refetch()`. Replace your recent view with this page; do not concatenate it with earlier message fragments. If `hasOlder` is true, fetch older pages with `page.startCursor`. Compare epochs before retaining any previously loaded history: a reconnect may preserve or replace the epoch. A live `replacement` event still means the previous epoch is invalid. + +Persisted history is recovered; transient events such as an offline turn-completion notification are not replayed. Recovery buffers at most 128 current-connection updates. Overflow or a concurrent replacement discards the obsolete buffer and reads a fresh page. A recovery read failure delivers `{ agentId, event: { type: "error", error } }` and releases that observation; establish a new subscription when ready to retry. Release suppresses pending recovery callbacks. Each same-agent subscription recovers independently. Assistant messages can arrive in pieces. Concatenate their text when you need a complete message, or use `run()` and read `lastMessage` when you only need the final reply. @@ -72,7 +92,7 @@ const page = await agent.timeline.refetch({ }); for (const entry of page.entries) { - console.log(entry.seq, entry.event.type); + console.log(entry.seqStart, entry.seqEnd, entry.item.type); } ``` @@ -80,24 +100,25 @@ Use `startCursor`, `endCursor`, `hasOlder`, and `hasNewer` from the result to pa ## Follow workspace updates -Workspace updates also require a directory subscription: - ```ts -await client.workspaces.list({ - subscribe: { subscriptionId: "issue-board-workspaces" }, +const directory = await client.workspaces.list({ subscribe: {} }); +directory.subscription.subscribe({ + snapshot({ entries }) { + console.log(entries); + }, + update(message) { + if (message.type === "workspace_update") console.log(message.payload); + }, }); -const workspace = client.workspaces.ref(workspaceId); -const unsubscribe = workspace.subscribe((update) => { - if (update.kind === "upsert") { - console.log(update.workspace.status); - } -}); +// When this view closes: +await directory.subscription.release(); ``` ## Follow provider catalog changes ```ts +const observation = client.observeEvents(["providers_snapshot_update"]); const unsubscribe = client.providers.subscribe((update) => { const ready = update.entries.filter((entry) => entry.status === "ready"); console.log( @@ -105,6 +126,14 @@ const unsubscribe = client.providers.subscribe((update) => { ready.map((entry) => entry.provider), ); }); +await observation.ready; +console.log(await client.providers.snapshot()); + +// When this view closes: +unsubscribe(); +await observation.release(); ``` -Always call the returned unsubscribe functions before discarding the owning object. Call `client.close()` when the application no longer needs the daemon connection. +Project listeners work the same way: request `client.observeEvents(["project.update"])` before relying on `client.projects.subscribe()`. For an initial project cache, buffer updates while awaiting `client.projects.list()`, then apply them after the snapshot. + +Call `client.close()` when the application no longer needs the connection. Observation requires a host that advertises independent subscriptions; an older host returns an update-host error. Plain reads remain available. diff --git a/public-docs/sdk/reference.md b/public-docs/sdk/reference.md index 85e4d60b9d..960404c58a 100644 --- a/public-docs/sdk/reference.md +++ b/public-docs/sdk/reference.md @@ -53,7 +53,9 @@ Create a new client after `close()`. | `list(options?)` | `PaseoAgentListResult` | Lists a page of agents. `scope`, `filter`, `sort`, `page`, and `subscribe` match the daemon directory query. | | `create(options)` | `PaseoAgentHandle` | Creates an agent and a fresh workspace for `cwd`. Requires `config`. | | `ref(agentOrId)` | `PaseoAgentHandle` | Creates a local handle without fetching. | -| `subscribe(handler)` | Unsubscribe function | Listens for connection-local agent directory updates. Call `list({ subscribe })` first. | +| `subscribe(handler)` | Unsubscribe function | Local listener for this API instance. Requires an owned `list({ subscribe: {} })` observation. | + +`list({ subscribe: {} })` also returns a server-issued `subscriptionId` and an owned `subscription`. Its `subscribe({ snapshot, update, error? })` callbacks receive the snapshot before scoped wire updates; `release()` ends that observation. Plain lists create no observation. The same contract applies to workspace lists. See [events](./events.md). Creation options include `config`, `cwd`, `parent`, `title`, `prompt`, `env`, `outputSchema`, `images`, `attachments`, `git`, `worktree`, `autoArchive`, and `labels`. @@ -108,27 +110,27 @@ Creation options include `config`, `cwd`, `parent`, `title`, `prompt`, `env`, `o `agent.timeline.refetch(options?)` fetches a page. Options are `direction`, `cursor`, `limit`, `projection`, and `requestId`. -`agent.timeline.subscribe(handler)` establishes network demand for this agent and restores it after reconnect. Its unsubscribe function releases that demand; await `unsubscribe.ready` for initial daemon acknowledgement before starting work. The handler also receives `{ agentId, event: { type: "replacement", epoch } }` when history is replaced; refetch the page you need. See [events](./events.md#follow-timeline-events). +`agent.timeline.subscribe(handler)` establishes network demand for this agent and restores it after reconnect. Its unsubscribe function releases that demand; await `unsubscribe.ready` for initial daemon acknowledgement before starting work. Initial delivery is live-only. Reconnect delivers a bounded projected history snapshot before later updates; a live replacement invalidates the previous epoch. See the callback shapes, paging and failure behavior in [timeline events](./events.md#follow-timeline-events). ## `client.projects` -| Method | Result | Behavior | -| -------------------- | ------------------------ | --------------------------------------------------------------------------------------- | -| `list(options?)` | `PaseoProjectListResult` | Lists every registered project, including projects with no active workspaces. | -| `subscribe(handler)` | Unsubscribe function | Listens only for future project upserts/removals. Pair with `list()` for initial state. | +| Method | Result | Behavior | +| -------------------- | ------------------------ | ---------------------------------------------------------------------------------------------- | +| `list(options?)` | `PaseoProjectListResult` | Lists every registered project, including projects with no active workspaces. | +| `subscribe(handler)` | Unsubscribe function | Local listener. Requires `observeEvents(["project.update"])`; `list()` supplies initial state. | -To build a complete project cache without an initialization gap, subscribe and buffer updates before awaiting `list()`. Initialize the cache from the list result, then apply buffered updates in arrival order. +See [events](./events.md#follow-provider-catalog-changes) for explicit event observation and cleanup. ## `client.workspaces` -| Method | Result | Behavior | -| ------------------------ | ----------------------------- | --------------------------------------------------------------------------------- | -| `list(options?)` | `PaseoWorkspaceListResult` | Lists, filters, pages, or subscribes to the workspace directory. | -| `open(cwd)` | `PaseoWorkspaceHandle` | Reuses the active workspace for a directory or creates one. | -| `create(options)` | `PaseoWorkspaceHandle` | Always creates a fresh directory-backed or Paseo-worktree workspace. | -| `ref(workspaceOrId)` | `PaseoWorkspaceHandle` | Creates a local handle. | -| `archive(workspaceOrId)` | `PaseoWorkspaceArchiveResult` | Archives without first creating a handle. | -| `subscribe(handler)` | Unsubscribe function | Listens for connection-local workspace updates. Call `list({ subscribe })` first. | +| Method | Result | Behavior | +| ------------------------ | ----------------------------- | ---------------------------------------------------------------------------------------------- | +| `list(options?)` | `PaseoWorkspaceListResult` | Lists, filters, pages, or subscribes to the workspace directory. | +| `open(cwd)` | `PaseoWorkspaceHandle` | Reuses the active workspace for a directory or creates one. | +| `create(options)` | `PaseoWorkspaceHandle` | Always creates a fresh directory-backed or Paseo-worktree workspace. | +| `ref(workspaceOrId)` | `PaseoWorkspaceHandle` | Creates a local handle. | +| `archive(workspaceOrId)` | `PaseoWorkspaceArchiveResult` | Archives without first creating a handle. | +| `subscribe(handler)` | Unsubscribe function | Local listener for this API instance. Requires an owned `list({ subscribe: {} })` observation. | A workspace handle exposes `id`, `projectId`, `directory`, `name`, `status`, `current()`, `refresh()`, `setTitle(title)`, `archive()`, and `subscribe()`. Pass `null` to `setTitle` to restore the derived workspace name. Use `workspace.agents.create(options)` to create an agent without repeating the workspace ID or directory. @@ -185,7 +187,7 @@ Use `workspace.terminals.create(options?)` and `workspace.terminals.list(options | `listFeatures(draftConfig)` | Features result | Discovers features for the current draft provider configuration. | | `diagnostic(provider)` | Diagnostic result | Returns human-readable setup diagnostics. | | `listUsage(options?)` | `PaseoProviderUsageResult` | Returns normalized subscription windows, balances, and provider details. Rejects with an update-host error when unsupported. Options: `requestId`. | -| `subscribe(handler)` | Unsubscribe function | Listens for catalog updates. | +| `subscribe(handler)` | Unsubscribe function | Local listener. Requires `observeEvents(["providers_snapshot_update"])`. | ## `client.config`