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.
This commit is contained in:
Mohamed Boudra
2026-09-09 22:43:18 +02:00
parent ec4e81ce0f
commit 9b09ba7d48
198 changed files with 9341 additions and 4491 deletions
+2 -2
View File
@@ -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 ...
+14 -3
View File
@@ -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
+10
View File
@@ -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
+11
View File
@@ -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:
@@ -205,7 +205,7 @@ test.describe("Worktree restore", () => {
"restore-success",
);
await worktreeClient.fetchWorkspaces({
subscribe: { subscriptionId: `restore-secondary-${randomUUID()}` },
subscribe: {},
});
let updateTimeout: ReturnType<typeof setTimeout> | null = null;
let unsubscribeSecondaryWorkspaceUpdate = () => {};
@@ -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<StreamViewportHandle>();
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<StreamViewportHandle>();
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 () => {
@@ -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.
+7 -25
View File
@@ -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,
]);
+92 -118
View File
@@ -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<ReturnType<typeof setTimeout> | null>(null);
const attentionNotifiedRef = useRef<Map<string, number>>(new Map());
const appStateRef = useRef(AppState.currentState);
const forcedTimelineTailReplacements = useRef(new Set<string>());
const viewedTimelineSyncRef = useRef<ViewedTimelineOwner | null>(null);
const audioOutputBuffersRef = useRef<Map<string, BufferedAudioChunk[]>>(new Map());
const activeAudioGroupsRef = useRef<Set<string>>(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,
+91 -39
View File
@@ -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<T extends { subscriptionId: string }>(
snapshot: T,
types: RouterMessageType[],
released: () => void,
matches: (message: RouterMessage) => boolean = () => true,
): OwnedSubscription<T> {
const observers = new Set<SubscriptionObserver<T>>();
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",
+113 -184
View File
@@ -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<SessionOutboundMessage, { type: "checkout_diff_update" }>;
type SubscribeCheckoutDiffResponseMessage = Extract<
SessionOutboundMessage,
{ type: "subscribe_checkout_diff_response" }
>;
type StatusMessage = Extract<SessionOutboundMessage, { type: "status" }>;
type TerminalsChangedMessage = Extract<SessionOutboundMessage, { type: "terminals_changed" }>;
type ServerDataEventType =
| "providers_snapshot_update"
| "checkout_diff_update"
| "subscribe_checkout_diff_response"
| "status"
| "terminals_changed";
type CheckoutDiffResponsePayload = SubscribeCheckoutDiffResponseMessage["payload"];
type CheckoutDiffCachePayload = Omit<CheckoutDiffResponsePayload, "subscriptionId">;
type ListTerminalsPayload = ListTerminalsResponse["payload"];
@@ -53,6 +47,10 @@ interface CheckoutDiffRoute {
compare: CheckoutDiffCompare;
}
interface CheckoutDiffRegistration extends CheckoutDiffRoute {
subscription: OwnedSubscription<CheckoutDiffResponsePayload>;
}
interface WorkspaceTerminalsRoute {
domain: "workspaceTerminals";
enabled: boolean;
@@ -61,6 +59,10 @@ interface WorkspaceTerminalsRoute {
workspaceId?: string;
}
interface WorkspaceTerminalsRegistration extends WorkspaceTerminalsRoute {
subscription: OwnedSubscription<TerminalsChangedMessage["payload"]>;
}
type ServerDataRoute = CheckoutDiffRoute | WorkspaceTerminalsRoute;
export interface ServerDataQueryMeta extends Record<string, unknown> {
@@ -71,18 +73,9 @@ export type ProvidersSnapshotUpdate = ProvidersSnapshotUpdateMessage;
interface ServerDataPushClient {
getProvidersSnapshot: import("@getpaseo/client/internal/daemon-client").DaemonClient["getProvidersSnapshot"];
on<TType extends ServerDataEventType>(
type: TType,
handler: (message: Extract<SessionOutboundMessage, { type: TType }>) => void,
): () => void;
subscribeCheckoutDiff(
cwd: string,
compare: CheckoutDiffCompare,
options: { subscriptionId: string; requestId?: string },
): Promise<CheckoutDiffResponsePayload>;
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<string, Set<() => 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<string, CheckoutDiffRoute>();
const activeTerminalSubscriptions = new Map<string, WorkspaceTerminalsRoute>();
const activeCheckoutDiffSubscriptions = new Map<string, CheckoutDiffRegistration>();
const activeTerminalSubscriptions = new Map<string, WorkspaceTerminalsRegistration>();
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<string, CheckoutDiffRoute>;
active: Map<string, CheckoutDiffRegistration>;
client: ServerDataPushClient;
desired: Map<string, CheckoutDiffRoute>;
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<CheckoutDiffResponsePayload, "requestId"> & { 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<string, WorkspaceTerminalsRoute>;
active: Map<string, WorkspaceTerminalsRegistration>;
client: ServerDataPushClient;
desired: Map<string, WorkspaceTerminalsRoute>;
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<string, CheckoutDiffRoute>;
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<string, CheckoutDiffRoute>;
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<string, CheckoutDiffRoute>;
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;
}
@@ -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: {
+26 -15
View File
@@ -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]);
+1 -1
View File
@@ -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(
@@ -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]);
}
@@ -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,
@@ -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({
@@ -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;
},
+9 -13
View File
@@ -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();
}
@@ -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([]);
});
+1 -1
View File
@@ -8,7 +8,7 @@ vi.mock("./navigation", () => ({
}));
vi.mock("./client-runtime", () => ({
createPluginClientRuntime: () => ({
paseo: {},
paseo: { dispose: async () => {} },
rpc: async () => undefined,
openSurface: () => undefined,
openPanel: () => undefined,
+26 -6
View File
@@ -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<ReturnType<DaemonClient["getPluginCatalog"]>>[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) => {
@@ -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 (
<QueryClientProvider client={plugin.queryClient}>
<PaseoApiProvider paseo={runtime.paseo}>
+2 -4
View File
@@ -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 <Text style={styles.message}>{t("settings.plugins.screens.offline")}</Text>;
// COMPAT(pluginSettings): added in v0.8, remove after 2027-03-05.
if (!supported) return <Text style={styles.message}>{t("settings.plugins.screens.update")}</Text>;
const runtime = createPluginSurfaceRuntime(client, pluginId);
if (!plugin || !screen || !runtime)
if (!plugin || !screen || !client)
return <Text style={styles.message}>{t("settings.plugins.screens.unavailable")}</Text>;
const Component = screen.Component;
return (
@@ -113,7 +111,7 @@ function SettingsContent({
resetKey={attempt}
renderError={renderError}
>
<PluginRuntimeBoundary plugin={plugin} runtime={runtime}>
<PluginRuntimeBoundary plugin={plugin} client={client}>
<Component theme={theme} layout={layout} host={host} />
</PluginRuntimeBoundary>
</SurfaceErrorBoundary>
@@ -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 }],
@@ -16,6 +16,7 @@ function installation(
id: pluginId,
serverId,
clientBundle: serverId,
lifetime: new AbortController(),
queryClient: new QueryClient(),
cleanup: () => undefined,
settingsScreens: [],
@@ -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();
});
});
+30 -4
View File
@@ -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<InstalledPlugin, "id" | "lifetime">,
): 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;
}
+6 -7
View File
@@ -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<PluginSurfaceProps>;
runtime: NonNullable<ReturnType<typeof createPluginSurfaceRuntime>>;
client: DaemonClient;
plugin: NonNullable<ReturnType<typeof useInstalledPlugin>>;
layout: PluginSurfaceProps["layout"];
host: PluginSurfaceProps["host"];
@@ -70,7 +70,7 @@ function SurfaceRenderer({
}) {
const navigation = usePluginHostNavigation(host.id);
return (
<PluginRuntimeBoundary plugin={plugin} runtime={runtime}>
<PluginRuntimeBoundary plugin={plugin} client={client}>
<Surface theme={theme} host={host} layout={layout} navigation={navigation} />
</PluginRuntimeBoundary>
);
@@ -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() {
<View style={styles.screen}>
<ScreenHeader left={headerLeft} right={headerRight} />
<View style={styles.body}>
{plugin && surface && runtime ? (
{plugin && surface && client ? (
<SurfaceErrorBoundary
key={`${serverId}/${pluginId}/${identity?.kind}/${contributionId}`}
installation={plugin}
@@ -233,7 +232,7 @@ export function PluginSurfaceScreen() {
>
<ThemedSurfaceRenderer
Surface={surface.Component}
runtime={runtime}
client={client}
plugin={plugin}
host={host}
layout={layout}
+1
View File
@@ -49,6 +49,7 @@ function installed(serverId: string, themes: PluginThemeContribution[]): Install
cleanup: () => undefined,
serverId,
clientBundle: serverId,
lifetime: new AbortController(),
queryClient: new QueryClient(),
settingsScreens: [],
surfaces: [],
@@ -11,6 +11,7 @@ function plugin(input: {
id: input.id,
serverId: "host-1",
clientBundle: "bundle",
lifetime: new AbortController(),
queryClient: new QueryClient(),
cleanup: () => {},
settingsScreens: [],
+158 -9
View File
@@ -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(
<PluginTimelineItemView serverId="host-1" agentId="agent-1" item={timelineItem} />,
const container = document.createElement("div");
containers.push(container);
const root = createRoot(container);
roots.push(root);
await act(async () =>
root.render(
<PluginTimelineItemView serverId="host-1" agentId="agent-1" item={timelineItem} />,
),
);
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<DaemonTransport["onMessage"]>[0] = () => {};
let opened: () => void = () => {};
let nextId = 0;
const retained = new Set<string>();
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(
<StrictMode>
<PluginTimelineItemView serverId="host-1" agentId="agent-1" item={item} />
</StrictMode>,
),
);
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();
}
});
+2 -7
View File
@@ -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 <TimelineItemUnavailable />;
}
@@ -91,7 +86,7 @@ function PluginTimelineItemBody({
};
return (
<SurfaceErrorBoundary installation={plugin} resetKey={item.data} Surface={Component}>
<PluginRuntimeBoundary plugin={plugin} runtime={runtime}>
<PluginRuntimeBoundary plugin={plugin} client={client}>
<PluginClientStateProvider source={stateSource}>
<Component {...props} />
</PluginClientStateProvider>
+1
View File
@@ -38,6 +38,7 @@ export interface EvaluatedPlugin {
}
export interface InstalledPlugin extends EvaluatedPlugin {
lifetime: AbortController;
serverId: string;
requirements?: PluginRequirements;
clientBundle: string;
@@ -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 <PluginPanelUnavailable />;
}
if (!runtime) {
if (!client) {
return <PluginPanelUnavailable message="Plugin host is offline." />;
}
@@ -110,7 +105,7 @@ function PluginPanelBody({ theme }: { theme: PluginTheme }) {
Surface={Surface}
key={`${serverId}/${target.pluginId}/${target.panelId}/${target.context}`}
>
<PluginRuntimeBoundary plugin={plugin} runtime={runtime}>
<PluginRuntimeBoundary plugin={plugin} client={client}>
<PluginClientStateProvider source={stateSource}>{panel}</PluginClientStateProvider>
</PluginRuntimeBoundary>
</SurfaceErrorBoundary>
@@ -8,6 +8,7 @@ function installed(): InstalledPlugin {
id: "review",
serverId: "host-1",
clientBundle: "bundle",
lifetime: new AbortController(),
queryClient: new QueryClient(),
cleanup: () => {},
settingsScreens: [],
@@ -22,6 +22,8 @@ import {
type DirectoryCheckpointStorage,
} from "./index";
import { subscriptionFixture } from "../subscription-fixture";
type WorkspaceFetchResult = Awaited<ReturnType<DaemonClient["fetchWorkspaces"]>>;
type ProjectListResult = Awaited<ReturnType<DaemonClient["listProjects"]>>;
type AgentFetchResult = Awaited<ReturnType<DaemonClient["fetchAgents"]>>;
@@ -123,6 +125,25 @@ class FakeDirectoryClient {
};
}
observeAgents(options: Parameters<DaemonClient["observeAgents"]>[0]) {
return subscriptionFixture(this.fetchAgents({ ...options, subscribe: {} }), (receive) =>
this.on("agent_update", receive),
);
}
observeWorkspaces(options: Parameters<DaemonClient["observeWorkspaces"]>[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;
+100 -136
View File
@@ -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<ReturnType<DaemonClient["fetchAgents"]>>;
type FetchWorkspacesPayload = Awaited<ReturnType<DaemonClient["fetchWorkspaces"]>>;
const PAGE_LIMIT = 200;
const AGENT_SORT: NonNullable<FetchAgentsOptions["sort"]> = [
{ 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<FetchAgentsPayload> | null = null;
private workspaceSubscription: OwnedSubscription<FetchWorkspacesPayload> | null = null;
private eventSubscription: ReturnType<DaemonClient["observeEvents"]> | null = null;
private readonly abortSessionWaits = new Set<() => void>();
private cacheLoad: Promise<void> | 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<DaemonClient["observeWorkspaces"]>[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<FetchAgentsOptions, "subscribe"> = {
...(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: [],
}));
}
+81 -98
View File
@@ -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<DaemonClient["observeAgents"]>[0]) {
return subscriptionFixture(this.fetchAgents({ ...options, subscribe: {} }), (receive) =>
this.on("agent_update", receive),
);
}
observeEvents() {
return subscriptionFixture(Promise.resolve({}), () => () => {});
}
async waitForAgentUpdates(): Promise<void> {
if (this.agentUpdateListeners.size > 0) return;
await new Promise<void>((resolve) => {
@@ -119,8 +131,16 @@ class FakeDaemonClient {
return this.state;
}
getLastServerInfoMessage(): null {
return null;
public ownedSubscriptions = true;
getLastServerInfoMessage(): ReturnType<DaemonClient["getLastServerInfoMessage"]> {
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<DaemonClient | null> = [];
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<Awaited<ReturnType<DaemonClient["fetchAgents"]>>>();
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<Awaited<ReturnType<DaemonClient["fetchAgents"]>>>();
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);
+53 -41
View File
@@ -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<DaemonClient["fetchAgentTimeline"]>[1],
): Promise<Awaited<ReturnType<DaemonClient["fetchAgentTimeline"]>>> {
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(
@@ -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<T>(
snapshot: Promise<T>,
listen: (receive: (message: SessionOutboundMessage) => void) => () => void,
): OwnedSubscription<T> {
const subscriptionId = crypto.randomUUID();
const observers = new Set<SubscriptionObserver<T & { subscriptionId: string }>>();
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();
},
};
}
@@ -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() {
<View style={styles.logo}>
<PaseoLogo size={52} />
</View>
{hosts.map((host) => (
<HostError key={host.serverId} serverId={host.serverId} label={host.label} />
))}
<View style={styles.tiles}>
<HomeTile
icon={FolderOpen}
@@ -112,6 +117,15 @@ export function OpenProjectScreen() {
);
}
function HostError({ serverId, label }: { serverId: string; label: string }) {
const error = useHostRuntimeLastError(serverId);
return error ? (
<Text accessibilityRole="alert" style={styles.hostError}>
{label}: {error}
</Text>
) : 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%",
@@ -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<PluginListItem[]> => []),
@@ -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;
+3 -3
View File
@@ -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
@@ -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<void> {
@@ -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();
});
@@ -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<DaemonClient["observeTerminal"]> | 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<DaemonClient["observeTerminal"]>[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<DaemonClient["observeTerminal"]>;
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 });
}
}
+1 -2
View File
@@ -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 }),
@@ -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<T> {
@@ -36,7 +39,9 @@ interface TimelineFetch {
}
class TimelineWorld {
constructor(private readonly observeMembership?: ViewedTimelineSyncPorts["observe"]) {}
readonly errors: string[] = [];
readonly releasedMemberships: string[][] = [];
readonly cursors = new Map<string, { epoch: string; endSeq: number }>();
readonly cacheRequests: string[] = [];
readonly forcedTimelineTailReplacements = new Set<string>();
@@ -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<void>();
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();
});
@@ -308,10 +308,9 @@ function applyAuthoritativeTimelineResponse(input: {
}
export interface ViewedTimelineSyncPorts {
initialDeliveryMode: TimelineDeliveryMode;
prepare(agentId: string): Promise<void>;
replaceDemandedAgentIds(agentIds: string[]): void;
setSubscription(agentIds: string[]): Promise<void>;
observe(agentIds: string[]): { ready: Promise<unknown>; release(): Promise<void> };
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<string>();
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<ViewedTimelineSyncPorts["observe"]>;
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<void> => {
if (disposed || !connected || deliveryMode !== "selective") return;
const reconcileMembership = async (): Promise<void> => {
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;
+31 -12
View File
@@ -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;
@@ -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");
@@ -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<string, Agent>;
workspaces: Map<string, WorkspaceDescriptor>;
}
interface LegacyDaemonWorkspaceDirectoryReadResult {
entries: FetchAgentsEntry[];
subscriptionId: string | null;
}
const LEGACY_AGENT_DIRECTORY_SORT: NonNullable<FetchAgentsOptions["sort"]> = [
{ 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<DaemonClient, "fetchAgents">;
subscribe?: FetchAgentsOptions["subscribe"];
page?: FetchAgentsOptions["page"];
isCancelled?: () => boolean;
}): Promise<LegacyDaemonWorkspaceDirectoryReadResult | null> {
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<ReturnType<DaemonClient["fetchAgents"]>>["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<ReturnType<DaemonClient["fetchAgents"]>>["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<string, WorkspaceDescriptor> {
const workspaces = new Map<string, WorkspaceDescriptor>();
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<string, WorkspaceDescriptor> | 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;
}
@@ -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);
}
+12
View File
@@ -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
@@ -10,13 +10,12 @@ export async function subscribeToEvents(
url: string,
agentId: string,
workspaceId: string,
): Promise<() => void> {
): Promise<() => Promise<void>> {
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();
}
};
}
+437 -191
View File
@@ -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<string, unknown>;
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();
}
});
+7 -97
View File
@@ -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<Exclude<ClientCapability, typeof CLIENT_CAPS.browserHost>, true>;
/** Calling releases demand; ready acknowledges the initial daemon membership. */
export type TimelineSubscription = (() => void) & { readonly ready: Promise<void> };
class TimelineInterest {
resolve!: () => void;
reject!: (error: unknown) => void;
readonly ready = new Promise<void>((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<string>();
private timelines = new Map<string, Set<TimelineInterest>>();
private events: SessionEventSubscription[] = [];
constructor(
private readonly send: {
timelines(agentIds: string[]): Promise<void> | null;
events(events: SessionEventSubscription[]): Promise<void>;
failed(error: unknown): void;
},
) {}
private agentIds(): string[] {
return [...new Set([...this.viewed, ...this.timelines.keys()])].sort();
}
setViewed(agentIds: string[]): Promise<void> {
this.viewed = new Set(agentIds);
return this.syncTimelines();
}
observeTimeline(agentId: string): TimelineSubscription {
const interests = this.timelines.get(agentId) ?? new Set<TimelineInterest>();
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<void> {
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<void>;
readonly subscriptionId: string | null;
release(): Promise<void>;
};
@@ -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<Snapshot>(
(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();
});
+235
View File
@@ -0,0 +1,235 @@
import type { SessionOutboundMessage } from "@getpaseo/protocol/messages";
export type SubscriptionSnapshot<T> = T & { subscriptionId: string };
export interface SubscriptionObserver<T> {
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<T> {
readonly subscriptionId: string | null;
readonly ready: Promise<SubscriptionSnapshot<T>>;
subscribe(observer: SubscriptionObserver<SubscriptionSnapshot<T>>): () => void;
release(): Promise<void>;
}
interface Registration {
start(): void;
disconnected(): void;
receive(message: SessionOutboundMessage): void;
release(): Promise<void>;
}
export interface OwnedSubscriptionsHost {
release(subscriptionId: string): Promise<void>;
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<Registration>();
private readonly routes = new Map<string, Registration>();
private generation = 0;
private connected = false;
constructor(private readonly host: OwnedSubscriptionsHost) {}
observe<T>(
request: (accept: (snapshot: T) => void) => Promise<T>,
signal?: AbortSignal,
requestFailed?: () => void,
): OwnedSubscription<T> {
let id: string | null = null;
let snapshot: SubscriptionSnapshot<T> | undefined;
let pending: Promise<void> | null = null;
let teardown: Promise<void> | null = null;
let disposed = false;
let overflow = false;
const buffered: SessionOutboundMessage[] = [];
const observers = new Set<SubscriptionObserver<SubscriptionSnapshot<T>>>();
let resolve!: (snapshot: SubscriptionSnapshot<T>) => void;
let reject!: (error: unknown) => void;
const ready = new Promise<SubscriptionSnapshot<T>>((accept, fail) => {
resolve = accept;
reject = fail;
});
void ready.catch(() => {});
const deliver = (
observer: SubscriptionObserver<SubscriptionSnapshot<T>>,
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<T> = {
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<void> {
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");
}
@@ -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");
+180 -136
View File
@@ -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 () => {
+355 -360
View File
@@ -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<SessionInboundMessage, { type: "fetch_agents_request" }>;
export type FetchAgentsOptions = Omit<FetchAgentsRequest, "type" | "requestId"> & {
signal?: AbortSignal;
requestId?: string;
timeout?: number;
};
@@ -691,6 +693,7 @@ type FetchWorkspacesPayload = Extract<
>["payload"];
type FetchWorkspacesRequest = Extract<SessionInboundMessage, { type: "fetch_workspaces_request" }>;
export type FetchWorkspacesOptions = Omit<FetchWorkspacesRequest, "type" | "requestId"> & {
signal?: AbortSignal;
requestId?: string;
};
export type FetchWorkspacesEntry = FetchWorkspacesPayload["entries"][number];
@@ -871,13 +874,15 @@ type SetDaemonConfigResponse = Extract<
>;
type CorrelatedResponseMessage =
| Extract<SessionOutboundMessage, { payload: { requestId: string } }>
| Extract<SessionOutboundMessage, { type: "terminals_changed" }>
| GetDaemonConfigResponse
| SetDaemonConfigResponse;
type CorrelatedResponseType = CorrelatedResponseMessage["type"];
type CorrelatedResponsePayload<TType extends CorrelatedResponseType> = Extract<
CorrelatedResponseMessage,
{ type: TType }
>["payload"];
type CorrelatedResponsePayloads = {
[Message in CorrelatedResponseMessage as Message["type"]]: Message["payload"];
};
type CorrelatedResponsePayload<TType extends CorrelatedResponseType> =
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<string, { cwd: string; workspaceId?: string }>();
private fileSubscriptions = new Map<
string,
{ cwd: string; path: string; onUpdate: (version: FileVersion) => void }
>();
private readonly terminalStreams = new TerminalStreamRouter();
private pendingBinaryFileReads = new Map<string, PendingBinaryFileRead>();
private activeBinaryFileTransfers = new Map<string, BinaryFileTransferState>();
@@ -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<FetchAgentsPayload> {
private requireOwnedSubscriptions(): void {
if (this.lastServerInfoMessage?.features?.ownedSubscriptions !== true)
throw new Error("Update the host to use independent subscriptions.");
}
private observe<T extends CorrelatedResponseType>(
responseType: T,
message: { type: SessionInboundMessage["type"] } & Record<string, unknown>,
options?: { requestId?: string; timeout?: number; signal?: AbortSignal },
): OwnedSubscription<CorrelatedResponsePayload<T>> {
let resetSource = false;
return this.owned.observe<CorrelatedResponsePayload<T>>(
async (accept) => {
resetSource = false;
this.requireOwnedSubscriptions();
const requestId = this.createRequestId(options?.requestId);
const request = SessionInboundMessageSchema.parse({ ...message, requestId });
try {
return await this.sendCorrelatedRequest<T, CorrelatedResponsePayload<T>>({
message: request,
responseType,
requestId,
timeout: options?.timeout,
options: { skipQueue: true },
selectPayload: (payload): CorrelatedResponsePayload<T> | 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<SessionInboundMessage, { type: "browser.host.register.request" }>,
"type" | "requestId"
>,
options?: { signal?: AbortSignal },
): OwnedSubscription<CorrelatedResponsePayload<"browser.host.register.response">> {
return this.observe(
"browser.host.register.response",
{ type: "browser.host.register.request", ...registration },
options,
);
}
observeEvents(
events: SessionEventSubscription[],
options?: { signal?: AbortSignal; notifications?: boolean },
): OwnedSubscription<CorrelatedResponsePayload<"session.events.set_subscription.response">> {
return this.observe(
"session.events.set_subscription.response",
{
type: "session.events.set_subscription.request",
events,
notifications: options?.notifications,
},
options,
);
}
observeAgents(
options: Omit<FetchAgentsOptions, "subscribe"> = {},
): OwnedSubscription<FetchAgentsPayload> {
const { signal, requestId, timeout, ...query } = options;
return this.observe(
"fetch_agents_response",
{ ...query, type: "fetch_agents_request", subscribe: {} },
{ signal, requestId, timeout },
);
}
observeWorkspaces(
options: Omit<FetchWorkspacesOptions, "subscribe"> = {},
): OwnedSubscription<FetchWorkspacesPayload> {
const { signal, requestId, ...query } = options;
return this.observe(
"fetch_workspaces_response",
{ ...query, type: "fetch_workspaces_request", subscribe: {} },
{ signal, requestId },
);
}
fetchAgents(
options: FetchAgentsOptions & { subscribe: {} },
): Promise<FetchAgentsPayload & { subscription: OwnedSubscription<FetchAgentsPayload> }>;
fetchAgents(options?: FetchAgentsOptions): Promise<FetchAgentsPayload>;
async fetchAgents(
options?: FetchAgentsOptions,
): Promise<FetchAgentsPayload & { subscription?: OwnedSubscription<FetchAgentsPayload> }> {
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<FetchWorkspacesPayload> {
fetchWorkspaces(
options: FetchWorkspacesOptions & { subscribe: {} },
): Promise<FetchWorkspacesPayload & { subscription: OwnedSubscription<FetchWorkspacesPayload> }>;
fetchWorkspaces(options?: FetchWorkspacesOptions): Promise<FetchWorkspacesPayload>;
async fetchWorkspaces(
options?: FetchWorkspacesOptions,
): Promise<
FetchWorkspacesPayload & { subscription?: OwnedSubscription<FetchWorkspacesPayload> }
> {
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<WorkspaceLabelListPayload> {
listWorkspaceLabels(
options: {
sync?: { generation: string; afterSeq: number };
requestId?: string;
} = {},
): Promise<WorkspaceLabelListPayload> {
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<WorkspaceLabelListPayload> {
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<void> {
return this.subscriptions.setViewed(agentIds);
observeTimeline(
agentIds: string[],
options?: { signal?: AbortSignal },
): OwnedSubscription<CorrelatedResponsePayload<"agent.timeline.set_subscription.response">> {
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<void> {
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<void> {
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<CheckoutDiffPayload> {
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<SubscribeCheckoutDiffPayload> {
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<SubscribeCheckoutDiffPayload> {
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<CorrelatedResponsePayload<"fs.file.subscribe.response">> {
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<CorrelatedResponsePayload<"fs.file.subscribe.response">>;
unsubscribe: () => Promise<void>;
}> {
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<CorrelatedResponsePayload<"terminals_changed">> {
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<SubscribeTerminalPayload> {
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<SubscribeTerminalPayload> {
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<SubscribeTerminalPayload> }
> {
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<KillTerminalPayload> {
@@ -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)));
}
}
}
+124 -11
View File
@@ -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<string, boolean> = {
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<string> {
const ready = client.agents.list({ subscribe: {} });
acknowledgeObservation(ws, "agents-sdk");
return (await ready).subscriptionId!;
}
function createWorkspace(input: Partial<PaseoWorkspace> = {}): 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<string | undefined> = [];
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);
+310 -115
View File
@@ -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<PaseoAgentListResult>;
requestId: string;
subscriptionId?: string | null;
entries: FetchAgentsEntry[];
@@ -127,6 +130,7 @@ export type PaseoWorkspaceListOptions = Omit<
};
export interface PaseoWorkspaceListResult {
subscription?: OwnedSubscription<PaseoWorkspaceListResult>;
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<PaseoWorkspaceListResult>;
}
>;
list(options?: PaseoWorkspaceListOptions): Promise<PaseoWorkspaceListResult>;
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<DaemonClient["subscribeAgentTimeline"]>;
@@ -302,8 +318,10 @@ export interface PaseoAgentTimelineHandle {
*/
refetch(options?: PaseoAgentTimelineRefetchOptions): Promise<FetchAgentTimelinePayload>;
/**
* 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<PaseoAgentListResult>;
}
>;
list(options?: PaseoAgentListOptions): Promise<PaseoAgentListResult>;
ref(agent: string | PaseoAgent): PaseoAgentHandle;
create(options: PaseoAgentCreateOptions): Promise<PaseoAgentHandle>;
@@ -450,6 +474,8 @@ export interface PaseoConfigActions {
}
export interface PaseoApi {
dispose(): Promise<void>;
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<void> }>();
const agentListeners = new Set<PaseoAgentUpdateHandler>();
const workspaceListeners = new Set<PaseoWorkspaceUpdateHandler>();
const projectListeners = new Set<PaseoProjectUpdateHandler>();
const providerListeners = new Set<(update: PaseoProviderSnapshotUpdate) => void>();
const lifetime = new AbortController();
const own = <T extends { release(): Promise<void> }>(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<void> | null = null;
const dispose = (): Promise<void> => {
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<PaseoWorkspaceListResult>;
}
>;
function listWorkspaces(options?: PaseoWorkspaceListOptions): Promise<PaseoWorkspaceListResult>;
async function listWorkspaces(
options?: PaseoWorkspaceListOptions,
): Promise<PaseoWorkspaceListResult> {
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<PaseoAgentListResult>;
}
>;
function listAgents(options?: PaseoAgentListOptions): Promise<PaseoAgentListResult>;
async function listAgents(options?: PaseoAgentListOptions): Promise<PaseoAgentListResult> {
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<DaemonClient["observeEvents"]>,
signal: AbortSignal,
options: PaseoProviderWaitOptions = {},
): Promise<PaseoProviderSnapshotResult> {
// 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<string | undefined, PaseoProviderSnapshotUpdate>();
let latestEntries: PaseoProviderSnapshotResult["entries"] = [];
try {
await observation.ready;
signal.throwIfAborted();
return await new Promise<PaseoProviderSnapshotResult>((resolve, reject) => {
let settled = false;
let requestId: string | null = null;
let snapshotCwd: string | undefined;
const pendingUpdates = new Map<string | undefined, PaseoProviderSnapshotUpdate>();
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 {
+21 -28
View File
@@ -13,11 +13,11 @@ interface PendingSnapshot {
/** Resolve announcements only while observed, coalescing changes during a fetch. */
export class ProviderSnapshotUpdates {
private pending = new Map<string | undefined, PendingSnapshot>();
private paused = false;
private pending = new Map<string, PendingSnapshot>();
constructor(
private readonly host: {
active(update: Update): boolean;
fetch(cwd: string | undefined): Promise<Snapshot>;
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<void> {
private async resolve(key: string, pending: PendingSnapshot): Promise<void> {
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);
}
}
}
@@ -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);
});
+44 -101
View File
@@ -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<string, number>();
private readonly slotTerminals = new Map<number, string>();
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. */
}
}
}
@@ -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,
@@ -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<FetchAgentTimelinePayload>,
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<TimelineUpdate, { type: "agent.timeline.replacement" }>;
}
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;
}
@@ -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(),
@@ -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",
+94 -4
View File
@@ -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<typeof SessionEventSubscriptionSchema>;
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<typeof SessionInboundMessageSchema>;
// ============================================================================
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,
@@ -140,14 +140,15 @@ async function main(): Promise<void> {
await client.connect();
const seen = new Set<string>();
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 },
});
+1 -1
View File
@@ -151,7 +151,7 @@ async function main(): Promise<void> {
await client.connect();
await client.fetchAgents({
subscribe: { subscriptionId: "mcp-inject-real-claude" },
subscribe: {},
});
const results: ProviderRunResult[] = [];
@@ -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));
@@ -49,7 +49,7 @@ async function main(): Promise<void> {
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<void> {
}
});
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<void> {
});
});
await offStream.ready;
let audioChunkCount = 0;
const firstAudio = new Promise<void>((resolve) => {
const offAudio = client.on("audio_output", (msg) => {
@@ -229,6 +229,7 @@ const AGENT_RUN_START_TIMEOUT_MS = 60_000;
export async function waitForAgentRunStartWithTimeout(
agentManager: AgentManager,
agentId: string,
signal?: AbortSignal,
): Promise<void> {
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);
}
@@ -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",
@@ -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;
}
@@ -73,6 +73,12 @@ export class CheckoutDiffManager {
this.workspaceGitService = options.workspaceGitService;
}
async read(
params: Omit<CheckoutDiffSubscriptionRequest, "signal">,
): Promise<CheckoutDiffSnapshotPayload> {
return this.computeCheckoutDiffSnapshot(params.cwd, this.normalizeCompare(params.compare));
}
async subscribe(
params: CheckoutDiffSubscriptionRequest,
listener: (snapshot: CheckoutDiffSnapshotPayload) => void,
@@ -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.
@@ -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) => {
@@ -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",
@@ -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")
@@ -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 });
@@ -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;
@@ -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");
@@ -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 });
}
@@ -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({
@@ -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({
@@ -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",
@@ -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);
@@ -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 });
@@ -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",
@@ -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,
@@ -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);
@@ -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();
@@ -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 () => {
@@ -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);

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