From bc385b5930c012bdb92b0e983530e2edb57f234d Mon Sep 17 00:00:00 2001 From: Christoph Leiter Date: Wed, 9 Sep 2026 19:45:24 +0200 Subject: [PATCH 01/46] fix(app): don't steal focus when workspace creation finishes late (#2986) Preserve the selected workspace when delayed creation finishes, while still starting the requested agent or terminal. Keep normal navigation when the initiating screen remains active, and preserve newer draft text and context. Verified with before/after recordings, ten passing real-browser journeys using isolated daemons, focused regression tests, green CI, and independent review. QA: https://github.com/getpaseo/paseo/tree/qa/workspace-focus-2986 Co-authored-by: Christoph Leiter --- .../new-workspace-navigation-guard.spec.ts | 22 ++ .../helpers/new-workspace-navigation.ts | 197 ++++++++++++++++++ .../app/e2e/support/helpers/new-workspace.ts | 120 +++++++++++ .../composer/draft/create-agent-request.ts | 35 ++++ .../app/src/composer/draft/workspace-tab.tsx | 11 +- .../app/src/screens/new-workspace-screen.tsx | 120 +++++++++-- .../new-workspace/background-handoff.test.ts | 176 ++++++++++++++++ .../new-workspace/background-handoff.ts | 54 +++++ .../new-workspace/screen-presence.test.ts | 22 ++ .../screens/new-workspace/screen-presence.ts | 41 ++++ 10 files changed, 780 insertions(+), 18 deletions(-) create mode 100644 packages/app/e2e/browser/new-workspace-navigation-guard.spec.ts create mode 100644 packages/app/e2e/support/helpers/new-workspace-navigation.ts create mode 100644 packages/app/src/composer/draft/create-agent-request.ts create mode 100644 packages/app/src/screens/new-workspace/background-handoff.test.ts create mode 100644 packages/app/src/screens/new-workspace/background-handoff.ts create mode 100644 packages/app/src/screens/new-workspace/screen-presence.test.ts create mode 100644 packages/app/src/screens/new-workspace/screen-presence.ts diff --git a/packages/app/e2e/browser/new-workspace-navigation-guard.spec.ts b/packages/app/e2e/browser/new-workspace-navigation-guard.spec.ts new file mode 100644 index 0000000000..69becc94ab --- /dev/null +++ b/packages/app/e2e/browser/new-workspace-navigation-guard.spec.ts @@ -0,0 +1,22 @@ +import { test } from "../support/fixtures"; +import { verifyDelayedWorkspaceCreation } from "../support/helpers/new-workspace-navigation"; + +test.describe("Delayed workspace creation", () => { + test.describe.configure({ timeout: 120_000 }); + + for (const launch of ["chat", "terminal", "empty"] as const) { + test(`${launch}: keeps the workspace chosen during creation`, async ({ page }) => { + await verifyDelayedWorkspaceCreation(page, launch, "leave"); + }); + test(`worktree ${launch}: keeps the workspace chosen during creation`, async ({ page }) => { + await verifyDelayedWorkspaceCreation(page, launch, "leave", "worktree"); + }); + test(`${launch}: opens the created workspace when the user stays`, async ({ page }) => { + await verifyDelayedWorkspaceCreation(page, launch, "stay"); + }); + } + + test("preserves a newer draft opened while creation is pending", async ({ page }) => { + await verifyDelayedWorkspaceCreation(page, "chat", "new-draft"); + }); +}); diff --git a/packages/app/e2e/support/helpers/new-workspace-navigation.ts b/packages/app/e2e/support/helpers/new-workspace-navigation.ts new file mode 100644 index 0000000000..6e6220c581 --- /dev/null +++ b/packages/app/e2e/support/helpers/new-workspace-navigation.ts @@ -0,0 +1,197 @@ +import { expect, test, type Page } from "../fixtures"; +import { readFile } from "node:fs/promises"; +import { buildHostWorkspaceRoute } from "@/utils/host-routes"; +import { gotoAppShell } from "./app"; +import { scrollTimelineToOldestLoadedEdge } from "./timeline-pagination"; +import { + archiveLocalWorkspaceFromDaemon, + archiveWorkspaceFromDaemon, + connectNewWorkspaceDaemonClient, + countWorkspaceAgents, + countWorkspaceTerminals, + delayBrowserWorkspaceCreatedResponse, + expectNewWorkspaceDraft, + fillNewWorkspaceDraft, + openNewWorkspaceComposer, + openProjectViaDaemon, + selectWorkspaceIsolation, + submitNewWorkspaceEmpty, + submitNewWorkspacePrompt, + waitForCreatedWorkspace, +} from "./new-workspace"; +import { createTempGitRepo } from "./workspace"; +import { expectAppRoute } from "./route-assertions"; +import { getServerId } from "./server-id"; +import { + expectTerminalOutputContains, + expectWorkspaceOpensWithTerminalTab, + fillTerminalPrompt, + seedTerminalProfiles, + selectLaunchOption, + submitTerminalLaunch, + type TerminalProfile, +} from "./new-workspace-launch"; +import { switchWorkspaceViaSidebar, waitForSidebarHydration } from "./workspace-ui"; +import { dropFileOnComposer, expectAttachmentPill } from "./composer"; + +const PROMPT = "Hello from the navigation guard"; +const NEXT_PROMPT = "Keep this newer draft"; +const CONTEXT = { + name: "context.json", + mimeType: "application/json", + buffer: Buffer.from('{"preserve":"attachment"}'), +}; +const PROFILE: TerminalProfile = { + id: "e2e-nav-guard-echo", + name: "Nav Guard Echo", + command: "/bin/sh", + args: ["-c", 'echo captured: "$0"; sleep 120', "{{{prompt}}}"], +}; + +export async function verifyDelayedWorkspaceCreation( + page: Page, + launch: "chat" | "terminal" | "empty", + destination: "leave" | "stay" | "new-draft", + isolation: "local" | "worktree" = "local", +): Promise { + const client = await connectNewWorkspaceDaemonClient(); + const repo = await createTempGitRepo("workspace-focus-"); + const profileSeed = await seedTerminalProfiles([PROFILE]); + const delay = await delayBrowserWorkspaceCreatedResponse(page); + const serverId = getServerId(); + let localWorkspaceId: string | undefined; + let createdDirectory: string | undefined; + let newDraftRoute = ""; + + try { + const project = await openProjectViaDaemon(client, repo.path); + localWorkspaceId = project.workspaceId; + const knownIds = new Set((await client.fetchWorkspaces()).entries.map((entry) => entry.id)); + await gotoAppShell(page); + await waitForSidebarHydration(page); + await switchWorkspaceViaSidebar({ page, serverId, workspaceId: project.workspaceId }); + + await test.step("Submit workspace creation with the requested content", async () => { + await openNewWorkspaceComposer(page, project); + await selectWorkspaceIsolation(page, isolation); + if (launch === "chat") { + await dropFileOnComposer(page, CONTEXT); + await expectAttachmentPill(page, "composer-file-attachment-pill"); + await submitNewWorkspacePrompt(page, PROMPT); + } else if (launch === "terminal") { + await selectLaunchOption(page, PROFILE.id); + await fillTerminalPrompt(page, PROMPT); + await submitTerminalLaunch(page); + } else { + await submitNewWorkspaceEmpty(page); + } + await delay.waitForCreateRequest(); + }); + + await test.step("Choose where to work while the creation response is held", async () => { + if (destination !== "stay") { + await switchWorkspaceViaSidebar({ page, serverId, workspaceId: project.workspaceId }); + } + if (destination === "new-draft") { + await openNewWorkspaceComposer(page, project); + await fillNewWorkspaceDraft(page, NEXT_PROMPT); + const url = new URL(page.url()); + newDraftRoute = url.pathname + url.search; + } + // Recording pacing only; the response is held deterministically above. + if (process.env.E2E_RECORD_VIDEO === "1") { + await page.mouse.move(1100, 80); + await page.waitForTimeout(1500); + } + delay.release(); + }); + + const created = await waitForCreatedWorkspace(client, knownIds); + createdDirectory = created.workspaceDirectory; + expect(created.workspaceKind).toBe(isolation === "worktree" ? "worktree" : "local_checkout"); + await test.step("Completion creates exactly the requested resource and preserves focus", async () => { + await expect + .poll(() => countWorkspaceAgents(client, created.id)) + .toBe(launch === "chat" ? 1 : 0); + await expect + .poll(() => countWorkspaceTerminals(client, created.id)) + .toBe(launch === "terminal" ? 1 : 0); + // A negative navigation assertion needs an observation window after completion. + await page.waitForTimeout(5000); + const expectedRoute = + destination === "new-draft" + ? newDraftRoute + : buildHostWorkspaceRoute( + serverId, + destination === "stay" ? created.id : project.workspaceId, + ); + await expectAppRoute(page, expectedRoute); + if (destination === "new-draft") await expectNewWorkspaceDraft(page, NEXT_PROMPT); + if (launch === "chat") { + expect(delay.agentRequests).toHaveLength(1); + const upload = delay.agentRequests[0]?.attachments?.find( + (item) => item.type === "uploaded_file", + ); + if (!upload) throw new Error("The create request did not contain the submitted file"); + expect(await readFile(upload.path)).toEqual(CONTEXT.buffer); + expect(delay.agentRequests[0]).toMatchObject({ + workspaceId: created.id, + initialPrompt: PROMPT, + config: { + provider: "mock", + model: "ten-second-stream", + modeId: "load-test", + thinkingOptionId: "low", + cwd: created.workspaceDirectory, + }, + attachments: [ + expect.objectContaining({ + fileName: CONTEXT.name, + mimeType: CONTEXT.mimeType, + size: CONTEXT.buffer.length, + }), + ], + }); + } + }); + + await test.step("Visit the new workspace and return without submitting a second draft", async () => { + await switchWorkspaceViaSidebar({ page, serverId, workspaceId: created.id }); + if (launch === "chat") { + await scrollTimelineToOldestLoadedEdge(page); + await expect(page.getByText(PROMPT, { exact: true }).first()).toBeVisible(); + } else if (launch === "terminal") { + await expectWorkspaceOpensWithTerminalTab(page); + await expectTerminalOutputContains(page, `captured: ${PROMPT}`); + } + await switchWorkspaceViaSidebar({ page, serverId, workspaceId: project.workspaceId }); + await switchWorkspaceViaSidebar({ page, serverId, workspaceId: created.id }); + await expect + .poll(() => countWorkspaceAgents(client, created.id)) + .toBe(launch === "chat" ? 1 : 0); + await expect + .poll(() => countWorkspaceTerminals(client, created.id)) + .toBe(launch === "terminal" ? 1 : 0); + if (launch === "chat") { + expect(delay.agentRequests).toHaveLength(1); + await openNewWorkspaceComposer(page, project); + await expectNewWorkspaceDraft(page, destination === "new-draft" ? NEXT_PROMPT : ""); + } + if (process.env.E2E_RECORD_VIDEO === "1") { + await page.mouse.move(1100, 80); + await page.waitForTimeout(1500); + } + }); + } catch (error) { + await page.screenshot({ path: test.info().outputPath("before-cleanup.png") }); + console.error(error); + throw error; + } finally { + delay.release(); + if (createdDirectory) await archiveWorkspaceFromDaemon(client, createdDirectory); + if (localWorkspaceId) await archiveLocalWorkspaceFromDaemon(client, localWorkspaceId); + await profileSeed.restore(); + await client.close(); + await repo.cleanup(); + } +} diff --git a/packages/app/e2e/support/helpers/new-workspace.ts b/packages/app/e2e/support/helpers/new-workspace.ts index 05e8cddc9a..5cebc309b1 100644 --- a/packages/app/e2e/support/helpers/new-workspace.ts +++ b/packages/app/e2e/support/helpers/new-workspace.ts @@ -1,4 +1,5 @@ import { expect, type BrowserContext, type Page } from "@playwright/test"; +import type { CreateAgentRequestMessage } from "@getpaseo/protocol/messages"; import type { DaemonClient as InternalDaemonClient } from "@getpaseo/client/internal/daemon-client"; import { decodeWorkspaceIdFromPathSegment } from "@/utils/host-routes"; import { connectDaemonClient } from "./daemon-client-loader"; @@ -16,6 +17,7 @@ type NewWorkspaceDaemonClient = Pick< | "connect" | "createPaseoWorktree" | "createWorkspace" + | "fetchAgents" | "fetchWorkspaces" | "getPaseoWorktreeList" | "getDaemonConfig" @@ -24,6 +26,7 @@ type NewWorkspaceDaemonClient = Pick< | "enablePlugin" | "inspectWorkspaceRecovery" | "listProjects" + | "listTerminals" | "on" | "patchDaemonConfig" | "removeProject" @@ -622,3 +625,120 @@ export async function delayBrowserAgentCreatedStatus( waitForDelayedCreatedStatus: () => delayedCreatedStatusSeen, }; } + +export interface WorkspaceCreatedDelayControl { + agentRequests: readonly CreateAgentRequestMessage[]; + release(): void; + waitForCreateRequest(): Promise; +} + +/** + * Holds `workspace.create.response` so a test can act as the user does while the daemon is still + * running `git worktree add` — most importantly, navigate somewhere else. + */ +export async function delayBrowserWorkspaceCreatedResponse( + page: Page, +): Promise { + const daemonPortPattern = daemonWsRoutePattern(); + const agentRequests: CreateAgentRequestMessage[] = []; + const createRequestIds = new Set(); + const delayedForwards: Array<() => void> = []; + let releaseRequested = false; + let resolveCreateRequest: (() => void) | null = null; + const createRequestSeen = new Promise((resolve) => { + resolveCreateRequest = resolve; + }); + + await page.routeWebSocket(daemonPortPattern, (ws) => { + const server = ws.connectToServer(); + + ws.onMessage((message) => { + const sessionMessage = getSessionMessage(message); + if (sessionMessage?.type === "create_agent_request") + agentRequests.push(sessionMessage as CreateAgentRequestMessage); + if (sessionMessage?.type === "workspace.create.request") { + const requestId = getStringField(sessionMessage, "requestId"); + if (requestId) { + createRequestIds.add(requestId); + resolveCreateRequest?.(); + } + } + server.send(message); + }); + + server.onMessage((message) => { + const sessionMessage = getSessionMessage(message); + const payload = + sessionMessage?.type === "workspace.create.response" && + typeof sessionMessage.payload === "object" + ? (sessionMessage.payload as Record) + : null; + const requestId = payload ? getStringField(payload, "requestId") : null; + + if (requestId && createRequestIds.has(requestId) && !releaseRequested) { + delayedForwards.push(() => ws.send(message)); + return; + } + + ws.send(message); + }); + }); + + return { + agentRequests, + release() { + releaseRequested = true; + for (const forward of delayedForwards.splice(0)) { + forward(); + } + }, + waitForCreateRequest: () => createRequestSeen, + }; +} + +type WorkspaceEntry = Awaited< + ReturnType +>["entries"][number]; + +/** + * Waits for a workspace the caller did not already know about, and returns it. Tests that create a + * workspace without navigating to it have no route to assert on, so the daemon's own list is the + * signal that creation finished. + */ +export async function waitForCreatedWorkspace( + client: NewWorkspaceDaemonClient, + knownWorkspaceIds: ReadonlySet, + options?: { timeout?: number }, +): Promise { + let created: WorkspaceEntry | undefined; + await expect + .poll( + async () => { + const payload = await client.fetchWorkspaces(); + created = payload.entries.find((entry) => !knownWorkspaceIds.has(entry.id)); + return created !== undefined; + }, + { timeout: options?.timeout ?? 60_000 }, + ) + .toBe(true); + if (!created) { + throw new Error("Workspace list reported a new workspace but it could not be read back"); + } + return created; +} + +export async function countWorkspaceAgents( + client: NewWorkspaceDaemonClient, + workspaceId: string, +): Promise { + const payload = await client.fetchAgents({ filter: { includeArchived: true } }); + return payload.entries.filter((entry) => entry.agent.workspaceId === workspaceId).length; +} + +export async function countWorkspaceTerminals( + client: NewWorkspaceDaemonClient, + workspaceId: string, +): Promise { + const payload = await client.listTerminals(undefined, undefined, { workspaceId }); + return payload.terminals.length; +} diff --git a/packages/app/src/composer/draft/create-agent-request.ts b/packages/app/src/composer/draft/create-agent-request.ts new file mode 100644 index 0000000000..23fa22e8c9 --- /dev/null +++ b/packages/app/src/composer/draft/create-agent-request.ts @@ -0,0 +1,35 @@ +import type { AgentSessionConfig } from "@getpaseo/protocol/agent-types"; +import type { AgentSnapshotPayload, CreateAgentRequestMessage } from "@getpaseo/protocol/messages"; +import type { DaemonClient } from "@getpaseo/client/internal/daemon-client"; +import { encodeImages } from "@/utils/encode-images"; +import type { UserMessageImageAttachment } from "@/types/stream"; + +export interface WorkspaceDraftAgentRequest { + workspaceId: string; + config: AgentSessionConfig; + text: string; + clientMessageId: string; + images?: UserMessageImageAttachment[]; + attachments?: CreateAgentRequestMessage["attachments"]; +} + +/** + * Shared by the workspace draft tab and by the new-workspace screen when it finishes creation + * after the user has already navigated away and no draft tab will ever mount. + */ +export async function requestWorkspaceDraftAgent( + client: DaemonClient, + request: WorkspaceDraftAgentRequest, +): Promise { + const images = await encodeImages(request.images); + return await client.createAgent({ + config: request.config, + workspaceId: request.workspaceId, + clientMessageId: request.clientMessageId, + ...(request.text ? { initialPrompt: request.text } : {}), + ...(images && images.length > 0 ? { images } : {}), + ...(request.attachments && request.attachments.length > 0 + ? { attachments: request.attachments } + : {}), + }); +} diff --git a/packages/app/src/composer/draft/workspace-tab.tsx b/packages/app/src/composer/draft/workspace-tab.tsx index 93c800cbf9..4dca9df166 100644 --- a/packages/app/src/composer/draft/workspace-tab.tsx +++ b/packages/app/src/composer/draft/workspace-tab.tsx @@ -24,7 +24,7 @@ import type { Agent } from "@/stores/session-store"; import { useWorkspaceFields } from "@/stores/session-store-hooks"; import { useWorkspaceDraftSubmissionStore } from "@/stores/workspace-draft-submission-store"; import { useAgentControlCommandCenterActions } from "@/command-center/agent-control-registration"; -import { encodeImages } from "@/utils/encode-images"; +import { requestWorkspaceDraftAgent } from "@/composer/draft/create-agent-request"; import type { WorkspaceFileOpenRequest } from "@/workspace/file-open"; import { shouldAutoFocusWorkspaceDraftComposer } from "@/screens/workspace/workspace-draft-pane-focus"; import { @@ -194,15 +194,14 @@ async function submitDraftCreateRequest(input: { featureValues: autoSubmitConfig?.featureValues ?? composerState.featureValues, }); - const imagesData = await encodeImages(images); const attachmentsArray = Array.isArray(attachments) ? attachments : undefined; - const result = await client.createAgent({ + const result = await requestWorkspaceDraftAgent(client, { config, workspaceId, - ...(text ? { initialPrompt: text } : {}), + text, clientMessageId: attempt.clientMessageId, - ...(imagesData && imagesData.length > 0 ? { images: imagesData } : {}), - ...(attachmentsArray && attachmentsArray.length > 0 ? { attachments: attachmentsArray } : {}), + ...(images ? { images } : {}), + ...(attachmentsArray ? { attachments: attachmentsArray } : {}), }); return { diff --git a/packages/app/src/screens/new-workspace-screen.tsx b/packages/app/src/screens/new-workspace-screen.tsx index ca189cf7b4..7f7b6b13a8 100644 --- a/packages/app/src/screens/new-workspace-screen.tsx +++ b/packages/app/src/screens/new-workspace-screen.tsx @@ -85,10 +85,15 @@ import { useProjectIcons } from "@/projects/icons"; import { ICON_SIZE, type Theme } from "@/styles/theme"; import type { ComposerAttachment } from "@/attachments/types"; import { useDraftWorkspaceAttachmentScopeKey } from "@/attachments/workspace-attachments-store"; +import { requestWorkspaceDraftAgent } from "@/composer/draft/create-agent-request"; +import { buildWorkspaceDraftAgentConfig } from "@/screens/workspace/workspace-draft-agent-config"; import type { MessagePayload } from "@/composer/types"; import type { UserComposerAttachment } from "@/attachments/types"; import type { AgentAttachment, ForgeSearchItem } from "@getpaseo/protocol/messages"; -import type { CreatePaseoWorktreeInput } from "@getpaseo/client/internal/daemon-client"; +import type { + CreatePaseoWorktreeInput, + DaemonClient, +} from "@getpaseo/client/internal/daemon-client"; import type { AgentProvider } from "@getpaseo/protocol/agent-types"; import type { WorkspaceDraftTabSetup, WorkspaceTabTarget } from "@/workspace-tabs/model"; import { isEmptyWorkspaceSubmission, runCreateEmptyWorkspace } from "./new-workspace-empty"; @@ -123,6 +128,11 @@ import { type ListTerminalsPayload, upsertCreatedTerminalPayload, } from "./workspace/terminals/state"; +import { + captureWorkspaceDraftCleanup, + createWorkspaceAgentInBackground, +} from "./new-workspace/background-handoff"; +import { useNewWorkspaceScreenPresence } from "./new-workspace/screen-presence"; const ThemedFolderPlus = withUnistyles(FolderPlus); const foregroundMutedColorMapping = (theme: Theme) => ({ color: theme.colors.foregroundMuted }); @@ -747,10 +757,19 @@ function normalizeBranchDetails( return names.map((name) => ({ name, committerDate: 0 })); } +/** + * "background" means the user left the New workspace screen mid-creation, so nothing navigated + * and the screen — if still mounted under another route — has to drop its pending state itself. + */ +type SubmitOutcome = "navigated" | "background"; + interface SubmitDraftInput { + clearConsumedDraft: () => void; serverId: string; + draftKey: string; clearDraft: (lifecycle: "sent" | "abandoned") => void; draftId?: string; + draftContextScopeKey: string | null; initialSetup?: WorkspaceDraftTabSetup; workspaceId: string; workspaceDirectory: string; @@ -759,6 +778,8 @@ interface SubmitDraftInput { provider: AgentProvider; composerState: NewWorkspaceComposerState; supportsForgeSearch: boolean; + resolveClient: () => DaemonClient; + isStillOnCreateScreen: () => boolean; } type NewWorkspaceComposerState = NonNullable< @@ -860,9 +881,13 @@ interface CreateChatAgentInput { withInitialAgent: boolean; }) => Promise>; serverId: string; + draftKey: string; clearDraft: (lifecycle: "sent" | "abandoned") => void; draftId?: string; + draftContextScopeKey: string | null; supportsForgeSearch: boolean; + resolveClient: () => DaemonClient; + isStillOnCreateScreen: () => boolean; labels: { composerStateRequired: string; selectModel: string; @@ -918,8 +943,9 @@ function buildComposerInitialValues(input: { return undefined; } -async function runCreateChatAgent(input: CreateChatAgentInput): Promise { +async function runCreateChatAgent(input: CreateChatAgentInput): Promise { const { payload, composerState, ensureWorkspace, serverId, clearDraft } = input; + const clearConsumedDraft = captureWorkspaceDraftCleanup(input); const { text, attachments, cwd } = payload; if (!composerState) { throw new Error(input.labels.composerStateRequired); @@ -947,10 +973,13 @@ async function runCreateChatAgent(input: CreateChatAgentInput): Promise { provider, composerState, }); - submitWorkspaceDraft({ + return await submitWorkspaceDraft({ + clearConsumedDraft, serverId, clearDraft, + draftKey: input.draftKey, draftId: input.draftId, + draftContextScopeKey: input.draftContextScopeKey, initialSetup, workspaceId: ensuredWorkspace.id, workspaceDirectory: ensuredWorkspace.workspaceDirectory, @@ -959,6 +988,8 @@ async function runCreateChatAgent(input: CreateChatAgentInput): Promise { provider, composerState, supportsForgeSearch: input.supportsForgeSearch, + resolveClient: input.resolveClient, + isStillOnCreateScreen: input.isStillOnCreateScreen, }); } @@ -1021,7 +1052,7 @@ function resolveWorkspaceDraftSubmissionConfig(input: { }; } -function submitWorkspaceDraft(input: SubmitDraftInput): void { +async function submitWorkspaceDraft(input: SubmitDraftInput): Promise { const { serverId, clearDraft, @@ -1049,6 +1080,33 @@ function submitWorkspaceDraft(input: SubmitDraftInput): void { composerState, initialSetup, }); + // Creation blocks on a slow daemon RPC. If the user moved on while it ran, the destination + // screen's draft tab will never mount to issue create_agent, so this path does it instead. + if (!input.isStillOnCreateScreen()) { + await createWorkspaceAgentInBackground({ + clearConsumedDraft: input.clearConsumedDraft, + createAgent: () => + requestWorkspaceDraftAgent(input.resolveClient(), { + workspaceId, + config: buildWorkspaceDraftAgentConfig({ + provider: submission.provider, + cwd: submission.cwd, + ...(submission.modeId ? { modeId: submission.modeId } : {}), + ...(submission.model ? { model: submission.model } : {}), + ...(submission.thinkingOptionId + ? { thinkingOptionId: submission.thinkingOptionId } + : {}), + ...(submission.featureValues ? { featureValues: submission.featureValues } : {}), + }), + text: text.trim(), + clientMessageId, + ...(wirePayload.images.length > 0 ? { images: wirePayload.images } : {}), + ...(wirePayload.attachments.length > 0 ? { attachments: wirePayload.attachments } : {}), + }), + }); + return "background"; + } + useCreateFlowStore.getState().setPending({ serverId, draftId, @@ -1082,6 +1140,7 @@ function submitWorkspaceDraft(input: SubmitDraftInput): void { workspaceId, target: submission.target, }); + return "navigated"; } function useNewWorkspaceHostSelector(input: { @@ -1591,6 +1650,7 @@ export function NewWorkspaceScreen({ const isolationPickerAnchorRef = useRef(null); const hostPickerAnchorRef = useRef(null); const isDraftHandoffActive = useIsNewWorkspaceDraftHandoffActive({ draftId, selectedServerId }); + const isStillOnCreateScreen = useNewWorkspaceScreenPresence(); // Launch target: what the composer submits to (chat agent, or a terminal // profile). Mirrors useWorkspaceIsolation's pattern below: the derived @@ -1696,11 +1756,12 @@ export function NewWorkspaceScreen({ }, []); const withConnectedClient = useCallback(() => { - if (!client || !isConnected) { + const connectedClient = getHostRuntimeStore().getClient(selectedServerId); + if (!connectedClient?.isConnected) { throw new Error(t("newWorkspace.errors.hostDisconnected")); } - return client; - }, [client, isConnected, t]); + return connectedClient; + }, [selectedServerId, t]); const clientReady = isConnected && Boolean(client); const hasSelectedSourceDirectory = selectedSourceDirectory !== null; @@ -2043,31 +2104,49 @@ export function NewWorkspaceScreen({ await updateFormPreferences({ launchTarget }); if (isEmptyWorkspaceSubmission(payload)) { setPendingAction("empty"); + let outcome: SubmitOutcome = "background"; await runCreateEmptyWorkspace({ payload, ensureWorkspace, serverId: selectedServerId, - navigate: (targetServerId, workspaceId) => - navigateToWorkspace({ serverId: targetServerId, workspaceId }), + navigate: (targetServerId, workspaceId) => { + if (!isStillOnCreateScreen()) { + return; + } + outcome = "navigated"; + navigateToWorkspace({ serverId: targetServerId, workspaceId }); + }, }); + // Nothing navigated, so this screen may still be mounted under another route. Release + // the pending lock it would otherwise keep forever. + if (outcome === "background") { + setPendingAction(null); + } return; } setPendingAction("chat"); - await runCreateChatAgent({ + const outcome = await runCreateChatAgent({ payload, composerState, forkDraftSetup, ensureWorkspace, serverId: selectedServerId, clearDraft: chatDraft.clear, + draftKey, draftId, + draftContextScopeKey, supportsForgeSearch, + resolveClient: withConnectedClient, + isStillOnCreateScreen, labels: { composerStateRequired: t("newWorkspace.errors.composerStateRequired"), selectModel: t("newWorkspace.errors.selectModel"), }, }); + if (outcome === "background") { + setPendingAction(null); + } } catch (error) { const message = toErrorMessage(error); setPendingAction(null); @@ -2077,16 +2156,20 @@ export function NewWorkspaceScreen({ }, [ composerState, + draftContextScopeKey, draftId, chatDraft.clear, + draftKey, ensureWorkspace, forkDraftSetup, + isStillOnCreateScreen, launchTarget, selectedServerId, supportsForgeSearch, t, toast, updateFormPreferences, + withConnectedClient, ], ); @@ -2095,6 +2178,7 @@ export function NewWorkspaceScreen({ setErrorMessage(null); await updateFormPreferences({ launchTarget }); setPendingAction("terminal"); + let outcome: SubmitOutcome = "background"; await runCreateTerminalWorkspace({ cwd: selectedSourceDirectory ?? "", prompt: terminalPromptText, @@ -2128,9 +2212,20 @@ export function NewWorkspaceScreen({ withConnectedClient().sendTerminalInput(terminalId, { type: "input", data }); }, serverId: selectedServerId, - navigate: (targetServerId, workspaceId, target) => - navigateToWorkspace({ serverId: targetServerId, workspaceId, target }), + // The terminal is spawned and fed its command before this runs, so skipping the + // navigation costs nothing: it is standalone, and `reconcileTabs` auto-opens standalone + // terminals when the workspace is next visited. + navigate: (targetServerId, workspaceId, target) => { + if (!isStillOnCreateScreen()) { + return; + } + outcome = "navigated"; + navigateToWorkspace({ serverId: targetServerId, workspaceId, target }); + }, }); + if (outcome === "background") { + setPendingAction(null); + } } catch (error) { const message = toErrorMessage(error); setPendingAction(null); @@ -2139,6 +2234,7 @@ export function NewWorkspaceScreen({ } }, [ ensureWorkspace, + isStillOnCreateScreen, launchTarget, queryClient, selectedServerId, diff --git a/packages/app/src/screens/new-workspace/background-handoff.test.ts b/packages/app/src/screens/new-workspace/background-handoff.test.ts new file mode 100644 index 0000000000..a84369fdf4 --- /dev/null +++ b/packages/app/src/screens/new-workspace/background-handoff.test.ts @@ -0,0 +1,176 @@ +import { beforeEach, describe, expect, it } from "vitest"; +import { useCreateFlowStore } from "@/stores/create-flow-store"; +import { useDraftStore } from "@/stores/draft-store"; +import { useWorkspaceAttachmentsStore } from "@/attachments/workspace-attachments-store"; +import { useWorkspaceDraftSubmissionStore } from "@/stores/workspace-draft-submission-store"; +import { + captureWorkspaceDraftCleanup, + createWorkspaceAgentInBackground, +} from "./background-handoff"; + +const DRAFT_ID = "draft_1"; +const DRAFT_KEY = "new-workspace"; +const SCOPE_KEY = "draft:draft_1"; + +function saveDraft(text: string) { + useDraftStore + .getState() + .saveDraftInput({ draftKey: DRAFT_KEY, draft: { text, attachments: [] } }); +} + +function draftText(): string | undefined { + return useDraftStore.getState().drafts[DRAFT_KEY]?.input.text; +} + +function captureCleanup() { + return captureWorkspaceDraftCleanup({ + draftId: DRAFT_ID, + draftKey: DRAFT_KEY, + clearDraft: (lifecycle) => + useDraftStore.getState().clearDraftInput({ draftKey: DRAFT_KEY, lifecycle }), + draftContextScopeKey: SCOPE_KEY, + }); +} + +function run(createAgent: () => Promise, clearConsumedDraft = captureCleanup()) { + return createWorkspaceAgentInBackground({ createAgent, clearConsumedDraft }); +} + +beforeEach(() => { + useWorkspaceDraftSubmissionStore.setState({ pendingByDraftId: {}, setupByDraftId: {} }); + useCreateFlowStore.setState({ pendingByDraftId: {} }); + useWorkspaceAttachmentsStore.setState({ attachmentsByScope: { [SCOPE_KEY]: [] } }); + saveDraft("do the thing"); +}); + +describe("createWorkspaceAgentInBackground", () => { + it("preserves context added to a newer draft without editing its text", async () => { + await run(async () => { + useWorkspaceAttachmentsStore.getState().addWorkspaceAttachment({ + scopeKey: SCOPE_KEY, + attachment: { + kind: "chat_history", + id: "fork-context", + attachment: { + type: "text", + mimeType: "text/plain", + contextKind: "chat_history", + title: "Chat history", + text: "Preserve this context", + }, + source: { serverId: "local", agentId: "agent-1" }, + }, + }); + useWorkspaceDraftSubmissionStore.getState().setDraftSetup({ + draftId: DRAFT_ID, + setup: { + cwd: "/work/new-repo", + provider: "codex", + modeId: null, + model: null, + thinkingOptionId: null, + featureValues: {}, + }, + }); + }); + expect(useWorkspaceAttachmentsStore.getState().attachmentsByScope[SCOPE_KEY]).toHaveLength(1); + expect(useWorkspaceDraftSubmissionStore.getState().setupByDraftId[DRAFT_ID]?.setup.cwd).toBe( + "/work/new-repo", + ); + }); + + it("creates one agent and clears the consumed draft setup, attachments and input", async () => { + let calls = 0; + useWorkspaceDraftSubmissionStore.getState().setDraftSetup({ + draftId: DRAFT_ID, + setup: { + cwd: "/work/repo", + provider: "claude", + modeId: null, + model: null, + thinkingOptionId: null, + featureValues: {}, + }, + }); + + await run(async () => { + calls += 1; + }); + + expect(calls).toBe(1); + expect(useWorkspaceDraftSubmissionStore.getState().setupByDraftId).toEqual({}); + expect(useWorkspaceAttachmentsStore.getState().attachmentsByScope[SCOPE_KEY]).toBeUndefined(); + expect(draftText()).toBe(""); + }); + + it("preserves context changed while workspace creation was pending", async () => { + const clearConsumedDraft = captureCleanup(); + useWorkspaceAttachmentsStore.getState().addWorkspaceAttachment({ + scopeKey: SCOPE_KEY, + attachment: { + kind: "chat_history", + id: "fork-context", + attachment: { + type: "text", + mimeType: "text/plain", + contextKind: "chat_history", + title: "Chat history", + text: "Preserve this context", + }, + source: { serverId: "local", agentId: "agent-1" }, + }, + }); + await run(async () => undefined, clearConsumedDraft); + expect(useWorkspaceAttachmentsStore.getState().attachmentsByScope[SCOPE_KEY]).toHaveLength(1); + expect(draftText()).toBe("do the thing"); + }); + + it("leaves no pending submission behind, so no draft tab can create a second agent", async () => { + await run(async () => undefined); + + expect(useWorkspaceDraftSubmissionStore.getState().pendingByDraftId).toEqual({}); + expect(useCreateFlowStore.getState().pendingByDraftId).toEqual({}); + }); + + it("rethrows a failed creation and keeps the prompt for retry", async () => { + const failure = new Error("daemon exploded"); + + await expect( + run(async () => { + throw failure; + }), + ).rejects.toThrow(failure); + + expect(draftText()).toBe("do the thing"); + }); + + it("keeps a prompt typed into the shared draft while creation was in flight", async () => { + await run(async () => { + saveDraft("a different idea"); + }); + + expect(draftText()).toBe("a different idea"); + }); + it("keeps a newer draft written while workspace creation was pending", async () => { + useWorkspaceAttachmentsStore.getState().addWorkspaceAttachment({ + scopeKey: SCOPE_KEY, + attachment: { + kind: "chat_history", + id: "fork-context", + attachment: { + type: "text", + mimeType: "text/plain", + contextKind: "chat_history", + title: "Chat history", + text: "Preserve this context", + }, + source: { serverId: "local", agentId: "agent-1" }, + }, + }); + const clearConsumedDraft = captureCleanup(); + saveDraft("a newer workspace idea"); + await run(async () => undefined, clearConsumedDraft); + expect(draftText()).toBe("a newer workspace idea"); + expect(useWorkspaceAttachmentsStore.getState().attachmentsByScope[SCOPE_KEY]).toHaveLength(1); + }); +}); diff --git a/packages/app/src/screens/new-workspace/background-handoff.ts b/packages/app/src/screens/new-workspace/background-handoff.ts new file mode 100644 index 0000000000..598772f1a1 --- /dev/null +++ b/packages/app/src/screens/new-workspace/background-handoff.ts @@ -0,0 +1,54 @@ +import { useWorkspaceAttachmentsStore } from "@/attachments/workspace-attachments-store"; +import { useDraftStore } from "@/stores/draft-store"; +import { useWorkspaceDraftSubmissionStore } from "@/stores/workspace-draft-submission-store"; + +interface WorkspaceDraftCleanupInput { + draftId?: string; + draftKey: string; + clearDraft: (lifecycle: "sent" | "abandoned") => void; + draftContextScopeKey: string | null; +} + +/** Capture ownership before workspace creation starts, not when its response arrives. */ +export function captureWorkspaceDraftCleanup(input: WorkspaceDraftCleanupInput): () => void { + const draftId = input.draftId?.trim() ?? ""; + const scopeKey = input.draftContextScopeKey ?? ""; + const draftVersion = useDraftStore.getState().drafts[input.draftKey]?.version; + const setup = useWorkspaceDraftSubmissionStore.getState().setupByDraftId[draftId]; + const attachments = useWorkspaceAttachmentsStore.getState().attachmentsByScope[scopeKey]; + + return () => { + // These stores update independently. A context-only edit does not bump the text version. + // Any newer draft state owns the whole draft, including its unchanged context. + if ( + useDraftStore.getState().drafts[input.draftKey]?.version !== draftVersion || + useWorkspaceDraftSubmissionStore.getState().setupByDraftId[draftId] !== setup || + useWorkspaceAttachmentsStore.getState().attachmentsByScope[scopeKey] !== attachments + ) { + return; + } + useWorkspaceDraftSubmissionStore.getState().clearDraftSetup({ draftId }); + if (scopeKey) { + useWorkspaceAttachmentsStore.getState().clearWorkspaceAttachments({ scopeKey }); + } + input.clearDraft("sent"); + }; +} + +/** + * The workspace draft tab normally issues create_agent once it mounts on the destination screen. + * When the user leaves the New workspace screen before creation resolves, that tab never mounts, + * so this path issues the request itself. The agent tab opens on its own when the workspace is + * next visited — `reconcileTabs` auto-opens agents. + * + * The caller must not write a pending entry to the draft-submission or create-flow stores on this + * path: those drive the draft tab's auto-submit, and the daemon does not dedupe create_agent by + * clientMessageId, so a leftover entry would create a second agent when the workspace is opened. + */ +export async function createWorkspaceAgentInBackground(input: { + clearConsumedDraft: () => void; + createAgent: () => Promise; +}): Promise { + await input.createAgent(); + input.clearConsumedDraft(); +} diff --git a/packages/app/src/screens/new-workspace/screen-presence.test.ts b/packages/app/src/screens/new-workspace/screen-presence.test.ts new file mode 100644 index 0000000000..7213583c13 --- /dev/null +++ b/packages/app/src/screens/new-workspace/screen-presence.test.ts @@ -0,0 +1,22 @@ +import { describe, expect, it } from "vitest"; +import { isNewWorkspaceScreenActive } from "./screen-presence"; + +describe("isNewWorkspaceScreenActive", () => { + it("is active while the screen is mounted and the route is /new", () => { + expect(isNewWorkspaceScreenActive({ isMounted: true, pathname: "/new" })).toBe(true); + }); + + it("is inactive once the screen has been popped off the stack", () => { + expect(isNewWorkspaceScreenActive({ isMounted: false, pathname: "/new" })).toBe(false); + }); + + it("is inactive while another route sits on top of the screen", () => { + expect(isNewWorkspaceScreenActive({ isMounted: true, pathname: "/settings" })).toBe(false); + }); + + it("is inactive on a workspace route", () => { + expect(isNewWorkspaceScreenActive({ isMounted: true, pathname: "/host/workspace/wks_1" })).toBe( + false, + ); + }); +}); diff --git a/packages/app/src/screens/new-workspace/screen-presence.ts b/packages/app/src/screens/new-workspace/screen-presence.ts new file mode 100644 index 0000000000..812138e399 --- /dev/null +++ b/packages/app/src/screens/new-workspace/screen-presence.ts @@ -0,0 +1,41 @@ +import { usePathname } from "expo-router"; +import { useCallback, useEffect, useRef } from "react"; + +const NEW_WORKSPACE_PATHNAME = "/new"; + +export interface NewWorkspaceScreenPresence { + isMounted: boolean; + pathname: string; +} + +/** + * Workspace creation blocks on a slow daemon RPC, so by the time it resolves the user may have + * moved on. Both signals are needed: popping to a workspace unmounts the screen while its last + * observed pathname stays "/new", and pushing a route on top of it keeps it mounted underneath. + */ +export function isNewWorkspaceScreenActive(input: NewWorkspaceScreenPresence): boolean { + return input.isMounted && input.pathname === NEW_WORKSPACE_PATHNAME; +} + +export function useNewWorkspaceScreenPresence(): () => boolean { + const pathname = usePathname(); + const pathnameRef = useRef(pathname); + pathnameRef.current = pathname; + const isMountedRef = useRef(true); + + useEffect(() => { + isMountedRef.current = true; + return () => { + isMountedRef.current = false; + }; + }, []); + + return useCallback( + () => + isNewWorkspaceScreenActive({ + isMounted: isMountedRef.current, + pathname: pathnameRef.current, + }), + [], + ); +} From ec4e81ce0fa2a00ed4ae8272c709ca3d64aa7dd7 Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Wed, 9 Sep 2026 20:32:21 +0200 Subject: [PATCH 02/46] Show Codex questions while the agent keeps working (#4587) * fix(codex): show asynchronous questions and preserve their lifecycle * fix(server): reject concurrent replies to the same question * test(codex): use native temporary paths in question fixtures --- .../app/src/components/question-form-card.tsx | 17 +- .../server/src/server/agent/agent-manager.ts | 22 +- .../agent/providers/codex-app-server-agent.ts | 100 +++- .../providers/codex-async-questions.test.ts | 436 ++++++++++++++++++ .../agent/providers/codex/async-questions.ts | 155 +++++++ .../codex/test-utils/fake-app-server.ts | 2 +- 6 files changed, 710 insertions(+), 22 deletions(-) create mode 100644 packages/server/src/server/agent/providers/codex-async-questions.test.ts create mode 100644 packages/server/src/server/agent/providers/codex/async-questions.ts diff --git a/packages/app/src/components/question-form-card.tsx b/packages/app/src/components/question-form-card.tsx index d612bca0c9..dc5c66a529 100644 --- a/packages/app/src/components/question-form-card.tsx +++ b/packages/app/src/components/question-form-card.tsx @@ -80,8 +80,11 @@ function QuestionOptionRow({ ); const optionLabelStyle = useMemo( - () => [styles.optionLabel, { color: theme.colors.foreground }], - [theme.colors.foreground], + () => [ + styles.optionLabel, + { color: isSelected ? theme.colors.foreground : theme.colors.foregroundMuted }, + ], + [isSelected, theme.colors.foreground, theme.colors.foregroundMuted], ); const optionDescriptionStyle = useMemo( () => [styles.optionDescription, { color: theme.colors.foregroundMuted }], @@ -96,11 +99,11 @@ function QuestionOptionRow({ styles.selectionControl, multiSelect ? styles.selectionControlCheckbox : styles.selectionControlRadio, { - borderColor: isSelected ? theme.colors.accent : theme.colors.foregroundMuted, + borderColor: isSelected ? theme.colors.accent : theme.colors.foregroundExtraMuted, backgroundColor: isSelected && multiSelect ? theme.colors.accent : "transparent", }, ], - [isSelected, multiSelect, theme.colors.accent, theme.colors.foregroundMuted], + [isSelected, multiSelect, theme.colors.accent, theme.colors.foregroundExtraMuted], ); const radioDotStyle = useMemo( () => [styles.selectionRadioDot, { backgroundColor: theme.colors.accent }], @@ -628,7 +631,7 @@ const styles = StyleSheet.create((theme) => ({ questionText: { flex: 1, fontSize: theme.fontSize.base, - fontWeight: theme.fontWeight.medium, + fontWeight: theme.fontWeight.normal, lineHeight: 22, }, optionsWrap: { @@ -653,7 +656,7 @@ const styles = StyleSheet.create((theme) => ({ }, questionNavText: { fontSize: theme.fontSize.base, - fontWeight: theme.fontWeight.medium, + fontWeight: theme.fontWeight.normal, }, optionItem: { flexDirection: "row", @@ -677,7 +680,7 @@ const styles = StyleSheet.create((theme) => ({ }, optionLabel: { fontSize: theme.fontSize.base, - fontWeight: theme.fontWeight.semibold, + fontWeight: theme.fontWeight.normal, lineHeight: 22, }, optionDescription: { diff --git a/packages/server/src/server/agent/agent-manager.ts b/packages/server/src/server/agent/agent-manager.ts index d5dc1ab52b..5fd1c85847 100644 --- a/packages/server/src/server/agent/agent-manager.ts +++ b/packages/server/src/server/agent/agent-manager.ts @@ -2885,6 +2885,9 @@ export class AgentManager { response: AgentPermissionResponse, ): Promise { const agent = this.requireAgent(agentId); + if (agent.inFlightPermissionResponses.has(requestId)) { + throw new Error("A response to this permission request is already being submitted"); + } agent.inFlightPermissionResponses.add(requestId); try { @@ -3079,8 +3082,11 @@ export class AgentManager { epoch: this.timelineStore.getEpoch(agentId), }); } - await this.refreshRuntimeInfo(agent); + // Rewind stages provider events under the run lock; publish its final state directly. + this.refreshSessionPersistence(agent); + await this.refreshSessionState(agent, { emit: false }); await this.persistSnapshot(agent); + this.emitState(agent, { persist: false }); this.logger.info( { agentId, provider: agent.provider, messageId, mode }, "agent.rewind.complete", @@ -4231,14 +4237,18 @@ export class AgentManager { private onStreamThreadStarted(agent: ActiveManagedAgent): void { const previousSessionId = agent.persistence?.sessionId ?? null; + this.refreshSessionPersistence(agent); + if (agent.persistence?.sessionId !== previousSessionId) { + this.emitState(agent); + } + void this.refreshRuntimeInfo(agent); + } + + private refreshSessionPersistence(agent: ActiveManagedAgent): void { const handle = agent.session.describePersistence(); if (handle) { agent.persistence = attachPersistenceCwd(handle, agent.cwd); - if (agent.persistence?.sessionId !== previousSessionId) { - this.emitState(agent); - } } - void this.refreshRuntimeInfo(agent); } private async onStreamTimelineEvent(params: { @@ -4443,6 +4453,7 @@ export class AgentManager { ): void { const hadPendingPermissions = agent.pendingPermissions.size > 0; agent.pendingPermissions.set(event.request.id, event.request); + this.refreshSessionPersistence(agent); if (!hadPendingPermissions && !agent.internal) { this.broadcastAgentAttention(agent, "permission"); } @@ -4457,6 +4468,7 @@ export class AgentManager { }): void { const { agent, event, options, flags } = params; agent.pendingPermissions.delete(event.requestId); + this.refreshSessionPersistence(agent); if (!options?.fromHistory && agent.inFlightPermissionResponses.has(event.requestId)) { agent.bufferedPermissionResolutions.set(event.requestId, event); flags.shouldDispatchEvent = false; diff --git a/packages/server/src/server/agent/providers/codex-app-server-agent.ts b/packages/server/src/server/agent/providers/codex-app-server-agent.ts index adff09dd9c..7123db17cc 100644 --- a/packages/server/src/server/agent/providers/codex-app-server-agent.ts +++ b/packages/server/src/server/agent/providers/codex-app-server-agent.ts @@ -52,6 +52,7 @@ import { z } from "zod"; import { renderPromptAttachmentAsText } from "../prompt-attachments.js"; import { composeSystemPromptParts } from "../system-prompt.js"; import { curateAgentActivity } from "../activity-curator.js"; +import { CodexAsyncQuestions, codexAsyncQuestionToTimeline } from "./codex/async-questions.js"; import { mapCodexToolCallEnvelope, mapCodexToolCallFromThreadItem, @@ -1845,6 +1846,17 @@ function mapCodexThreadImageItem( ); } +function mapCodexAgentMessage(item: Record): AgentTimelineItem { + const question = codexAsyncQuestionToTimeline(item); + if (question) return question; + const messageId = nonEmptyString(item.id); + return { + type: "assistant_message", + text: typeof item.text === "string" ? item.text : "", + ...(messageId ? { messageId } : {}), + }; +} + export function threadItemToTimeline( item: unknown, options?: { includeUserMessage?: boolean; cwd?: string | null }, @@ -1871,14 +1883,8 @@ export function threadItemToTimeline( switch (normalizedType) { case "userMessage": return mapCodexThreadUserMessageItem(normalizedItem, includeUserMessage); - case "agentMessage": { - const messageId = nonEmptyString(normalizedItem.id); - return { - type: "assistant_message", - text: typeof normalizedItem.text === "string" ? normalizedItem.text : "", - ...(messageId ? { messageId } : {}), - }; - } + case "agentMessage": + return mapCodexAgentMessage(normalizedItem); case "plan": return mapCodexThreadPlanItem(normalizedItem); case "reasoning": @@ -3293,6 +3299,7 @@ export class CodexAppServerAgentSession implements AgentSession { private readonly logger: Logger; private readonly config: AgentSessionConfig; + private readonly asyncQuestions: CodexAsyncQuestions; private currentMode: string; private hasWorkflowModeOverride: boolean; private readonly providerOptions: CodexProviderOptions; @@ -3405,6 +3412,7 @@ export class CodexAppServerAgentSession implements AgentSession { this.currentMode = config.modeId ?? DEFAULT_CODEX_MODE_ID; this.providerOptions = CodexProviderOptionsSchema.parse(config.providerOptions ?? {}); this.config = config; + this.asyncQuestions = new CodexAsyncQuestions(resumeHandle?.metadata?.asyncQuestions); this.config.thinkingOptionId = normalizeCodexThinkingOptionId(this.config.thinkingOptionId); if (this.config.featureValues?.fast_mode && codexModelSupportsFastMode(this.config.model)) { this.serviceTier = "fast"; @@ -3799,6 +3807,9 @@ export class CodexAppServerAgentSession implements AgentSession { } this.resetCodexUserMessageTurns(); for (const entry of timeline) { + if (entry.item.type === "tool_call" && entry.item.name === "request_user_input_async") { + entry.item = this.asyncQuestions.timeline(entry.item.callId) ?? entry.item; + } if (entry.item.type === "user_message") { this.rememberCodexUserMessageTurn(entry.item.messageId, entry.providerTurnId); } @@ -4464,13 +4475,16 @@ export class CodexAppServerAgentSession implements AgentSession { } getPendingPermissions(): AgentPermissionRequest[] { - return Array.from(this.pendingPermissions.values()); + return [...this.pendingPermissions.values(), ...this.asyncQuestions.pending()]; } async respondToPermission( requestId: string, response: AgentPermissionResponse, ): Promise { + if (this.asyncQuestions.hasPending(requestId)) { + return this.respondToAsyncQuestion(requestId, response); + } const pending = this.pendingPermissionHandlers.get(requestId); if (!pending) { throw new Error(`No pending Codex app-server permission request with id '${requestId}'`); @@ -4564,6 +4578,33 @@ export class CodexAppServerAgentSession implements AgentSession { pending.resolve({ answers: {} }); } + private async respondToAsyncQuestion( + requestId: string, + response: AgentPermissionResponse, + ): Promise { + const prepared = this.asyncQuestions.prepareResponse(requestId, response); + let followUpPrompt = prepared.prompt; + const expectedTurnId = this.activeForegroundTurnId; + if (prepared.prompt && expectedTurnId) { + const result = await this.steerActiveTurn(prepared.prompt, { + expectedTurnId, + clientMessageId: randomUUID(), + }); + if (result.status !== "accepted") { + throw new Error("The active Codex turn changed. Retry sending your answer."); + } + followUpPrompt = undefined; + } + this.emitEvent({ type: "timeline", provider: CODEX_PROVIDER, item: prepared.complete() }); + this.emitEvent({ + type: "permission_resolved", + provider: CODEX_PROVIDER, + requestId, + resolution: response, + }); + return followUpPrompt ? { followUpPrompt } : undefined; + } + private handlePlanPermissionResponse(params: { requestId: string; response: AgentPermissionResponse; @@ -4700,6 +4741,7 @@ export class CodexAppServerAgentSession implements AgentSession { toolPolicy: this.config.toolPolicy, systemPrompt: this.config.systemPrompt, mcpServers: this.config.mcpServers, + asyncQuestions: this.asyncQuestions.serialize(), }, }; } @@ -4729,6 +4771,7 @@ export class CodexAppServerAgentSession implements AgentSession { this.persistedHistory = []; this.historyPending = false; await this.loadPersistedHistory(); + this.reconcileAsyncQuestionsAfterRewind(); }, }); } @@ -5903,6 +5946,7 @@ export class CodexAppServerAgentSession implements AgentSession { error: parsed.errorMessage ?? "Codex turn failed", }); } else if (parsed.status === "interrupted") { + this.dismissInterruptedAsyncQuestions(); this.emitEvent({ type: "turn_canceled", provider: CODEX_PROVIDER, reason: "interrupted" }); } else { if (this.planModeEnabled && this.latestPlanResult?.text) { @@ -6255,6 +6299,43 @@ export class CodexAppServerAgentSession implements AgentSession { } } + private receiveAsyncQuestion(threadId: string | null, item: unknown): void { + if (threadId !== this.currentThreadId) return; + const request = this.asyncQuestions.receive(item); + if (request) + this.emitEvent({ type: "permission_requested", provider: CODEX_PROVIDER, request }); + } + + private dismissInterruptedAsyncQuestions(): void { + const resolution: AgentPermissionResponse = { behavior: "deny", message: "Interrupted" }; + for (const request of this.asyncQuestions.pending()) { + const prepared = this.asyncQuestions.prepareResponse(request.id, resolution); + this.emitEvent({ type: "timeline", provider: CODEX_PROVIDER, item: prepared.complete() }); + this.emitEvent({ + type: "permission_resolved", + provider: CODEX_PROVIDER, + requestId: request.id, + resolution, + }); + } + } + + private reconcileAsyncQuestionsAfterRewind(): void { + const retainedIds = new Set( + this.persistedHistory.flatMap(({ item }) => + item.type === "tool_call" && item.name === "request_user_input_async" ? [item.callId] : [], + ), + ); + for (const requestId of this.asyncQuestions.retain(retainedIds)) { + this.emitEvent({ + type: "permission_resolved", + provider: CODEX_PROVIDER, + requestId, + resolution: { behavior: "deny", message: "Removed by rewind" }, + }); + } + } + private handleItemCompletedNotification( parsed: Extract, ): void { @@ -6265,6 +6346,7 @@ export class CodexAppServerAgentSession implements AgentSession { if (shouldIgnoreMirroredLifecycleItem(parsed.source, parsed.item)) { return; } + this.receiveAsyncQuestion(parsed.threadId, parsed.item); if (this.isUserMessageItem(parsed.item)) { this.handleUserMessageItem(parsed); return; diff --git a/packages/server/src/server/agent/providers/codex-async-questions.test.ts b/packages/server/src/server/agent/providers/codex-async-questions.test.ts new file mode 100644 index 0000000000..8915688bde --- /dev/null +++ b/packages/server/src/server/agent/providers/codex-async-questions.test.ts @@ -0,0 +1,436 @@ +import { tmpdir } from "node:os"; +import { expect, test } from "vitest"; +import { CodexAppServerAgentSession } from "./codex-app-server-agent.js"; +import { + createFakeCodexAppServer, + waitForNextEvent, + waitForTimelineToolCall, +} from "./codex/test-utils/fake-app-server.js"; +import { createTestLogger } from "../../../test-utils/test-logger.js"; +import type { AgentStreamEvent } from "../agent-sdk-types.js"; +import { AgentManager } from "../agent-manager.js"; + +const questionItem = { + type: "agentMessage", + id: "async-question-1", + text: "Which color?\n- Blue\n- Green", + phase: "final_answer", + delivery: "async", + questions: [{ title: "Which color?", options: ["Blue", "Green"] }], +}; + +async function setup(metadata?: Record, rejectSteer = false) { + const appServer = createFakeCodexAppServer({ + "turn/interrupt": () => ({}), + "turn/steer": () => { + if (rejectSteer) return { __jsonRpcError: { code: -32000, message: "Delivery failed" } }; + return { turnId: "native-turn" }; + }, + "thread/read": () => ({ thread: { id: "thread-1", turns: [] } }), + }); + const session = new CodexAppServerAgentSession( + { provider: "codex", cwd: tmpdir(), model: "gpt-5.4", modeId: "full-access" }, + metadata ? { sessionId: "thread-1", metadata } : null, + createTestLogger(), + async () => appServer.child, + ); + const events: AgentStreamEvent[] = []; + session.subscribe((event) => events.push(event)); + await session.startTurn("Help me choose a color while you inspect the project."); + const started = waitForNextEvent(session, "turn_started"); + appServer.startsTurn({ threadId: "thread-1", turnId: "native-turn" }); + await started; + async function ask() { + const shown = waitForTimelineToolCall(session, questionItem.id); + appServer.child.stdout.write( + JSON.stringify({ + method: "item/completed", + params: { threadId: "thread-1", turnId: "native-turn", item: questionItem }, + }) + "\n", + ); + await shown; + } + async function say(text: string) { + const shown = waitForNextEvent( + session, + "timeline", + (event) => event.item.type === "assistant_message" && event.item.text.includes(text), + ); + appServer.says({ threadId: "thread-1", text }); + await shown; + } + async function finish(status: "completed" | "interrupted" = "completed") { + const finished = waitForNextEvent( + session, + status === "completed" ? "turn_completed" : "turn_canceled", + ); + appServer.completeTurn({ status }); + await finished; + } + function allowAnswers() { + rejectSteer = false; + } + return { session, appServer, events, ask, say, finish, allowAnswers }; +} + +const answer = { behavior: "allow" as const, updatedInput: { answers: { "Question 1": "Green" } } }; + +async function setupRewind(fail = false) { + const records = [ + { item: { ...questionItem, id: "earlier-pending" } }, + { item: { ...questionItem, id: "earlier-answered" }, resolution: ["Blue"] }, + { item: { ...questionItem, id: "removed-pending" } }, + { item: { ...questionItem, id: "removed-answered" }, resolution: ["Green"] }, + ]; + function userMessage(id: string) { + return { type: "userMessage", id, content: [{ type: "text", text: id }] }; + } + const turns = [ + { + id: "earlier-turn", + items: [userMessage("earlier"), ...records.slice(0, 2).map((r) => r.item)], + }, + { + id: "removed-turn", + items: [userMessage("rewind-here"), ...records.slice(2).map((r) => r.item)], + }, + ]; + const appServer = createFakeCodexAppServer({ + "thread/read": (params) => { + const { threadId } = params as { threadId: string }; + return { + thread: { id: threadId, turns: threadId === "thread-1" ? turns : turns.slice(0, 1) }, + }; + }, + ...(fail + ? { + "thread/rollback": () => ({ __jsonRpcError: { code: -32000, message: "Rewind failed" } }), + } + : {}), + }); + const session = new CodexAppServerAgentSession( + { provider: "codex", cwd: tmpdir(), model: "gpt-5.4", modeId: "full-access" }, + { sessionId: "thread-1", metadata: { asyncQuestions: records } }, + createTestLogger(), + async () => appServer.child, + ); + const events: AgentStreamEvent[] = []; + session.subscribe((event) => events.push(event)); + await session.connect(); + return { session, events, records }; +} + +test("rewind removes only questions outside the remaining history, including after resume", async () => { + const { session, events, records } = await setupRewind(); + let metadata: Record | undefined; + try { + await session.revertConversation({ messageId: "rewind-here" }); + expect(session.getPendingPermissions().map((p) => p.id)).toEqual([ + "permission-earlier-pending", + ]); + metadata = session.describePersistence()!.metadata; + expect(metadata.asyncQuestions).toEqual( + records.slice(0, 2).map((record) => ({ + resolution: record.resolution, + item: { + type: "agentMessage", + id: record.item.id, + delivery: "async", + questions: questionItem.questions, + }, + })), + ); + expect(events.filter((e) => e.type === "permission_resolved")).toEqual([ + expect.objectContaining({ requestId: "permission-removed-pending" }), + ]); + const history = []; + for await (const event of session.streamHistory()) history.push(event); + expect( + history.some( + (e) => + e.type === "timeline" && + e.item.type === "tool_call" && + e.item.callId === "removed-pending", + ), + ).toBe(false); + } finally { + await session.close(); + } + const resumed = await setup(metadata); + try { + expect(resumed.session.getPendingPermissions().map((p) => p.id)).toEqual([ + "permission-earlier-pending", + ]); + } finally { + await resumed.session.close(); + } +}); + +test("failed rewind preserves question state", async () => { + const { session, events } = await setupRewind(true); + try { + const before = session.describePersistence(); + await expect(session.revertConversation({ messageId: "rewind-here" })).rejects.toThrow( + "Rewind failed", + ); + expect(session.describePersistence()).toEqual(before); + expect(session.getPendingPermissions().map((p) => p.id)).toEqual([ + "permission-earlier-pending", + "permission-removed-pending", + ]); + expect(events.some((e) => e.type === "permission_resolved")).toBe(false); + } finally { + await session.close(); + } +}); + +async function manage(session: CodexAppServerAgentSession) { + const manager = new AgentManager({ + clients: { + codex: { + provider: "codex", + capabilities: session.capabilities, + createSession: async () => session, + resumeSession: async () => session, + isAvailable: async () => true, + fetchCatalog: async () => ({ models: [], modes: [] }), + }, + }, + logger: createTestLogger(), + }); + const agent = await manager.createAgent( + { provider: "codex", cwd: tmpdir(), model: "gpt-5.4" }, + undefined, + { workspaceId: undefined }, + ); + return { manager, agent }; +} + +test("manager publishes and saves the provider state after rewind", async () => { + const { session } = await setupRewind(); + const { manager, agent } = await manage(session); + try { + await manager.rewind(agent.id, "rewind-here", "conversation"); + const snapshot = manager.getAgent(agent.id)!; + expect(Array.from(snapshot.pendingPermissions.keys())).toEqual(["permission-earlier-pending"]); + expect(snapshot.persistence).toEqual(session.describePersistence()); + expect(snapshot.persistence?.sessionId).toBe("forked-thread"); + } finally { + await manager.closeAgent(agent.id); + } +}); + +test("manager snapshots capture pending and answered question state before the turn ends", async () => { + const { session, ask } = await setup(); + const { manager, agent } = await manage(session); + try { + await ask(); + const [permission] = session.getPendingPermissions(); + expect(manager.getAgent(agent.id)?.persistence?.metadata?.asyncQuestions).toEqual([ + { + item: { + type: "agentMessage", + id: questionItem.id, + delivery: "async", + questions: questionItem.questions, + }, + }, + ]); + await manager.respondToPermission(agent.id, permission.id, answer); + expect(manager.getAgent(agent.id)?.persistence?.metadata?.asyncQuestions).toEqual([ + { + item: { + type: "agentMessage", + id: questionItem.id, + delivery: "async", + questions: questionItem.questions, + }, + resolution: ["Green"], + }, + ]); + } finally { + await manager.closeAgent(agent.id); + } +}); + +test("concurrent answers deliver only one response to the active Codex turn", async () => { + const { session, appServer, ask } = await setup(); + const { manager, agent } = await manage(session); + try { + await ask(); + const [permission] = session.getPendingPermissions(); + const results = await Promise.allSettled([ + manager.respondToPermission(agent.id, permission.id, answer), + manager.respondToPermission(agent.id, permission.id, answer), + ]); + expect(results.map((result) => result.status)).toEqual(["fulfilled", "rejected"]); + expect(appServer.requests().filter((request) => request.method === "turn/steer")).toHaveLength( + 1, + ); + expect(session.getPendingPermissions()).toEqual([]); + } finally { + await manager.closeAgent(agent.id); + } +}); + +test("a failed submission releases the request so the user can retry", async () => { + const { session, ask, allowAnswers } = await setup(undefined, true); + const { manager, agent } = await manage(session); + try { + await ask(); + const [permission] = session.getPendingPermissions(); + await expect(manager.respondToPermission(agent.id, permission.id, answer)).rejects.toThrow( + "Delivery failed", + ); + expect(session.getPendingPermissions().map((request) => request.id)).toEqual([permission.id]); + allowAnswers(); + await manager.respondToPermission(agent.id, permission.id, answer); + expect(session.getPendingPermissions()).toEqual([]); + } finally { + await manager.closeAgent(agent.id); + } +}); + +test("shows an async question, keeps streaming, and delivers its answer without interrupting", async () => { + const { session, appServer, events, ask, say } = await setup(); + try { + await ask(); + const [permission] = session.getPendingPermissions(); + expect(permission).toMatchObject({ + kind: "question", + input: { + questions: [ + { + header: "Question 1", + question: "Which color?", + isOther: true, + options: [{ label: "Blue" }, { label: "Green" }], + }, + ], + }, + }); + await say("I am still inspecting the project."); + expect( + events.some( + (event) => + event.type === "timeline" && + event.item.type === "assistant_message" && + event.item.text.includes("still inspecting"), + ), + ).toBe(true); + await session.respondToPermission(permission.id, answer); + expect(session.getPendingPermissions()).toEqual([]); + expect(appServer.requests().filter((request) => request.method === "turn/steer")).toMatchObject( + [ + { + params: { + expectedTurnId: "native-turn", + clientUserMessageId: expect.any(String), + input: [{ type: "text", text: expect.stringContaining("Which color?\nGreen") }], + }, + }, + ], + ); + expect(appServer.requests().filter((request) => request.method === "turn/interrupt")).toEqual( + [], + ); + expect( + events.some( + (event) => event.type === "permission_resolved" && event.requestId === permission.id, + ), + ).toBe(true); + } finally { + await session.close(); + } +}); + +test("keeps a failed answer pending for retry", async () => { + const { session, ask } = await setup(undefined, true); + try { + await ask(); + const [permission] = session.getPendingPermissions(); + expect(permission).toBeDefined(); + await expect(session.respondToPermission(permission.id, answer)).rejects.toThrow(); + expect(session.getPendingPermissions()).toHaveLength(1); + } finally { + await session.close(); + } +}); + +test("Stop dismisses async questions before cancellation and keeps them dismissed after resume", async () => { + const first = await setup(); + let metadata: Record | undefined; + try { + await first.ask(); + expect(first.session.getPendingPermissions()).toHaveLength(1); + first.session.subscribe((event) => { + if (event.type === "turn_canceled") { + metadata = first.session.describePersistence()!.metadata; + } + }); + await first.session.interrupt(); + await first.finish("interrupted"); + expect(first.session.getPendingPermissions()).toEqual([]); + expect(metadata?.asyncQuestions).toEqual([ + expect.objectContaining({ resolution: "dismissed" }), + ]); + expect(first.events).toContainEqual( + expect.objectContaining({ + type: "permission_resolved", + requestId: "permission-async-question-1", + resolution: { behavior: "deny", message: "Interrupted" }, + }), + ); + } finally { + await first.session.close(); + } + const resumed = await setup(metadata); + try { + await resumed.ask(); + expect(resumed.session.getPendingPermissions()).toEqual([]); + } finally { + await resumed.session.close(); + } +}); + +test("late answers use the existing follow-up prompt and dismissal does not interrupt", async () => { + const { session, appServer, ask, finish } = await setup(); + try { + await ask(); + const [permission] = session.getPendingPermissions(); + expect(permission).toBeDefined(); + await finish(); + expect(session.getPendingPermissions()).toHaveLength(1); + expect(await session.respondToPermission(permission.id, answer)).toEqual({ + followUpPrompt: "Answers to your questions:\n\nWhich color?\nGreen", + }); + expect(appServer.requests().some((request) => request.method === "turn/interrupt")).toBe(false); + } finally { + await session.close(); + } +}); + +test("restores unanswered questions and does not reopen answered questions on duplicate events", async () => { + const first = await setup(); + let metadata: Record | undefined; + try { + await first.ask(); + expect(first.session.getPendingPermissions()).toHaveLength(1); + metadata = first.session.describePersistence()!.metadata; + } finally { + await first.session.close(); + } + const resumed = await setup(metadata); + try { + const [permission] = resumed.session.getPendingPermissions(); + expect(permission).toBeDefined(); + await resumed.session.respondToPermission(permission.id, { behavior: "deny" }); + await resumed.ask(); + expect(resumed.session.getPendingPermissions()).toEqual([]); + expect( + resumed.appServer.requests().some((request) => request.method === "turn/interrupt"), + ).toBe(false); + } finally { + await resumed.session.close(); + } +}); diff --git a/packages/server/src/server/agent/providers/codex/async-questions.ts b/packages/server/src/server/agent/providers/codex/async-questions.ts new file mode 100644 index 0000000000..6fdba7b67e --- /dev/null +++ b/packages/server/src/server/agent/providers/codex/async-questions.ts @@ -0,0 +1,155 @@ +import { z } from "zod"; +import type { + AgentPermissionRequest, + AgentPermissionResponse, + ToolCallTimelineItem, +} from "../../agent-sdk-types.js"; + +const QuestionSchema = z.object({ + title: z.string().trim().min(1), + options: z.array(z.string().min(1)).nullish(), +}); +const ItemSchema = z.object({ + type: z.literal("agentMessage"), + id: z.string().min(1), + delivery: z.literal("async"), + questions: z.array(QuestionSchema).min(1), +}); +const RecordSchema = z.object({ + item: ItemSchema, + resolution: z.union([z.literal("dismissed"), z.array(z.string())]).optional(), +}); +type QuestionRecord = z.infer; + +function requestId(itemId: string): string { + return `permission-${itemId}`; +} + +function toPermission(record: QuestionRecord): AgentPermissionRequest { + return { + id: requestId(record.item.id), + provider: "codex", + name: "request_user_input_async", + kind: "question", + title: "Question", + input: { + questions: record.item.questions.map((question, index) => ({ + id: String(index), + header: `Question ${index + 1}`, + question: question.title, + options: (question.options ?? []).map((label) => ({ label })), + isOther: true, + })), + }, + }; +} + +function toTimeline(record: QuestionRecord): ToolCallTimelineItem { + const answers = Array.isArray(record.resolution) ? record.resolution : undefined; + return { + type: "tool_call", + callId: record.item.id, + name: "request_user_input_async", + status: "completed", + error: null, + detail: { + type: "plain_text", + icon: "brain", + text: + record.item.questions + .map((question, index) => + [question.title, answers ? answers[index] : (question.options ?? []).join(", ")] + .filter(Boolean) + .join("\n"), + ) + .join("\n\n") + (record.resolution === "dismissed" ? "\n\nDismissed" : ""), + }, + }; +} + +export function codexAsyncQuestionToTimeline(item: unknown): ToolCallTimelineItem | null { + const parsed = ItemSchema.safeParse(item); + return parsed.success ? toTimeline({ item: parsed.data }) : null; +} + +/** Codex emits these as completed messages; the outstanding answer belongs to the session. */ +export class CodexAsyncQuestions { + private readonly records = new Map(); + + constructor(saved: unknown) { + const parsed = z.array(RecordSchema).safeParse(saved); + if (parsed.success) { + for (const record of parsed.data) this.records.set(requestId(record.item.id), record); + } + } + + receive(item: unknown): AgentPermissionRequest | null { + const parsed = ItemSchema.safeParse(item); + if (!parsed.success || this.records.has(requestId(parsed.data.id))) return null; + const record = { item: parsed.data }; + this.records.set(requestId(parsed.data.id), record); + return toPermission(record); + } + + pending(): AgentPermissionRequest[] { + return Array.from(this.records.values()) + .filter((record) => record.resolution === undefined) + .map(toPermission); + } + + hasPending(id: string): boolean { + const record = this.records.get(id); + return record !== undefined && record.resolution === undefined; + } + + prepareResponse( + id: string, + response: AgentPermissionResponse, + ): { prompt?: string; complete: () => ToolCallTimelineItem } { + const record = this.records.get(id); + if (!record || record.resolution !== undefined) + throw new Error("Question is no longer pending"); + let resolution: QuestionRecord["resolution"] = "dismissed"; + let prompt: string | undefined; + if (response.behavior === "allow") { + const answers = z.record(z.string(), z.string()).parse(response.updatedInput?.answers); + resolution = record.item.questions.map((_, index) => { + const answer = answers[`Question ${index + 1}`]?.trim(); + if (!answer) throw new Error(`Answer Question ${index + 1} before submitting`); + return answer; + }); + const values = resolution; + prompt = + "Answers to your questions:\n\n" + + record.item.questions + .map((question, index) => `${question.title}\n${values[index]}`) + .join("\n\n"); + } + return { + prompt, + complete: () => { + record.resolution = resolution; + return toTimeline(record); + }, + }; + } + + timeline(itemId: string): ToolCallTimelineItem | null { + const record = this.records.get(requestId(itemId)); + return record ? toTimeline(record) : null; + } + + retain(itemIds: ReadonlySet): string[] { + const removedPendingIds: string[] = []; + for (const [id, record] of this.records) { + if (itemIds.has(record.item.id)) continue; + this.records.delete(id); + if (record.resolution === undefined) removedPendingIds.push(id); + } + return removedPendingIds; + } + + serialize(): unknown { + return Array.from(this.records.values()); + } +} diff --git a/packages/server/src/server/agent/providers/codex/test-utils/fake-app-server.ts b/packages/server/src/server/agent/providers/codex/test-utils/fake-app-server.ts index 3f4a7af01c..31e231cdb1 100644 --- a/packages/server/src/server/agent/providers/codex/test-utils/fake-app-server.ts +++ b/packages/server/src/server/agent/providers/codex/test-utils/fake-app-server.ts @@ -618,7 +618,7 @@ function toJsonObject(value: unknown): JsonObject { type StreamEventType = AgentStreamEvent["type"]; type StreamEventOfType = Extract; -function waitForNextEvent( +export function waitForNextEvent( session: AgentSession, type: TType, accepts?: (event: StreamEventOfType) => boolean, From 726067b43759c5c489ab865eee0fe05e8334ea77 Mon Sep 17 00:00:00 2001 From: Tom Gringauz Date: Wed, 9 Sep 2026 23:45:30 +0300 Subject: [PATCH 03/46] fix(claude): open a single compaction marker per compaction (#4391) Suppress repeated Claude compaction heartbeat markers at the provider and re-arm marker creation at compaction and turn boundaries. Verify emitted events through the public session interface, including subsequent compactions after interrupted or abandoned turns. Closes #4390. Co-authored-by: Tom Gringauz <17593920+tomgrin10@users.noreply.github.com> --- .../agent/providers/claude/agent.test.ts | 119 ++++++++++++++++++ .../server/agent/providers/claude/agent.ts | 21 +++- 2 files changed, 135 insertions(+), 5 deletions(-) diff --git a/packages/server/src/server/agent/providers/claude/agent.test.ts b/packages/server/src/server/agent/providers/claude/agent.test.ts index 5b5a9deda6..25d4729dc6 100644 --- a/packages/server/src/server/agent/providers/claude/agent.test.ts +++ b/packages/server/src/server/agent/providers/claude/agent.test.ts @@ -24,6 +24,12 @@ interface TestClaudeSession { close(): Promise; } +function isLoadingCompactionEvent(event: AgentStreamEvent): boolean { + return ( + event.type === "timeline" && event.item.type === "compaction" && event.item.status === "loading" + ); +} + function isPermissionResolvedEvent( event: AgentStreamEvent, ): event is Extract { @@ -1981,6 +1987,15 @@ describe("ClaudeAgentSession context window usage", () => { }; } + function createCompactingStatus(): Record { + return { + type: "system", + subtype: "status", + status: "compacting", + session_id: "session-1", + }; + } + test("emits turn_started before the submitted user message", async () => { const session = await createSessionForTurns([[]]); const events: AgentStreamEvent[] = []; @@ -2857,6 +2872,110 @@ describe("ClaudeAgentSession context window usage", () => { } }); + test("repeated compacting statuses open a single compaction marker", async () => { + const session = await createSessionForTurns([ + [ + createCompactingStatus(), + createCompactingStatus(), + createCompactingStatus(), + createCompactBoundary(), + createCompactingStatus(), + createCompactingStatus(), + createCompactBoundary(), + createSuccessResult(), + ], + ]); + + try { + const events = await collectStreamEvents(session, "compact twice"); + const compactions = events.flatMap((event) => + event.type === "timeline" && event.item.type === "compaction" ? [event.item.status] : [], + ); + expect(compactions).toEqual(["loading", "completed", "loading", "completed"]); + } finally { + await session.close(); + } + }); + + test("a compaction abandoned mid-turn does not suppress the next compaction marker", async () => { + // The first turn starts compacting and then ends without ever reaching a + // compact_boundary, so the marker it opened is never resolved. + const session = await createSessionForTurns([ + [createCompactingStatus(), createSuccessResult()], + [createCompactingStatus(), createSuccessResult()], + ]); + + try { + const abandonedTurn = await collectStreamEvents(session, "abandoned compaction"); + expect(abandonedTurn.filter(isLoadingCompactionEvent)).toHaveLength(1); + + const nextTurn = await collectStreamEvents(session, "next compaction"); + expect(nextTurn.filter(isLoadingCompactionEvent)).toHaveLength(1); + } finally { + await session.close(); + } + }); + + test("an interrupted compaction does not suppress the next compaction marker", async () => { + // The first turn starts compacting and is then interrupted, so it never reaches a + // compact_boundary and the marker it opened is never resolved. + const session = await createSessionForTurns([ + [createCompactingStatus()], + [createCompactingStatus(), createSuccessResult()], + ]); + + try { + const interruptedTurn: AgentStreamEvent[] = []; + const streaming = (async () => { + for await (const event of streamSession(session, "interrupted compaction")) { + interruptedTurn.push(event); + } + })(); + + await vi.waitFor(() => { + expect(interruptedTurn.filter(isLoadingCompactionEvent)).toHaveLength(1); + }); + await session.interrupt(); + await streaming; + + expect(interruptedTurn).toContainEqual( + expect.objectContaining({ type: "turn_canceled", provider: "claude" }), + ); + + const nextTurn = await collectStreamEvents(session, "next compaction"); + expect(nextTurn.filter(isLoadingCompactionEvent)).toHaveLength(1); + } finally { + await session.close(); + } + }); + + test("a compaction abandoned in an autonomous turn does not suppress the next marker", async () => { + // Trailing output after the foreground result opens an autonomous turn, which starts + // compacting and is then ended by the next foreground turn, never reaching a boundary. + const session = await createSessionForTurns([ + [createSuccessResult(), createMessageStartEvent(), createCompactingStatus()], + [createCompactingStatus(), createSuccessResult()], + ]); + + try { + const observed: AgentStreamEvent[] = []; + const unsubscribe = session.subscribe((event) => { + observed.push(event); + }); + + await collectStreamEvents(session, "foreground turn"); + await vi.waitFor(() => { + expect(observed.filter(isLoadingCompactionEvent)).toHaveLength(1); + }); + unsubscribe(); + + const nextTurn = await collectStreamEvents(session, "next compaction"); + expect(nextTurn.filter(isLoadingCompactionEvent)).toHaveLength(1); + } finally { + await session.close(); + } + }); + test("result.result is surfaced as an assistant message when no model output was produced", async () => { const session = await createSessionForTest(); diff --git a/packages/server/src/server/agent/providers/claude/agent.ts b/packages/server/src/server/agent/providers/claude/agent.ts index a7469a75f3..4adbf0ce1c 100644 --- a/packages/server/src/server/agent/providers/claude/agent.ts +++ b/packages/server/src/server/agent/providers/claude/agent.ts @@ -2095,6 +2095,7 @@ class ClaudeAgentSession implements AgentSession { private lastOptionsModel: string | null = null; private lastRuntimeModel: string | null = null; private compacting = false; + private compactionMarkerOpen = false; private queryPumpPromise: Promise | null = null; private queryRestartNeeded = false; private pendingInterruptAbort = false; @@ -3572,6 +3573,7 @@ class ClaudeAgentSession implements AgentSession { this.activeForegroundInput = null; this.cancelCurrentTurn = null; this.activeTurnHasAssistantText = false; + this.compactionMarkerOpen = false; this.syncTurnState("foreground turn terminal"); } @@ -3583,6 +3585,7 @@ class ClaudeAgentSession implements AgentSession { } if (terminalSeen) { + this.compactionMarkerOpen = false; if (this.activeForegroundTurnId) { this.activeForegroundTurnId = null; this.activeForegroundQuery = null; @@ -3624,6 +3627,7 @@ class ClaudeAgentSession implements AgentSession { this.activeForegroundQuery = null; this.activeForegroundInput = null; this.activeTurnHasAssistantText = false; + this.compactionMarkerOpen = false; this.syncTurnState("autonomous turn completed"); } @@ -4260,15 +4264,22 @@ class ClaudeAgentSession implements AgentSession { const status = toObjectRecord(message)?.status; if (status === "compacting") { this.compacting = true; - events.push({ - type: "timeline", - item: { type: "compaction", status: "loading" }, - provider: "claude", - }); + // Claude Code repeats this status every 30 seconds until the compaction + // finishes. Each repeat used to open its own marker, and the app only ever + // resolves one of them, so the rest stayed on "Compacting..." forever. + if (!this.compactionMarkerOpen) { + this.compactionMarkerOpen = true; + events.push({ + type: "timeline", + item: { type: "compaction", status: "loading" }, + provider: "claude", + }); + } } return; } if (message.subtype === "compact_boundary") { + this.compactionMarkerOpen = false; const compactMetadata = readCompactionMetadata(message); events.push({ type: "timeline", From 92504cd525c7e594539a369ed441eb836372dd2d Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Wed, 9 Sep 2026 23:37:54 +0200 Subject: [PATCH 04/46] docs(hub): document configurable startup timeout (#4601) * docs(hub): document configurable startup timeout * docs: keep startup timeout guidance in one place --- public-docs/hub/configuration/hub-yml.md | 29 ++++++++++++------------ public-docs/hub/configuration/index.md | 17 ++++++++++++++ 2 files changed, 32 insertions(+), 14 deletions(-) diff --git a/public-docs/hub/configuration/hub-yml.md b/public-docs/hub/configuration/hub-yml.md index 0bc82abf16..906d75f8ce 100644 --- a/public-docs/hub/configuration/hub-yml.md +++ b/public-docs/hub/configuration/hub-yml.md @@ -188,20 +188,21 @@ An environment or dynamic named-agent expression must have a finite set of possi ### Steps -| Field | Required | Notes | -| --------------- | -------- | ---------------------------------------------------------------------------------------- | -| `id` | yes | Unique within the workflow. | -| `environment` | yes | Literal environment name or finite expression resolving to one. | -| `max_runtime` | yes | Step hard limit. | -| `idle_timeout` | yes | Idle limit no longer than `max_runtime`. | -| `agent` | yes | Named agent, finite expression selecting a named agent, or complete static inline agent. | -| `prompt` | yes | Ordered `text` and `include` blocks. | -| `if` | no | Expression deciding whether the step runs. | -| `env` | no | Environment variables from connection values. | -| `output.schema` | no | JSON Schema for structured step output. | -| `allow_outputs` | no | Provider output capabilities with optional `max` and `required`. | -| `auto_archive` | no | Archive the agent after the step ends. | -| `github` | no | Explicit GitHub authority for this step. | +| Field | Required | Notes | +| ----------------- | -------- | -------------------------------------------------------------------------------------------------------------------- | +| `id` | yes | Unique within the workflow. | +| `environment` | yes | Literal environment name or finite expression resolving to one. | +| `max_runtime` | yes | Step hard limit. | +| `idle_timeout` | yes | Idle limit no longer than `max_runtime`. | +| `startup_timeout` | no | Step startup waiting budget. See [startup timeout](/docs/hub/configuration#startup-timeout) for defaults and limits. | +| `agent` | yes | Named agent, finite expression selecting a named agent, or complete static inline agent. | +| `prompt` | yes | Ordered `text` and `include` blocks. | +| `if` | no | Expression deciding whether the step runs. | +| `env` | no | Environment variables from connection values. | +| `output.schema` | no | JSON Schema for structured step output. | +| `allow_outputs` | no | Provider output capabilities with optional `max` and `required`. | +| `auto_archive` | no | Archive the agent after the step ends. | +| `github` | no | Explicit GitHub authority for this step. | An inline agent is static and complete: diff --git a/public-docs/hub/configuration/index.md b/public-docs/hub/configuration/index.md index 1d82ff27d6..202fb4d05d 100644 --- a/public-docs/hub/configuration/index.md +++ b/public-docs/hub/configuration/index.md @@ -64,6 +64,23 @@ A Discord starter uses `discord.mention`, your Discord user ID, and `discord.rep Setup asks before replacing the selected trigger file. It preserves other triggers and any existing legacy bundle. Read [Hub security](/docs/hub/security) before widening `from_users` or the agent's authority. +## Startup timeout + +Hub waits up to **two minutes** for agent startup, including worktree creation, provider startup, and initial prompt acceptance. For a slower machine, set `run.startup_timeout` in the trigger YAML: + +```yaml +name: inspect +on: + manual.run: {} +run: + target: { daemon: devbox, cwd: /workspace/project } + agent: { provider: codex, mode: full-access } + startup_timeout: 5m + prompt: Inspect this repository and summarize its current state. +``` + +Use a positive duration in `ms`, `s`, `m`, or `h`, up to `24h`. Omitting the field uses `2m`. The existing `max_runtime` and `idle_timeout` limits still apply during startup and can expire sooner. This setting changes Hub's waiting budget; provider-specific timeouts remain in effect. + ## Deploy from the CLI Run from the repository root: From f3917173a91b76b095f6e3133dd44aa2ae656a34 Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Thu, 10 Sep 2026 08:42:50 +0200 Subject: [PATCH 05/46] fix(server): align Codex Fast with documented model support (#4640) The prefix allowlist hid Fast for GPT-6 Astra and exposed it for models outside documented Codex Fast support. Use explicit model IDs and avoid claiming a universal usage multiplier. --- .../codex-app-server-agent.features.test.ts | 88 ++++++++++++++++++- .../providers/codex-feature-definitions.ts | 18 ++-- 2 files changed, 98 insertions(+), 8 deletions(-) diff --git a/packages/server/src/server/agent/providers/codex-app-server-agent.features.test.ts b/packages/server/src/server/agent/providers/codex-app-server-agent.features.test.ts index d6583a2759..e5d4596e77 100644 --- a/packages/server/src/server/agent/providers/codex-app-server-agent.features.test.ts +++ b/packages/server/src/server/agent/providers/codex-app-server-agent.features.test.ts @@ -97,6 +97,88 @@ async function createConnectedSession( } describe("Codex app-server provider features", () => { + test.each([ + "gpt-6-astra", + "gpt-5.6", + "gpt-5.6-sol", + "gpt-5.6-terra", + "gpt-5.6-luna", + "gpt-5.5", + "gpt-5.4", + ])("exposes and sends Fast for %s", async (model) => { + const { session, appServer } = await createConnectedSession({ model }); + try { + expect(session.features).toContainEqual( + expect.objectContaining({ + id: "fast_mode", + value: false, + }), + ); + await session.setFeature?.("fast_mode", true); + await session.startTurn("hello"); + await expect(appServer.waitForTurnStart()).resolves.toMatchObject({ + model, + serviceTier: "fast", + }); + } finally { + await session.close(); + } + }); + + test.each([ + "gpt-5.3-codex-spark", + "gpt-5.3-codex", + "gpt-5.4-mini", + "gpt-5.4-nano", + "gpt-5.5-pro", + "gpt-5", + "gpt-4.1", + "o3", + "o4-mini", + "gpt-6-unknown", + ])("does not expose or restore Fast for %s", async (model) => { + const { session, appServer } = await createConnectedSession({ + model, + featureValues: { fast_mode: true }, + }); + try { + expect(session.features.map((feature) => feature.id)).toEqual(["plan_mode"]); + await expect(session.setFeature?.("fast_mode", true)).rejects.toThrow( + `Codex fast mode is not available for model '${model}'`, + ); + await session.startTurn("hello"); + await expect(appServer.waitForTurnStart()).resolves.not.toMatchObject({ + serviceTier: expect.anything(), + }); + } finally { + await session.close(); + } + }); + + test("restores Fast on Astra and preserves it when switching supported models", async () => { + const { session, appServer } = await createConnectedSession({ + model: "gpt-6-astra", + featureValues: { fast_mode: true }, + }); + try { + expect(session.features).toContainEqual( + expect.objectContaining({ + id: "fast_mode", + value: true, + }), + ); + await session.setModel("gpt-5.6-sol"); + await session.setModel("gpt-6-astra"); + await session.startTurn("hello"); + await expect(appServer.waitForTurnStart()).resolves.toMatchObject({ + model: "gpt-6-astra", + serviceTier: "fast", + }); + } finally { + await session.close(); + } + }); + test("features returns fast and plan toggles when supported", async () => { const { session } = await createConnectedSession(); @@ -105,7 +187,7 @@ describe("Codex app-server provider features", () => { type: "toggle", id: "fast_mode", label: "Fast", - description: "Priority inference at 2x usage", + description: "Priority inference at increased usage", tooltip: "Toggle fast mode", icon: "zap", value: false, @@ -129,7 +211,7 @@ describe("Codex app-server provider features", () => { type: "toggle", id: "fast_mode", label: "Fast", - description: "Priority inference at 2x usage", + description: "Priority inference at increased usage", tooltip: "Toggle fast mode", icon: "zap", value: true, @@ -250,7 +332,7 @@ describe("Codex app-server provider features", () => { type: "toggle", id: "fast_mode", label: "Fast", - description: "Priority inference at 2x usage", + description: "Priority inference at increased usage", tooltip: "Toggle fast mode", icon: "zap", value: true, diff --git a/packages/server/src/server/agent/providers/codex-feature-definitions.ts b/packages/server/src/server/agent/providers/codex-feature-definitions.ts index a850317e56..7bd38d7efc 100644 --- a/packages/server/src/server/agent/providers/codex-feature-definitions.ts +++ b/packages/server/src/server/agent/providers/codex-feature-definitions.ts @@ -1,12 +1,22 @@ import type { AgentFeature, AgentFeatureToggle } from "../agent-sdk-types.js"; -const CODEX_FAST_MODE_SUPPORTED_MODEL_PREFIXES = ["gpt-5", "gpt-4.1", "o3", "o4-mini"] as const; +// Codex Fast is distinct from API Priority processing. Keep model support aligned with +// https://developers.openai.com/codex/speed and https://developers.openai.com/codex/models. +const CODEX_FAST_MODE_SUPPORTED_MODELS = new Set([ + "gpt-6-astra", + "gpt-5.6", + "gpt-5.6-sol", + "gpt-5.6-terra", + "gpt-5.6-luna", + "gpt-5.5", + "gpt-5.4", +]); export const CODEX_FAST_MODE_FEATURE: Omit = { type: "toggle", id: "fast_mode", label: "Fast", - description: "Priority inference at 2x usage", + description: "Priority inference at increased usage", tooltip: "Toggle fast mode", icon: "zap", }; @@ -30,9 +40,7 @@ export function codexModelSupportsFastMode(modelId: string | null | undefined): if (!normalizedModelId) { return false; } - return CODEX_FAST_MODE_SUPPORTED_MODEL_PREFIXES.some( - (prefix) => normalizedModelId === prefix || normalizedModelId.startsWith(prefix), - ); + return CODEX_FAST_MODE_SUPPORTED_MODELS.has(normalizedModelId); } export function buildCodexFeatures(input: { From 2ee0191ea1dc4bbbbba41e9ee6b4fe086b5072b7 Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Thu, 10 Sep 2026 09:00:33 +0200 Subject: [PATCH 06/46] docs(hub): explain pending dashboard navigation (#4608) --- public-docs/hub/faq.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/public-docs/hub/faq.md b/public-docs/hub/faq.md index a3ef6a2311..e6704fef2d 100644 --- a/public-docs/hub/faq.md +++ b/public-docs/hub/faq.md @@ -38,6 +38,10 @@ Each trigger and its ordered steps live in one direct `.paseo/workflows/*.yml` f Yes, with a manual source. A project using a GitHub source is read-only in the dashboard, since the repository is the source of truth. Switching to manual copies the active revision into the editor and stops syncing. +## Why does the previous page stay visible after I click a dashboard link? + +Hub keeps the current page and its navigation labels together while the next page loads. If loading takes longer than a second, the labels switch to your destination and a loading placeholder replaces the previous page. The sidebar stays available, so you can choose another page or use Back while you wait. + ## Who can trigger an agent? Only the users listed in a trigger's `from_users`. It is required and cannot be empty. From ae1cf642bb52e2a1b46c3c5a92fc0168cbf3181d Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Thu, 10 Sep 2026 09:05:43 +0200 Subject: [PATCH 07/46] docs(hub): remove navigation implementation FAQ (#4645) * docs(hub): explain pending dashboard navigation * docs(hub): remove navigation implementation FAQ --- public-docs/hub/faq.md | 4 ---- 1 file changed, 4 deletions(-) diff --git a/public-docs/hub/faq.md b/public-docs/hub/faq.md index e6704fef2d..a3ef6a2311 100644 --- a/public-docs/hub/faq.md +++ b/public-docs/hub/faq.md @@ -38,10 +38,6 @@ Each trigger and its ordered steps live in one direct `.paseo/workflows/*.yml` f Yes, with a manual source. A project using a GitHub source is read-only in the dashboard, since the repository is the source of truth. Switching to manual copies the active revision into the editor and stops syncing. -## Why does the previous page stay visible after I click a dashboard link? - -Hub keeps the current page and its navigation labels together while the next page loads. If loading takes longer than a second, the labels switch to your destination and a loading placeholder replaces the previous page. The sidebar stays available, so you can choose another page or use Back while you wait. - ## Who can trigger an agent? Only the users listed in a trigger's `from_users`. It is required and cannot be empty. From 8598b6aab9fd29696d595fb5ea612836f0299c54 Mon Sep 17 00:00:00 2001 From: Matt Cowger Date: Thu, 10 Sep 2026 00:20:25 -0700 Subject: [PATCH 08/46] fix(plugin-provider): recover stale sessions after reload (#4629) * fix(plugin-provider): recover stale sessions after reload * fix(plugin-provider): auto-recover stale sessions on next prompt * fix(plugin-provider): retry stale prompt streams * fix(plugin-provider): recover replacement prompts * test(plugin-provider): await runtime retirement --------- Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com> --- .../src/server/agent/agent-manager.test.ts | 228 ++++++++++++++++++ .../server/src/server/agent/agent-manager.ts | 21 ++ .../server/src/server/agent/agent-prompt.ts | 46 +++- .../src/server/agent/plugin-provider.test.ts | 94 +++++++- .../src/server/agent/plugin-provider.ts | 11 + .../agent/provider-snapshot-manager.test.ts | 1 + .../server/agent/provider-snapshot-manager.ts | 6 +- .../agent/stale-provider-session-error.ts | 11 + .../server/src/server/schedule/service.ts | 1 + 9 files changed, 414 insertions(+), 5 deletions(-) create mode 100644 packages/server/src/server/agent/stale-provider-session-error.ts diff --git a/packages/server/src/server/agent/agent-manager.test.ts b/packages/server/src/server/agent/agent-manager.test.ts index 1e18ba9de4..f4d4069d26 100644 --- a/packages/server/src/server/agent/agent-manager.test.ts +++ b/packages/server/src/server/agent/agent-manager.test.ts @@ -19,6 +19,7 @@ import { toAgentPayload } from "./agent-projections.js"; import { projectTimelineRows } from "./timeline-projection.js"; import { getOpenAgentTabLabel, PARENT_AGENT_ID_LABEL } from "@getpaseo/protocol/agent-labels"; import { formatSystemNotificationPrompt, startAgentRun } from "./agent-prompt.js"; +import { StaleProviderSessionError } from "./stale-provider-session-error.js"; import { ensureAgentLoaded, ensureUnarchivedAgentLoaded } from "./agent-loading.js"; import type { StoredAgentRecord } from "./agent-storage.js"; import type { @@ -68,6 +69,12 @@ function deferred(): Deferred { return { promise, resolve, reject }; } +async function drainAsyncGenerator(generator: AsyncGenerator): Promise { + for await (const _ of generator) { + // Drain provider events while AgentManager subscribers observe them. + } +} + function waitForAgentLifecycle( manager: AgentManager, agentId: string, @@ -519,6 +526,32 @@ class TestAgentSession implements AgentSession { async close(): Promise {} } +class ResumeTrackingTestAgentClient extends TestAgentClient { + private readonly retryStarted = deferred(); + + override async resumeSession( + _handle: AgentPersistenceHandle, + config?: Partial, + ): Promise { + this.resumeOverrides.push(config); + const signalRetryStarted = () => this.retryStarted.resolve(); + return new (class extends TestAgentSession { + override async startTurn(): Promise<{ turnId: string }> { + signalRetryStarted(); + return await super.startTurn(); + } + })({ + provider: this.provider, + cwd: config?.cwd ?? process.cwd(), + daemonAppendSystemPrompt: config?.daemonAppendSystemPrompt, + }); + } + + waitForRetryStart(): Promise { + return this.retryStarted.promise; + } +} + class McpCapableTestAgentSession extends TestAgentSession { override readonly capabilities = { ...TEST_CAPABILITIES, @@ -3677,6 +3710,201 @@ test("updateProviderRegistry removes providers omitted from the next registry", expect(removedClient.createSessionCalls).toBe(0); }); +test("retires loaded agents when their plugin provider is replaced", async () => { + const workdir = mkdtempSync(join(tmpdir(), "agent-manager-plugin-provider-reload-")); + const storage = new AgentStorage(join(workdir, "agents"), logger); + const provider = "plugin-provider"; + + class OriginalPluginClient extends TestAgentClient { + session: CloseRecordingTestAgentSession | null = null; + + constructor() { + super(provider); + } + + override async createSession(config: AgentSessionConfig): Promise { + const session = new CloseRecordingTestAgentSession(config); + session.describePersistence = () => ({ + provider, + sessionId: "plugin-session", + }); + this.session = session; + return session; + } + } + + const original = new OriginalPluginClient(); + const replacement = new TestAgentClient(provider); + const manager = new AgentManager({ + clients: { [provider]: original }, + providerDefinitions: { [provider]: { enabled: true } }, + registry: storage, + logger, + }); + const created = await manager.createAgent({ provider, cwd: workdir }, undefined, { + workspaceId: undefined, + }); + + try { + manager.updateProviderRegistry({ + providerDefinitions: { [provider]: { enabled: true } }, + clients: { [provider]: replacement }, + retiredProviders: [provider], + }); + + await manager.waitForAgentClose(created.id); + expect(original.session?.closed).toBe(true); + expect(manager.getAgent(created.id)).toBeNull(); + + const resumed = await ensureAgentLoaded(created.id, { + agentManager: manager, + agentStorage: storage, + logger, + }); + expect(resumed.id).toBe(created.id); + expect(replacement.resumeOverrides).toEqual([ + expect.objectContaining({ cwd: workdir, provider }), + ]); + } finally { + await manager.closeAgent(created.id).catch(() => undefined); + await manager.flush().catch(() => undefined); + await storage.flush().catch(() => undefined); + rmSync(workdir, { recursive: true, force: true }); + } +}); + +test("a prompt after provider replacement reopens the stale session", async () => { + const workdir = mkdtempSync(join(tmpdir(), "agent-manager-stale-prompt-")); + const storage = new AgentStorage(join(workdir, "agents"), logger); + const provider = "plugin-provider"; + + class StalePluginSession extends CloseRecordingTestAgentSession { + override async startTurn(): Promise<{ turnId: string }> { + throw new StaleProviderSessionError("stale-bridge"); + } + } + + class StalePluginClient extends TestAgentClient { + constructor() { + super(provider); + } + + override async createSession(config: AgentSessionConfig): Promise { + const session = new StalePluginSession(config); + session.describePersistence = () => ({ + provider, + sessionId: "plugin-session", + }); + return session; + } + } + + const staleClient = new StalePluginClient(); + const replacement = new ResumeTrackingTestAgentClient(provider); + const manager = new AgentManager({ + clients: { [provider]: staleClient }, + providerDefinitions: { [provider]: { enabled: true } }, + registry: storage, + logger, + }); + const created = await manager.createAgent({ provider, cwd: workdir }, undefined, { + workspaceId: undefined, + }); + + try { + manager.updateProviderRegistry({ + providerDefinitions: { [provider]: { enabled: true } }, + clients: { [provider]: replacement }, + }); + + const dispatch = await startAgentRun(manager, created.id, "continue after reload", logger); + expect(dispatch.disposition).toBe("turn_started"); + await replacement.waitForRetryStart(); + const result = await manager.waitForAgentEvent(created.id); + expect(result.status).toBe("idle"); + expect(replacement.resumeOverrides).toHaveLength(1); + } finally { + await manager.closeAgent(created.id).catch(() => undefined); + await manager.flush().catch(() => undefined); + await storage.flush().catch(() => undefined); + rmSync(workdir, { recursive: true, force: true }); + } +}); + +test("a replacement prompt recovers when the retired session fails to start", async () => { + const workdir = mkdtempSync(join(tmpdir(), "agent-manager-stale-replacement-")); + const storage = new AgentStorage(join(workdir, "agents"), logger); + const provider = "plugin-provider"; + + class StaleReplacementSession extends TestAgentSession { + private starts = 0; + + override async startTurn(): Promise<{ turnId: string }> { + this.starts += 1; + if (this.starts > 1) throw new StaleProviderSessionError("stale-bridge"); + return { turnId: "initial-turn" }; + } + + override async interrupt(): Promise { + if (this.starts > 1) throw new StaleProviderSessionError("stale-bridge"); + this.pushEvent({ + type: "turn_canceled", + provider: this.provider, + turnId: "initial-turn", + }); + } + } + + class StaleReplacementClient extends TestAgentClient { + constructor() { + super(provider); + } + + override async createSession(config: AgentSessionConfig): Promise { + const session = new StaleReplacementSession(config); + session.describePersistence = () => ({ provider, sessionId: "plugin-session" }); + return session; + } + } + + const replacement = new ResumeTrackingTestAgentClient(provider); + const manager = new AgentManager({ + clients: { [provider]: new StaleReplacementClient() }, + providerDefinitions: { [provider]: { enabled: true } }, + registry: storage, + logger, + rescueTimeouts: { interruptSessionMs: 20 }, + }); + const created = await manager.createAgent({ provider, cwd: workdir }, undefined, { + workspaceId: undefined, + }); + + try { + const initial = manager.streamAgent(created.id, "initial"); + void drainAsyncGenerator(initial); + await manager.waitForAgentRunStart(created.id); + + manager.updateProviderRegistry({ + providerDefinitions: { [provider]: { enabled: true } }, + clients: { [provider]: replacement }, + }); + + const dispatch = await startAgentRun(manager, created.id, "replacement", logger, { + replaceRunning: true, + }); + expect(dispatch.disposition).toBe("turn_started"); + await replacement.waitForRetryStart(); + const result = await manager.waitForAgentEvent(created.id); + expect(result.status).toBe("idle"); + expect(replacement.resumeOverrides).toHaveLength(1); + } finally { + await manager.closeAgent(created.id).catch(() => undefined); + await manager.flush().catch(() => undefined); + await storage.flush().catch(() => undefined); + rmSync(workdir, { recursive: true, force: true }); + } +}); + test("createAgent passes explicit model strings through to the provider", async () => { const workdir = mkdtempSync(join(tmpdir(), "agent-manager-test-")); const storagePath = join(workdir, "agents"); diff --git a/packages/server/src/server/agent/agent-manager.ts b/packages/server/src/server/agent/agent-manager.ts index 5fd1c85847..70b4e62dbc 100644 --- a/packages/server/src/server/agent/agent-manager.ts +++ b/packages/server/src/server/agent/agent-manager.ts @@ -77,6 +77,7 @@ import { } from "./agent-run-state.js"; import { invokeRewindCapability, type RewindMode } from "./rewind/rewind.js"; import { isSystemInjectedEnvelope } from "./agent-prompt.js"; +import { isStaleProviderSessionError } from "./stale-provider-session-error.js"; import { stripInternalPaseoMcpServer, withRuntimePaseoMcpServer } from "./runtime-mcp-config.js"; import { resolveCreateAgentTitles } from "./create-agent-title.js"; import type { PaseoToolCatalogFactory } from "./tools/types.js"; @@ -775,6 +776,7 @@ export class AgentManager { updateProviderRegistry(input: { providerDefinitions: ProviderEnabledMap; clients: ProviderClientMap; + retiredProviders?: readonly AgentProvider[]; }): void { this.providerEnabled.clear(); this.providerDefinitions.clear(); @@ -791,6 +793,18 @@ export class AgentManager { this.clients.set(provider, client); } } + + for (const provider of input.retiredProviders ?? []) { + for (const agent of this.agents.values()) { + if (agent.provider !== provider) continue; + void this.closeAgent(agent.id).catch((error) => { + this.logger.warn( + { err: error, agentId: agent.id, provider }, + "Failed to close agent after provider retirement", + ); + }); + } + } } getRegisteredProviderIds(): AgentProvider[] { @@ -2360,6 +2374,13 @@ export class AgentManager { if (pendingRun.settled) { throw error; } + if (isStaleProviderSessionError(error)) { + pendingRun.start = { status: "failed", error: error.message }; + agent.pendingReplacement = false; + if (!agent.activeForegroundTurnId) agent.lifecycle = "idle"; + this.runs.settleForegroundRun(agentId, pendingRun.token); + throw error; + } agent.pendingReplacement = false; const errorMsg = error instanceof Error ? error.message : "Failed to start turn"; pendingRun.start = { status: "failed", error: errorMsg }; diff --git a/packages/server/src/server/agent/agent-prompt.ts b/packages/server/src/server/agent/agent-prompt.ts index 96f5ce5ee3..f46822d924 100644 --- a/packages/server/src/server/agent/agent-prompt.ts +++ b/packages/server/src/server/agent/agent-prompt.ts @@ -8,6 +8,7 @@ import type { import type { AgentManager, ManagedAgent } from "./agent-manager.js"; import type { AgentStorage } from "./agent-storage.js"; import { ensureAgentLoaded } from "./agent-loading.js"; +import { isStaleProviderSessionError } from "./stale-provider-session-error.js"; import { getParentAgentIdFromLabels } from "@getpaseo/protocol/agent-labels"; import type { ActiveTurnBehavior } from "@getpaseo/protocol/messages"; @@ -21,7 +22,9 @@ export type AgentRunController = Pick< | "replaceAgentRun" | "steerOrReplaceActiveTurn" | "streamAgent" ->; +> & { + reloadAgentSession(agentId: string): Promise; +}; export interface StartAgentRunOptions { replaceRunning?: boolean; @@ -78,6 +81,14 @@ async function startOrReplaceRun( return { iterator, replaced }; } +async function drainAgentRunIterator( + iterator: AsyncGenerator, +): Promise { + for await (const _ of iterator) { + // Events are broadcast via AgentManager subscribers. + } +} + export async function startAgentRun( agentManager: AgentRunController, agentId: string, @@ -104,6 +115,26 @@ export async function startAgentRun( if (agentManager.tryRunOutOfBand(agentId, prompt, options?.runOptions)) { return { disposition: "out_of_band" }; } + try { + return await startAgentRunInner(agentManager, agentId, prompt, logger, options); + } catch (error) { + if (!isStaleProviderSessionError(error)) throw error; + logger.info({ agentId, err: error }, "Provider session went stale; reopening from persistence"); + // The live session belongs to a retired plugin runtime. Reload swaps in a + // fresh session on the current runtime while preserving history and labels. + await agentManager.reloadAgentSession(agentId); + return await startAgentRunInner(agentManager, agentId, prompt, logger, options); + } +} + +async function startAgentRunInner( + agentManager: AgentRunController, + agentId: string, + prompt: AgentPromptInput, + logger: Logger, + options?: StartAgentRunOptions, +): Promise<{ disposition: PromptDispatchDisposition }> { + const snapshot = agentManager.getAgent(agentId); const steered = await steerOrReplaceActiveRun(agentManager, agentId, prompt, options); if (steered?.disposition === "steered") { return steered; @@ -122,8 +153,17 @@ export async function startAgentRun( ); void (async () => { try { - for await (const _ of iterator) { - // Events are broadcast via AgentManager subscribers. + try { + await drainAgentRunIterator(iterator); + } catch (error) { + if (!isStaleProviderSessionError(error)) throw error; + logger.info( + { agentId, err: error }, + "Provider session went stale; reopening from persistence", + ); + await agentManager.reloadAgentSession(agentId); + const retry = await startOrReplaceRun(agentManager, agentId, prompt, options); + await drainAgentRunIterator(retry.iterator); } logger.trace( { diff --git a/packages/server/src/server/agent/plugin-provider.test.ts b/packages/server/src/server/agent/plugin-provider.test.ts index 75046396be..99d156473d 100644 --- a/packages/server/src/server/agent/plugin-provider.test.ts +++ b/packages/server/src/server/agent/plugin-provider.test.ts @@ -8,6 +8,10 @@ import { describe, expect, test } from "vitest"; import { createTestLogger } from "../../test-utils/test-logger.js"; import type { AgentStreamEvent } from "./agent-sdk-types.js"; import { PluginAgentClientRegistry } from "./plugin-provider.js"; +import { + isStaleProviderSessionError, + StaleProviderSessionError, +} from "./stale-provider-session-error.js"; const CAPABILITIES = [ "prompt.message", @@ -25,6 +29,10 @@ interface ProviderHarnessOptions { function createProviderHarness(options: ProviderHarnessOptions = {}) { let listener: ((event: ProviderEvent) => void) | null = null; let closeCount = 0; + let resolveClosed!: () => void; + const closed = new Promise((resolve) => { + resolveClosed = resolve; + }); const inputs: ProviderInput[] = []; const emit = (event: ProviderEvent) => listener?.(event); const capabilities = options.capabilities ?? CAPABILITIES; @@ -180,6 +188,7 @@ function createProviderHarness(options: ProviderHarnessOptions = {}) { }, async close() { closeCount += 1; + resolveClosed(); }, }; @@ -191,7 +200,12 @@ function createProviderHarness(options: ProviderHarnessOptions = {}) { }, }; - return { registration, inputs, closeCount: () => closeCount }; + return { + registration, + inputs, + closeCount: () => closeCount, + waitForClose: () => closed, + }; } function eventsOfType(events: AgentStreamEvent[], type: AgentStreamEvent["type"]) { @@ -258,6 +272,84 @@ describe("PluginAgentClientRegistry", () => { expect(eventsOfType(events, "turn_failed")).toHaveLength(1); }); + test("closes a stale session after its plugin provider is replaced", async () => { + const old = createProviderHarness(); + const next = createProviderHarness(); + const registry = new PluginAgentClientRegistry(createTestLogger()); + + try { + registry.replace([old.registration]); + + const stale = await registry.clients()[old.registration.id]!.createSession({ + provider: old.registration.id, + cwd: "/workspace", + }); + const persistence = stale.describePersistence(); + expect(persistence).not.toBeNull(); + + registry.replace([next.registration]); + await old.waitForClose(); + expect(old.closeCount()).toBe(1); + + await expect(stale.close()).resolves.toBeUndefined(); + + const replacement = registry.clients()[next.registration.id]; + expect(replacement).toBeDefined(); + const resumed = await replacement!.resumeSession(persistence!, { + cwd: "/workspace", + }); + + await expect( + resumed.startTurn("after reload", { clientMessageId: "after-reload" }), + ).resolves.toEqual({ turnId: "turn-1" }); + + expect(next.inputs).toContainEqual( + expect.objectContaining({ + type: "session.open", + history: "replay", + persistence: { + version: 1, + data: { token: "root" }, + }, + }), + ); + + await resumed.close(); + } finally { + await registry.shutdown(); + } + }); + + test("prompting a stale session raises StaleProviderSessionError", async () => { + const old = createProviderHarness(); + const registry = new PluginAgentClientRegistry(createTestLogger()); + + try { + registry.replace([old.registration]); + const stale = await registry.clients()[old.registration.id]!.createSession({ + provider: old.registration.id, + cwd: "/workspace", + }); + + registry.replace([]); + await old.waitForClose(); + expect(old.closeCount()).toBe(1); + + const failure = await stale + .startTurn("after reload", { clientMessageId: "after-reload" }) + .then( + () => null, + (error: unknown) => error, + ); + expect(failure).toBeInstanceOf(StaleProviderSessionError); + expect(isStaleProviderSessionError(failure)).toBe(true); + expect(isStaleProviderSessionError(new Error("Provider connection is closed"))).toBe(false); + expect(isStaleProviderSessionError(new Error("boom"))).toBe(false); + } finally { + await registry.shutdown(); + } + }); + test("adapts callback providers into the existing AgentClient and AgentSession path", async () => { const harness = createProviderHarness(); const registry = new PluginAgentClientRegistry(createTestLogger()); diff --git a/packages/server/src/server/agent/plugin-provider.ts b/packages/server/src/server/agent/plugin-provider.ts index b5f5ff7b92..0ab3ddecd7 100644 --- a/packages/server/src/server/agent/plugin-provider.ts +++ b/packages/server/src/server/agent/plugin-provider.ts @@ -58,6 +58,7 @@ import { } from "./create-agent-mode.js"; import type { ProviderDefinition } from "./provider-registry.js"; import { runProviderTurn } from "./providers/provider-runner.js"; +import { StaleProviderSessionError } from "./stale-provider-session-error.js"; interface Deferred { promise: Promise; @@ -130,6 +131,10 @@ class ProviderRuntime { return this.connection?.capabilities ?? []; } + get isClosed(): boolean { + return this.closed; + } + async isAvailable(): Promise { await this.getConnection(); return true; @@ -559,6 +564,7 @@ class ProviderRuntimeSession { sessionId: this.providerSessionId, prompt, }); + if (this.terminal) throw new StaleProviderSessionError(this.id); const pending = deferred>(); this.prompts.set(prompt.clientMessageId, pending); try { @@ -568,6 +574,9 @@ class ProviderRuntimeSession { prompt, }); return (await pending.promise).result; + } catch (error) { + if (this.terminal || this.runtime.isClosed) throw new StaleProviderSessionError(this.id); + throw error; } finally { this.prompts.delete(prompt.clientMessageId); } @@ -623,6 +632,8 @@ class ProviderRuntimeSession { requestId: randomUUID(), sessionId: this.providerSessionId, }); + } catch (error) { + if (!this.runtime.isClosed) throw error; } finally { this.runtime.removeSession(this.id, this.providerSessionId); } diff --git a/packages/server/src/server/agent/provider-snapshot-manager.test.ts b/packages/server/src/server/agent/provider-snapshot-manager.test.ts index 7ca0510f51..68837c8c5f 100644 --- a/packages/server/src/server/agent/provider-snapshot-manager.test.ts +++ b/packages/server/src/server/agent/provider-snapshot-manager.test.ts @@ -2281,6 +2281,7 @@ describe("ProviderSnapshotManager cwd routing", () => { const withoutPlugin = manager.replacePluginProviders([]); expect(manager.hasProvider(registration.id)).toBe(false); expect(withoutPlugin.clients[registration.id]).toBeUndefined(); + expect(withoutPlugin.retiredProviders).toEqual([registration.id]); } finally { await manager.shutdown(); manager.destroy(); diff --git a/packages/server/src/server/agent/provider-snapshot-manager.ts b/packages/server/src/server/agent/provider-snapshot-manager.ts index abf88d4488..610f7c8322 100644 --- a/packages/server/src/server/agent/provider-snapshot-manager.ts +++ b/packages/server/src/server/agent/provider-snapshot-manager.ts @@ -191,6 +191,7 @@ export interface AgentManagerProviderState { > >; clients: Partial>; + retiredProviders?: readonly AgentProvider[]; } interface ProviderLoadOptions { @@ -402,6 +403,9 @@ export class ProviderSnapshotManager { this.createAgentManagerState(this.generation.definitions, clients); this.pluginProviders.replace(registrations); const plugins = this.pluginProviders.definitions(); + const retiredProviders = Object.keys(previousPlugins).filter( + (provider) => previousPlugins[provider] !== plugins[provider], + ); const definitions = { ...this.generation.definitions }; const changed = new Set(); for (const provider of new Set([...Object.keys(previousPlugins), ...Object.keys(plugins)])) { @@ -415,7 +419,7 @@ export class ProviderSnapshotManager { const generation = this.createGeneration(definitions, this.providerOverrides); const state = this.createAgentManagerState(definitions, clients); this.installGeneration(generation, clients, changed); - return state; + return { ...state, retiredProviders }; } private ensureClient( diff --git a/packages/server/src/server/agent/stale-provider-session-error.ts b/packages/server/src/server/agent/stale-provider-session-error.ts new file mode 100644 index 0000000000..7039da3c61 --- /dev/null +++ b/packages/server/src/server/agent/stale-provider-session-error.ts @@ -0,0 +1,11 @@ +/** Thrown when a prompt reaches a plugin session whose connection died with its plugin. */ +export class StaleProviderSessionError extends Error { + constructor(sessionId: string) { + super(`Provider session ${sessionId} is stale after its plugin reloaded`); + this.name = "StaleProviderSessionError"; + } +} + +export function isStaleProviderSessionError(error: unknown): error is StaleProviderSessionError { + return error instanceof StaleProviderSessionError; +} diff --git a/packages/server/src/server/schedule/service.ts b/packages/server/src/server/schedule/service.ts index e3e9504e46..192b424337 100644 --- a/packages/server/src/server/schedule/service.ts +++ b/packages/server/src/server/schedule/service.ts @@ -202,6 +202,7 @@ function buildRunOutput(params: { type ScheduleAgentManager = Pick< AgentRunController, | "getAgent" + | "reloadAgentSession" | "tryRunOutOfBand" | "hasInFlightRun" | "replaceAgentRun" From 7bcf167862ce9bb040007d1469c77c00928a84c8 Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Thu, 10 Sep 2026 10:08:54 +0200 Subject: [PATCH 09/46] Document scoped credentials during Hub continuation (#4607) * docs(hub): explain scoped credentials during agent continuation * docs(hub): align credential lifetime across trigger guides * docs(hub): centralize continuation credential lifecycle * docs(hub): keep continuation guidance focused on agent reuse * docs(hub): state when shared tokens are revoked --- public-docs/hub/configuration/hub-yml.md | 2 +- public-docs/hub/github.md | 4 ++-- public-docs/hub/triggers/index.md | 4 ++-- 3 files changed, 5 insertions(+), 5 deletions(-) diff --git a/public-docs/hub/configuration/hub-yml.md b/public-docs/hub/configuration/hub-yml.md index 906d75f8ce..d243b1a08c 100644 --- a/public-docs/hub/configuration/hub-yml.md +++ b/public-docs/hub/configuration/hub-yml.md @@ -271,7 +271,7 @@ Keys use the existing expression syntax and must resolve to a non-empty string o An existing session keeps its daemon, agent configuration, target, environment, and tool contracts. Changing those settings for the same key fails with an explanation; choose a different key or **New agent**. Prompts and output destinations belong to each arrival and may change. A worktree branch is chosen when the session is first created and reused on later arrivals. -Triggers that mint temporary environment credentials, including a `run.github` grant or a connection token in `run.env`, must choose **New agent** when the event has a conversation or uses a custom key. These credentials expire with the execution, and an existing agent's environment cannot be refreshed. Hub-managed reply tools remain available with continuation. +A follow-up steers the active agent without extending its runtime deadline or credential expiry. With a `run.github` grant or connection token in `run.env`, follow-ups share the active agent’s credentials. Once all requests finish, Hub revokes leased tokens; the next arrival starts a new agent with fresh credentials. Agents without temporary credentials can be reused after completion. ### Upgrading diff --git a/public-docs/hub/github.md b/public-docs/hub/github.md index 51b9a0e28b..c31abe4b56 100644 --- a/public-docs/hub/github.md +++ b/public-docs/hub/github.md @@ -37,7 +37,7 @@ steps: ${{ paseo.prompt }} ``` -The agent can use `git` and `gh` within the declared repositories and permissions. Hub mints the token when the step starts and revokes it when execution ends. +The agent can use `git` and `gh` within the declared repositories and permissions. See [agent continuation](/docs/hub/configuration/hub-yml#agent-continuation) for the token lifecycle when requests continue an agent. ## Fields @@ -73,4 +73,4 @@ env: SOME_TOKEN: "${{ paseo.connections.some-connection.token }}" ``` -Hub resolves the value for the step and does not persist it. See [Hub security](/docs/hub/security) for provider and host boundaries. +Hub resolves the value when it prepares the agent's environment. See [Hub security](/docs/hub/security) for provider and host boundaries. diff --git a/public-docs/hub/triggers/index.md b/public-docs/hub/triggers/index.md index fa595d34b1..5fc4367934 100644 --- a/public-docs/hub/triggers/index.md +++ b/public-docs/hub/triggers/index.md @@ -44,7 +44,7 @@ If the daemon is offline or needs a newer Paseo version, the agent selectors sho Dashboard triggers default to **Same conversation**. Messages in the same Slack or Discord thread, events on the same GitHub issue or pull request, and events on the same Linear issue continue the existing agent in that project. An event without a conversation starts a new agent. -If the agent is busy, the new prompt steers its current work. If its workspace is archived, Hub asks Paseo to restore it before sending the prompt. Each arrival still has its own deadline, output limits, and completion status. +If the agent is busy, the new prompt steers its current work. Each arrival keeps its own output limits and completion status. If a reusable workspace is archived, Hub asks Paseo to restore it before sending the prompt. Choose **Custom key** to group arrivals by an input, or **New agent** to keep them separate. A self-contained trigger document can express the same choice: @@ -67,7 +67,7 @@ run: prompt: Handle this request and call finish_execution when complete. ``` -The [continuation reference](/docs/hub/configuration/hub-yml#agent-continuation) describes keys and compatibility. The run detail shows whether each arrival created, continued, or restored an agent. +The [continuation reference](/docs/hub/configuration/hub-yml#agent-continuation) covers keys and agent reuse. The run detail shows whether each arrival created, continued, or restored an agent. Hub includes an `executionId` in each prompt. When using a continuing agent, pass that ID to `reply` and `finish_execution`. These tools act on that arrival's destination and contract; an old or unrelated execution ID is rejected. From 94fc4e5d493a57f193e3423e98d1a14b13cd5846 Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Thu, 10 Sep 2026 11:10:21 +0200 Subject: [PATCH 10/46] docs(release): document temporary next and hotfix branches (#4651) --- CLAUDE.md | 9 +++++++-- docs/release.md | 32 +++++++++++++++++++++++++++++++- 2 files changed, 38 insertions(+), 3 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 9ed4aba4d0..b6ecb7ef7e 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -106,9 +106,14 @@ Repo dev commands use checkout-local state by default. In this checkout, `PASEO_ See [docs/development.md](docs/development.md) for full setup, build sync requirements, and debugging. -## Critical rules +## Release branches -- **Before changing the plugin SDK, compiler, host module maps, scaffold, or examples, read [SDK import boundaries](docs/plugins.md#sdk-import-boundaries).** Classify the export by runtime first and preserve the enforced boundaries. +When the user says "this goes to next", create or +retarget the PR to `next` and preserve that destination through delivery. Follow +[release branch discipline](docs/release.md#release-branch-discipline) for creating +and updating `next`, integrating it after a release, and releasing a hotfix from a tag. + +## Critical rules - **NEVER restart the main Paseo daemon on port 6767 without permission** — it manages all running agents. If you're an agent, restarting it kills your own process. - **NEVER assume a timeout means the service needs restarting** — timeouts can be transient. diff --git a/docs/release.md b/docs/release.md index 52b0f17ac2..e66b8e6fbb 100644 --- a/docs/release.md +++ b/docs/release.md @@ -47,6 +47,36 @@ commit the prepared inputs locally and run the release command. Its branch and tag push is the one remote release batch and starts CI for the complete release commit. +## Release branch discipline + +While you finalize a release on `main`, use a temporary `next` branch for work intended for the following +release. This applies to both beta and stable releases. + +- Create each new `next` from freshly fetched `origin/main`. Reuse it while active. +- "This goes to next" means create the PR against `next` or retarget an existing + PR, and keep that destination through delivery. +- Keep `next` current by merging `origin/main` into it as release fixes land. + Avoid rebasing this shared branch because agents and open PRs depend on its history. +- After the release ships, bring `next` up to date and open a `next` → `main` PR. + Pass CI and merge without squashing away the individual PR commits needed for + the changelog. Retarget remaining PRs based on `next` to `main` and delete the integrated + `next`. Create it fresh when needed again. + +**Setup still needed:** CI, Docker, and Nix PR checks currently target only `main`, +and GitHub permits only squash merges. Enable checks and required-check protection +for `next`, CI on its pushes, and merge commits for the integration PR. Handle PR +base changes (`edited` events) so retargeting runs checks against the new base; +GitHub's default PR events do not cover this. Deployment triggers stay unchanged. + +### Hotfix from a release tag + +If `main` contains changes you do not want to release, branch from the affected +release tag and cherry-pick only the required fixes. Run CI on that branch, then +use the normal release flow with it as the explicit source, choosing a new patch +or beta version. Ensure the fixes and changelog also reach `main` and any active +`next`, preserving newer development and version changes there. This is a +short-lived hotfix branch, not another maintained release track. + ## ACP catalog updates ACP catalog work enters a release through an explicit user request: @@ -64,7 +94,7 @@ release push as the changelog and version commit. There are two supported release paths: -1. **Direct stable release**: you are ready to ship the current `main` commit to everyone immediately. +1. **Direct stable release**: you are ready to ship the resolved release source to everyone immediately (default `origin/main`). 2. **Beta flow**: release candidates on the `beta` channel. Each beta carries its own changelog entry, publishes npm only on the explicit `beta` dist-tag, and stays behind the Stable/Beta switch on `/download`. Paseo has one linear release track even though npm dist-tags are independent From f7e68e32d1c95215407a23d4ab533d3ccc6e18bf Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Thu, 10 Sep 2026 11:42:09 +0200 Subject: [PATCH 11/46] fix(desktop): restore Linux launcher identity (#4649) * fix(desktop): restore Linux launcher identity Electron now derives Linux window identity from the scoped package name before app startup. Pin the existing Paseo.desktop identity in packaged metadata and check the built archive in the packaged-app smoke test. * test(desktop): declare packaged smoke archive dependency * fix(desktop): update Nix hash for smoke dependency --- nix/npm-deps.hash | 2 +- package-lock.json | 1 + packages/desktop/e2e/packaged-app-smoke.js | 15 +++++++++++++++ packages/desktop/package.json | 4 +++- 4 files changed, 20 insertions(+), 2 deletions(-) diff --git a/nix/npm-deps.hash b/nix/npm-deps.hash index 9ad4290b4d..e838ca8391 100644 --- a/nix/npm-deps.hash +++ b/nix/npm-deps.hash @@ -1 +1 @@ -sha256-8P7kk9B2A81e13IIyyfo8fIoNGlrutfV/WGKAFHscUc= +sha256-j8U5S71SJoC+5yh7IjkadrRZaHOYeMsi1z8B6K7j4R4= diff --git a/package-lock.json b/package-lock.json index f8162e6c0d..1a94812378 100644 --- a/package-lock.json +++ b/package-lock.json @@ -37538,6 +37538,7 @@ "zod": "^4.4.3" }, "devDependencies": { + "@electron/asar": "3.4.1", "@types/node": "24.6.0", "@types/ws": "^8.5.14", "electron": "44.2.0", diff --git a/packages/desktop/e2e/packaged-app-smoke.js b/packages/desktop/e2e/packaged-app-smoke.js index b07b75df4d..6a77538952 100644 --- a/packages/desktop/e2e/packaged-app-smoke.js +++ b/packages/desktop/e2e/packaged-app-smoke.js @@ -5,6 +5,7 @@ const os = require("node:os"); const path = require("node:path"); const { setTimeout: delay } = require("node:timers/promises"); const { chromium } = require("playwright"); +const { extractFile } = require("@electron/asar"); const EXECUTABLE_NAME = "Paseo"; const SMOKE_TIMEOUT_MS = 60_000; @@ -805,9 +806,23 @@ async function stopCliDaemon({ appPath, env }) { }); } +function assertLinuxDesktopIdentity(appPath) { + if (process.platform === "linux") { + const metadata = JSON.parse( + extractFile(path.join(appPath, "resources", "app.asar"), "package.json").toString(), + ); + if (metadata.desktopName !== `${EXECUTABLE_NAME}.desktop`) { + throw new Error( + `Packaged Linux desktop identity ${JSON.stringify(metadata.desktopName)} does not match ${EXECUTABLE_NAME}.desktop`, + ); + } + } +} + async function smokePackagedDesktopApp({ appPath }) { const executablePath = getExecutablePath(appPath); assertExecutable(executablePath, "Packaged app executable"); + assertLinuxDesktopIdentity(appPath); ensureLinuxSandboxPermissions(appPath); await smokeColdCliDaemonStart({ appPath }); diff --git a/packages/desktop/package.json b/packages/desktop/package.json index 84d71dfafa..11ebbb9cb7 100644 --- a/packages/desktop/package.json +++ b/packages/desktop/package.json @@ -35,6 +35,7 @@ "zod": "^4.4.3" }, "devDependencies": { + "@electron/asar": "3.4.1", "@types/node": "24.6.0", "@types/ws": "^8.5.14", "electron": "44.2.0", @@ -43,5 +44,6 @@ "unzip-crx-3": "^0.2.0", "vitest": "^4.1.6", "wait-on": "8.0.5" - } + }, + "desktopName": "Paseo.desktop" } From 1f3bb0239b631ea91c9f0f83bc9e511a7a26fd2d Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Thu, 10 Sep 2026 12:14:08 +0200 Subject: [PATCH 12/46] docs(changelog): prepare 0.8.0 stable release --- CHANGELOG.md | 129 +++++++++++++++++++++++++++------------------------ 1 file changed, 68 insertions(+), 61 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 3dd69ac7ba..9f5c291e87 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,91 +1,98 @@ # Changelog -## 0.8.0-beta.1 - 2026-09-08 +## 0.8.0 - 2026-09-10 -This release considerably extends what plugins can do: complete providers, custom components in the chat, slash commands, settings screens, and agent lifecycle hooks. +Paseo 0.8 adds plugin header buttons, custom providers, and richer chat components, alongside fixes for desktop updates and mobile keyboards. -The plugin API has a breaking change. Plugins now use separate client and server entries and runtime-owned SDK entry points, so plugins written for 0.7 must follow the [migration guide](https://paseo.sh/docs/plugins/v0.8/migration) before they load on 0.8. - -It also fixes regressions across desktop updates, mobile, Import session, and agent lifecycle. - -### Plugins - -- Added provider contributions: a plugin registers a complete provider, directly or through the bundled ACP shim, with its own icon, session settings, permissions, and timeline rendering ([#4314](https://github.com/getpaseo/paseo/pull/4314)) -- Added live timeline items: a plugin renders custom components in the chat by transforming timeline items at render time ([e34aea2](https://github.com/getpaseo/paseo/commit/e34aea206e6ec7ac2d5fa9766db28fb221ae990c)) -- Added durable timeline rows: a daemon handler appends its own persisted rows to an agent's timeline ([e34aea2](https://github.com/getpaseo/paseo/commit/e34aea206e6ec7ac2d5fa9766db28fb221ae990c)) -- Added slash commands: a plugin registers commands in the composer that run on the client without a provider turn ([e34aea2](https://github.com/getpaseo/paseo/commit/e34aea206e6ec7ac2d5fa9766db28fb221ae990c)) -- Added settings screens: a plugin contributes screens under Settings → Plugins from shared settings components, with host-scoped persistence, validation, and Command Center navigation ([#4357](https://github.com/getpaseo/paseo/pull/4357)) -- Added lifecycle hooks: server callbacks observe agent creation, turns, permissions, and archive events, and can answer permissions or send follow-ups ([#4435](https://github.com/getpaseo/paseo/pull/4435)) -- Added agent creation transforms: before hooks rewrite a new agent's provider, model, launch environment, MCP servers, and workspace isolation ([#4435](https://github.com/getpaseo/paseo/pull/4435)) -- Added modal bodies: a plugin owns the whole sheet body with custom padding, ScrollView or FlatList scrolling, horizontal tabs, keyboard-aware inputs, and a clipboard copy action ([#4392](https://github.com/getpaseo/paseo/pull/4392), [#4431](https://github.com/getpaseo/paseo/pull/4431)) -- Added terminals to the SDK: create, list, write, send keys, capture, and kill terminals by workspace ID ([#4358](https://github.com/getpaseo/paseo/pull/4358)) -- Added `paseo.projects.subscribe()` for live project updates ([#3983](https://github.com/getpaseo/paseo/pull/3983) by [@omercnet](https://github.com/omercnet)) -- Added `agent.respondToPermission()` to answer pending permissions from a plugin ([#3985](https://github.com/getpaseo/paseo/pull/3985) by [@omercnet](https://github.com/omercnet)) -- Added version requirements: `requirements.paseo` in `paseo-plugin.json` declares the supported Paseo versions, incompatible installs, updates, and reloads are rejected before any code runs, and prerelease Paseo versions satisfy any range their stable core satisfies ([#4430](https://github.com/getpaseo/paseo/pull/4430), [#4452](https://github.com/getpaseo/paseo/pull/4452)) -- Changed plugins to separate `index.client.ts` and `index.server.ts` entries with `client/`, `server/`, and `shared/` directories; plugins written for 0.7 must follow the [migration guide](https://paseo.sh/docs/plugins/v0.8/migration) ([#4206](https://github.com/getpaseo/paseo/pull/4206)) -- Changed the plugin SDK into runtime-owned entry points: `@getpaseo/plugin` for shared code, `@getpaseo/plugin/client` for the app, and `@getpaseo/plugin/server` for the daemon, with crossed imports rejected at compile time ([#4347](https://github.com/getpaseo/paseo/pull/4347) by [@liujin0506](https://github.com/liujin0506), [@panrafal](https://github.com/panrafal)) -- Changed legacy plugin load failures to say the plugin was built for an older Paseo and link to the migration guide ([#4265](https://github.com/getpaseo/paseo/pull/4265)) -- Changed `paseo plugin ls` to report runtime state, source, installed commit, and load errors without contacting a remote ([#4265](https://github.com/getpaseo/paseo/pull/4265)) -- Fixed workspace plugin panels missing from the Explorer tab-rail menu ([#4446](https://github.com/getpaseo/paseo/pull/4446)) -- Added versioned plugin documentation for v0.7 and v0.8 with a migration guide ([#4245](https://github.com/getpaseo/paseo/pull/4245), [#4452](https://github.com/getpaseo/paseo/pull/4452)) +**Before upgrading:** desktop now requires macOS 13 or newer. Plugins written for 0.7 need the [0.8 migration guide](https://paseo.sh/docs/plugins/v0.8/migration), including separate client/server entries and the revised composer pill API. ### Added -- Added Import session to the sidebar footer, the History and sidebar empty states, and the Command Center ([#4216](https://github.com/getpaseo/paseo/pull/4216)) -- Added search, Load more, and a This workspace / Show all scope to the Import session sheet ([#4216](https://github.com/getpaseo/paseo/pull/4216)) -- Added customizable top-level sidebar items: reorder and hide New workspace, History, Search, Schedules, and plugin items from Appearance settings ([#4203](https://github.com/getpaseo/paseo/pull/4203)) -- Added provider notifications from Pi extensions and OpenCode as timeline items with info, warning, and error levels ([#3411](https://github.com/getpaseo/paseo/pull/3411) by [@trim21](https://github.com/trim21)) -- Added a fullscreen viewer for Mermaid diagrams on web and desktop ([#4107](https://github.com/getpaseo/paseo/pull/4107) by [@gengjiawen](https://github.com/gengjiawen)) -- Added Copy, Copy line, and Select all to the web diff context menu, with Cmd/Ctrl+A and Escape ([#4229](https://github.com/getpaseo/paseo/pull/4229)) +- Added answer forms for Codex questions asked while the agent continues working ([#4587](https://github.com/getpaseo/paseo/pull/4587)) +- Added Mark as unread to finished workspaces in the sidebar menu ([#3603](https://github.com/getpaseo/paseo/pull/3603) by [@edihasaj](https://github.com/edihasaj)) +- Added an in-app What's new sheet in the help menu, Settings → About, and desktop update callout ([#4576](https://github.com/getpaseo/paseo/pull/4576)) +- Added Import session to the sidebar footer, History, and Command Center ([#4216](https://github.com/getpaseo/paseo/pull/4216)) +- Added search, pagination, and workspace filtering to Import session ([#4216](https://github.com/getpaseo/paseo/pull/4216)) +- Added sidebar item reordering and visibility controls in Appearance settings ([#4203](https://github.com/getpaseo/paseo/pull/4203)) +- Added provider notifications from Pi extensions and OpenCode to the timeline ([#3411](https://github.com/getpaseo/paseo/pull/3411) by [@trim21](https://github.com/trim21)) +- Added a fullscreen Mermaid viewer on web and desktop ([#4107](https://github.com/getpaseo/paseo/pull/4107) by [@gengjiawen](https://github.com/gengjiawen)) +- Added Copy, Copy line, and Select all to the web diff context menu ([#4229](https://github.com/getpaseo/paseo/pull/4229)) - Added Gajae Code to the ACP provider catalog ([#3471](https://github.com/getpaseo/paseo/pull/3471) by [@Yeachan-Heo](https://github.com/Yeachan-Heo)) -- Added per-provider Paseo tool policy in daemon config to disable all or selected Paseo tools for a provider ([#4277](https://github.com/getpaseo/paseo/pull/4277) by [@mcowger](https://github.com/mcowger)) -- Added explicit setup approval for fork pull request workspaces: setup, automatic terminals, and scripts wait for Run setup or `paseo workspace setup ` ([#4215](https://github.com/getpaseo/paseo/pull/4215)) -- Added Hub follow-ups to existing agents, archived workspace recovery, and provider catalog discovery through the daemon API ([#4354](https://github.com/getpaseo/paseo/pull/4354), [#4242](https://github.com/getpaseo/paseo/pull/4242)) +- Added per-provider controls for disabling all or selected Paseo tools ([#4277](https://github.com/getpaseo/paseo/pull/4277) by [@mcowger](https://github.com/mcowger)) +- Added explicit approval before running setup, automatic terminals, or scripts in fork pull request workspaces ([#4215](https://github.com/getpaseo/paseo/pull/4215)) +- Added Hub follow-ups to existing agents, including archived workspace recovery ([#4354](https://github.com/getpaseo/paseo/pull/4354)) +- Added provider catalog discovery to Hub executions ([#4242](https://github.com/getpaseo/paseo/pull/4242)) + +### Plugins + +- Added custom providers with their own icons, settings, permissions, and timeline rendering ([#4314](https://github.com/getpaseo/paseo/pull/4314)) +- Added provider session recovery after plugin reload ([#4629](https://github.com/getpaseo/paseo/pull/4629) by [@mcowger](https://github.com/mcowger)) +- Added custom timeline components and persisted timeline rows ([e34aea2](https://github.com/getpaseo/paseo/commit/e34aea206e6ec7ac2d5fa9766db28fb221ae990c)) +- Added client slash commands that run without a provider turn ([e34aea2](https://github.com/getpaseo/paseo/commit/e34aea206e6ec7ac2d5fa9766db28fb221ae990c)) +- Added plugin settings screens under Settings → Plugins ([#4357](https://github.com/getpaseo/paseo/pull/4357)) +- Added lifecycle hooks for agent creation, turns, permissions, and archive events ([#4435](https://github.com/getpaseo/paseo/pull/4435)) +- Added transforms for new agents' provider, model, environment, MCP servers, and workspace isolation ([#4435](https://github.com/getpaseo/paseo/pull/4435)) +- Added header buttons and composer pills with actions, menus, custom popovers, and live updates ([#4577](https://github.com/getpaseo/paseo/pull/4577)) +- Added custom modal bodies with scrolling, keyboard-aware inputs, and clipboard actions ([#4392](https://github.com/getpaseo/paseo/pull/4392)) +- Added terminal creation, input, capture, and management to the SDK ([#4358](https://github.com/getpaseo/paseo/pull/4358)) +- Added live project subscriptions through `paseo.projects.subscribe()` ([#3983](https://github.com/getpaseo/paseo/pull/3983) by [@omercnet](https://github.com/omercnet)) +- Added permission responses through `agent.respondToPermission()` ([#3985](https://github.com/getpaseo/paseo/pull/3985) by [@omercnet](https://github.com/omercnet)) +- Added provider usage queries through `providers.listUsage()` ([#4062](https://github.com/getpaseo/paseo/pull/4062) by [@ivanbrykov](https://github.com/ivanbrykov)) +- Added supported Paseo version requirements to plugin manifests ([#4430](https://github.com/getpaseo/paseo/pull/4430)) +- Changed plugins to separate client and server entries with runtime-specific SDK imports ([#4347](https://github.com/getpaseo/paseo/pull/4347) by [@liujin0506](https://github.com/liujin0506), [@panrafal](https://github.com/panrafal)) +- Changed composer pill registration to use `button`, `registration.update()`, and `registration.remove()` ([#4577](https://github.com/getpaseo/paseo/pull/4577)) +- Changed `paseo plugin ls` to show runtime state, installed commit, and load errors without contacting a remote ([#4265](https://github.com/getpaseo/paseo/pull/4265)) +- Fixed workspace plugin panels missing from the Explorer menu ([#4446](https://github.com/getpaseo/paseo/pull/4446)) ### Changed - Changed the desktop app to require macOS 13 or newer; macOS 12 stops receiving desktop updates ([#4322](https://github.com/getpaseo/paseo/pull/4322)) - Changed `--host` to a global CLI option: `paseo --host ` ([#4238](https://github.com/getpaseo/paseo/pull/4238)) -- Changed the sidebar footer Home button to Import session; the Home route and tile remain ([#4216](https://github.com/getpaseo/paseo/pull/4216)) +- Changed the sidebar footer Home button to Import session ([#4216](https://github.com/getpaseo/paseo/pull/4216)) +- Changed `paseo hub init` and default `paseo hub deploy` to use organization triggers in `.paseo/triggers/` ([#4517](https://github.com/getpaseo/paseo/pull/4517)) +- Changed Codex Fast availability to the supported model list, including GPT-6 Astra ([#4640](https://github.com/getpaseo/paseo/pull/4640)) +- Changed CLI installs to resolve patched React 19.1.x releases ([#4100](https://github.com/getpaseo/paseo/pull/4100) by [@liujin0506](https://github.com/liujin0506)) ### Improved -- Reconnected every host immediately on app foreground instead of waiting for reconnect backoff ([#4160](https://github.com/getpaseo/paseo/pull/4160)) -- Smoothed mobile keyboard transitions by moving the timeline and composer together instead of relaying out every frame ([#4275](https://github.com/getpaseo/paseo/pull/4275)) -- Reloaded only the providers whose configuration changed instead of restarting every provider and resetting every catalog ([#4332](https://github.com/getpaseo/paseo/pull/4332)) -- Shortened agent tab tooltips to one title line with the agent ID and last activity ([#4214](https://github.com/getpaseo/paseo/pull/4214)) -- Added macOS updater and ShipIt state to App Diagnostics ([#4322](https://github.com/getpaseo/paseo/pull/4322)) +- Reconnected every host immediately when returning to the app ([#4160](https://github.com/getpaseo/paseo/pull/4160)) +- Smoothed mobile keyboard transitions by moving the timeline and composer together ([#4275](https://github.com/getpaseo/paseo/pull/4275)) +- Reduced stalls when opening large diffs ([#4574](https://github.com/getpaseo/paseo/pull/4574)) +- Reloaded only providers whose configuration changed ([#4332](https://github.com/getpaseo/paseo/pull/4332)) +- Removed unsolicited timeline and catalog traffic from idle SDK connections ([#4470](https://github.com/getpaseo/paseo/pull/4470)) +- Added macOS updater diagnostics to App Diagnostics ([#4322](https://github.com/getpaseo/paseo/pull/4322)) ### Fixed -- Fixed the mobile terminal keyboard closing and reopening when using toolbar keys or Enter ([#4469](https://github.com/getpaseo/paseo/pull/4469)) - Fixed macOS desktop updates closing the app without installing the new version ([#4322](https://github.com/getpaseo/paseo/pull/4322)) -- Fixed the desktop updater reporting up to date while a new release had no updater manifests yet ([#4201](https://github.com/getpaseo/paseo/pull/4201)) +- Fixed the desktop updater reporting up to date before release manifests were available ([#4201](https://github.com/getpaseo/paseo/pull/4201)) - Fixed `paseo restart` failing from the desktop-installed CLI on Linux `.deb`, RPM, and tarball installs ([#4207](https://github.com/getpaseo/paseo/pull/4207)) - Fixed the packaged macOS Dock icon rendering oversized ([#4389](https://github.com/getpaseo/paseo/pull/4389)) -- Fixed cached conversations and the workspace list not painting when live updates arrived during cache reads ([#4436](https://github.com/getpaseo/paseo/pull/4436)) +- Fixed the mobile terminal keyboard closing and reopening when using toolbar keys or Enter ([#4469](https://github.com/getpaseo/paseo/pull/4469)) +- Fixed cached conversations and workspace lists failing to appear when live updates arrived during loading ([#4436](https://github.com/getpaseo/paseo/pull/4436)) - Fixed queued messages not sending after a turn completed while the app was in the background ([#4160](https://github.com/getpaseo/paseo/pull/4160)) -- Fixed the New workspace form losing the selected model after creating a workspace ([#4401](https://github.com/getpaseo/paseo/pull/4401)) -- Fixed a crash with `Select a model` when the selected provider disappeared during workspace creation ([#4332](https://github.com/getpaseo/paseo/pull/4332)) -- Fixed Codex model rows staying on Loading forever in the New workspace model picker ([#4283](https://github.com/getpaseo/paseo/pull/4283) by [@colonelpanic8](https://github.com/colonelpanic8)) -- Fixed reloading a Codex agent failing with `already has an active writer` ([#4353](https://github.com/getpaseo/paseo/pull/4353)) +- Fixed Codex reloads and follow-ups after workspace archive failing with an active-writer error ([#4353](https://github.com/getpaseo/paseo/pull/4353)) +- Fixed repeated Claude compaction updates leaving extra Compacting rows spinning ([#4391](https://github.com/getpaseo/paseo/pull/4391) by [@tomgrin10](https://github.com/tomgrin10)) - Fixed Pi agents stuck running after an extension-triggered turn ([#3849](https://github.com/getpaseo/paseo/pull/3849) by [@mjakl](https://github.com/mjakl)) -- Fixed nested provider subagents shown as direct children of the root agent and their notifications appearing in the root timeline ([#4321](https://github.com/getpaseo/paseo/pull/4321)) -- Fixed repeated provider tool-call IDs across autonomous turns producing duplicate timeline rows ([140b0bb](https://github.com/getpaseo/paseo/commit/140b0bb716205cf0e00c0ff5b6b3c20af6c79413)) -- Fixed the Import session spinner never stopping when one provider hung; the provider now shows an inline error with Retry ([#4216](https://github.com/getpaseo/paseo/pull/4216)) +- Fixed delayed workspace creation navigating away after you switched elsewhere ([#2986](https://github.com/getpaseo/paseo/pull/2986) by [@cleiter](https://github.com/cleiter)) +- Fixed New workspace losing the selected model after creating a workspace ([#4401](https://github.com/getpaseo/paseo/pull/4401)) +- Fixed a crash when the selected provider disappeared during workspace creation ([#4332](https://github.com/getpaseo/paseo/pull/4332)) +- Fixed Codex model rows staying on Loading in the model picker ([#4283](https://github.com/getpaseo/paseo/pull/4283) by [@colonelpanic8](https://github.com/colonelpanic8)) +- Fixed nested subagents appearing under the root agent instead of their actual parent ([#4321](https://github.com/getpaseo/paseo/pull/4321)) +- Fixed repeated tool-call IDs across autonomous turns producing duplicate timeline rows ([140b0bb](https://github.com/getpaseo/paseo/commit/140b0bb716205cf0e00c0ff5b6b3c20af6c79413)) +- Fixed Import session loading forever when one provider hung ([#4216](https://github.com/getpaseo/paseo/pull/4216)) - Fixed Import session ignoring Claude Code `/rename` titles ([#4216](https://github.com/getpaseo/paseo/pull/4216)) -- Fixed importing a session into a directory with an active workspace creating a duplicate workspace ([#4216](https://github.com/getpaseo/paseo/pull/4216)) +- Fixed importing a session creating a duplicate workspace for an already-open directory ([#4216](https://github.com/getpaseo/paseo/pull/4216)) - Fixed empty ACP sessions appearing in Import session ([#4335](https://github.com/getpaseo/paseo/pull/4335)) -- Fixed diff file headers lagging behind the document while scrolling on web and desktop ([#4240](https://github.com/getpaseo/paseo/pull/4240)) -- Fixed the Changes tree and its working diff going out of sync when switching between Committed and Uncommitted ([#4199](https://github.com/getpaseo/paseo/pull/4199)) -- Fixed Mermaid diagrams shrinking on every streaming re-render and the inline diagram box collapsing ([#4107](https://github.com/getpaseo/paseo/pull/4107) by [@gengjiawen](https://github.com/gengjiawen)) -- Fixed Mermaid source corrupted on reload when cached history overlapped the live message ([#4210](https://github.com/getpaseo/paseo/pull/4210)) +- Fixed restored terminal titles briefly showing Terminal before loading ([#4553](https://github.com/getpaseo/paseo/pull/4553)) +- Fixed diff file headers lagging behind while scrolling on web and desktop ([#4240](https://github.com/getpaseo/paseo/pull/4240)) +- Fixed Changes and its diff going out of sync when switching between Committed and Uncommitted ([#4199](https://github.com/getpaseo/paseo/pull/4199)) +- Fixed Mermaid diagrams shrinking during streaming or becoming corrupted on reload ([#4210](https://github.com/getpaseo/paseo/pull/4210)) - Fixed Settings navigation retaining closed views in memory on desktop ([#4228](https://github.com/getpaseo/paseo/pull/4228)) -- Fixed the file watcher missing nested directory changes on Windows ([#4354](https://github.com/getpaseo/paseo/pull/4354), [#4358](https://github.com/getpaseo/paseo/pull/4358)) -- Fixed daemon Git commands honoring a repository's `core.fsmonitor`, which let a checkout run its own command ([#4208](https://github.com/getpaseo/paseo/pull/4208)) -- Fixed terminal activity reporting for OpenCode 2 sessions started from a terminal ([#4300](https://github.com/getpaseo/paseo/pull/4300) by [@mr-karan](https://github.com/mr-karan)) -- Changed the plugin SDK's React peer range to `~19.1.0` so CLI installs resolve a patched 19.1.x instead of the version dependency scanners flag ([#4100](https://github.com/getpaseo/paseo/pull/4100) by [@liujin0506](https://github.com/liujin0506)) -- Fixed the paseo.sh homepage offering the Mac download to every visitor before hydration and desktop builds to phones ([#4222](https://github.com/getpaseo/paseo/pull/4222)) +- Fixed the file watcher missing nested directory changes on Windows ([#4354](https://github.com/getpaseo/paseo/pull/4354)) +- Fixed daemon Git commands allowing a repository's `core.fsmonitor` to execute its own command ([#4208](https://github.com/getpaseo/paseo/pull/4208)) +- Fixed terminal activity reporting for OpenCode 2 sessions ([#4300](https://github.com/getpaseo/paseo/pull/4300) by [@mr-karan](https://github.com/mr-karan)) +- Fixed the website offering downloads for the wrong platform before loading ([#4222](https://github.com/getpaseo/paseo/pull/4222)) ## 0.7.2 - 2026-09-02 From b8e24677e12b226c7c38c1c3a40649daa9f1152f Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Thu, 10 Sep 2026 12:17:08 +0200 Subject: [PATCH 13/46] chore(release): cut 0.8.0 --- .../android/en-US/changelogs/80001.txt | 8 +++ .../android/en-US/changelogs/80002.txt | 8 +++ .../android/en-US/changelogs/80003.txt | 8 +++ .../android/en-US/changelogs/80004.txt | 8 +++ package-lock.json | 54 +++++++++---------- package.json | 2 +- packages/app/package.json | 2 +- packages/cli/package.json | 8 +-- packages/client/package.json | 6 +-- packages/desktop/package.json | 2 +- packages/expo-two-way-audio/package.json | 2 +- packages/highlight/package.json | 2 +- packages/plugin/package.json | 10 ++-- packages/protocol/package.json | 2 +- packages/relay/package.json | 2 +- packages/server/package.json | 12 ++--- packages/website/package.json | 2 +- 17 files changed, 85 insertions(+), 53 deletions(-) create mode 100644 fastlane/metadata/android/en-US/changelogs/80001.txt create mode 100644 fastlane/metadata/android/en-US/changelogs/80002.txt create mode 100644 fastlane/metadata/android/en-US/changelogs/80003.txt create mode 100644 fastlane/metadata/android/en-US/changelogs/80004.txt diff --git a/fastlane/metadata/android/en-US/changelogs/80001.txt b/fastlane/metadata/android/en-US/changelogs/80001.txt new file mode 100644 index 0000000000..5e55ccd9e3 --- /dev/null +++ b/fastlane/metadata/android/en-US/changelogs/80001.txt @@ -0,0 +1,8 @@ +Paseo 0.8 adds plugin header buttons, custom providers, and richer chat components, alongside fixes for desktop updates and mobile keyboards. + +Before upgrading: desktop now requires macOS 13 or newer. Plugins written for 0.7 need the 0.8 migration guide, including separate client/server entries and the revised composer pill API. + +Added +- Added answer forms for Codex questions asked while the agent continues working + +Full notes: https://paseo.sh/changelog diff --git a/fastlane/metadata/android/en-US/changelogs/80002.txt b/fastlane/metadata/android/en-US/changelogs/80002.txt new file mode 100644 index 0000000000..5e55ccd9e3 --- /dev/null +++ b/fastlane/metadata/android/en-US/changelogs/80002.txt @@ -0,0 +1,8 @@ +Paseo 0.8 adds plugin header buttons, custom providers, and richer chat components, alongside fixes for desktop updates and mobile keyboards. + +Before upgrading: desktop now requires macOS 13 or newer. Plugins written for 0.7 need the 0.8 migration guide, including separate client/server entries and the revised composer pill API. + +Added +- Added answer forms for Codex questions asked while the agent continues working + +Full notes: https://paseo.sh/changelog diff --git a/fastlane/metadata/android/en-US/changelogs/80003.txt b/fastlane/metadata/android/en-US/changelogs/80003.txt new file mode 100644 index 0000000000..5e55ccd9e3 --- /dev/null +++ b/fastlane/metadata/android/en-US/changelogs/80003.txt @@ -0,0 +1,8 @@ +Paseo 0.8 adds plugin header buttons, custom providers, and richer chat components, alongside fixes for desktop updates and mobile keyboards. + +Before upgrading: desktop now requires macOS 13 or newer. Plugins written for 0.7 need the 0.8 migration guide, including separate client/server entries and the revised composer pill API. + +Added +- Added answer forms for Codex questions asked while the agent continues working + +Full notes: https://paseo.sh/changelog diff --git a/fastlane/metadata/android/en-US/changelogs/80004.txt b/fastlane/metadata/android/en-US/changelogs/80004.txt new file mode 100644 index 0000000000..5e55ccd9e3 --- /dev/null +++ b/fastlane/metadata/android/en-US/changelogs/80004.txt @@ -0,0 +1,8 @@ +Paseo 0.8 adds plugin header buttons, custom providers, and richer chat components, alongside fixes for desktop updates and mobile keyboards. + +Before upgrading: desktop now requires macOS 13 or newer. Plugins written for 0.7 need the 0.8 migration guide, including separate client/server entries and the revised composer pill API. + +Added +- Added answer forms for Codex questions asked while the agent continues working + +Full notes: https://paseo.sh/changelog diff --git a/package-lock.json b/package-lock.json index 1a94812378..690fe6050b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "paseo", - "version": "0.8.0-beta.1", + "version": "0.8.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "paseo", - "version": "0.8.0-beta.1", + "version": "0.8.0", "hasInstallScript": true, "license": "Apache-2.0", "workspaces": [ @@ -36196,7 +36196,7 @@ }, "packages/app": { "name": "@getpaseo/app", - "version": "0.8.0-beta.1", + "version": "0.8.0", "dependencies": { "@codemirror/commands": "6.10.4", "@codemirror/language": "6.12.4", @@ -37261,12 +37261,12 @@ }, "packages/cli": { "name": "@getpaseo/cli", - "version": "0.8.0-beta.1", + "version": "0.8.0", "dependencies": { "@clack/prompts": "^1.0.0", - "@getpaseo/client": "0.8.0-beta.1", - "@getpaseo/protocol": "0.8.0-beta.1", - "@getpaseo/server": "0.8.0-beta.1", + "@getpaseo/client": "0.8.0", + "@getpaseo/protocol": "0.8.0", + "@getpaseo/server": "0.8.0", "chalk": "^5.3.0", "commander": "^12.0.0", "mime-types": "^2.1.35", @@ -37513,10 +37513,10 @@ }, "packages/client": { "name": "@getpaseo/client", - "version": "0.8.0-beta.1", + "version": "0.8.0", "dependencies": { - "@getpaseo/protocol": "0.8.0-beta.1", - "@getpaseo/relay": "0.8.0-beta.1", + "@getpaseo/protocol": "0.8.0", + "@getpaseo/relay": "0.8.0", "zod": "^4.4.3" }, "devDependencies": { @@ -37527,7 +37527,7 @@ }, "packages/desktop": { "name": "@getpaseo/desktop", - "version": "0.8.0-beta.1", + "version": "0.8.0", "license": "Apache-2.0", "dependencies": { "@getpaseo/cli": "*", @@ -37771,7 +37771,7 @@ }, "packages/expo-two-way-audio": { "name": "@getpaseo/expo-two-way-audio", - "version": "0.8.0-beta.1", + "version": "0.8.0", "license": "MIT", "devDependencies": { "@types/jest": "^29.5.14", @@ -38667,7 +38667,7 @@ }, "packages/highlight": { "name": "@getpaseo/highlight", - "version": "0.8.0-beta.1", + "version": "0.8.0", "dependencies": { "@codemirror/language": "6.12.4", "@codemirror/legacy-modes": "^6.5.3", @@ -38696,14 +38696,14 @@ }, "packages/plugin": { "name": "@getpaseo/plugin", - "version": "0.8.0-beta.1", + "version": "0.8.0", "dependencies": { "@agentclientprotocol/sdk": "^1.4.0", "use-sync-external-store": "^1.6.0" }, "devDependencies": { - "@getpaseo/client": "0.8.0-beta.1", - "@getpaseo/protocol": "0.8.0-beta.1", + "@getpaseo/client": "0.8.0", + "@getpaseo/protocol": "0.8.0", "@types/node": "^20.9.0", "@types/react": "~19.2.0", "react": "19.1.0", @@ -38713,8 +38713,8 @@ "zod": "^4.4.3" }, "peerDependencies": { - "@getpaseo/client": "0.8.0-beta.1", - "@getpaseo/protocol": "0.8.0-beta.1", + "@getpaseo/client": "0.8.0", + "@getpaseo/protocol": "0.8.0", "react": "~19.1.0", "react-native": ">=0.81.5", "zod": "^4.4.3" @@ -38736,7 +38736,7 @@ }, "packages/protocol": { "name": "@getpaseo/protocol", - "version": "0.8.0-beta.1", + "version": "0.8.0", "dependencies": { "semver": "^7.8.5", "zod": "^4.4.3" @@ -38763,7 +38763,7 @@ }, "packages/relay": { "name": "@getpaseo/relay", - "version": "0.8.0-beta.1", + "version": "0.8.0", "dependencies": { "base64-js": "^1.5.1", "tweetnacl": "^1.0.3", @@ -38981,16 +38981,16 @@ }, "packages/server": { "name": "@getpaseo/server", - "version": "0.8.0-beta.1", + "version": "0.8.0", "dependencies": { "@agentclientprotocol/sdk": "^0.17.1", "@anthropic-ai/claude-agent-sdk": "0.3.246", "@anthropic-ai/sdk": "^0.104.2", - "@getpaseo/client": "0.8.0-beta.1", - "@getpaseo/highlight": "0.8.0-beta.1", - "@getpaseo/plugin": "0.8.0-beta.1", - "@getpaseo/protocol": "0.8.0-beta.1", - "@getpaseo/relay": "0.8.0-beta.1", + "@getpaseo/client": "0.8.0", + "@getpaseo/highlight": "0.8.0", + "@getpaseo/plugin": "0.8.0", + "@getpaseo/protocol": "0.8.0", + "@getpaseo/relay": "0.8.0", "@isaacs/ttlcache": "^2.1.4", "@modelcontextprotocol/sdk": "1.29.0", "@opencode-ai/sdk": "1.14.46", @@ -39868,7 +39868,7 @@ }, "packages/website": { "name": "@getpaseo/website", - "version": "0.8.0-beta.1", + "version": "0.8.0", "dependencies": { "@cloudflare/vite-plugin": "^1.29.1", "@cloudflare/workers-types": "^4.20260317.1", diff --git a/package.json b/package.json index 5d13d92ad4..638573bff2 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "paseo", - "version": "0.8.0-beta.1", + "version": "0.8.0", "private": true, "description": "Paseo: voice-controlled development environment for local AI coding agents", "keywords": [ diff --git a/packages/app/package.json b/packages/app/package.json index a4d49a0c1e..eab1cb207c 100644 --- a/packages/app/package.json +++ b/packages/app/package.json @@ -1,6 +1,6 @@ { "name": "@getpaseo/app", - "version": "0.8.0-beta.1", + "version": "0.8.0", "private": true, "main": "index.ts", "scripts": { diff --git a/packages/cli/package.json b/packages/cli/package.json index f0e7c43313..b1f3c17be9 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -1,6 +1,6 @@ { "name": "@getpaseo/cli", - "version": "0.8.0-beta.1", + "version": "0.8.0", "description": "Paseo CLI - control your AI coding agents from the command line", "bin": { "paseo": "bin/paseo" @@ -28,9 +28,9 @@ }, "dependencies": { "@clack/prompts": "^1.0.0", - "@getpaseo/client": "0.8.0-beta.1", - "@getpaseo/protocol": "0.8.0-beta.1", - "@getpaseo/server": "0.8.0-beta.1", + "@getpaseo/client": "0.8.0", + "@getpaseo/protocol": "0.8.0", + "@getpaseo/server": "0.8.0", "chalk": "^5.3.0", "commander": "^12.0.0", "mime-types": "^2.1.35", diff --git a/packages/client/package.json b/packages/client/package.json index 8d1a6169a3..7cba1623a8 100644 --- a/packages/client/package.json +++ b/packages/client/package.json @@ -1,6 +1,6 @@ { "name": "@getpaseo/client", - "version": "0.8.0-beta.1", + "version": "0.8.0", "description": "Paseo client SDK package", "files": [ "dist", @@ -39,8 +39,8 @@ "test": "vitest run" }, "dependencies": { - "@getpaseo/protocol": "0.8.0-beta.1", - "@getpaseo/relay": "0.8.0-beta.1", + "@getpaseo/protocol": "0.8.0", + "@getpaseo/relay": "0.8.0", "zod": "^4.4.3" }, "devDependencies": { diff --git a/packages/desktop/package.json b/packages/desktop/package.json index 11ebbb9cb7..0eb997b14c 100644 --- a/packages/desktop/package.json +++ b/packages/desktop/package.json @@ -1,6 +1,6 @@ { "name": "@getpaseo/desktop", - "version": "0.8.0-beta.1", + "version": "0.8.0", "private": true, "description": "Paseo desktop app (Electron wrapper)", "homepage": "https://paseo.sh", diff --git a/packages/expo-two-way-audio/package.json b/packages/expo-two-way-audio/package.json index 8d25b8a1f9..cc5546b80f 100644 --- a/packages/expo-two-way-audio/package.json +++ b/packages/expo-two-way-audio/package.json @@ -1,6 +1,6 @@ { "name": "@getpaseo/expo-two-way-audio", - "version": "0.8.0-beta.1", + "version": "0.8.0", "description": "Native module for two way audio streaming", "keywords": [ "ExpoTwoWayAudio", diff --git a/packages/highlight/package.json b/packages/highlight/package.json index eabc12f4b0..11a51ce138 100644 --- a/packages/highlight/package.json +++ b/packages/highlight/package.json @@ -1,6 +1,6 @@ { "name": "@getpaseo/highlight", - "version": "0.8.0-beta.1", + "version": "0.8.0", "files": [ "dist", "!dist/**/*.map", diff --git a/packages/plugin/package.json b/packages/plugin/package.json index 4f054cf37b..5529abe19f 100644 --- a/packages/plugin/package.json +++ b/packages/plugin/package.json @@ -1,6 +1,6 @@ { "name": "@getpaseo/plugin", - "version": "0.8.0-beta.1", + "version": "0.8.0", "description": "Paseo plugin SDK package", "files": [ "dist", @@ -65,8 +65,8 @@ "use-sync-external-store": "^1.6.0" }, "devDependencies": { - "@getpaseo/client": "0.8.0-beta.1", - "@getpaseo/protocol": "0.8.0-beta.1", + "@getpaseo/client": "0.8.0", + "@getpaseo/protocol": "0.8.0", "@types/node": "^20.9.0", "@types/react": "~19.2.0", "react": "19.1.0", @@ -76,8 +76,8 @@ "zod": "^4.4.3" }, "peerDependencies": { - "@getpaseo/client": "0.8.0-beta.1", - "@getpaseo/protocol": "0.8.0-beta.1", + "@getpaseo/client": "0.8.0", + "@getpaseo/protocol": "0.8.0", "react": "~19.1.0", "react-native": ">=0.81.5", "zod": "^4.4.3" diff --git a/packages/protocol/package.json b/packages/protocol/package.json index 63210e1212..11431461cf 100644 --- a/packages/protocol/package.json +++ b/packages/protocol/package.json @@ -1,6 +1,6 @@ { "name": "@getpaseo/protocol", - "version": "0.8.0-beta.1", + "version": "0.8.0", "description": "Paseo shared protocol schemas and wire types", "files": [ "dist", diff --git a/packages/relay/package.json b/packages/relay/package.json index 4805058da4..df9d3b974d 100644 --- a/packages/relay/package.json +++ b/packages/relay/package.json @@ -1,6 +1,6 @@ { "name": "@getpaseo/relay", - "version": "0.8.0-beta.1", + "version": "0.8.0", "description": "Paseo relay for bridging daemon and client connections", "files": [ "dist", diff --git a/packages/server/package.json b/packages/server/package.json index 5913dfdb62..d9c30a4b8a 100644 --- a/packages/server/package.json +++ b/packages/server/package.json @@ -1,6 +1,6 @@ { "name": "@getpaseo/server", - "version": "0.8.0-beta.1", + "version": "0.8.0", "description": "Paseo backend server", "files": [ "dist/server", @@ -71,11 +71,11 @@ "@agentclientprotocol/sdk": "^0.17.1", "@anthropic-ai/claude-agent-sdk": "0.3.246", "@anthropic-ai/sdk": "^0.104.2", - "@getpaseo/client": "0.8.0-beta.1", - "@getpaseo/highlight": "0.8.0-beta.1", - "@getpaseo/plugin": "0.8.0-beta.1", - "@getpaseo/protocol": "0.8.0-beta.1", - "@getpaseo/relay": "0.8.0-beta.1", + "@getpaseo/client": "0.8.0", + "@getpaseo/highlight": "0.8.0", + "@getpaseo/plugin": "0.8.0", + "@getpaseo/protocol": "0.8.0", + "@getpaseo/relay": "0.8.0", "@isaacs/ttlcache": "^2.1.4", "@modelcontextprotocol/sdk": "1.29.0", "@opencode-ai/sdk": "1.14.46", diff --git a/packages/website/package.json b/packages/website/package.json index 346bbeda1f..0545ce6850 100644 --- a/packages/website/package.json +++ b/packages/website/package.json @@ -1,6 +1,6 @@ { "name": "@getpaseo/website", - "version": "0.8.0-beta.1", + "version": "0.8.0", "private": true, "type": "module", "scripts": { From d7c7044dfc91d1d18721dc8757ac3bb913d8c232 Mon Sep 17 00:00:00 2001 From: "paseo-ai[bot]" <266920839+paseo-ai[bot]@users.noreply.github.com> Date: Thu, 10 Sep 2026 10:26:51 +0000 Subject: [PATCH 14/46] fix: update lockfile signatures and Nix hash [skip ci] --- nix/npm-deps.hash | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/nix/npm-deps.hash b/nix/npm-deps.hash index e838ca8391..6a7e58387b 100644 --- a/nix/npm-deps.hash +++ b/nix/npm-deps.hash @@ -1 +1 @@ -sha256-j8U5S71SJoC+5yh7IjkadrRZaHOYeMsi1z8B6K7j4R4= +sha256-gDB48rHd0K1VOAblaQlPP4XnKGHI8cAt9S09aRxx6b4= From 16d932651c67b910769c94b6a7ec493b849837b5 Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Fri, 11 Sep 2026 08:05:45 +0200 Subject: [PATCH 15/46] fix(desktop): throttle hidden browsers between screenshots (#4646) * fix(desktop): throttle hidden browsers between screenshots Keep Chromium background throttling enabled outside serialized captures. Temporarily enable frame production and wait for fresh paint so hidden-window screenshots remain usable without continuously animating idle guests. * fix(desktop): share screenshot deadline across paint and capture * test(desktop): wait for browser fixture readiness before sampling * test(desktop): exit Electron after screenshot verification CI completed every screenshot assertion but remained alive after app.quit. Exit the standalone test explicitly and keep its deadline active until process shutdown. * test(desktop): clean browser profile after Electron exits Explicit Electron exit skips quit handlers, and removing the profile before shutdown lets Chromium recreate it. The launcher owns the temporary profile and removes it after the child exits. * test(desktop): reuse browser E2E for hidden screenshot regression Exercise the running app and browser MCP tools within the existing browser journey. Remove the separate Electron fixtures, npm script, and CI step. --- docs/browser-capture-harness.md | 19 ++- packages/desktop/capture-harness/main.js | 23 +-- packages/desktop/e2e/browser-tabs.e2e.mjs | 134 +++++++++++++++++- packages/desktop/package.json | 2 +- .../features/browser-automation/ipc.test.ts | 43 ++++++ .../src/features/browser-automation/ipc.ts | 11 ++ .../browser-automation/service.test.ts | 32 +++++ .../features/browser-automation/service.ts | 22 ++- .../features/browser-webviews/index.test.ts | 9 +- .../src/features/browser-webviews/index.ts | 4 +- 10 files changed, 267 insertions(+), 32 deletions(-) diff --git a/docs/browser-capture-harness.md b/docs/browser-capture-harness.md index 734dda3c45..21f80eba39 100644 --- a/docs/browser-capture-harness.md +++ b/docs/browser-capture-harness.md @@ -12,8 +12,7 @@ It validates the compositor behavior that unit tests cannot see: by retrying until the frame appears; - both viewport `capturePage` and full-page CDP screenshots return real pixels from the permanent production parking state; -- guest background throttling can be disabled once at attach without per-capture - renderer coordination; +- parked guests remain capturable with Chromium background throttling enabled; - the real-Electron host-composer sentinel proves guest Enter cannot submit a focused host composer; - the automation group loads the compiled production keyboard boundary and guest @@ -73,11 +72,17 @@ The harness writes PNG evidence and `results.json` to: packages/desktop/capture-harness/out/ ``` -A passing run prints `PASS` lines for the production P1 attach-off parking state, +A passing run prints `PASS` lines for the production P1 default-throttling parking state, including fresh, settled, 75-second soak, multi-tab, viewport, and full-page checks. The PNG sizes may be device-pixel scaled; on a Retina display the 1280x800 logical viewport is usually saved as 2560x1600. +The existing `npm run test:e2e:browser-tabs --workspace=@getpaseo/desktop` journey +verifies that a hidden window stops guest animation, captures fresh viewport pixels, +and resumes animation after restoring the window. Its artifacts include the screenshot +and animation measurements. Full-page content correctness remains separately tracked in +[the full-page repetition bug](https://github.com/getpaseo/paseo/issues/3196). + ## Mechanism Electron captures copy from the guest web contents' compositor surface. A resident @@ -96,8 +101,8 @@ plane stays below the overlay plane regardless of body insertion order; menus ke layering inside `overlay-root`. Activating a presented browser also focuses its registered guest `WebContents` in main so macOS assigns keyboard first-responder ownership to the page. -There is no renderer prep/restore handshake. Main disables guest background throttling -once when the webview attaches, then screenshot capture uses the shared serialized queue, -invalidates before each attempt, and retries known first-frame failures within the -5-second capture budget. Viewport screenshots use `capturePage({ stayHidden:false })`; +There is no renderer prep/restore handshake or lifetime background-throttling override. +Screenshot capture temporarily enables frame production inside the shared serialized queue, +restores the previous throttling policy on success or failure, invalidates before each attempt, +and retries known first-frame failures within the 5-second capture budget. Viewport screenshots use `capturePage({ stayHidden:false })`; full-page screenshots use the existing CDP path with layout metrics and screenshot clip. diff --git a/packages/desktop/capture-harness/main.js b/packages/desktop/capture-harness/main.js index b51735275b..4beed26643 100644 --- a/packages/desktop/capture-harness/main.js +++ b/packages/desktop/capture-harness/main.js @@ -4,6 +4,7 @@ const http = require("node:http"); const path = require("node:path"); const { isDeepStrictEqual } = require("node:util"); const { app, BrowserWindow, ipcMain, Menu, nativeImage, screen, session } = require("electron"); +const { adaptWebContents } = require("../dist/features/browser-automation/ipc.js"); const ROOT = __dirname; const OUT_DIR = process.env.PASEO_CAPTURE_HARNESS_OUT_DIR || path.join(ROOT, "out"); @@ -48,7 +49,7 @@ const PERMANENT_STATE_FILTER = new Set( .filter(Boolean), ); const PERMANENT_VARIANT_FILTER = new Set( - (process.env.PASEO_CAPTURE_HARNESS_VARIANTS || "attach-off") + (process.env.PASEO_CAPTURE_HARNESS_VARIANTS || "default") .split(",") .map((variant) => variant.trim()) .filter(Boolean), @@ -56,9 +57,9 @@ const PERMANENT_VARIANT_FILTER = new Set( const PERMANENT_CAPTURE_MODES = ["viewport", "full-page"]; const PERMANENT_THROTTLING_VARIANTS = [ { - id: "capture-only-throttling", - code: "capture-only", - label: "backgroundThrottling disabled only during each capture", + id: "default-throttling", + code: "default", + label: "Chromium background throttling enabled", disableGuestBackgroundThrottlingAtAttach: false, }, { @@ -442,8 +443,10 @@ async function readGuestMetrics(contents) { } async function capturePageSequence(contents) { - contents.invalidate(); - return await withTimeout(contents.capturePage(undefined, { stayHidden: false }), "capturePage"); + return adaptWebContents(contents).withFrameProduction(async () => { + contents.invalidate(); + return await withTimeout(contents.capturePage(undefined, { stayHidden: false }), "capturePage"); + }); } async function captureFullPage(contents) { @@ -485,8 +488,10 @@ async function captureFullPage(contents) { } async function captureFullPageSequence(contents) { - contents.invalidate(); - return await captureFullPage(contents); + return adaptWebContents(contents).withFrameProduction(async () => { + contents.invalidate(); + return await captureFullPage(contents); + }); } function installHarnessWebviewGuards(win, options = {}) { @@ -2115,7 +2120,7 @@ async function runAutomationGroup() { installHarnessWebviewGuards(win, { preloadPath: PRODUCTION_BROWSER_GUEST_PRELOAD_PATH, }); - const tracker = trackAttachedGuests(win, { disableGuestBackgroundThrottlingAtAttach: true }); + const tracker = trackAttachedGuests(win); try { await withTimeout( win.loadFile(path.join(ROOT, "index.html"), { diff --git a/packages/desktop/e2e/browser-tabs.e2e.mjs b/packages/desktop/e2e/browser-tabs.e2e.mjs index f0c34060c9..20c9866bc0 100644 --- a/packages/desktop/e2e/browser-tabs.e2e.mjs +++ b/packages/desktop/e2e/browser-tabs.e2e.mjs @@ -198,7 +198,14 @@ async function startTargetPage() { response.writeHead(200, { "content-type": "text/html; charset=utf-8" }); response.end(` - Desktop browser target + Desktop browser target + + + @@ -464,7 +471,122 @@ function recordViewportMismatch(failures, label, actual, expected) { ); } -async function runRegression({ page, client, serverId, targetUrl, callerAgentId, artifactDir }) { +async function setWindowHidden(inspectorPort, hidden) { + const [target] = await (await fetch(`http://127.0.0.1:${inspectorPort}/json/list`)).json(); + const socket = new WebSocket(target.webSocketDebuggerUrl); + try { + await new Promise((resolve, reject) => { + socket.addEventListener("error", reject, { once: true }); + socket.addEventListener("message", ({ data }) => { + const response = JSON.parse(data); + if (response.id !== 1) return; + if (response.error || response.result?.exceptionDetails) reject(new Error(data)); + else resolve(); + }); + socket.addEventListener( + "open", + () => + socket.send( + JSON.stringify({ + id: 1, + method: "Runtime.evaluate", + params: { + expression: `(() => { const win = process.mainModule.require('electron').BrowserWindow.getAllWindows().find(win => win.webContents.getURL().includes('localhost:')); win.${hidden ? "hide" : "show"}(); if (win.isVisible() !== ${!hidden}) throw new Error('Window visibility did not change'); })()`, + }, + }), + ), + { once: true }, + ); + }); + } finally { + socket.close(); + } +} + +async function verifyHiddenBrowserScreenshots({ + page, + client, + browserId, + artifactDir, + inspectorPort, +}) { + const readFrames = async () => { + const result = await callBrowserTool(client, "browser_evaluate", { + browserId, + function: "() => window.frameCount", + }); + return JSON.parse(result.resultJson); + }; + const expectAnimation = async () => { + const start = await readFrames(); + assert(typeof start === "number", "Animation fixture was not loaded"); + const deadline = Date.now() + 5_000; + while (Date.now() < deadline) { + if ((await readFrames()) > start + 2) return; + await delay(50); + } + throw new Error("Browser did not animate in the restored window"); + }; + const measurements = []; + const expectIdle = async (label) => { + await delay(500); // Let Chromium consume the visibility change before sampling. + const start = await readFrames(); + await delay(1_000); // A fixed sampling interval is the behavior under test. + const frames = (await readFrames()) - start; + measurements.push({ label, frames }); + writeJson(path.join(artifactDir, "hidden-browser-frames.json"), measurements); + assert(frames === 0, `${label}: hidden browser produced ${frames} animation frames`); + }; + try { + await expectAnimation(); + await setWindowHidden(inspectorPort, true); + await expectIdle("before-capture"); + await callBrowserTool(client, "browser_evaluate", { + browserId, + function: "() => { document.body.style.background = 'rgb(0,255,0)'; }", + }); + const response = await client.callTool({ name: "browser_screenshot", args: { browserId } }); + mcpPayload(response, "browser_screenshot"); + const screenshot = response.content.find((item) => item.type === "image"); + assert(screenshot, "browser_screenshot returned no image"); + fs.writeFileSync( + path.join(artifactDir, "hidden-browser-viewport.png"), + Buffer.from(screenshot.data, "base64"), + ); + const pixel = await page.evaluate(async (base64) => { + const image = new Image(); + image.src = `data:image/png;base64,${base64}`; + await image.decode(); + const canvas = document.createElement("canvas"); + canvas.width = image.width; + canvas.height = image.height; + const context = canvas.getContext("2d"); + context.drawImage(image, 0, 0); + return [ + ...context.getImageData(Math.floor(image.width / 2), Math.floor(image.height / 2), 1, 1) + .data, + ]; + }, screenshot.data); + assert( + JSON.stringify(pixel) === "[0,255,0,255]", + `Hidden screenshot returned stale pixels: ${pixel}`, + ); + await expectIdle("after-capture"); + } finally { + await setWindowHidden(inspectorPort, false); + } + await expectAnimation(); +} + +async function runRegression({ + page, + client, + serverId, + targetUrl, + callerAgentId, + artifactDir, + inspectorPort, +}) { const failures = []; const originalWorkspaceId = workspaceIds[0]; const originalWorkspaceRow = page.getByTestId( @@ -719,6 +841,8 @@ async function runRegression({ page, client, serverId, targetUrl, callerAgentId, const parkedGuest = await readGuest(page, browserId); assert(parkedGuest, "Browser guest was not parked after workspace eviction"); + await verifyHiddenBrowserScreenshots({ page, client, browserId, artifactDir, inspectorPort }); + const listed = await callBrowserTool(client, "browser_list_tabs"); assert( listed.tabs.some((tab) => tab.browserId === browserId), @@ -886,6 +1010,7 @@ async function runRegression({ page, client, serverId, targetUrl, callerAgentId, guestFocus: "passed", overlayPlane: "passed", inactiveCapture: "passed", + hiddenWindowCapture: "passed", list: "passed", snapshot: "passed", click: "passed", @@ -904,7 +1029,8 @@ async function main() { const workspaceRoot = path.join(runtimeDir, "workspaces"); fs.mkdirSync(paseoHome, { recursive: true }); - const [daemonPort, expoPort, cdpPort] = await Promise.all([ + const [daemonPort, expoPort, cdpPort, inspectorPort] = await Promise.all([ + reservePort(), reservePort(), reservePort(), reservePort(), @@ -942,6 +1068,7 @@ async function main() { const desktopArgs = [ process.execPath, devRunner, + `--inspect=127.0.0.1:${inspectorPort}`, ...(process.platform === "linux" ? ["--no-sandbox"] : []), ]; const desktopCommand = process.platform === "linux" ? "xvfb-run" : desktopArgs.shift(); @@ -996,6 +1123,7 @@ async function main() { client, serverId: status.serverId, targetUrl: target.url, + inspectorPort, callerAgentId, artifactDir, }); diff --git a/packages/desktop/package.json b/packages/desktop/package.json index 0eb997b14c..cae4c2b777 100644 --- a/packages/desktop/package.json +++ b/packages/desktop/package.json @@ -17,7 +17,7 @@ "scripts": { "build": "npm --prefix ../.. run build:server:clean && npm run build:main && electron-builder --config electron-builder.yml", "build:main": "tsc -p tsconfig.json", - "capture-harness": "./capture-harness/run.sh", + "capture-harness": "npm run build:main && ./capture-harness/run.sh", "dev": "./scripts/dev.sh", "dev:win": "powershell ./scripts/dev.ps1", "test:e2e:renderer": "cross-env E2E_DESKTOP_RUNTIME=1 playwright test --config=playwright.config.ts --project=desktop", diff --git a/packages/desktop/src/features/browser-automation/ipc.test.ts b/packages/desktop/src/features/browser-automation/ipc.test.ts index dba7581964..a36b7dea0d 100644 --- a/packages/desktop/src/features/browser-automation/ipc.test.ts +++ b/packages/desktop/src/features/browser-automation/ipc.test.ts @@ -108,6 +108,14 @@ type ConsoleMessageListener = ( ) => void; class FakeWebContents { + public backgroundThrottling = true; + public getBackgroundThrottling(): boolean { + return this.backgroundThrottling; + } + public setBackgroundThrottling(allowed: boolean): void { + if (this.destroyed) throw new Error("Object has been destroyed"); + this.backgroundThrottling = allowed; + } public readonly debugger = new FakeDebugger(); public readonly inputEvents: IsolatedKeyboardInputEvent[] = []; public readonly loadedUrls: string[] = []; @@ -672,3 +680,38 @@ function deferred() { }); return { promise, resolve }; } + +describe("pixel capture frame production", () => { + test.each([true, false])( + "restores the prior throttling policy (%s) after capture or failure", + async (previous) => { + const contents = new FakeWebContents(8001); + contents.backgroundThrottling = previous; + const tab = adaptWebContents(contents); + expect( + await tab.withFrameProduction(async () => { + expect(contents.backgroundThrottling).toBe(false); + return "pixels"; + }), + ).toBe("pixels"); + expect(contents.backgroundThrottling).toBe(previous); + await expect( + tab.withFrameProduction(async () => { + throw new Error("capture failed"); + }), + ).rejects.toThrow("capture failed"); + expect(contents.backgroundThrottling).toBe(previous); + }, + ); + + test("does not touch a guest destroyed during capture", async () => { + const contents = new FakeWebContents(8002); + const tab = adaptWebContents(contents); + await expect( + tab.withFrameProduction(async () => { + contents.destroyed = true; + throw new Error("capture closed"); + }), + ).rejects.toThrow("capture closed"); + }); +}); diff --git a/packages/desktop/src/features/browser-automation/ipc.ts b/packages/desktop/src/features/browser-automation/ipc.ts index ea1941c318..d21c51b84f 100644 --- a/packages/desktop/src/features/browser-automation/ipc.ts +++ b/packages/desktop/src/features/browser-automation/ipc.ts @@ -107,6 +107,8 @@ interface BrowserAutomationWebContents extends ConsoleMessageEmitter { reload(): void; capturePage(rect?: Rectangle, options?: { stayHidden?: boolean }): Promise; invalidate(): void; + getBackgroundThrottling(): boolean; + setBackgroundThrottling(allowed: boolean): void; sendInputEvent(event: IsolatedKeyboardInputEvent): void; } @@ -130,6 +132,15 @@ export function adaptWebContents(contents: BrowserAutomationWebContents): TabCon reload: () => contents.reload(), capturePage: (captureOptions) => contents.capturePage(undefined, captureOptions), invalidate: () => contents.invalidate(), + withFrameProduction: async (capture) => { + const previous = contents.getBackgroundThrottling(); + contents.setBackgroundThrottling(false); + try { + return await capture(); + } finally { + if (!contents.isDestroyed()) contents.setBackgroundThrottling(previous); + } + }, sendInputEvent: (event) => contents.sendInputEvent(event), getConsoleMessages: () => consoleMessagesByContentsId.get(contentsId) ?? [], captureDialogs: (task) => dialogMonitor.capture(task), diff --git a/packages/desktop/src/features/browser-automation/service.test.ts b/packages/desktop/src/features/browser-automation/service.test.ts index c9246324b2..cbce7ae3bc 100644 --- a/packages/desktop/src/features/browser-automation/service.test.ts +++ b/packages/desktop/src/features/browser-automation/service.test.ts @@ -33,6 +33,9 @@ class FakeImage implements TabImage { } class FakeTab implements TabContents { + public withFrameProduction(capture: () => Promise): Promise { + return capture(); + } public readonly loadedUrls: string[] = []; public readonly scripts: string[] = []; public readonly actions: string[] = []; @@ -64,6 +67,7 @@ class FakeTab implements TabContents { public networkEntries: unknown[] = []; public consoleMessages: BrowserAutomationConsoleLogEntry[] = []; public dialogsToCapture: BrowserAutomationDialogEvent[] = []; + public paintDelayMs = 0; public captureNeverPaints = false; public captureThrows = false; public captureErrorMessage = "capture failed"; @@ -115,6 +119,9 @@ class FakeTab implements TabContents { public async executeJavaScript(code: string): Promise { this.scripts.push(code); + if (code.includes("requestAnimationFrame") && this.paintDelayMs > 0) { + await new Promise((resolve) => setTimeout(resolve, this.paintDelayMs)); + } if (code.includes("document.body.innerText")) { return this.bodyText; } @@ -1588,6 +1595,31 @@ describe("executeAutomationCommand", () => { } }); + test("paint and pixel capture share the five-second screenshot budget", async () => { + vi.useFakeTimers(); + try { + const browser = new BrowserAutomationHarness(); + browser.tab.paintDelayMs = 4_000; + browser.tab.captureNeverPaints = true; + let result: unknown; + void browser + .execute({ + command: "screenshot", + args: { browserId: BROWSER_A }, + }) + .then((value) => { + result = value; + return value; + }); + await vi.advanceTimersByTimeAsync(4_999); + expect(result).toBeUndefined(); + await vi.advanceTimersByTimeAsync(1); + expect(result).toMatchObject({ ok: false, error: { code: "screenshot_no_frame" } }); + } finally { + vi.useRealTimers(); + } + }); + test("screenshot surfaces ordinary viewport capture errors", async () => { const browser = new BrowserAutomationHarness(); browser.tab.captureThrows = true; diff --git a/packages/desktop/src/features/browser-automation/service.ts b/packages/desktop/src/features/browser-automation/service.ts index 7ab9099db2..a5e89378c9 100644 --- a/packages/desktop/src/features/browser-automation/service.ts +++ b/packages/desktop/src/features/browser-automation/service.ts @@ -37,6 +37,7 @@ export interface TabContents { reload(): void; capturePage(options?: TabCapturePageOptions): Promise; invalidate(): void; + withFrameProduction(capture: () => Promise): Promise; sendInputEvent(event: IsolatedKeyboardInputEvent): void; getConsoleMessages?(): BrowserAutomationConsoleLogEntry[]; captureDialogs?( @@ -161,8 +162,8 @@ async function runSerializedPixelCapture(capture: () => Promise): Promise< async function capturePixelFrameWithRetry( contents: TabContents, capture: () => Promise, + deadline: number, ): Promise { - const deadline = Date.now() + PIXEL_CAPTURE_TIMEOUT_MS; while (Date.now() < deadline) { try { contents.invalidate(); @@ -189,11 +190,28 @@ function isKnownNoFrameCaptureError(error: unknown): boolean { ); } +async function waitForPaint(contents: TabContents, deadline: number): Promise { + // A hidden page may have unpainted DOM updates. The first animation callback + // precedes paint; the next frame ensures capture cannot reuse the old surface. + await withPixelCaptureTimeout( + contents.executeJavaScript( + "new Promise(resolve => requestAnimationFrame(() => requestAnimationFrame(() => resolve())))", + ), + deadline - Date.now(), + ); +} + async function runPaintedPixelCapture( contents: TabContents, capture: () => Promise, ): Promise { - return runSerializedPixelCapture(() => capturePixelFrameWithRetry(contents, capture)); + return runSerializedPixelCapture(() => + contents.withFrameProduction(async () => { + const deadline = Date.now() + PIXEL_CAPTURE_TIMEOUT_MS; + await waitForPaint(contents, deadline); + return capturePixelFrameWithRetry(contents, capture, deadline); + }), + ); } async function capturePaintedViewport(contents: TabContents): Promise { diff --git a/packages/desktop/src/features/browser-webviews/index.test.ts b/packages/desktop/src/features/browser-webviews/index.test.ts index bbeffbe8c3..cac803134a 100644 --- a/packages/desktop/src/features/browser-webviews/index.test.ts +++ b/packages/desktop/src/features/browser-webviews/index.test.ts @@ -19,7 +19,6 @@ class FakeRenderer { } class FakeBrowserGuest { - public readonly backgroundThrottlingCalls: boolean[] = []; private destroyedListener: (() => void) | null = null; private destroyed = false; @@ -33,10 +32,6 @@ class FakeBrowserGuest { return this.destroyed; } - public setBackgroundThrottling(allowed: boolean): void { - this.backgroundThrottlingCalls.push(allowed); - } - public once(event: "destroyed", listener: () => void): void { expect(event).toBe("destroyed"); this.destroyedListener = listener; @@ -187,7 +182,7 @@ describe("browser webview attachment", () => { unregisterPaseoBrowser("browser-shared-hosts"); }); - test("prepares throttling once and removes registration when the guest is destroyed", () => { + test("removes registration when the guest is destroyed", () => { const profileSession = {}; const renderer = new FakeRenderer(31); const guest = new FakeBrowserGuest(601, renderer, profileSession); @@ -201,12 +196,10 @@ describe("browser webview attachment", () => { findWebContents: () => guest, }); - expect(guest.backgroundThrottlingCalls).toEqual([false]); expect(getPaseoBrowserIdForWebContents(guest)).toBe("browser-cleanup"); guest.destroy(); expect(getPaseoBrowserIdForWebContents(guest)).toBeNull(); - expect(guest.backgroundThrottlingCalls).toEqual([false]); }); }); diff --git a/packages/desktop/src/features/browser-webviews/index.ts b/packages/desktop/src/features/browser-webviews/index.ts index b415f43397..319d4922cb 100644 --- a/packages/desktop/src/features/browser-webviews/index.ts +++ b/packages/desktop/src/features/browser-webviews/index.ts @@ -24,7 +24,6 @@ interface BrowserWebContentsIdentity { interface RegisteredBrowserWebContents extends BrowserWebContentsIdentity { readonly hostWebContents: BrowserWebContentsIdentity | null; readonly session: object; - setBackgroundThrottling(allowed: boolean): void; once(event: "destroyed", listener: () => void): void; } @@ -56,7 +55,8 @@ export function getPaseoBrowserWebviewRegistry(): PaseoBrowserWebviewRegistry { export function preparePaseoBrowserWebContents(contents: RegisteredBrowserWebContents): void { const webContentsId = contents.id; - contents.setBackgroundThrottling(false); + // Preserve Chromium throttling when the host window is hidden. Browser + // residency and screenshot capture do not require a lifetime override. contents.once("destroyed", () => { browserRegistry.unregisterWebContents(webContentsId); }); From 33a52d0ae790942a8a47f4f9d9ea25e61efecf2d Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Fri, 11 Sep 2026 08:07:01 +0200 Subject: [PATCH 16/46] Speed up cold diffs and wrapped text measurement (#4676) * perf(app): optimize wrapped measurements and checkpoint native layout experiment Compute full grapheme geometry only for final wrap fragments. Preserve shaped-text search behavior while bounding additive wrap probes. Keep the Android background layout experiment isolated from the normal diff renderer; native renderer integration and iOS bindings remain unfinished. * perf(server): reduce cold diff generation work Keep active diff reads owned across invalidation and wait for the initial watcher inventory before cold generation. Batch patch and blob reads while preserving per-file size isolation, bounded output, and daemon responsiveness. Avoid constructing fallback tokens when full-file highlighting already covers both sides. * test(server): exercise oversized highlighted diff rejection The single-line fixture now bypasses highlighting under the long-line guard, so it no longer reaches the structured payload limit. Use short dense lines to keep testing oversized rejection and continued socket usability. * fix(server): queue fresh diffs for forced reads A forced request following a repository mutation must not reuse an older active build. Invalidate that result and coalesce waiting callers onto one subsequent build. Give the cache a named object input and completed-value type. * test(server): share deferred diff loader setup * test(server): name completed diff cache setup * refactor(server): use aliases for new diff imports * fix(server): keep shipped diff imports resolvable by Node The TypeScript build preserves path aliases without a runtime resolver. Restore relative imports for shipped code and declarations, and correct the server import guideline that prompted the regression. --- .../modules/paseo-diff-prototype/.gitignore | 2 + .../modules/paseo-diff-prototype/README.md | 35 +++ .../android/CMakeLists.txt | 15 + .../paseo-diff-prototype/android/build.gradle | 27 ++ .../android/src/main/AndroidManifest.xml | 1 + .../diffprototype/PaseoDiffPrototypeModule.kt | 34 +++ .../cpp/generate-graphemes.mjs | 18 ++ .../modules/paseo-diff-prototype/cpp/jni.cpp | 136 +++++++++ .../paseo-diff-prototype/cpp/layout.cpp | 277 ++++++++++++++++++ .../modules/paseo-diff-prototype/cpp/layout.h | 37 +++ .../expo-module.config.json | 6 + .../modules/paseo-diff-prototype/package.json | 5 + .../diff-document/measurement-cost.test.ts | 29 +- .../app/src/git/diff-document/model.test.ts | 72 +++++ packages/app/src/git/diff-document/model.ts | 23 +- .../diff-document/text-measurement.test.ts | 23 ++ .../src/git/diff-document/text-measurement.ts | 22 ++ .../app/src/git/diff-document/text.native.ts | 46 +-- packages/app/src/git/diff-document/types.ts | 2 + packages/server/CLAUDE.md | 2 +- .../src/server/checkout-diff-cache.test.ts | 116 ++++++++ .../server/src/server/checkout-diff-cache.ts | 92 ++++++ .../checkout-diff-subscription.e2e.test.ts | 4 +- .../src/server/utils/diff-highlighter.ts | 44 +-- .../workspace-git-service.observation.test.ts | 29 ++ .../src/server/workspace-git-service.ts | 51 ++-- .../src/utils/checkout-git-batching.test.ts | 79 ++--- .../server/src/utils/checkout-git.test.ts | 3 +- packages/server/src/utils/checkout-git.ts | 223 ++++++++++---- .../src/utils/git-file-contents.test.ts | 53 ++++ .../server/src/utils/git-file-contents.ts | 48 +++ packages/server/src/utils/run-git-command.ts | 41 ++- 32 files changed, 1409 insertions(+), 186 deletions(-) create mode 100644 packages/app/modules/paseo-diff-prototype/.gitignore create mode 100644 packages/app/modules/paseo-diff-prototype/README.md create mode 100644 packages/app/modules/paseo-diff-prototype/android/CMakeLists.txt create mode 100644 packages/app/modules/paseo-diff-prototype/android/build.gradle create mode 100644 packages/app/modules/paseo-diff-prototype/android/src/main/AndroidManifest.xml create mode 100644 packages/app/modules/paseo-diff-prototype/android/src/main/java/sh/paseo/diffprototype/PaseoDiffPrototypeModule.kt create mode 100644 packages/app/modules/paseo-diff-prototype/cpp/generate-graphemes.mjs create mode 100644 packages/app/modules/paseo-diff-prototype/cpp/jni.cpp create mode 100644 packages/app/modules/paseo-diff-prototype/cpp/layout.cpp create mode 100644 packages/app/modules/paseo-diff-prototype/cpp/layout.h create mode 100644 packages/app/modules/paseo-diff-prototype/expo-module.config.json create mode 100644 packages/app/modules/paseo-diff-prototype/package.json create mode 100644 packages/server/src/server/checkout-diff-cache.test.ts create mode 100644 packages/server/src/server/checkout-diff-cache.ts create mode 100644 packages/server/src/utils/git-file-contents.test.ts create mode 100644 packages/server/src/utils/git-file-contents.ts diff --git a/packages/app/modules/paseo-diff-prototype/.gitignore b/packages/app/modules/paseo-diff-prototype/.gitignore new file mode 100644 index 0000000000..d80969eb27 --- /dev/null +++ b/packages/app/modules/paseo-diff-prototype/.gitignore @@ -0,0 +1,2 @@ +/android/.cxx/ +/android/build/ diff --git a/packages/app/modules/paseo-diff-prototype/README.md b/packages/app/modules/paseo-diff-prototype/README.md new file mode 100644 index 0000000000..f872033889 --- /dev/null +++ b/packages/app/modules/paseo-diff-prototype/README.md @@ -0,0 +1,35 @@ +# Native diff layout experiment + +Android-only Expo module for evaluating background wrapped-text layout. The normal +diff surface does not call this module; it still uses the JavaScript model and +renderer. This is a checkpoint of the layout experiment, not a replacement renderer. + +The C++ core owns cell text and wrapped grapheme geometry. Kotlin/JNI supplies the +Android font manager and dispatches preparation through Expo's background queue. +It links the Skia libraries shipped by the installed React Native Skia package. +An iOS binding, renderer integration, and shipping library ownership are unfinished. + +## Diagnostic interface + +Access the module through `expo.modules.PaseoDiffPrototype` in a development build: + +- `create()` returns a numeric handle. +- `prepare(handle, texts, widths, family, fontSize)` asynchronously builds geometry + and returns counts, copy/layout timings, and a partial geometry allocation estimate. + Text and width arrays must have matching lengths. Input is copied; it is not zero-copy. +- `read(handle, startCell, count)` synchronously returns packed numerical geometry + for at most 256 cells. This exists for parity checks, not whole-model transfer. +- `release(handle)` cancels pending work and releases ownership. Release handles + after success or failure; module destruction also cancels and releases all handles. + +Each packed cell begins with its index and fragment count. Each fragment contains +UTF-16 start/end offsets, width, and grapheme count, followed by each grapheme's +UTF-16 start/end offsets and width. The caller retains source text. + +The experiment accepts normalized cell inputs. It does not parse wire payloads, +construct file/header/review models, apply highlighting, or draw pixels. A renderer +integration must preserve native ownership and request bounded visible data rather +than reconstructing the entire JavaScript graph. + +Grapheme property ranges are generated at build time from the installed JavaScript +dependency so segmentation uses the same data as the reference implementation. diff --git a/packages/app/modules/paseo-diff-prototype/android/CMakeLists.txt b/packages/app/modules/paseo-diff-prototype/android/CMakeLists.txt new file mode 100644 index 0000000000..1230d875a2 --- /dev/null +++ b/packages/app/modules/paseo-diff-prototype/android/CMakeLists.txt @@ -0,0 +1,15 @@ +cmake_minimum_required(VERSION 3.22) +project(paseo_diff_prototype) +set(CMAKE_CXX_STANDARD 20) +execute_process(COMMAND node "${CMAKE_CURRENT_SOURCE_DIR}/../cpp/generate-graphemes.mjs" "${CMAKE_CURRENT_BINARY_DIR}/graphemes.h" COMMAND_ERROR_IS_FATAL ANY) + +add_library(paseo_diff_prototype SHARED ../cpp/layout.cpp ../cpp/jni.cpp) +target_include_directories(paseo_diff_prototype PRIVATE "${SKIA_PACKAGE}/cpp/skia" "${CMAKE_CURRENT_BINARY_DIR}") +target_compile_definitions(paseo_diff_prototype PRIVATE SK_BUILD_FOR_ANDROID) +target_compile_options(paseo_diff_prototype PRIVATE -O2 -Wall -Wextra) +target_link_options(paseo_diff_prototype PRIVATE "-Wl,-z,max-page-size=16384") +foreach(lib skparagraph skshaper skunicode_core skunicode_icu skia) + add_library(${lib} STATIC IMPORTED) + set_target_properties(${lib} PROPERTIES IMPORTED_LOCATION "${SKIA_PACKAGE}/libs/android/${ANDROID_ABI}/lib${lib}.a") +endforeach() +target_link_libraries(paseo_diff_prototype PRIVATE skparagraph skshaper skunicode_core skunicode_icu skia android log EGL GLESv2) diff --git a/packages/app/modules/paseo-diff-prototype/android/build.gradle b/packages/app/modules/paseo-diff-prototype/android/build.gradle new file mode 100644 index 0000000000..f218c03ef8 --- /dev/null +++ b/packages/app/modules/paseo-diff-prototype/android/build.gradle @@ -0,0 +1,27 @@ +apply plugin: 'com.android.library' + +group = 'sh.paseo.diffprototype' +version = '0.1.0' + +def expoModulesCorePlugin = new File(project(":expo-modules-core").projectDir.absolutePath, "ExpoModulesCorePlugin.gradle") +apply from: expoModulesCorePlugin +applyKotlinExpoModulesCorePlugin() +useCoreDependencies() +useDefaultAndroidSdkVersions() + +def skiaPackage = new File(["node", "--print", "require.resolve('@shopify/react-native-skia/package.json')"].execute(null, projectDir).text.trim()).parentFile + +android { + namespace "sh.paseo.diffprototype" + defaultConfig { + externalNativeBuild { + cmake { + arguments "-DSKIA_PACKAGE=${skiaPackage.absolutePath}" + cppFlags "-std=c++20 -O2 -fexceptions -frtti" + } + } + } + externalNativeBuild { + cmake { path "CMakeLists.txt" } + } +} diff --git a/packages/app/modules/paseo-diff-prototype/android/src/main/AndroidManifest.xml b/packages/app/modules/paseo-diff-prototype/android/src/main/AndroidManifest.xml new file mode 100644 index 0000000000..94cbbcfc39 --- /dev/null +++ b/packages/app/modules/paseo-diff-prototype/android/src/main/AndroidManifest.xml @@ -0,0 +1 @@ + diff --git a/packages/app/modules/paseo-diff-prototype/android/src/main/java/sh/paseo/diffprototype/PaseoDiffPrototypeModule.kt b/packages/app/modules/paseo-diff-prototype/android/src/main/java/sh/paseo/diffprototype/PaseoDiffPrototypeModule.kt new file mode 100644 index 0000000000..d542f0b17b --- /dev/null +++ b/packages/app/modules/paseo-diff-prototype/android/src/main/java/sh/paseo/diffprototype/PaseoDiffPrototypeModule.kt @@ -0,0 +1,34 @@ +package sh.paseo.diffprototype + +import expo.modules.kotlin.modules.Module +import expo.modules.kotlin.modules.ModuleDefinition + +// Experimental binding only. The layout engine has no JVM or platform dependency. +class PaseoDiffPrototypeModule : Module() { + companion object { + init { System.loadLibrary("paseo_diff_prototype") } + } + + private external fun createDocument(): Double + private external fun prepareDocument(id: Double, texts: Array, widths: DoubleArray, family: String, size: Double): DoubleArray + private external fun readDocument(id: Double, start: Int, count: Int): DoubleArray + private external fun releaseDocument(id: Double) + private external fun releaseAllDocuments() + + override fun definition() = ModuleDefinition { + Name("PaseoDiffPrototype") + + Function("create") { createDocument() } + // Expo dispatches this non-suspending AsyncFunction on its background queue. + AsyncFunction("prepare") { id: Double, texts: List, widths: List, family: String, size: Double -> + val stats = prepareDocument(id, texts.toTypedArray(), widths.toDoubleArray(), family, size) + mapOf( + "cells" to stats[0], "fragments" to stats[1], "graphemes" to stats[2], + "copyMs" to stats[3], "layoutMs" to stats[4], "geometryBytes" to stats[5] + ) + } + Function("read") { id: Double, start: Int, count: Int -> readDocument(id, start, count) } + Function("release") { id: Double -> releaseDocument(id) } + OnDestroy { releaseAllDocuments() } + } +} diff --git a/packages/app/modules/paseo-diff-prototype/cpp/generate-graphemes.mjs b/packages/app/modules/paseo-diff-prototype/cpp/generate-graphemes.mjs new file mode 100644 index 0000000000..70422df3ce --- /dev/null +++ b/packages/app/modules/paseo-diff-prototype/cpp/generate-graphemes.mjs @@ -0,0 +1,18 @@ +import { writeFileSync } from "node:fs"; +import { isExtendingChar } from "@marijn/find-cluster-break"; + +// Use the installed JS engine's Unicode property data, not a different ICU version. +const ranges = []; +let start = null; +for (let code = 0; code <= 0x110000; code++) { + const extending = code < 0x110000 && isExtendingChar(code); + if (extending && start === null) start = code; + if (!extending && start !== null) { + ranges.push(`{${start},${code}}`); + start = null; + } +} +writeFileSync( + process.argv[2], + `#pragma once\n#include \n#include \ninline constexpr std::array,${ranges.length}> extendingRanges = {{${ranges.join(",")}}};\n`, +); diff --git a/packages/app/modules/paseo-diff-prototype/cpp/jni.cpp b/packages/app/modules/paseo-diff-prototype/cpp/jni.cpp new file mode 100644 index 0000000000..a8f25fac54 --- /dev/null +++ b/packages/app/modules/paseo-diff-prototype/cpp/jni.cpp @@ -0,0 +1,136 @@ +#include "layout.h" +#include +#include +#include +#include +#include +#include +#include +#include "include/ports/SkFontMgr_android.h" +#include "modules/skunicode/include/SkUnicode.h" + +namespace { +using Clock = std::chrono::steady_clock; +using paseo::diff::Document; +struct Job { + std::atomic cancelled{false}; + std::atomic started{false}; + std::shared_ptr document; +}; +std::mutex mutex; +std::unordered_map> jobs; +uint64_t nextId = 0; + +double elapsed(Clock::time_point start) { + return std::chrono::duration(Clock::now() - start).count(); +} +std::shared_ptr findJob(double id) { + std::lock_guard lock(mutex); + const auto found = jobs.find(static_cast(id)); + if (found == jobs.end()) throw std::runtime_error("Unknown or released layout"); + return found->second; +} +std::u16string copyString(JNIEnv* env, jstring source) { + if (!source) throw std::runtime_error("Missing text"); + const jsize length = env->GetStringLength(source); + const jchar* chars = env->GetStringChars(source, nullptr); + if (!chars) throw std::runtime_error("Could not copy text"); + std::u16string result(reinterpret_cast(chars), length); + env->ReleaseStringChars(source, chars); + return result; +} +jdoubleArray numbers(JNIEnv* env, const std::vector& values) { + auto result = env->NewDoubleArray(values.size()); + if (result) env->SetDoubleArrayRegion(result, 0, values.size(), values.data()); + return result; +} +void fail(JNIEnv* env, const std::exception& error) { + env->ThrowNew(env->FindClass("java/lang/IllegalStateException"), error.what()); +} +} // namespace + +#define METHOD(name) Java_sh_paseo_diffprototype_PaseoDiffPrototypeModule_##name + +extern "C" JNIEXPORT jdouble JNICALL METHOD(createDocument)(JNIEnv*, jobject) { + std::lock_guard lock(mutex); + const auto id = ++nextId; + jobs.emplace(id, std::make_shared()); + return id; +} + +extern "C" JNIEXPORT jdoubleArray JNICALL METHOD(prepareDocument)( + JNIEnv* env, jobject, jdouble id, jobjectArray texts, jdoubleArray widths, + jstring family, jdouble size) { + try { + auto job = findJob(id); + if (job->started.exchange(true)) throw std::runtime_error("Layout already started"); + const auto copyStart = Clock::now(); + const jsize count = env->GetArrayLength(texts); + if (count > 100000 || env->GetArrayLength(widths) != count || !std::isfinite(size) || size <= 0 || size > 100) + throw std::runtime_error("Invalid layout input"); + std::vector available(count); + env->GetDoubleArrayRegion(widths, 0, count, available.data()); + auto document = std::make_shared(); + document->cells.reserve(count); + size_t units = 0; + for (jsize i = 0; i < count; i++) { + if (job->cancelled.load()) throw std::runtime_error("Layout cancelled"); + if (!std::isfinite(available[i]) || available[i] <= 0) throw std::runtime_error("Invalid width"); + auto text = static_cast(env->GetObjectArrayElement(texts, i)); + auto copied = copyString(env, text); + env->DeleteLocalRef(text); + units += copied.size(); + if (units > 16000000) throw std::runtime_error("Prototype input limit exceeded"); + document->cells.push_back({std::move(copied), available[i], {}}); + } + const auto fontFamily = SkUnicode::convertUtf16ToUtf8(copyString(env, family)); + const double copyMs = elapsed(copyStart); + const auto layoutStart = Clock::now(); + paseo::diff::prepare(*document, SkFontMgr_New_Android(nullptr), fontFamily.c_str(), size, job->cancelled); + const double layoutMs = elapsed(layoutStart); + { + std::lock_guard lock(mutex); + if (job->cancelled.load()) throw std::runtime_error("Layout cancelled"); + job->document = document; + } + return numbers(env, {static_cast(document->cells.size()), + static_cast(document->fragmentCount), static_cast(document->graphemeCount), + copyMs, layoutMs, static_cast(document->geometryBytes)}); + } catch (const std::exception& error) { + fail(env, error); + return nullptr; + } +} + +extern "C" JNIEXPORT jdoubleArray JNICALL METHOD(readDocument)( + JNIEnv* env, jobject, jdouble id, jint start, jint count) { + try { + const auto job = findJob(id); + std::shared_ptr document; + { + std::lock_guard lock(mutex); + document = job->document; + } + if (!document) throw std::runtime_error("Layout is not ready"); + if (start < 0 || count < 0 || count > 256 || static_cast(start) > document->cells.size()) + throw std::runtime_error("Invalid viewport range"); + return numbers(env, paseo::diff::read(*document, start, count)); + } catch (const std::exception& error) { + fail(env, error); + return nullptr; + } +} + +extern "C" JNIEXPORT void JNICALL METHOD(releaseDocument)(JNIEnv*, jobject, jdouble id) { + std::lock_guard lock(mutex); + const auto found = jobs.find(static_cast(id)); + if (found == jobs.end()) return; + found->second->cancelled.store(true); + jobs.erase(found); +} + +extern "C" JNIEXPORT void JNICALL METHOD(releaseAllDocuments)(JNIEnv*, jobject) { + std::lock_guard lock(mutex); + for (auto& [id, job] : jobs) job->cancelled.store(true); + jobs.clear(); +} diff --git a/packages/app/modules/paseo-diff-prototype/cpp/layout.cpp b/packages/app/modules/paseo-diff-prototype/cpp/layout.cpp new file mode 100644 index 0000000000..e5f7d0f8a8 --- /dev/null +++ b/packages/app/modules/paseo-diff-prototype/cpp/layout.cpp @@ -0,0 +1,277 @@ +#include "layout.h" +#include "graphemes.h" + +#include +#include +#include +#include +#include +#include + +#include "include/core/SkFont.h" +#include "modules/skparagraph/include/FontCollection.h" +#include "modules/skparagraph/include/Paragraph.h" +#include "modules/skparagraph/include/ParagraphBuilder.h" +#include "modules/skunicode/include/SkUnicode.h" + +namespace paseo::diff { +namespace { +namespace para = skia::textlayout; + +int codePoint(const std::u16string& text, size_t pos) { + int code = text[pos]; + if (code >= 0xd800 && code < 0xdc00 && pos + 1 < text.size()) { + int low = text[pos + 1]; + if (low >= 0xdc00 && low < 0xe000) return ((code - 0xd800) << 10) + low - 0xdc00 + 0x10000; + } + return code; +} +bool extending(int code) { + const auto found = std::upper_bound(extendingRanges.begin(), extendingRanges.end(), code, + [](int value, const auto& range) { return value < range.first; }); + return found != extendingRanges.begin() && code < std::prev(found)->second; +} +bool regional(int code) { return code >= 0x1f1e6 && code <= 0x1f1ff; } + +// Same forward-cluster rules as @marijn/find-cluster-break, including ZWJ/RI behavior. +std::vector segment(const std::u16string& text) { + std::vector result; + size_t pos = 0; + size_t column = 0; + while (pos < text.size()) { + const auto start = pos; + int previous = codePoint(text, pos); + pos += previous < 0x10000 ? 1 : 2; + while (pos < text.size()) { + const int next = codePoint(text, pos); + if (previous == 0x200d || next == 0x200d || extending(next)) { + pos += next < 0x10000 ? 1 : 2; + previous = next; + } else if (regional(next)) { + size_t count = 0; + for (int index = static_cast(pos) - 2; index >= 0 && regional(codePoint(text, index)); index -= 2) count++; + if (count % 2 == 0) break; + pos += 2; + } else { + break; + } + } + auto display = text.substr(start, pos - start); + const bool tab = display == u"\t"; + const size_t spaces = tab ? 4 - column % 4 : 0; + if (tab) display.assign(spaces, u' '); + result.push_back({static_cast(start), static_cast(pos), std::move(display)}); + column += tab ? spaces : 1; + } + return result; +} + +bool needsShaping(const std::u16string& text) { + for (const auto c : text) if ((c < 0x20 || c > 0x7e) && c != u'\t') return true; + for (const auto* pair : {u"==", u"!=", u"=>", u"<=", u">=", u"->", u"::", u"++", u"--"}) { + if (text.find(pair) != std::u16string::npos) return true; + } + return false; +} +bool cleanBoundary(const Grapheme& grapheme) { + for (const auto c : grapheme.text) { + if (c < 0x20 || c > 0x7e || std::u16string_view(u"=!<>+-:&|*/~^?.").find(c) != std::u16string_view::npos) return false; + } + return true; +} +size_t chunkEnd(const std::vector& graphemes, size_t start, size_t end) { + const size_t limit = start + 64; + if (limit >= end) return end; + const size_t floor = std::max(start + 1, limit - 16); + for (size_t seam = limit; seam > floor; seam--) { + if (cleanBoundary(graphemes[seam - 1]) && cleanBoundary(graphemes[seam])) return seam; + } + return limit; +} + +class Measurer { + public: + Measurer(sk_sp manager, const std::string& family, float size, + const std::atomic& cancelled) : cancelled_(cancelled) { + families_ = {SkString(family), SkString("Menlo"), SkString("SF Mono"), SkString("monospace"), SkString("System")}; + std::unordered_set available; + for (int i = 0; i < manager->countFamilies(); i++) { + SkString name; + manager->getFamilyName(i, &name); + available.insert(name.c_str()); + } + std::string primary = "System"; + for (const auto& name : families_) { + if (available.contains(name.c_str())) { primary = name.c_str(); break; } + } + font_ = SkFont(manager->matchFamilyStyle(primary.c_str(), SkFontStyle()), size); + fonts_ = sk_make_sp(); + fonts_->setDefaultFontManager(manager); + fonts_->enableFontFallback(); + para::StrutStyle strut; + strut.setStrutEnabled(true); + strut.setForceStrutHeight(true); + strut.setFontFamilies(families_); + strut.setFontSize(size); + strut.setHeight(std::round(size * 1.5) / size); + strut.setHeightOverride(true); + strut.setHalfLeading(true); + style_.setMaxLines(1); + style_.setStrutStyle(strut); + textStyle_.setFontFamilies(families_); + textStyle_.setFontSize(size); + textStyle_.setColor(SK_ColorBLACK); + } + + double measure(const std::vector& graphemes, size_t start, size_t end, + std::vector* advances = nullptr) { + double base = 0; + for (size_t chunkStart = start; chunkStart < end;) { + if (cancelled_.load()) throw std::runtime_error("Layout cancelled"); + const size_t chunkLimit = chunkEnd(graphemes, chunkStart, end); + std::u16string text; + for (size_t i = chunkStart; i < chunkLimit; i++) text += graphemes[i].text; + double width = 0; + if (!needsShaping(text) && hasEveryGlyph(text)) { + for (size_t i = chunkStart; i < chunkLimit; i++) { + width += additiveWidth(graphemes[i].text); + if (advances) advances->push_back(base + width); + } + } else { + auto paragraph = shape(text); + if (advances) { + size_t offset = 0; + for (size_t i = chunkStart; i < chunkLimit; i++) { + offset += graphemes[i].text.size(); + width = paragraphWidth(*paragraph, offset); + advances->push_back(base + width); + } + } else { + width = paragraphWidth(*paragraph, text.size()); + } + } + base += width; + chunkStart = chunkLimit; + } + return base; + } + + private: + bool hasEveryGlyph(const std::u16string& text) { + for (const auto c : text) { + auto [it, inserted] = glyphs_.try_emplace(c, false); + if (inserted) it->second = font_.unicharToGlyph(c) != 0; + if (!it->second) return false; + } + return true; + } + double additiveWidth(const std::u16string& text) { + auto [it, inserted] = widths_.try_emplace(text, 0); + if (inserted) { + std::vector glyphs(font_.countText(text.data(), text.size() * 2, SkTextEncoding::kUTF16)); + font_.textToGlyphs(text.data(), text.size() * 2, SkTextEncoding::kUTF16, SkSpan(glyphs)); + std::vector widths(glyphs.size()); + font_.getWidthsBounds(SkSpan(glyphs), SkSpan(widths), {}, nullptr); + // Match RNSkia 2.2.12 getTextWidth's integer accumulator exactly. + it->second = std::accumulate(widths.begin(), widths.end(), 0); + } + return it->second; + } + std::unique_ptr shape(const std::u16string& text) { + auto builder = para::ParagraphBuilder::make(style_, fonts_); + builder->pushStyle(textStyle_); + const auto utf8 = SkUnicode::convertUtf16ToUtf8(text); + builder->addText(utf8.c_str(), utf8.size()); + builder->pop(); + auto paragraph = builder->Build(); + paragraph->layout(100000); + return paragraph; + } + static double paragraphWidth(para::Paragraph& paragraph, size_t end) { + double width = 0; + for (const auto& box : paragraph.getRectsForRange(0, end, para::RectHeightStyle::kTight, para::RectWidthStyle::kTight)) { + // JS adds the two float-valued properties in double precision. + width = std::max(width, static_cast(box.rect.x()) + box.rect.width()); + } + return width; + } + SkFont font_; + std::vector families_; + sk_sp fonts_; + para::ParagraphStyle style_; + para::TextStyle textStyle_; + std::unordered_map glyphs_; + std::unordered_map widths_; + const std::atomic& cancelled_; +}; +} // namespace + +void prepare(Document& document, sk_sp fonts, const std::string& family, float size, + const std::atomic& cancelled) { + Measurer measure(std::move(fonts), family, size, cancelled); + for (auto& cell : document.cells) { + if (cancelled.load()) throw std::runtime_error("Layout cancelled"); + auto graphemes = segment(cell.text); + const bool bounded = !needsShaping(cell.text); + size_t start = 0; + do { + size_t fitting = graphemes.size(); + if (!graphemes.empty()) { + size_t low = start + 1; + size_t high = bounded ? std::min(graphemes.size(), start + 64) : graphemes.size(); + fitting = low; + if (bounded) { + while (measure.measure(graphemes, start, high) <= cell.availableWidth) { + fitting = high; + low = high + 1; + if (high == graphemes.size()) break; + high = std::min(graphemes.size(), start + (high - start) * 2); + } + } + while (low <= high) { + const size_t middle = (low + high) / 2; + const double width = measure.measure(graphemes, start, middle); + if (width <= cell.availableWidth || middle == start + 1) { + fitting = middle; + low = middle + 1; + } else { + high = middle - 1; + } + } + } + std::vector advances; + advances.reserve(fitting - start); + const double width = measure.measure(graphemes, start, fitting, &advances); + Fragment fragment{start < graphemes.size() ? graphemes[start].start : 0, + fitting > start ? graphemes[fitting - 1].end : 0, width, {}}; + fragment.graphemes.reserve(fitting - start); + for (size_t i = start; i < fitting; i++) { + auto grapheme = graphemes[i]; + grapheme.width = advances[i - start] - (i > start ? advances[i - start - 1] : 0); + fragment.graphemes.push_back(std::move(grapheme)); + } + document.fragmentCount++; + document.graphemeCount += fragment.graphemes.size(); + document.geometryBytes += sizeof(Fragment) + fragment.graphemes.capacity() * sizeof(Grapheme); + cell.fragments.push_back(std::move(fragment)); + start = fitting; + } while (start < graphemes.size()); + } +} + +std::vector read(const Document& document, size_t start, size_t count) { + std::vector output; + const size_t end = start + std::min(count, document.cells.size() - start); + for (size_t i = start; i < end; i++) { + const auto& cell = document.cells[i]; + output.insert(output.end(), {static_cast(i), static_cast(cell.fragments.size())}); + for (const auto& fragment : cell.fragments) { + output.insert(output.end(), {static_cast(fragment.start), static_cast(fragment.end), fragment.width, static_cast(fragment.graphemes.size())}); + for (const auto& grapheme : fragment.graphemes) { + output.insert(output.end(), {static_cast(grapheme.start), static_cast(grapheme.end), grapheme.width}); + } + } + } + return output; +} +} // namespace paseo::diff diff --git a/packages/app/modules/paseo-diff-prototype/cpp/layout.h b/packages/app/modules/paseo-diff-prototype/cpp/layout.h new file mode 100644 index 0000000000..7de6f00fe2 --- /dev/null +++ b/packages/app/modules/paseo-diff-prototype/cpp/layout.h @@ -0,0 +1,37 @@ +#pragma once + +#include +#include +#include +#include +#include "include/core/SkFontMgr.h" + +namespace paseo::diff { +struct Grapheme { + int start; + int end; + std::u16string text; + double width = 0; +}; +struct Fragment { + int start; + int end; + double width; + std::vector graphemes; +}; +struct Cell { + std::u16string text; + double availableWidth; + std::vector fragments; +}; +struct Document { + std::vector cells; + size_t fragmentCount = 0; + size_t graphemeCount = 0; + size_t geometryBytes = 0; +}; + +void prepare(Document& document, sk_sp fonts, const std::string& family, float fontSize, + const std::atomic& cancelled); +std::vector read(const Document& document, size_t start, size_t count); +} // namespace paseo::diff diff --git a/packages/app/modules/paseo-diff-prototype/expo-module.config.json b/packages/app/modules/paseo-diff-prototype/expo-module.config.json new file mode 100644 index 0000000000..2a4587d5f8 --- /dev/null +++ b/packages/app/modules/paseo-diff-prototype/expo-module.config.json @@ -0,0 +1,6 @@ +{ + "platforms": ["android"], + "android": { + "modules": ["sh.paseo.diffprototype.PaseoDiffPrototypeModule"] + } +} diff --git a/packages/app/modules/paseo-diff-prototype/package.json b/packages/app/modules/paseo-diff-prototype/package.json new file mode 100644 index 0000000000..9c775282ed --- /dev/null +++ b/packages/app/modules/paseo-diff-prototype/package.json @@ -0,0 +1,5 @@ +{ + "name": "paseo-diff-prototype", + "version": "0.1.0", + "private": true +} diff --git a/packages/app/src/git/diff-document/measurement-cost.test.ts b/packages/app/src/git/diff-document/measurement-cost.test.ts index ef2f6231df..b870fbe9cb 100644 --- a/packages/app/src/git/diff-document/measurement-cost.test.ts +++ b/packages/app/src/git/diff-document/measurement-cost.test.ts @@ -1,6 +1,6 @@ import type { ParsedDiffFile } from "@getpaseo/protocol/messages"; import { describe, expect, it } from "vitest"; -import { buildDiffDocumentModel } from "./model"; +import { buildDiffDocumentModel, measureFragments } from "./model"; import type { BuildDiffDocumentModelInput, TextMeasurer } from "./types"; /** @@ -92,6 +92,33 @@ function shapedCharactersFor(options: { lineCount: number; lineLength: number }) } describe("diff document measurement cost", () => { + it("keeps additive wrapped search work proportional to long source lines", () => { + function measuredGraphemes(length: number): number { + let count = 0; + measureFragments({ + text: "a".repeat(length), + availableWidth: 200, + wrapLines: true, + lineHeight: 18, + measureText: { + measure: (text) => text.length * 10, + measureWidth(graphemes) { + count += graphemes.length; + return graphemes.length * 10; + }, + measureAdvances(graphemes) { + count += graphemes.length; + return graphemes.map((_, index) => (index + 1) * 10); + }, + }, + }); + return count; + } + const short = measuredGraphemes(100); + const long = measuredGraphemes(1600); + expect(long / short).toBeLessThan(32); + }); + it("shapes a bounded number of characters per character of text", () => { // The shape of the CHANGELOG.md working diff that froze the renderer: 42 // lines, longest 597 characters. diff --git a/packages/app/src/git/diff-document/model.test.ts b/packages/app/src/git/diff-document/model.test.ts index 1aa78ab3c6..c2b6d86c9c 100644 --- a/packages/app/src/git/diff-document/model.test.ts +++ b/packages/app/src/git/diff-document/model.test.ts @@ -71,6 +71,78 @@ function input(overrides: Partial = {}): BuildDiffD } describe("diff document model", () => { + it("preserves shaped wrap breaks when joining makes a longer prefix narrower", () => { + const measure = (text: string) => { + if (text === "لا") return 30; + if (text === "لام") return 15; + return text.length * 10; + }; + const fragments = measureFragments({ + text: "لام", + availableWidth: 20, + wrapLines: true, + lineHeight: 18, + measureText: { measure, measureWidth: (graphemes) => measure(graphemes.join("")) }, + }); + expect(fragments.map((fragment) => fragment.text)).toEqual(["ل", "ام"]); + expect(fragments.map((fragment) => fragment.width)).toEqual([10, 20]); + }); + + it("does not search wrap boundaries when the remaining text already fits", () => { + let widthQueries = 0; + const fragments = measureFragments({ + text: "const answer = value + 1;", + availableWidth: 300, + wrapLines: true, + lineHeight: 18, + measureText: { + measure: measurer.measure, + measureWidth(graphemes) { + widthQueries++; + return graphemes.length * 10; + }, + }, + }); + expect(fragments).toHaveLength(1); + expect(fragments[0]?.width).toBe(250); + expect(widthQueries).toBe(1); + }); + + it("builds character geometry only for displayed wrapped fragments", () => { + let advancedGraphemes = 0; + const measureWidth = (graphemes: readonly string[]) => graphemes.length * 10; + const measureText = { + // Whole-string widths need not match the chunked advance contract. + measure: () => 999, + measureWidth, + measureAdvances(graphemes: readonly string[]) { + advancedGraphemes += graphemes.length; + return graphemes.map((_, index) => (index + 1) * 10); + }, + }; + const text = "const next = (value) => value + 1;".repeat(4); + const fragments = measureFragments({ + text, + availableWidth: 80, + wrapLines: true, + lineHeight: 18, + measureText, + }); + + expect(fragments.map((fragment) => fragment.text).join("")).toBe(text); + expect(fragments.every((fragment) => fragment.width <= 80)).toBe(true); + expect(fragments).toEqual( + measureFragments({ + text, + availableWidth: 80, + wrapLines: true, + lineHeight: 18, + measureText: { measure: measurer.measure }, + }), + ); + expect(advancedGraphemes).toBe(text.length); + }); + it("materializes only visible rows of a large file while preserving full horizontal extent", () => { const source = file("large.ts"); source.hunks[0]!.lines = Array.from({ length: 1000 }, (_, index) => ({ diff --git a/packages/app/src/git/diff-document/model.ts b/packages/app/src/git/diff-document/model.ts index a8b2baed90..fb2b9a04f0 100644 --- a/packages/app/src/git/diff-document/model.ts +++ b/packages/app/src/git/diff-document/model.ts @@ -8,7 +8,7 @@ import { } from "@/utils/diff-layout"; import { compactHighlightTokens } from "@/utils/diff-rendering"; import { getInlineReviewThreadState, getSplitInlineReviewThreadState } from "@/review/geometry"; -import { advancesFor } from "./text-measurement"; +import { advancesFor, requiresShaping } from "./text-measurement"; import type { BuildDiffDocumentModelInput, DiffCell, @@ -571,14 +571,31 @@ export function measureFragments(input: { return [createFragment(graphemes, 0, graphemes.length, 0, input.lineHeight, input.measureText)]; } const fragments: DiffFragment[] = []; + const displayGraphemes = graphemes.map((grapheme) => grapheme.text); + const measureWidth = + input.measureText.measureWidth?.bind(input.measureText) ?? + ((slice: readonly string[]) => advancesFor(input.measureText)(slice).at(-1) ?? 0); + // Joining/ligatures can make a longer prefix narrower. Preserve the existing + // search order for those runs; changing the probes can change their breaks. + const canBoundSearch = !requiresShaping(input.text); let graphemeIndex = 0; while (graphemeIndex < graphemes.length) { let low = graphemeIndex + 1; - let high = graphemes.length; + // A small first probe finishes short lines in one query. Grow it only when + // it fits, so narrow wrapping never repeatedly measures a huge line's tail. + let high = canBoundSearch ? Math.min(graphemes.length, graphemeIndex + 64) : graphemes.length; let fitting = low; + if (canBoundSearch) { + while (measureWidth(displayGraphemes.slice(graphemeIndex, high)) <= input.availableWidth) { + fitting = high; + low = high + 1; + if (high === graphemes.length) break; + high = Math.min(graphemes.length, graphemeIndex + (high - graphemeIndex) * 2); + } + } while (low <= high) { const middle = (low + high) >>> 1; - const width = measureGraphemeSlice(graphemes, graphemeIndex, middle, input.measureText).width; + const width = measureWidth(displayGraphemes.slice(graphemeIndex, middle)); if (width <= input.availableWidth || middle === graphemeIndex + 1) { fitting = middle; low = middle + 1; diff --git a/packages/app/src/git/diff-document/text-measurement.test.ts b/packages/app/src/git/diff-document/text-measurement.test.ts index 144676e550..414054e76c 100644 --- a/packages/app/src/git/diff-document/text-measurement.test.ts +++ b/packages/app/src/git/diff-document/text-measurement.test.ts @@ -1,6 +1,7 @@ import { describe, expect, it } from "vitest"; import { createCachedAsciiTextMetrics, + createChunkedWidthMeasurer, createFallbackAwareTextMeasurer, createMeasuredAdvances, requiresShaping, @@ -56,6 +57,7 @@ describe("cached ASCII text metrics", () => { expect(metrics.measureAdvances(["a", "b", "a"])).toEqual([10, 20, 30]); expect(metrics.measureAdvances(["b", "a"])).toEqual([10, 20]); expect(metrics.measure("abba")).toBe(40); + expect(metrics.measureWidth(["a", "b", "b", "a"])).toBe(40); expect(measured).toEqual(["a", "b"]); }); @@ -105,6 +107,27 @@ describe("chunked advance measurement", () => { return Array.from(text, (_, index) => shapingMeasure(text.slice(0, index + 1))); } + it.each([ + "", + "a => b ", + `${"a".repeat(63)}=>${"b".repeat(70)}`, + "=>".repeat(100), + "中文 é 👩‍💻 مرحبا".repeat(10), + ])("keeps width-only measurement equal to the last shaped advance: %s", (text) => { + const graphemes = Array.from(text); + const width = createChunkedWidthMeasurer((chunk) => shapingMeasure(chunk.join(""))); + expect(width(graphemes)).toBe(createMeasuredAdvances(shapingMeasure)(graphemes).at(-1) ?? 0); + }); + + it("keeps expanded tabs on the same additive width path", () => { + const metrics = createCachedAsciiTextMetrics({ + glyphIds: (text) => Array.from(text, () => 1), + measure: (text) => text.length * 3.7, + }); + const graphemes = ["a", " ", "b", " "]; + expect(metrics.measureWidth(graphemes)).toBe(metrics.measureAdvances(graphemes).at(-1)); + }); + it("measures short runs in one shaping context", () => { expect(advancesOf("a => b")).toEqual(expected("a => b")); }); diff --git a/packages/app/src/git/diff-document/text-measurement.ts b/packages/app/src/git/diff-document/text-measurement.ts index a141d170a7..905a8915d1 100644 --- a/packages/app/src/git/diff-document/text-measurement.ts +++ b/packages/app/src/git/diff-document/text-measurement.ts @@ -10,6 +10,7 @@ export interface CachedAsciiTextMetrics { measure(text: string): number; hasEveryGlyph(text: string): boolean; measureAdvances(graphemes: readonly string[]): number[]; + measureWidth(graphemes: readonly string[]): number; } const CODE_LIGATURE_CANDIDATE = /(?:===?|!==?|=>|<=|>=|->|::|\+\+|--)/; @@ -68,6 +69,22 @@ export function createChunkedAdvanceMeasurer( }; } +/** Preserve advance chunk boundaries when only the final width is needed. */ +export function createChunkedWidthMeasurer( + measure: (graphemes: readonly string[]) => number, +): (graphemes: readonly string[]) => number { + return (graphemes) => { + let width = 0; + let start = 0; + while (start < graphemes.length) { + const end = chunkEnd(graphemes, start); + width += measure(graphemes.slice(start, end)); + start = end; + } + return width; + }; +} + /** * Advance measurement for a backend that can only measure a whole string, which * is every backend except a native paragraph shaper. @@ -144,6 +161,11 @@ export function createCachedAsciiTextMetrics(primary: PrimaryTextFace): CachedAs return true; }, measureAdvances: additiveAdvances(measure), + measureWidth(graphemes) { + let width = 0; + for (const grapheme of graphemes) width += measure(grapheme); + return width; + }, }; } diff --git a/packages/app/src/git/diff-document/text.native.ts b/packages/app/src/git/diff-document/text.native.ts index 0c6f16bb5d..c482bed1a2 100644 --- a/packages/app/src/git/diff-document/text.native.ts +++ b/packages/app/src/git/diff-document/text.native.ts @@ -10,6 +10,7 @@ import { codeTextColor } from "./palette"; import { createCachedAsciiTextMetrics, createChunkedAdvanceMeasurer, + createChunkedWidthMeasurer, requiresShaping, type CachedAsciiTextMetrics, } from "./text-measurement"; @@ -202,43 +203,40 @@ export function createNativeTextMeasurer(input: { }; const asciiMetrics = createCachedAsciiTextMetrics(primary); const black = Skia.Color("black"); - const measureParagraph = (text: string) => { - const paragraph = createParagraph({ + const shapeParagraph = (text: string) => + createParagraph({ text, families, fontSize: input.fontSize, lineHeight: Math.round(input.fontSize * 1.5), color: black, }); - const width = paragraph.getLongestLine(); - paragraph.dispose(); - return width; - }; return { measure(text) { - return requiresRetainedParagraph(text, asciiMetrics) - ? measureParagraph(text) - : asciiMetrics.measure(text); + if (!requiresRetainedParagraph(text, asciiMetrics)) return asciiMetrics.measure(text); + const paragraph = shapeParagraph(text); + const width = paragraph.getLongestLine(); + paragraph.dispose(); + return width; }, + measureWidth: createChunkedWidthMeasurer((graphemes) => { + const text = graphemes.join(""); + if (!requiresRetainedParagraph(text, asciiMetrics)) + return asciiMetrics.measureWidth(graphemes); + const paragraph = shapeParagraph(text); + const width = paragraphAdvance(paragraph, text.length); + paragraph.dispose(); + return width; + }), measureAdvances: createChunkedAdvanceMeasurer({ requiresShaping: (text) => requiresRetainedParagraph(text, asciiMetrics), measureAdditive: (graphemes) => asciiMetrics.measureAdvances(graphemes), measureShaped(graphemes) { - const paragraph = createParagraph({ - text: graphemes.join(""), - families, - fontSize: input.fontSize, - lineHeight: Math.round(input.fontSize * 1.5), - color: black, - }); + const paragraph = shapeParagraph(graphemes.join("")); let end = 0; const advances = graphemes.map((grapheme) => { end += grapheme.length; - const rectangles = paragraph.getRectsForRange(0, end); - return rectangles.reduce( - (right, rectangle) => Math.max(right, rectangle.x + rectangle.width), - 0, - ); + return paragraphAdvance(paragraph, end); }); paragraph.dispose(); return advances; @@ -247,6 +245,12 @@ export function createNativeTextMeasurer(input: { }; } +function paragraphAdvance(paragraph: SkParagraph, end: number): number { + return paragraph + .getRectsForRange(0, end) + .reduce((right, rectangle) => Math.max(right, rectangle.x + rectangle.width), 0); +} + function requiresRetainedParagraph(text: string, asciiMetrics: CachedAsciiTextMetrics): boolean { return requiresShaping(text) || !asciiMetrics.hasEveryGlyph(text); } diff --git a/packages/app/src/git/diff-document/types.ts b/packages/app/src/git/diff-document/types.ts index c307628d0c..4cb7aaff80 100644 --- a/packages/app/src/git/diff-document/types.ts +++ b/packages/app/src/git/diff-document/types.ts @@ -167,6 +167,8 @@ export interface DiffDocumentModel { export interface TextMeasurer { measure(text: string, weight?: "regular" | "semibold"): number; measureAdvances?(graphemes: readonly string[]): number[]; + /** Same width as the last cumulative advance, without computing every position. */ + measureWidth?(graphemes: readonly string[]): number; } export interface BuildDiffDocumentModelInput { diff --git a/packages/server/CLAUDE.md b/packages/server/CLAUDE.md index 1324b3842c..34f99a3943 100644 --- a/packages/server/CLAUDE.md +++ b/packages/server/CLAUDE.md @@ -84,7 +84,7 @@ npm run cli -- daemon status # Check daemon status ### Imports -- Use path alias `@server/*` in server package (maps to `./src/`) +- Use relative `.js` paths for imports within shipped server code, including type imports. TypeScript preserves `@server/*` aliases in JavaScript and declarations, and production Node has no alias resolver. Vitest tests support the alias. - No barrel `index.ts` re-exports — they create unnecessary indirection ### Naming diff --git a/packages/server/src/server/checkout-diff-cache.test.ts b/packages/server/src/server/checkout-diff-cache.test.ts new file mode 100644 index 0000000000..e2ac4f31ba --- /dev/null +++ b/packages/server/src/server/checkout-diff-cache.test.ts @@ -0,0 +1,116 @@ +import { expect, test } from "vitest"; +import { CheckoutDiffCache } from "@server/server/checkout-diff-cache.js"; + +function createSequencedLoader(first: Promise, second: Promise) { + let calls = 0; + return { + load: () => (++calls === 1 ? first : second), + get calls() { + return calls; + }, + }; +} + +async function fillCompletedCache(cache: CheckoutDiffCache) { + for (let i = 0; i < 70; i++) { + await cache.read({ + cwd: String(i), + compare: { mode: "base" }, + load: async () => ({ diff: "" }), + }); + } +} + +test("forced reads after a mutation share a fresh build after the active build finishes", async () => { + const cache = new CheckoutDiffCache(() => 0); + const first = Promise.withResolvers<{ diff: string }>(); + const second = Promise.withResolvers<{ diff: string }>(); + const loader = createSequencedLoader(first.promise, second.promise); + const { load } = loader; + const pending = cache.read({ cwd: "repo", compare: { mode: "base" }, load }); + await Promise.resolve(); + const options = { force: true, reason: "repository-mutation" }; + const fresh = cache.read({ cwd: "repo", compare: { mode: "base" }, ...options, load }); + const another = cache.read({ cwd: "repo", compare: { mode: "base" }, ...options, load }); + expect(loader.calls).toBe(1); + first.resolve({ diff: "before-mutation" }); + expect(await pending).toEqual({ diff: "before-mutation" }); + await Promise.resolve(); + await Promise.resolve(); + expect(loader.calls).toBe(2); + second.resolve({ diff: "after-mutation" }); + expect(await fresh).toEqual({ diff: "after-mutation" }); + expect(await another).toEqual({ diff: "after-mutation" }); + expect(await cache.read({ cwd: "repo", compare: { mode: "base" }, load })).toEqual({ + diff: "after-mutation", + }); + expect(loader.calls).toBe(2); +}); + +test("reads after invalidation wait for fresh work without overlapping the original read", async () => { + const cache = new CheckoutDiffCache(() => 0); + const first = Promise.withResolvers<{ diff: string }>(); + const second = Promise.withResolvers<{ diff: string }>(); + const loader = createSequencedLoader(first.promise, second.promise); + const { load } = loader; + const read = () => cache.read({ cwd: "repo", compare: { mode: "base" }, load }); + const pending = read(); + await Promise.resolve(); + cache.invalidate("repo", "base"); + cache.invalidate("repo", "base"); + const fresh = read(); + const another = read(); + expect(loader.calls).toBe(1); + first.resolve({ diff: "old" }); + expect(await pending).toEqual({ diff: "old" }); + await Promise.resolve(); + await Promise.resolve(); + expect(loader.calls).toBe(2); + second.resolve({ diff: "new" }); + expect(await fresh).toEqual({ diff: "new" }); + expect(await another).toEqual({ diff: "new" }); + expect(await read()).toEqual({ diff: "new" }); + expect(loader.calls).toBe(2); +}); + +test("edits during a read do not delay its caller or cache its outdated snapshot", async () => { + const cache = new CheckoutDiffCache(() => 0); + const deferred = Promise.withResolvers<{ diff: string }>(); + let calls = 0; + const load = () => { + calls += 1; + return deferred.promise; + }; + const pending = cache.read({ cwd: "repo", compare: { mode: "uncommitted" }, load }); + await Promise.resolve(); + cache.invalidate("repo", "uncommitted"); + deferred.resolve({ diff: "snapshot" }); + expect(await pending).toEqual({ diff: "snapshot" }); + expect(calls).toBe(1); + expect( + await cache.read({ + cwd: "repo", + compare: { mode: "uncommitted" }, + load: async () => ({ diff: "fresh" }), + }), + ).toEqual({ diff: "fresh" }); +}); + +test("active reads survive completed-payload eviction and failed reads can retry", async () => { + const cache = new CheckoutDiffCache(() => 0); + const deferred = Promise.withResolvers<{ diff: string }>(); + const load = () => deferred.promise; + const first = cache.read({ cwd: "active", compare: { mode: "uncommitted" }, load }); + const rejected = expect(first).rejects.toThrow("read failed"); + await fillCompletedCache(cache); + expect(cache.read({ cwd: "active", compare: { mode: "uncommitted" }, load })).toBe(first); + deferred.reject(new Error("read failed")); + await rejected; + expect( + await cache.read({ + cwd: "active", + compare: { mode: "uncommitted" }, + load: async () => ({ diff: "retry" }), + }), + ).toEqual({ diff: "retry" }); +}); diff --git a/packages/server/src/server/checkout-diff-cache.ts b/packages/server/src/server/checkout-diff-cache.ts new file mode 100644 index 0000000000..38675bea37 --- /dev/null +++ b/packages/server/src/server/checkout-diff-cache.ts @@ -0,0 +1,92 @@ +import { LRUCache } from "lru-cache"; +import type { CheckoutDiffCompare, CheckoutDiffResult } from "../utils/checkout-git.js"; + +interface ActiveRead { + promise: Promise; + invalidated: boolean; +} + +interface CachedDiff { + value: CheckoutDiffResult; + loadedAt: number; + lastReadStarted: number; +} + +interface CheckoutDiffReadInput { + cwd: string; + compare: CheckoutDiffCompare; + force?: boolean; + reason?: string; + load: () => Promise; +} + +/** Completed payloads are bounded; active work survives invalidation and eviction. */ +export class CheckoutDiffCache { + private readonly values = new LRUCache({ + max: 64, + }); + private readonly active = new Map(); + + constructor(private readonly now: () => number) {} + + read(input: CheckoutDiffReadInput): Promise { + const { cwd, compare, force, reason, load } = input; + if (force && !reason) throw new Error("WorkspaceGitService forced read requires a reason"); + const key = JSON.stringify([ + cwd, + compare.mode, + compare.mode === "base" ? (compare.baseRef ?? null) : null, + compare.ignoreWhitespace === true, + compare.includeStructured === true, + ]); + const cached = this.values.get(key); + const now = this.now(); + if ( + !force && + cached && + (now - cached.loadedAt <= 15_000 || now - cached.lastReadStarted < 2_000) + ) + return Promise.resolve(cached.value); + const pending = this.active.get(key); + if (pending) { + if (force) { + pending.invalidated = true; + this.values.delete(key); + } + if (!pending.invalidated) return pending.promise; + // A caller arriving after a change needs a fresh read, once the old one + // finishes. The original caller can still receive its snapshot promptly. + // The older result cannot enter the cache. Drop force when retrying so + // callers waiting on that same result share the next fresh build. + const reload = () => this.read({ ...input, force: false }); + return pending.promise.then(reload, reload); + } + if (cached) cached.lastReadStarted = now; + + // Defer load until ownership is installed, including synchronous invalidations. + const read: ActiveRead = { + invalidated: false, + promise: Promise.resolve() + .then(load) + .then((value) => { + // Never repopulate the cache with a snapshot invalidated during its build. + if (!read.invalidated) + this.values.set(key, { value, loadedAt: this.now(), lastReadStarted: now }); + return value; + }) + .finally(() => this.active.delete(key)), + }; + this.active.set(key, read); + return read.promise; + } + + invalidate(cwd: string, mode: CheckoutDiffCompare["mode"]): void { + for (const key of new Set([...this.values.keys(), ...this.active.keys()])) { + const [cachedCwd, cachedMode] = JSON.parse(key) as string[]; + if (cachedCwd !== cwd || cachedMode !== mode) continue; + this.values.delete(key); + const pending = this.active.get(key); + if (pending) pending.invalidated = true; + } + } +} diff --git a/packages/server/src/server/daemon-e2e/checkout-diff-subscription.e2e.test.ts b/packages/server/src/server/daemon-e2e/checkout-diff-subscription.e2e.test.ts index 7ce8cbb3a3..fac7d01a3f 100644 --- a/packages/server/src/server/daemon-e2e/checkout-diff-subscription.e2e.test.ts +++ b/packages/server/src/server/daemon-e2e/checkout-diff-subscription.e2e.test.ts @@ -146,7 +146,9 @@ test("keeps the socket usable after rejecting an oversized structured diff", asy initGitRepo(cwd); commitFile(cwd, "large-a.js", "const value = 0;\n"); commitFile(cwd, "large-b.js", "const value = 0;\n"); - const denseExpression = `const value = ${"a+".repeat(450_000)}a;\n`; + // Short lines stay eligible for highlighting; a giant single line is skipped + // before token expansion and cannot exercise the structured-payload limit. + const denseExpression = `const value = ${"a+".repeat(100)}a;\n`.repeat(4_000); writeFileSync(path.join(cwd, "large-a.js"), denseExpression); writeFileSync(path.join(cwd, "large-b.js"), denseExpression); diff --git a/packages/server/src/server/utils/diff-highlighter.ts b/packages/server/src/server/utils/diff-highlighter.ts index 4f7f800dc9..daa1ebbb3d 100644 --- a/packages/server/src/server/utils/diff-highlighter.ts +++ b/packages/server/src/server/utils/diff-highlighter.ts @@ -332,30 +332,30 @@ export async function highlightDiffWithFileContent( return file; } - const reconstructedTokens = buildReconstructedTokenLookups(file); - let newTokensByLine = reconstructedTokens.newTokensByLine; - let oldTokensByLine = reconstructedTokens.oldTokensByLine; - - if (typeof options.oldFileContent === "string") { - oldTokensByLine = - buildFullFileTokenLookup(options.oldFileContent, file.path) ?? oldTokensByLine; - } - + let newTokensByLine: Map | null = null; + const oldTokensByLine = + typeof options.oldFileContent === "string" + ? buildFullFileTokenLookup(options.oldFileContent, file.path) + : null; if (typeof options.newFileContent === "string") { - newTokensByLine = - buildFullFileTokenLookup(options.newFileContent, file.path) ?? newTokensByLine; + newTokensByLine = buildFullFileTokenLookup(options.newFileContent, file.path); + } else { + try { + const fileContent = await readFile(resolve(cwd, file.path), "utf-8"); + newTokensByLine = buildFullFileTokenLookup(fileContent, file.path); + } catch { + // Deleted or unavailable files use reconstructed tokens below. + } + } + + if (newTokensByLine && oldTokensByLine) return applyTokensToHunks(file, newTokensByLine, oldTokensByLine); - } - - const filePath = resolve(cwd, file.path); - try { - const fileContent = await readFile(filePath, "utf-8"); - newTokensByLine = buildFullFileTokenLookup(fileContent, file.path) ?? newTokensByLine; - } catch { - // If file read fails (deleted file, etc.), fall back to reconstructed new-side tokens. - } - - return applyTokensToHunks(file, newTokensByLine, oldTokensByLine); + const reconstructedTokens = buildReconstructedTokenLookups(file); + return applyTokensToHunks( + file, + newTokensByLine ?? reconstructedTokens.newTokensByLine, + oldTokensByLine ?? reconstructedTokens.oldTokensByLine, + ); } function applyTokensToHunks( diff --git a/packages/server/src/server/workspace-git-service.observation.test.ts b/packages/server/src/server/workspace-git-service.observation.test.ts index c55faa2bb5..36c50769c8 100644 --- a/packages/server/src/server/workspace-git-service.observation.test.ts +++ b/packages/server/src/server/workspace-git-service.observation.test.ts @@ -216,6 +216,35 @@ describe("WorkspaceGitService checkout observation", () => { vi.useRealTimers(); }); + test("waits for the initial watcher inventory before building a cold diff", async () => { + const watcher = createWatcherHarness(); + const inventoryFinished = createDeferred(); + let builds = 0; + const service = createService(watcher, { + subscribe: async (...args: Parameters) => { + await inventoryFinished.promise; + return watcher.subscribe(...args); + }, + getCheckoutDiff: async () => { + builds += 1; + return { diff: "", structured: [] }; + }, + }); + const subscription = service.registerWorkspace({ cwd: REPO_CWD }, () => {}); + try { + const read = service.getCheckoutDiff(REPO_CWD, { mode: "uncommitted" }); + await flushPromises(); + expect(builds).toBe(0); + inventoryFinished.resolve(); + await read; + expect(builds).toBe(1); + } finally { + inventoryFinished.resolve(); + subscription.unsubscribe(); + await service.dispose(); + } + }); + test("dispose waits for file observation to finish closing", async () => { const watcher = createWatcherHarness(); const closeFinished = createDeferred(); diff --git a/packages/server/src/server/workspace-git-service.ts b/packages/server/src/server/workspace-git-service.ts index fc1d211f33..524360678b 100644 --- a/packages/server/src/server/workspace-git-service.ts +++ b/packages/server/src/server/workspace-git-service.ts @@ -2,6 +2,7 @@ import { createHash } from "node:crypto"; import { readFile } from "node:fs/promises"; import { basename, join, resolve } from "node:path"; import { LRUCache } from "lru-cache"; +import { CheckoutDiffCache } from "./checkout-diff-cache.js"; import pLimit from "p-limit"; import type pino from "pino"; import type { ProjectCheckoutLitePayload } from "@getpaseo/protocol/messages"; @@ -94,8 +95,6 @@ const WATCH_RECOVERY_MAX_ATTEMPTS = 3; const WORKSPACE_GIT_AUXILIARY_READ_TTL_MS = 15_000; // Non-forced refresh triggers share this minimum gap to absorb watcher/self-heal bursts; force bypasses it. const WORKSPACE_GIT_INTERNAL_MIN_GAP_MS = 2_000; -// Heavy values (multi-MB highlighted diffs); cap aggressively. Ephemeral worktree cwds would otherwise pile up forever. -const WORKSPACE_GIT_CHECKOUT_DIFF_CACHE_MAX = 64; // Small values (booleans, short strings, small arrays); generous cap. const WORKSPACE_GIT_AUXILIARY_CACHE_MAX = 256; @@ -566,10 +565,7 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService { string, WorkspaceGitAuxiliaryReadCacheEntry >({ max: WORKSPACE_GIT_AUXILIARY_CACHE_MAX }); - private readonly checkoutDiffCache = new LRUCache< - string, - WorkspaceGitAuxiliaryReadCacheEntry - >({ max: WORKSPACE_GIT_CHECKOUT_DIFF_CACHE_MAX }); + private readonly checkoutDiffCache = new CheckoutDiffCache(() => this.deps.now().getTime()); private watcherErrorCallbackCount = 0; constructor(options: WorkspaceGitServiceOptions) { this.logger = options.logger.child({ module: "workspace-git-service" }); @@ -726,7 +722,7 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService { return this.workspaceTargets.get(cwd)?.latestSnapshot ?? null; } - getCheckoutDiff( + async getCheckoutDiff( cwd: string, options: CheckoutDiffCompare, readOptions?: WorkspaceGitReadOptions, @@ -734,13 +730,20 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService { this.assertNotDisposed(); const normalizedCwd = resolve(cwd); const normalizedOptions = this.normalizeCheckoutDiffOptions(options); - const key = this.buildCheckoutDiffCacheKey(normalizedCwd, normalizedOptions); - return this.readAuxiliaryCache(this.checkoutDiffCache, key, readOptions, () => - this.deps.getCheckoutDiff(normalizedCwd, normalizedOptions, { - paseoHome: this.paseoHome, - worktreesRoot: this.worktreesRoot, - }), - ); + // Initial inventory events cover the watcher setup gap. Process them before + // starting a cold diff, rather than invalidating that build halfway through. + await this.workspaceTargets.get(normalizedCwd)?.observationSetupPromise; + this.assertNotDisposed(); + return this.checkoutDiffCache.read({ + cwd: normalizedCwd, + compare: normalizedOptions, + ...readOptions, + load: () => + this.deps.getCheckoutDiff(normalizedCwd, normalizedOptions, { + paseoHome: this.paseoHome, + worktreesRoot: this.worktreesRoot, + }), + }); } private normalizeCheckoutDiffOptions(options: CheckoutDiffCompare): CheckoutDiffCompare { @@ -754,26 +757,8 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService { }; } - private buildCheckoutDiffCacheKey(cwd: string, options: CheckoutDiffCompare): string { - // Diff content varies by compare signature. Keep the cache per exact diff read shape so - // hot diff panes coalesce while base refs and rendering options never share stale patches. - return JSON.stringify([ - "checkout-diff", - cwd, - options.mode, - options.mode === "base" ? (options.baseRef ?? null) : null, - options.ignoreWhitespace === true, - options.includeStructured === true, - ]); - } - private invalidateCheckoutDiffCache(cwd: string, mode: CheckoutDiffCompare["mode"]): void { - for (const key of this.checkoutDiffCache.keys()) { - const [kind, cachedCwd, cachedMode] = JSON.parse(key) as unknown[]; - if (kind === "checkout-diff" && cachedCwd === cwd && cachedMode === mode) { - this.checkoutDiffCache.delete(key); - } - } + this.checkoutDiffCache.invalidate(cwd, mode); } validateBranchRef( diff --git a/packages/server/src/utils/checkout-git-batching.test.ts b/packages/server/src/utils/checkout-git-batching.test.ts index 85bea20c49..18b84bd0c3 100644 --- a/packages/server/src/utils/checkout-git-batching.test.ts +++ b/packages/server/src/utils/checkout-git-batching.test.ts @@ -2,39 +2,8 @@ import { execFileSync } from "node:child_process"; import { mkdirSync, mkdtempSync, rmSync, writeFileSync, realpathSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; -import { beforeEach, afterEach, describe, expect, it, vi } from "vitest"; - -const spawnCounters = vi.hoisted(() => ({ - trackedTextDiffCalls: 0, -})); - -vi.mock("child_process", async () => { - const actual = await vi.importActual("child_process"); - return { - ...actual, - spawn: (...args: Parameters) => { - const [command, commandArgs] = args; - if (command === "git" && Array.isArray(commandArgs)) { - const normalizedArgs = commandArgs.map((arg) => String(arg)); - // `runGitCommand` always prepends its two config overrides; skip them - // to find the actual git subcommand. - const subcommandIndex = - normalizedArgs[0] === "-c" && normalizedArgs[1] === "core.quotepath=false" ? 4 : 0; - const isTrackedTextDiff = - normalizedArgs[subcommandIndex] === "diff" && - normalizedArgs.includes("HEAD") && - !normalizedArgs.includes("--numstat") && - !normalizedArgs.includes("--no-index") && - !normalizedArgs.includes("--shortstat") && - !normalizedArgs.includes("--name-status"); - if (isTrackedTextDiff) { - spawnCounters.trackedTextDiffCalls += 1; - } - } - return actual.spawn(...args); - }, - }; -}); +import { beforeEach, afterEach, describe, expect, it } from "vitest"; +import { startGitCommandMetrics, stopGitCommandMetrics } from "@server/utils/run-git-command.js"; import { getCheckoutDiff } from "./checkout-git.js"; @@ -70,14 +39,14 @@ describe("checkout git diff batching", () => { const setup = initRepoWithTrackedChanges(20); tempDir = setup.tempDir; repoDir = setup.repoDir; - spawnCounters.trackedTextDiffCalls = 0; }); afterEach(() => { rmSync(tempDir, { recursive: true, force: true }); }); - it("uses per-file tracked git diff commands for tracked file diffs", async () => { + it("collects ordinary tracked patches with one bounded Git command", async () => { + startGitCommandMetrics(); const result = await getCheckoutDiff(repoDir, { mode: "uncommitted", includeStructured: false, @@ -85,6 +54,44 @@ describe("checkout git diff batching", () => { expect(result.diff).toContain("file-0.txt"); expect(result.diff).toContain("file-19.txt"); - expect(spawnCounters.trackedTextDiffCalls).toBe(20); + const metrics = stopGitCommandMetrics(); + expect( + metrics.commands.filter(({ args }) => args[0] === "diff" && args.includes("--")), + ).toHaveLength(1); + }); + + it("batches committed file contents and preserves the highlighted diff", async () => { + execFileSync("git", ["checkout", "-b", "feature"], { cwd: repoDir }); + execFileSync("git", ["add", "."], { cwd: repoDir }); + execFileSync("git", ["-c", "commit.gpgsign=false", "commit", "-m", "change"], { cwd: repoDir }); + startGitCommandMetrics(); + const result = await getCheckoutDiff(repoDir, { + mode: "base", + baseRef: "main", + includeStructured: true, + }); + const metrics = stopGitCommandMetrics(); + expect(result.structured).toHaveLength(20); + expect(result.structured?.every((file) => file.additions === 1 && file.deletions === 1)).toBe( + true, + ); + expect(metrics.commands.filter(({ args }) => args[0] === "show")).toHaveLength(0); + expect(metrics.commands.filter(({ args }) => args[0] === "cat-file")).toHaveLength(1); + }); + + it("keeps small neighbors when a patch exceeds the entire batch budget", async () => { + writeFileSync(join(repoDir, "file-0.txt"), "x".repeat(9 * 1024 * 1024) + "\n"); + const result = await getCheckoutDiff(repoDir, { + mode: "uncommitted", + includeStructured: true, + }); + expect(result.structured).toHaveLength(20); + expect(result.structured?.find((file) => file.path === "file-0.txt")).toMatchObject({ + status: "too_large", + hunks: [], + }); + expect(result.structured?.filter((file) => file.status === "ok")).toHaveLength(19); + expect(result.diff).toContain("+after-19"); + expect(result.diff).not.toContain("x".repeat(1_000)); }); }); diff --git a/packages/server/src/utils/checkout-git.test.ts b/packages/server/src/utils/checkout-git.test.ts index 45c0aff7c1..f83ee46ae5 100644 --- a/packages/server/src/utils/checkout-git.test.ts +++ b/packages/server/src/utils/checkout-git.test.ts @@ -1507,8 +1507,7 @@ const x = 1; expect(diff.diff).toContain(`-export const value = "old";`); expect(diff.diff).toContain(`+export const value = "new";`); expect(commands).toContain("diff --numstat HEAD"); - expect(commands).toContain("diff HEAD -- generated.js"); - expect(commands).toContain("diff HEAD -- small.ts"); + expect(commands).toContain("diff HEAD -- :(literal)generated.js :(literal)small.ts"); expect(metrics.maxConcurrent).toBeLessThanOrEqual(8); }); diff --git a/packages/server/src/utils/checkout-git.ts b/packages/server/src/utils/checkout-git.ts index d20a41616e..8e2a89f0bd 100644 --- a/packages/server/src/utils/checkout-git.ts +++ b/packages/server/src/utils/checkout-git.ts @@ -1,6 +1,7 @@ import { resolve, dirname, basename } from "path"; import { existsSync, realpathSync } from "fs"; import { open as openFile, readFile, stat as statFile } from "fs/promises"; +import { setImmediate } from "node:timers/promises"; import { TTLCache } from "@isaacs/ttlcache"; import type { CheckoutCommit, CheckoutCommitFile } from "@getpaseo/protocol/messages"; import { parseGitHubRemoteIdentity, parseGitRemoteLocation } from "@getpaseo/protocol/git-remote"; @@ -29,6 +30,7 @@ import { } from "../services/forge-cli-command.js"; import { parseGitRevParsePath, resolveGitRevParsePath } from "./git-rev-parse-path.js"; import { runGitCommand, type RunGitCommand } from "./run-git-command.js"; +import { readGitFileContents } from "./git-file-contents.js"; import { isPaseoOwnedWorktreeCwd, resolvePaseoWorktreesBaseRoot } from "./worktree.js"; import { branchNameFromRef, @@ -639,7 +641,7 @@ function buildGitDiffArgs(args: { ignoreWhitespace?: boolean; extra: string[] }) } const TRACKED_DIFF_NUMSTAT_MAX_BYTES = 2 * 1024 * 1024; // 2MB -const TRACKED_DIFF_BATCH_SIZE = 8; +const TRACKED_DIFF_BATCH_SIZE = 64; const EMPTY_TREE_OBJECT_ID = "4b825dc642cb6eb9a060e54bf8d69288fbee4904"; function isUnbornHeadDiffError(error: unknown): boolean { @@ -716,7 +718,7 @@ async function getTrackedDiffTextForPath(input: { const result = await runGitCommand( buildGitDiffArgs({ ignoreWhitespace: input.ignoreWhitespace, - extra: [...getCheckoutDiffRefArgs(input.refsForDiff), "--", input.path], + extra: [...getCheckoutDiffRefArgs(input.refsForDiff), "--", `:(literal)${input.path}`], }), { cwd: input.cwd, @@ -732,6 +734,56 @@ async function getTrackedDiffTextForPath(input: { }; } +async function getTrackedDiffTexts(input: { + cwd: string; + refsForDiff: CheckoutDiffRefs; + paths: string[]; + ignoreWhitespace: boolean; +}): Promise> { + if (input.paths.length === 0) return []; + if (input.paths.length === 1) + return [await getTrackedDiffTextForPath({ ...input, path: input.paths[0] })]; + const result = await runGitCommand( + buildGitDiffArgs({ + ignoreWhitespace: input.ignoreWhitespace, + extra: [ + ...getCheckoutDiffRefArgs(input.refsForDiff), + "--", + ...input.paths.map((path) => `:(literal)${path}`), + ], + }), + { cwd: input.cwd, envOverlay: READ_ONLY_GIT_ENV, maxOutputBytes: 8 * PER_FILE_DIFF_MAX_BYTES }, + ); + const files = new Map(); + if (!result.truncated) { + for (const text of result.stdout.split(/(?=^diff --git )/m).filter(Boolean)) { + // Only identify the file here. Oversized bodies must not reach the parser. + const hunkStart = text.indexOf("\n@@ "); + const file = parseDiff(hunkStart < 0 ? text : text.slice(0, hunkStart))[0]; + if (!file || !input.paths.includes(file.path)) break; + files.set(file.path, { + path: file.path, + text, + truncated: Buffer.byteLength(text) > PER_FILE_DIFF_MAX_BYTES, + }); + } + // Empty patches are valid when whitespace is ignored. Every emitted section + // must be accounted for before accepting a combined command. + if ( + [...files.values()].reduce((length, file) => length + file.text.length, 0) === + result.stdout.length + ) { + return input.paths.map((path) => files.get(path) ?? { path, text: "", truncated: false }); + } + } + // Bound oversized output without letting one large file hide its neighbors. + const middle = Math.ceil(input.paths.length / 2); + return [ + ...(await getTrackedDiffTexts({ ...input, paths: input.paths.slice(0, middle) })), + ...(await getTrackedDiffTexts({ ...input, paths: input.paths.slice(middle) })), + ]; +} + export class NotGitRepoError extends Error { readonly cwd: string; readonly code = "NOT_GIT_REPO"; @@ -2978,13 +3030,23 @@ async function buildHighlightedTrackedDiffFile(input: { change: CheckoutFileChange; parsedFile: ParsedDiffFile; refsForDiff: CheckoutDiffRefs; + contents?: Map; }): Promise { const { cwd, change, parsedFile, refsForDiff } = input; const refPath = change.oldPath ?? change.path; - const [oldFileContent, newFileContent] = await Promise.all([ - change.isNew ? null : readGitFileContentAtRef(cwd, refsForDiff.baseRef, refPath), - refsForDiff.targetRef ? readGitFileContentAtRef(cwd, refsForDiff.targetRef, change.path) : null, - ]); + const [oldFileContent, newFileContent] = input.contents + ? [ + change.isNew ? null : input.contents.get(`${refsForDiff.baseRef}:${refPath}`), + refsForDiff.targetRef + ? input.contents.get(`${refsForDiff.targetRef}:${change.path}`) + : null, + ] + : await Promise.all([ + change.isNew ? null : readGitFileContentAtRef(cwd, refsForDiff.baseRef, refPath), + refsForDiff.targetRef + ? readGitFileContentAtRef(cwd, refsForDiff.targetRef, change.path) + : null, + ]); const highlightedFile = await highlightDiffWithFileContent(parsedFile, cwd, { oldFileContent, newFileContent, @@ -3012,6 +3074,19 @@ function isWhitespaceOnlyTrackedChange(input: { ); } +function readTrackedDiffContents( + cwd: string, + changes: CheckoutFileChange[], + refs: CheckoutDiffRefs, +): Promise | null> { + const specs: string[] = []; + for (const change of changes) { + if (!change.isNew) specs.push(`${refs.baseRef}:${change.oldPath ?? change.path}`); + if (refs.targetRef) specs.push(`${refs.targetRef}:${change.path}`); + } + return readGitFileContents(cwd, specs); +} + async function appendStructuredTrackedDiffs( input: AppendStructuredTrackedDiffsInput, ): Promise { @@ -3030,54 +3105,68 @@ async function appendStructuredTrackedDiffs( const parsedTrackedFiles = trackedDiffText.length > 0 ? parseDiff(trackedDiffText) : []; const parsedTrackedByPath = new Map(parsedTrackedFiles.map((file) => [file.path, file])); - for (const change of trackedChanges) { - const placeholder = trackedPlaceholderByPath.get(change.path); - if (placeholder) { - const file = buildPlaceholderParsedDiffFile(change, { - status: placeholder.status, - stat: placeholder.stat, - }); + for (let start = 0; start < trackedChanges.length; start += TRACKED_DIFF_BATCH_SIZE) { + const changes = trackedChanges.slice(start, start + TRACKED_DIFF_BATCH_SIZE); + const contents = await readTrackedDiffContents( + cwd, + changes.filter( + (change) => + !trackedPlaceholderByPath.has(change.path) && parsedTrackedByPath.has(change.path), + ), + refsForDiff, + ); + for (const change of changes) { + const placeholder = trackedPlaceholderByPath.get(change.path); + if (placeholder) { + const file = buildPlaceholderParsedDiffFile(change, { + status: placeholder.status, + stat: placeholder.stat, + }); + if (!appendStructuredFile(structured, file)) { + return false; + } + appendTrackedPlaceholderComment(change, placeholder.status); + continue; + } + + const stat = trackedNumstatByPath.get(change.path) ?? null; + const parsedFile = parsedTrackedByPath.get(change.path); + if (parsedFile) { + const file = await buildHighlightedTrackedDiffFile({ + cwd, + change, + parsedFile, + refsForDiff, + contents: contents ?? undefined, + }); + if (!appendStructuredFile(structured, file)) { + return false; + } + // Batched blobs remove the per-file I/O pause; keep serving other daemon work. + await setImmediate(); + continue; + } + + // `git diff -w --name-status` can still report a modified path even when the + // whitespace-filtered patch and numstat are both empty. Skip emitting a + // structured placeholder in that case so whitespace-only edits truly disappear. + if (isWhitespaceOnlyTrackedChange({ change, stat, ignoreWhitespace })) { + continue; + } + + const file = { + path: change.path, + ...(change.oldPath ? { oldPath: change.oldPath } : {}), + isNew: change.isNew, + isDeleted: change.isDeleted, + additions: stat?.additions ?? 0, + deletions: stat?.deletions ?? 0, + hunks: [], + status: "ok", + } satisfies ParsedDiffFile; if (!appendStructuredFile(structured, file)) { return false; } - appendTrackedPlaceholderComment(change, placeholder.status); - continue; - } - - const stat = trackedNumstatByPath.get(change.path) ?? null; - const parsedFile = parsedTrackedByPath.get(change.path); - if (parsedFile) { - const file = await buildHighlightedTrackedDiffFile({ - cwd, - change, - parsedFile, - refsForDiff, - }); - if (!appendStructuredFile(structured, file)) { - return false; - } - continue; - } - - // `git diff -w --name-status` can still report a modified path even when the - // whitespace-filtered patch and numstat are both empty. Skip emitting a - // structured placeholder in that case so whitespace-only edits truly disappear. - if (isWhitespaceOnlyTrackedChange({ change, stat, ignoreWhitespace })) { - continue; - } - - const file = { - path: change.path, - ...(change.oldPath ? { oldPath: change.oldPath } : {}), - isNew: change.isNew, - isDeleted: change.isDeleted, - additions: stat?.additions ?? 0, - deletions: stat?.deletions ?? 0, - hunks: [], - status: "ok", - } satisfies ParsedDiffFile; - if (!appendStructuredFile(structured, file)) { - return false; } } @@ -3196,18 +3285,32 @@ async function processTrackedChanges( let trackedDiffText = ""; let trackedDiffBytes = 0; + const renamedPaths = new Set( + trackedChanges.filter((change) => change.oldPath).map((change) => change.path), + ); for (let start = 0; start < trackedDiffPaths.length; start += TRACKED_DIFF_BATCH_SIZE) { const paths = trackedDiffPaths.slice(start, start + TRACKED_DIFF_BATCH_SIZE); - const trackedDiffs = await Promise.all( - paths.map((path) => - getTrackedDiffTextForPath({ - cwd, - refsForDiff, - path, - ignoreWhitespace, - }), - ), + // Keep rename/copy path filtering identical to the existing per-file command. + const ordinary = await getTrackedDiffTexts({ + cwd, + refsForDiff, + paths: paths.filter((path) => !renamedPaths.has(path)), + ignoreWhitespace, + }); + const renamed = await Promise.all( + paths + .filter((path) => renamedPaths.has(path)) + .map((path) => + getTrackedDiffTextForPath({ + cwd, + refsForDiff, + path, + ignoreWhitespace, + }), + ), ); + const byPath = new Map([...ordinary, ...renamed].map((file) => [file.path, file])); + const trackedDiffs = paths.map((path) => byPath.get(path)!); for (const fileDiff of trackedDiffs) { if (fileDiff.truncated) { diff --git a/packages/server/src/utils/git-file-contents.test.ts b/packages/server/src/utils/git-file-contents.test.ts new file mode 100644 index 0000000000..7b25686e65 --- /dev/null +++ b/packages/server/src/utils/git-file-contents.test.ts @@ -0,0 +1,53 @@ +import { execFileSync } from "node:child_process"; +import { mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { afterEach, beforeEach, expect, test } from "vitest"; +import { readGitFileContents } from "@server/utils/git-file-contents.js"; +import { runGitCommand, runGitCommandBytes } from "@server/utils/run-git-command.js"; + +let cwd: string; +beforeEach(() => { + cwd = mkdtempSync(join(tmpdir(), "git-contents-")); + execFileSync("git", ["init", "-q"], { cwd }); +}); +afterEach(() => rmSync(cwd, { recursive: true, force: true })); + +test("batch byte framing survives Unicode, invalid UTF-8, and missing objects", async () => { + const text = Buffer.concat([Buffer.from("中文👩‍💻\n"), Buffer.from([0xff]), Buffer.from("\n")]); + const { stdout: oid } = await runGitCommand(["hash-object", "-w", "--stdin"], { + cwd, + input: text, + }); + const ref = oid.trim(); + const empty = execFileSync("git", ["hash-object", "-w", "--stdin"], { cwd, input: "" }) + .toString() + .trim(); + const contents = await readGitFileContents(cwd, [ref, "HEAD:missing", empty, ref]); + expect(contents).toEqual( + new Map([ + [ref, text.toString("utf8")], + ["HEAD:missing", null], + [empty, ""], + ]), + ); + const raw = await runGitCommandBytes(["cat-file", "blob", ref], { cwd }); + expect(raw.stdout.equals(text)).toBe(true); +}); + +test("oversized output stays bounded and requests the existing per-file path", async () => { + writeFileSync(join(cwd, "big"), Buffer.alloc(20 * 1024 * 1024, 97)); + const oid = execFileSync("git", ["hash-object", "-w", "big"], { cwd }).toString().trim(); + expect(await readGitFileContents(cwd, [oid])).toBeNull(); + const limited = await runGitCommandBytes(["cat-file", "--batch"], { + cwd, + input: oid + "\n", + maxOutputBytes: 100, + }); + expect(limited.truncated).toBe(true); + expect(limited.stdout.length).toBe(100); +}); + +test("newline paths do not become extra batch requests", async () => { + expect(await readGitFileContents(cwd, ["HEAD:with\nnewline"])).toBeNull(); +}); diff --git a/packages/server/src/utils/git-file-contents.ts b/packages/server/src/utils/git-file-contents.ts new file mode 100644 index 0000000000..e42d3c6c83 --- /dev/null +++ b/packages/server/src/utils/git-file-contents.ts @@ -0,0 +1,48 @@ +import { runGitCommandBytes } from "./run-git-command.js"; + +// Match the existing git-show ceiling. Oversized batches use bounded per-file reads. +const MAX_CONTENT_BYTES = 20 * 1024 * 1024; + +/** Null means the batch cannot preserve per-file read semantics within its budget. */ +export async function readGitFileContents( + cwd: string, + specs: string[], +): Promise | null> { + const unique = [...new Set(specs)]; + if (unique.length === 0) return new Map(); + // The line-based batch protocol cannot represent embedded newlines. + if (unique.some((spec) => spec.includes("\n"))) return null; + const result = await runGitCommandBytes(["cat-file", "--batch"], { + cwd, + input: unique.join("\n") + "\n", + envOverlay: { GIT_OPTIONAL_LOCKS: "0", LC_ALL: "C" }, + maxOutputBytes: MAX_CONTENT_BYTES, + }); + if (result.truncated) return null; + const contents = new Map(); + let offset = 0; + for (const spec of unique) { + const end = result.stdout.indexOf(10, offset); + if (end < 0) throw new Error("Incomplete git object header"); + const header = result.stdout.subarray(offset, end).toString("utf8"); + offset = end + 1; + if (header === `${spec} missing`) { + contents.set(spec, null); + continue; + } + const match = /^[a-f0-9]+ blob (\d+)$/.exec(header); + // Submodules and other non-blob objects retain git-show behavior. + if (!match) return null; + const size = Number(match[1]); + if ( + !Number.isSafeInteger(size) || + offset + size >= result.stdout.length || + result.stdout[offset + size] !== 10 + ) + throw new Error("Invalid git object length"); + contents.set(spec, result.stdout.subarray(offset, offset + size).toString("utf8")); + offset += size + 1; + } + if (offset !== result.stdout.length) throw new Error("Unexpected git object output"); + return contents; +} diff --git a/packages/server/src/utils/run-git-command.ts b/packages/server/src/utils/run-git-command.ts index aca69555ee..bdf7bc1f32 100644 --- a/packages/server/src/utils/run-git-command.ts +++ b/packages/server/src/utils/run-git-command.ts @@ -46,6 +46,7 @@ export function configureGitProcessPolicy(policy: GitProcessPolicy): void { export interface GitCommandOptions { cwd: string; + input?: string | Buffer; env?: ProcessEnvRecord; envOverlay?: ProcessEnvRecord; logger?: Pick; @@ -54,8 +55,8 @@ export interface GitCommandOptions { acceptExitCodes?: number[]; } -export interface GitCommandResult { - stdout: string; +export interface GitCommandResult { + stdout: Output; stderr: string; truncated: boolean; exitCode: number | null; @@ -263,6 +264,23 @@ function runGitCommandWithProvenance( options: GitCommandOptions, provenance?: string, ): Promise { + return executeGitCommand(args, options, (output) => output.toString("utf8"), provenance); +} + +/** Binary stdout preserves cat-file byte framing before text decoding. */ +export function runGitCommandBytes( + args: string[], + options: GitCommandOptions, +): Promise> { + return executeGitCommand(args, options, (output) => output); +} + +function executeGitCommand( + args: string[], + options: GitCommandOptions, + decode: (output: Buffer) => Output, + provenance?: string, +): Promise> { const metricsState = submitGitCommandMetric(args, options.cwd); const commandTrace = submitGitCommandTrace(args, options.cwd, { active: gitProcessScheduler.activeCount, @@ -274,7 +292,7 @@ function runGitCommandWithProvenance( const exited = new Promise((resolve) => { releaseProcessSlot = resolve; }); - const resultPromise = new Promise((resolve, reject) => { + const resultPromise = new Promise>((resolve, reject) => { startGitCommandTrace(commandTrace, { active: gitProcessScheduler.activeCount, pending: gitProcessScheduler.pendingCount, @@ -385,7 +403,7 @@ function runGitCommandWithProvenance( cwd: options.cwd, envOverlay, shell: false, - stdio: ["ignore", "pipe", "pipe"], + stdio: [options.input === undefined ? "ignore" : "pipe", "pipe", "pipe"], }, ); spawnGitCommandTrace(commandTrace, child.pid); @@ -465,12 +483,23 @@ function runGitCommandWithProvenance( } }); + if (options.input !== undefined) { + child.stdin!.on("error", (error: NodeJS.ErrnoException) => { + // A command may close stdin when it exits or reaches the output limit. + if (error.code !== "EPIPE") { + processError = error; + child.kill("SIGKILL"); + } + }); + child.stdin!.end(options.input); + } + child.on("exit", markProcessExited); child.on("close", (exitCode, signal) => { markProcessExited(exitCode, signal); - const result: GitCommandResult = { - stdout: Buffer.concat(stdoutChunks).toString("utf8"), + const result: GitCommandResult = { + stdout: decode(Buffer.concat(stdoutChunks)), stderr: Buffer.concat(stderrChunks).toString("utf8"), truncated, exitCode, From 6f2195904b54f5e24b96977f0bb9d24798c20ad3 Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Fri, 11 Sep 2026 08:07:35 +0200 Subject: [PATCH 17/46] fix(subscriptions): isolate observers and silence unrequested feeds (#4596) Give each observation a server-issued ID owned by its physical connection. Preserve snapshot-only reads and combined list-and-subscribe, source-bound replies, lifecycle cleanup and legacy wire compatibility. --- .github/workflows/ci.yml | 2 +- docs/ad-hoc-daemon-testing.md | 4 +- docs/architecture.md | 17 +- docs/plugins.md | 10 + docs/protocol-compatibility.md | 11 + ...ent-timeline-pagination-old-daemon.spec.ts | 86 - .../owned-subscriptions-old-daemon.spec.ts | 64 + .../app/e2e/browser/plugin-buttons.spec.ts | 7 + .../app/e2e/browser/plugin-modal-body.spec.ts | 2 + .../browser/plugin-workspace-panels.spec.ts | 17 +- .../schedules-edit-model-hydration.spec.ts | 98 +- .../browser/schedules-project-target.spec.ts | 55 +- .../browser/workspace-setup-streaming.spec.ts | 3 +- .../app/e2e/browser/worktree-restore.spec.ts | 2 +- .../app/e2e/support/helpers/plugin-buttons.ts | 83 +- .../e2e/support/helpers/schedule-fake-host.ts | 304 --- .../app/e2e/support/helpers/schedule-host.ts | 81 + .../e2e/support/helpers/workspace-setup.ts | 8 + .../chat-outline/use-chat-outline.test.tsx | 135 +- .../chat-outline/use-chat-outline.ts | 26 +- packages/app/src/components/terminal-pane.tsx | 32 +- packages/app/src/contexts/session-context.tsx | 210 +-- packages/app/src/data/push-router.test.ts | 144 +- packages/app/src/data/push-router.ts | 297 ++- .../src/desktop/browser/automation/handler.ts | 27 +- .../app/src/plugins/buttons/model.test.ts | 1 + packages/app/src/plugins/buttons/view.tsx | 30 +- packages/app/src/plugins/catalog-sync.tsx | 41 +- packages/app/src/plugins/client-runtime.ts | 2 +- .../plugins/client-slash-commands/index.ts | 63 +- .../command-center/contributions.test.ts | 7 +- .../plugins/command-center/contributions.ts | 8 +- .../plugins/command-center/registration.tsx | 4 +- packages/app/src/plugins/evaluate.ts | 22 +- .../src/plugins/registry-lifecycle.test.ts | 74 + packages/app/src/plugins/registry.test.ts | 2 +- packages/app/src/plugins/registry.ts | 29 +- packages/app/src/plugins/runtime-boundary.tsx | 9 +- packages/app/src/plugins/settings/index.tsx | 6 +- .../app/src/plugins/sidebar-groups.test.ts | 1 + .../src/plugins/surface-contribution.test.ts | 1 + .../app/src/plugins/surface-runtime.test.tsx | 19 +- packages/app/src/plugins/surface-runtime.ts | 34 +- packages/app/src/plugins/surface-screen.tsx | 13 +- packages/app/src/plugins/theme.test.ts | 1 + .../app/src/plugins/timeline/model.test.ts | 1 + .../app/src/plugins/timeline/view.test.tsx | 167 +- packages/app/src/plugins/timeline/view.tsx | 9 +- packages/app/src/plugins/types.ts | 1 + .../src/plugins/workspace-panels/panel.tsx | 9 +- .../workspace-panels/resolution.test.ts | 1 + .../src/runtime/directory-sync/index.test.ts | 66 +- .../app/src/runtime/directory-sync/index.ts | 236 +-- packages/app/src/runtime/host-runtime.test.ts | 179 +- packages/app/src/runtime/host-runtime.ts | 94 +- .../app/src/runtime/subscription-fixture.ts | 43 + .../app/src/screens/open-project-screen.tsx | 20 + .../screens/settings/plugins-page.test.tsx | 7 +- .../app/src/screens/settings/plugins-page.tsx | 18 +- packages/app/src/stores/session-store.ts | 6 +- .../terminal-stream-controller.test.ts | 93 +- .../runtime/terminal-stream-controller.ts | 146 +- packages/app/src/timeline/replica.test.ts | 3 +- .../src/timeline/viewed-timeline-sync.test.ts | 220 ++- .../app/src/timeline/viewed-timeline-sync.ts | 123 +- packages/app/src/workspace-labels/index.ts | 43 +- .../legacy-daemon-workspaces.test.ts | 105 +- .../src/workspace/legacy-daemon-workspaces.ts | 263 +-- packages/cli/src/commands/agent/attach.ts | 5 + packages/cli/src/commands/agent/logs.ts | 12 + .../client/examples/events-and-timeline.ts | 23 +- packages/client/src/connection.test.ts | 628 +++++-- packages/client/src/connection/index.ts | 104 +- packages/client/src/connection/owned.test.ts | 129 ++ packages/client/src/connection/owned.ts | 235 +++ .../src/daemon-client-transport.test.ts | 12 +- packages/client/src/daemon-client.test.ts | 316 ++-- packages/client/src/daemon-client.ts | 715 ++++--- packages/client/src/index.test.ts | 135 +- packages/client/src/index.ts | 425 +++-- .../client/src/provider-snapshots/index.ts | 49 +- .../client/src/terminal-stream-router.test.ts | 22 +- packages/client/src/terminal-stream-router.ts | 145 +- packages/client/src/terminals/index.test.ts | 2 + .../client/src/timeline-subscription/index.ts | 134 ++ .../src/browser-automation/rpc-schemas.ts | 1 + packages/protocol/src/client-capabilities.ts | 2 + packages/protocol/src/messages.ts | 98 +- .../benchmark-startup-git-real-home.ts | 5 +- packages/server/scripts/test-mcp-inject.ts | 2 +- .../server/scripts/voice-main-daemon-e2e.ts | 6 +- .../server/scripts/voice-roundtrip-debug.ts | 6 +- .../server/src/server/agent/agent-prompt.ts | 5 +- .../authorization/operation-permissions.ts | 6 + .../server/src/server/browser-tools/broker.ts | 9 +- .../src/server/checkout-diff-manager.ts | 6 + .../cli-run-workspace-precedence.e2e.test.ts | 2 +- .../src/server/daemon-client.e2e.test.ts | 8 +- .../agent-archive.native.real.e2e.test.ts | 2 +- .../daemon-e2e/agent-operations.e2e.test.ts | 4 +- ...nt-refresh-rehydrates-timeline.e2e.test.ts | 2 +- .../agent-reload.native.real.e2e.test.ts | 2 +- ...ve-unrelated-github-merge.real.e2e.test.ts | 2 +- .../checkout-diff-subscription.e2e.test.ts | 30 +- ...de-autonomous-wake-simple.real.e2e.test.ts | 2 +- .../claude-autonomous-wake.real.e2e.test.ts | 18 +- .../daemon-e2e/claude-live-usage.e2e.test.ts | 2 +- .../daemon-e2e/claude-rewind.real.e2e.test.ts | 2 +- .../claude-thinking-memory.real.e2e.test.ts | 2 +- .../codex-auto-review.local.e2e.test.ts | 2 +- .../codex-goal-mid-turn.real.e2e.test.ts | 6 +- .../daemon-e2e/codex-rewind.real.e2e.test.ts | 2 +- .../empty-project-persists.e2e.test.ts | 2 +- .../daemon-e2e/live-preferences.e2e.test.ts | 2 +- .../mode-switch-propagation.e2e.test.ts | 2 +- .../model-resolution-on-init.real.e2e.test.ts | 2 +- ...-runtime-reconcile-claude.real.e2e.test.ts | 2 +- .../server/daemon-e2e/omp.real.e2e.test.ts | 3 +- ...open-project-missing-directory.e2e.test.ts | 8 +- ...ject-worktree-reclassification.e2e.test.ts | 2 +- .../opencode-custom-agents.real.e2e.test.ts | 2 +- .../opencode-draft-features.real.e2e.test.ts | 3 +- ...encode-import-persistence.real.e2e.test.ts | 2 +- ...ncode-initial-prompt-wait.real.e2e.test.ts | 2 +- .../opencode-invalid-mode.real.e2e.test.ts | 2 +- .../opencode-invalid-model.real.e2e.test.ts | 2 +- .../daemon-e2e/opencode-omo-real-runtime.ts | 2 +- ...encode-plan-and-questions.real.e2e.test.ts | 2 +- .../opencode-rewind.real.e2e.test.ts | 2 +- .../opencode-send-interrupt.real.e2e.test.ts | 2 +- ...ode-slow-plugin-readiness.real.e2e.test.ts | 2 +- .../daemon-e2e/permissions-codex.e2e.test.ts | 53 +- .../server/daemon-e2e/persistence.e2e.test.ts | 1 + .../daemon-e2e/pi-rewind.real.e2e.test.ts | 2 +- .../src/server/daemon-e2e/pi.real.e2e.test.ts | 3 +- .../project-becomes-git.e2e.test.ts | 2 +- ...ser-message-dedupe-claude.real.e2e.test.ts | 2 +- ...d-during-tool-call-claude.real.e2e.test.ts | 6 +- ...nd-during-tool-call-codex.real.e2e.test.ts | 10 +- ...hile-running-stuck-claude.real.e2e.test.ts | 6 +- .../send-while-running-stuck.real.e2e.test.ts | 6 +- .../server/daemon-e2e/streaming.e2e.test.ts | 2 + .../terminal-byte-headless-parity.e2e.test.ts | 9 +- .../server/daemon-e2e/terminal.e2e.test.ts | 7 +- .../timeline-reconnect-contract.e2e.test.ts | 4 +- .../ui-action-stress.real.e2e.test.ts | 4 +- .../user-message-contract.real.e2e.test.ts | 2 +- .../daemon-e2e/wait-for-idle.e2e.test.ts | 4 +- .../dictation-stream-manager.test.ts | 51 + .../dictation/dictation-stream-manager.ts | 170 +- .../src/server/file-upload/index.test.ts | 33 +- .../server/src/server/file-upload/index.ts | 104 +- .../server/hub/execution-controller.test.ts | 31 +- .../src/server/hub/execution-controller.ts | 18 +- .../hub/test-utils/relationship-harness.ts | 2 +- .../owned-subscriptions-client.e2e.test.ts | 167 ++ .../server/owned-subscriptions.e2e.test.ts | 1674 +++++++++++++++++ .../plugins/agent-configuration.e2e.test.ts | 2 +- .../plugins/lifecycle-archive.e2e.test.ts | 2 +- .../src/server/plugins/lifecycle.e2e.test.ts | 6 +- .../server/plugins/lifecycle.real.e2e.test.ts | 2 +- .../src/server/plugins/plugin-process.ts | 7 + .../src/server/plugins/settings.e2e.test.ts | 1 + .../server/schedule-run-lifecycle.e2e.test.ts | 6 +- .../selective-timeline-delivery.e2e.test.ts | 76 +- packages/server/src/server/session.test.ts | 139 +- packages/server/src/server/session.ts | 1313 ++++++++----- .../session.workspace-git-watch.test.ts | 51 +- .../src/server/session.workspaces.test.ts | 378 ++-- .../agent-updates/agent-updates-service.ts | 246 +-- .../session/checkout/checkout-session.test.ts | 42 +- .../session/checkout/checkout-session.ts | 88 +- ...mon-self-update-session-controller.test.ts | 46 + .../server/session/daemon/daemon-session.ts | 4 + .../src/server/session/daemon/diagnostics.ts | 11 + .../files/workspace-files-session.test.ts | 24 +- .../session/files/workspace-files-session.ts | 85 +- .../session/owned-subscriptions/index.ts | 433 +++++ .../session/owned-subscriptions/replies.ts | 69 + .../provider/provider-catalog-session.test.ts | 10 +- .../provider/provider-catalog-session.ts | 19 +- .../src/server/session/voice/index.test.ts | 58 + .../server/src/server/session/voice/index.ts | 146 ++ .../src/server/session/voice/voice-session.ts | 73 +- .../workspace-git-observer-service.test.ts | 66 - .../workspace-git-observer-service.ts | 69 +- .../workspace-scripts-service.ts | 2 + .../server/test-utils/daemon-test-context.ts | 2 +- .../server/test-utils/fake-agent-client.ts | 10 + .../src/server/test-utils/session-stubs.ts | 5 +- .../src/server/voice-local-agent.e2e.test.ts | 6 +- .../websocket-server.browser-tools.test.ts | 10 +- .../websocket-server.notifications.test.ts | 9 +- .../websocket-server.relay-reconnect.test.ts | 23 +- ...cket-server.terminal-notifications.test.ts | 5 + .../server/src/server/websocket-server.ts | 285 ++- .../src/server/websocket/runtime-metrics.ts | 4 + .../server/src/server/wire-compat.test.ts | 52 +- ...orkspace-archive-record-scoped.e2e.test.ts | 6 +- ...kspace-create-git-fanout.local.e2e.test.ts | 35 +- .../src/server/workspace-directory.test.ts | 49 + .../server/src/server/workspace-directory.ts | 33 +- .../workspace-labels/internal/service.ts | 6 + .../workspace-same-cwd-isolation.e2e.test.ts | 1 + ...terminal-session-controller.resize.test.ts | 36 +- .../terminal-session-controller.test.ts | 457 ++++- .../terminal/terminal-session-controller.ts | 406 ++-- .../src/terminal/terminal-size-ownership.ts | 6 +- plugin-examples/local-plugin/client/main.tsx | 29 +- public-docs/plugins/v0.8/reference.md | 4 + public-docs/sdk/agents.md | 7 +- public-docs/sdk/events.md | 89 +- public-docs/sdk/reference.md | 34 +- 213 files changed, 9753 insertions(+), 5019 deletions(-) delete mode 100644 packages/app/e2e/browser/agent-timeline-pagination-old-daemon.spec.ts create mode 100644 packages/app/e2e/browser/owned-subscriptions-old-daemon.spec.ts delete mode 100644 packages/app/e2e/support/helpers/schedule-fake-host.ts create mode 100644 packages/app/e2e/support/helpers/schedule-host.ts create mode 100644 packages/app/src/plugins/registry-lifecycle.test.ts create mode 100644 packages/app/src/runtime/subscription-fixture.ts create mode 100644 packages/client/src/connection/owned.test.ts create mode 100644 packages/client/src/connection/owned.ts create mode 100644 packages/client/src/timeline-subscription/index.ts create mode 100644 packages/server/src/server/owned-subscriptions-client.e2e.test.ts create mode 100644 packages/server/src/server/owned-subscriptions.e2e.test.ts create mode 100644 packages/server/src/server/session/owned-subscriptions/index.ts create mode 100644 packages/server/src/server/session/owned-subscriptions/replies.ts create mode 100644 packages/server/src/server/session/voice/index.test.ts create mode 100644 packages/server/src/server/session/voice/index.ts diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 3452f38ec2..df4b86f9f1 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -296,7 +296,7 @@ jobs: run: node scripts/npm-retry.mjs ci - name: Build client dependencies - run: npm run build:client + run: npm run build:relay && npm run build:client - name: Build plugin SDK run: npm run build:plugin diff --git a/docs/ad-hoc-daemon-testing.md b/docs/ad-hoc-daemon-testing.md index 87176d24b4..c12f003df9 100644 --- a/docs/ad-hoc-daemon-testing.md +++ b/docs/ad-hoc-daemon-testing.md @@ -51,7 +51,7 @@ const client = new DaemonClient({ appVersion: "0.1.70", // see gotcha #1 }); await client.connect(); -await client.fetchAgents({ subscribe: { subscriptionId: "test" } }); +await client.fetchAgents({ subscribe: {} }); // ... do your testing ... @@ -81,7 +81,7 @@ const client = new DaemonClient({ appVersion: "0.1.70", }); await client.connect(); -await client.fetchAgents({ subscribe: { subscriptionId: "test" } }); +await client.fetchAgents({ subscribe: {} }); // ... test ... diff --git a/docs/architecture.md b/docs/architecture.md index 32fef93532..f305407903 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -239,11 +239,22 @@ Server → Client: status message with payload { status: "server_info", serverId, hostname, version, capabilities?, features } ``` -There is no dedicated welcome message; the server emits a `status` session message after accepting the hello, then begins streaming. The session stores client capabilities from the hello and rehydrates them on reconnect, so the wire boundary can ask one question: `session.supports(...)`. +There is no dedicated welcome message; the server emits one initial `status.server_info` after +accepting hello. Modern clients negotiate `owned_subscriptions` against +`features.ownedSubscriptions`. Connecting or attaching a local listener creates no application-data +demand. A list is a snapshot; adding `subscribe: {}` creates an independent server ID. The client +correlates the initial reply by request ID, then routes updates by subscription ID. + +A logical Session can contain several physical sockets. Each socket owns its capabilities, +subscriptions and ongoing operations; the final JSON/binary send boundary requires that source's +request or subscription provenance. Domain modules own filtering, bootstrap and resource teardown. +The shared ownership boundary only tracks source and lifetime. Releasing a subscription acknowledges +its teardown; reconnect creates new server IDs for surviving client handles. See +[protocol compatibility](protocol-compatibility.md) for the legacy boundary. **Top-level WS envelopes** are `hello`, `recording_state`, `ping`/`pong`, and `session` (which wraps the rich union of session messages). -Client liveness checks use the top-level JSON `ping`/`pong` envelope, not a session RPC or RFC6455 control ping. Current clients ping every 10 seconds, beginning one interval after connecting. The first ping claims an application-ownership lease for that physical socket, all later inbound activity renews it, and the daemon forcibly terminates the socket if the lease expires. A legacy or raw socket that never sends an application ping never enters this lease and is not closed for omitting one. Session RPC timeouts are operation failures and must not be treated as proof that the socket is dead. +Client liveness checks use the top-level JSON `ping`/`pong` envelope, not a session RPC or RFC6455 control ping. Current clients ping every 10 seconds, beginning one interval after connecting. The first ping claims an application-ownership lease for that physical socket, all later inbound activity renews it, and the daemon forcibly terminates the socket if the lease expires. A legacy or raw socket that never sends an application ping never enters this lease and is not closed for omitting one. Session RPC timeouts are operation failures, not proof that the socket is dead. A subscription bootstrap timeout closes its source to clear any unknown server-owned registration; the failed handle is released before other surviving handles reconnect. Every physical send path enforces an 8 MiB outbound high-water mark, including JSON broadcasts, binary terminal frames, and the encrypted relay adapter's asynchronous queue. This sits above the terminal stream's 4 MiB soft backpressure threshold, leaving room for snapshot catch-up before the hard cutoff. JSON is serialized once per broadcast after sockets already at the limit are removed, then its exact byte length is checked for every remaining socket. A frame that would cross the limit is not sent; that physical socket is forcibly terminated without disturbing other sockets attached to the same logical session. Multiple tabs and simultaneous direct and relay paths may legitimately share a client id. @@ -297,7 +308,7 @@ messages or transfers. - WebSocket schemas are append-only. Add fields, do not remove fields, and never make optional fields required. - New wire enum values must be gated at serialization with `session.supports(CLIENT_CAPS.someCapability)`. -- `Session` stores client capabilities from the `hello` handshake and rehydrates them on reconnect, so the wire boundary can ask one question: `session.supports(...)`. +- Capabilities belong to the physical source socket. `session.supports(...)` reads the requesting or observing source at the domain projection boundary; one socket must not borrow a sibling's capabilities. Example: adding a new enum value diff --git a/docs/plugins.md b/docs/plugins.md index 229679ddf7..dfd4d6dde3 100644 --- a/docs/plugins.md +++ b/docs/plugins.md @@ -355,6 +355,16 @@ Native sheets teleport their children. Button surfaces rebuild the installation' query, and toast providers inside the surface content, including overflow pages from different plugins. Providers only around the trigger do not reach those bodies. +Request observation with `client.paseo.agents.list({ subscribe: {} })` and consume the returned +`subscription` handle. Plain `list()` and local `.subscribe(handler)` listeners create no daemon +demand. Each list-and-subscribe call has its own server ID, even for the same query. Handle snapshots +also run after reconnect; replace your view before applying its subsequent updates. The installation +owns every observation created through its API and releases them on unload, including setup failure. +Mounted surfaces and command invocations have shorter API lifetimes. + +Keep the client entry synchronous: return its cleanup function immediately and start asynchronous +work inside it. See the maintained [composer pill example](../plugin-examples/local-plugin/client/main.tsx). + ## Contribute timeline items Timeline transformers and renderers are client contributions. The daemon's canonical rows and diff --git a/docs/protocol-compatibility.md b/docs/protocol-compatibility.md index 6a232b8061..089bf1a3e8 100644 --- a/docs/protocol-compatibility.md +++ b/docs/protocol-compatibility.md @@ -50,6 +50,17 @@ Connecting creates no timeline or event demand. Client subscriptions own their n release it on unsubscribe, and restore it after reconnect. Raw message observers inspect traffic without requesting streams. Application caches and which agents are visible remain caller-owned. +## Owned observations + +`owned_subscriptions` and `server_info.features.ownedSubscriptions` negotiate the entire source-owned +contract described in [architecture](architecture.md#websocket-protocol). The client gates observation +once and tells users to update an older host. It does not emulate independent handles over legacy slots. + +Old clients keep their existing wire shapes and slot behavior at the daemon's source boundary. +The adapter keys legacy slots by physical socket, so an old connection cannot replace a modern +sibling's observation even when both use the same logical client ID. Optional wire IDs stay accepted +for parsing compatibility; modern requests cannot select their subscription ID. + ## Every shim is tagged and dated A shim that exists for old-app or old-daemon support carries a comment naming it, the version it arrived in, and when it can go: diff --git a/packages/app/e2e/browser/agent-timeline-pagination-old-daemon.spec.ts b/packages/app/e2e/browser/agent-timeline-pagination-old-daemon.spec.ts deleted file mode 100644 index 6ec68a2b1e..0000000000 --- a/packages/app/e2e/browser/agent-timeline-pagination-old-daemon.spec.ts +++ /dev/null @@ -1,86 +0,0 @@ -import { randomUUID } from "node:crypto"; -import { metroTest as test } from "../support/fixtures"; -import { buildCreateAgentPreferences, buildSeededHost } from "../support/helpers/daemon-registry"; -import { startIsolatedHostDaemon } from "../support/helpers/isolated-host-daemon"; -import type { MockAgentWorkspace } from "../support/helpers/mock-agent"; -import { seedWorkspace } from "../support/helpers/seed-client"; -import { - expectTimelinePromptVisible, - holdOlderHistoryPages, - openAgentTimeline, - scrollThroughOlderHistoryPages, -} from "../support/helpers/timeline-pagination"; - -test("does not repeat an assistant block when the current app paginates a published 0.2.5 daemon", async ({ - page, -}) => { - test.setTimeout(120_000); - const serverId = `srv_old_pagination_${randomUUID().replaceAll("-", "").slice(0, 12)}`; - const daemon = await startIsolatedHostDaemon(serverId, { publishedVersion: "0.2.5" }); - const workspace = await seedWorkspace({ - repoPrefix: "timeline-old-daemon-pagination-", - port: daemon.port, - }); - const createdAgent = await workspace.client.createAgent({ - provider: "mock", - cwd: workspace.repoPath, - workspaceId: workspace.workspaceId, - title: "Published daemon pagination regression", - modeId: "load-test", - model: "ten-second-stream", - }); - const agent: MockAgentWorkspace = { - agentId: createdAgent.id, - workspaceId: workspace.workspaceId, - cwd: workspace.repoPath, - client: workspace.client, - cleanup: workspace.cleanup, - }; - - try { - for (let index = 0; index < 40; index += 1) { - await agent.client.sendAgentMessage( - agent.agentId, - `timeline-pagination-older-turn-${index}: emit 1 coalesced agent stream updates`, - ); - await agent.client.waitForFinish(agent.agentId, 15_000); - } - await agent.client.sendAgentMessage(agent.agentId, "build a realistic long mock timeline"); - await agent.client.waitForFinish(agent.agentId, 20_000); - for (let index = 0; index < 20; index += 1) { - await agent.client.sendAgentMessage( - agent.agentId, - `timeline-pagination-turn-${index}: emit 1 coalesced agent stream updates`, - ); - await agent.client.waitForFinish(agent.agentId, 15_000); - } - - const host = buildSeededHost({ - serverId, - endpoint: `127.0.0.1:${daemon.port}`, - nowIso: new Date().toISOString(), - }); - await page.addInitScript( - ({ seededHost, preferences }) => { - localStorage.setItem("@paseo:e2e", "1"); - localStorage.setItem("@paseo:daemon-registry", JSON.stringify([seededHost])); - localStorage.setItem("@paseo:create-agent-preferences", JSON.stringify(preferences)); - }, - { seededHost: host, preferences: buildCreateAgentPreferences() }, - ); - - const history = await holdOlderHistoryPages(page, agent, daemon.port); - await openAgentTimeline(page, agent, serverId); - await expectTimelinePromptVisible( - page, - "timeline-pagination-turn-19: emit 1 coalesced agent stream updates", - ); - await scrollThroughOlderHistoryPages(page, 3, history); - - history.expectRepeatedEntries(); - await history.expectOwnedTextRendered("Now I have a clearer picture."); - } finally { - await agent.cleanup(); - await daemon.close(); - } -}); diff --git a/packages/app/e2e/browser/owned-subscriptions-old-daemon.spec.ts b/packages/app/e2e/browser/owned-subscriptions-old-daemon.spec.ts new file mode 100644 index 0000000000..0f0109ee3c --- /dev/null +++ b/packages/app/e2e/browser/owned-subscriptions-old-daemon.spec.ts @@ -0,0 +1,64 @@ +import { randomUUID } from "node:crypto"; +import { metroTest as test, expect } from "../support/fixtures"; +import { buildCreateAgentPreferences, buildSeededHost } from "../support/helpers/daemon-registry"; +import { startIsolatedHostDaemon } from "../support/helpers/isolated-host-daemon"; +import { buildAgentRoute } from "../support/helpers/mock-agent"; +import { seedWorkspace } from "../support/helpers/seed-client"; + +test("requires a host update before observing a published 0.2.5 daemon", async ({ page }) => { + test.setTimeout(120_000); + const serverId = `srv_old_pagination_${randomUUID().replaceAll("-", "").slice(0, 12)}`; + const daemon = await startIsolatedHostDaemon(serverId, { publishedVersion: "0.2.5" }); + const workspace = await seedWorkspace({ + repoPrefix: "timeline-old-daemon-pagination-", + port: daemon.port, + }); + const createdAgent = await workspace.client.createAgent({ + provider: "mock", + cwd: workspace.repoPath, + workspaceId: workspace.workspaceId, + title: "Published daemon pagination regression", + modeId: "load-test", + model: "ten-second-stream", + }); + const requests: unknown[] = []; + page.on("websocket", (socket) => + socket.on("framesent", ({ payload }) => { + if (typeof payload !== "string") return; + const frame = JSON.parse(payload); + if (frame.type === "session" && frame.message.type !== "ping") requests.push(frame.message); + }), + ); + + try { + const host = buildSeededHost({ + serverId, + endpoint: `127.0.0.1:${daemon.port}`, + nowIso: new Date().toISOString(), + }); + await page.addInitScript( + ({ seededHost, preferences }) => { + localStorage.setItem("@paseo:e2e", "1"); + localStorage.setItem("@paseo:daemon-registry", JSON.stringify([seededHost])); + localStorage.setItem("@paseo:create-agent-preferences", JSON.stringify(preferences)); + }, + { seededHost: host, preferences: buildCreateAgentPreferences() }, + ); + + await page.goto(buildAgentRoute(workspace.workspaceId, createdAgent.id, serverId)); + const updateHost = page.getByText("Update the host to use this version of Paseo.", { + exact: true, + }); + await expect(updateHost).toHaveCount(1); + await expect(updateHost).toBeVisible(); + await expect(page.getByRole("button", { name: "Manage host", exact: true })).toBeVisible(); + expect(requests).toEqual([]); + await page.reload(); + await expect(updateHost).toHaveCount(1); + await expect(updateHost).toBeVisible(); + expect(requests).toEqual([]); + } finally { + await workspace.cleanup(); + await daemon.close(); + } +}); diff --git a/packages/app/e2e/browser/plugin-buttons.spec.ts b/packages/app/e2e/browser/plugin-buttons.spec.ts index 9f81d81a0f..21221f859b 100644 --- a/packages/app/e2e/browser/plugin-buttons.spec.ts +++ b/packages/app/e2e/browser/plugin-buttons.spec.ts @@ -12,3 +12,10 @@ test("plugin header and composer buttons share actions, menus, content, and upda await buttons.hideAndUnloadOpenButtons(); }); }); + +test("plugin button observations follow mounted content and error lifetimes", async ({ + page, +}, testInfo) => { + test.setTimeout(120_000); + await withButtonShowcase(page, testInfo, (buttons) => buttons.verifyObservationLifetime()); +}); diff --git a/packages/app/e2e/browser/plugin-modal-body.spec.ts b/packages/app/e2e/browser/plugin-modal-body.spec.ts index 6db456b72b..bdd822b9c8 100644 --- a/packages/app/e2e/browser/plugin-modal-body.spec.ts +++ b/packages/app/e2e/browser/plugin-modal-body.spec.ts @@ -14,6 +14,8 @@ async function openExample(page: Page, example: string) { async function closeExample(page: Page) { await page.getByRole("button", { name: "Close", exact: true }).last().click(); + // The launcher is visible behind a closing sheet; wait for dismissal before reopening. + await expect(page.getByText(/^Modal example:/)).toHaveCount(0); await expect(page.getByRole("button", { name: "Open Form", exact: true })).toBeVisible(); } diff --git a/packages/app/e2e/browser/plugin-workspace-panels.spec.ts b/packages/app/e2e/browser/plugin-workspace-panels.spec.ts index f1de94a3ec..c52bda217a 100644 --- a/packages/app/e2e/browser/plugin-workspace-panels.spec.ts +++ b/packages/app/e2e/browser/plugin-workspace-panels.spec.ts @@ -67,7 +67,7 @@ function contributeClient(client) { pills.get(agentId)?.(); pills.delete(agentId); }; - const unsubscribe = client.paseo.agents.subscribe((update) => { + const apply = (update) => { if (update.kind === "remove") { remove(update.agentId); return; @@ -94,9 +94,20 @@ function contributeClient(client) { }, }); pills.set(agent.id, () => pill.remove()); - }); + }; + const lifetime = new AbortController(); + void client.paseo.agents.list({ subscribe: {}, signal: lifetime.signal }).then(({ subscription }) => { + subscription.subscribe({ + snapshot({ entries }) { + for (const removePill of pills.values()) removePill(); + pills.clear(); + for (const { agent } of entries) apply({ kind: "upsert", agent }); + }, + update(message) { if (message.type === "agent_update") apply(message.payload); }, + }); + }).catch((error) => { if (!lifetime.signal.aborted) console.error(error); }); return () => { - unsubscribe(); + lifetime.abort(); for (const removePill of pills.values()) removePill(); pills.clear(); }; diff --git a/packages/app/e2e/browser/schedules-edit-model-hydration.spec.ts b/packages/app/e2e/browser/schedules-edit-model-hydration.spec.ts index 59b2e51696..de91c88569 100644 --- a/packages/app/e2e/browser/schedules-edit-model-hydration.spec.ts +++ b/packages/app/e2e/browser/schedules-edit-model-hydration.spec.ts @@ -1,12 +1,11 @@ import { expect, test } from "../support/fixtures"; import { gotoAppShell } from "../support/helpers/app"; import { - addFakeScheduleHostAndReload, - buildFakeScheduleHostWorkspace, - FAKE_HOST_MODEL_ID, - FAKE_HOST_MODEL_LABEL, - installFakeScheduleHost, -} from "../support/helpers/schedule-fake-host"; + addScheduleHostAndReload, + createScheduleHost, + SECONDARY_MODEL_ID, + SECONDARY_MODEL_LABEL, +} from "../support/helpers/schedule-host"; import { seedWorkspace, type SeededWorkspace } from "../support/helpers/seed-client"; import { expectSettled, expectStableHeight } from "../support/helpers/settled"; import { waitForSidebarHydration } from "../support/helpers/workspace-ui"; @@ -32,7 +31,11 @@ interface ScheduleSeedClient { scheduleDelete(input: { id: string }): Promise<{ error: string | null }>; } -async function seedMockSchedule(workspace: SeededWorkspace, name: string): Promise { +async function seedMockSchedule( + workspace: SeededWorkspace, + name: string, + model = "ten-second-stream", +): Promise { const client = workspace.client as unknown as ScheduleSeedClient; const result = await client.scheduleCreate({ prompt: "Say hello from the scheduled agent.", @@ -43,7 +46,7 @@ async function seedMockSchedule(workspace: SeededWorkspace, name: string): Promi config: { provider: "mock", cwd: workspace.repoPath, - model: "ten-second-stream", + model, modeId: "load-test", title: name, }, @@ -66,42 +69,6 @@ async function deleteSeededSchedule(workspace: SeededWorkspace, id: string): Pro .catch(ignoreScheduleDeleteError); } -type FakeScheduleHostSchedule = NonNullable< - Parameters[0]["schedules"] ->[number]; - -function buildFakeHostSchedule(input: { - id: string; - name: string; - cwd: string; -}): FakeScheduleHostSchedule { - const now = "2026-07-01T00:00:00.000Z"; - return { - id: input.id, - name: input.name, - prompt: "Run on the secondary host.", - cadence: { type: "cron", expression: "0 9 * * *" }, - target: { - type: "new-agent", - config: { - provider: "mock", - cwd: input.cwd, - model: FAKE_HOST_MODEL_ID, - modeId: "load-test", - title: input.name, - }, - }, - status: "active", - createdAt: now, - updatedAt: now, - nextRunAt: now, - lastRunAt: null, - pausedAt: null, - expiresAt: null, - maxRuns: null, - }; -} - test.describe("Schedules", () => { const cleanupTasks: Array<() => Promise> = []; @@ -154,34 +121,23 @@ test.describe("Schedules", () => { test("edit form hydrates a non-default host schedule after reload", async ({ page }) => { const workspace = await seedWorkspace({ repoPrefix: "schedule-host-b-hydration-" }); cleanupTasks.push(() => workspace.cleanup()); - const fakeHost = await buildFakeScheduleHostWorkspace(workspace); - const fakePort = String(59_000 + Math.floor(Math.random() * 900)); - const scheduleId = "fake-host-schedule"; - const scheduleName = "Secondary host schedule"; - - await installFakeScheduleHost({ - page, - port: fakePort, - serverId: fakeHost.serverId, - workspace: fakeHost.workspace, - project: fakeHost, - schedules: [ - buildFakeHostSchedule({ - id: scheduleId, - name: scheduleName, - cwd: String(fakeHost.workspace.workspaceDirectory), - }), - ], - }); + const secondary = await createScheduleHost(); + cleanupTasks.push(() => secondary.cleanup()); + const scheduleId = await seedMockSchedule( + secondary, + "Secondary host schedule", + SECONDARY_MODEL_ID, + ); + cleanupTasks.push(() => deleteSeededSchedule(secondary, scheduleId)); await gotoAppShell(page); await waitForSidebarHydration(page); await page.goto(buildSchedulesRoute()); - await addFakeScheduleHostAndReload({ + await addScheduleHostAndReload({ page, - serverId: fakeHost.serverId, - label: "Fake host", - port: fakePort, + serverId: secondary.serverId, + label: secondary.label, + port: secondary.port, }); await page.reload(); @@ -197,14 +153,14 @@ test.describe("Schedules", () => { const modelTrigger = page.getByTestId("schedule-model-trigger"); const modeTrigger = page.getByTestId("schedule-mode-trigger"); - await expect(hostTrigger).toContainText("Fake host", { timeout: 30_000 }); + await expect(hostTrigger).toContainText(secondary.label, { timeout: 30_000 }); await expect(hostTrigger).toBeDisabled(); await expectSettled(hostTrigger); - await expect(projectTrigger).toContainText(fakeHost.projectDisplayName, { timeout: 30_000 }); + await expect(projectTrigger).toContainText(secondary.projectDisplayName, { timeout: 30_000 }); await expectSettled(projectTrigger); - await expect(modelTrigger).toContainText(FAKE_HOST_MODEL_LABEL, { timeout: 30_000 }); + await expect(modelTrigger).toContainText(SECONDARY_MODEL_LABEL, { timeout: 30_000 }); await expectSettled(modelTrigger); - await expect(modeTrigger).toContainText("Load test", { timeout: 30_000 }); + await expect(modeTrigger).toContainText("Load Test", { timeout: 30_000 }); await expectSettled(modeTrigger); await expect(page.getByTestId("cadence-mode")).toHaveCount(0); await expect(page.getByTestId("schedule-cadence-preset-trigger")).toContainText("Daily 9:00"); diff --git a/packages/app/e2e/browser/schedules-project-target.spec.ts b/packages/app/e2e/browser/schedules-project-target.spec.ts index 15d26dde56..4c36eb3985 100644 --- a/packages/app/e2e/browser/schedules-project-target.spec.ts +++ b/packages/app/e2e/browser/schedules-project-target.spec.ts @@ -1,10 +1,6 @@ import { expect, test, type Page } from "../support/fixtures"; import { gotoAppShell } from "../support/helpers/app"; -import { - addFakeScheduleHostAndReload, - buildFakeScheduleHostWorkspace, - installFakeScheduleHost, -} from "../support/helpers/schedule-fake-host"; +import { addScheduleHostAndReload, createScheduleHost } from "../support/helpers/schedule-host"; import { seedWorkspace, type SeededWorkspace } from "../support/helpers/seed-client"; import { getServerId } from "../support/helpers/server-id"; import { projectEquivalenceViewKey } from "../support/helpers/project-view-key"; @@ -242,20 +238,13 @@ test.describe("Schedules project target", () => { cadenceExpression: "0 9 * * *", }); - const fakeHost = await buildFakeScheduleHostWorkspace(workspace); - const fakePort = String(59_000 + Math.floor(Math.random() * 900)); - await installFakeScheduleHost({ + const secondary = await createScheduleHost(); + cleanupTasks.push(() => secondary.cleanup()); + await addScheduleHostAndReload({ page, - port: fakePort, - serverId: fakeHost.serverId, - workspace: fakeHost.workspace, - project: fakeHost, - }); - await addFakeScheduleHostAndReload({ - page, - serverId: fakeHost.serverId, - label: "Fake host", - port: fakePort, + serverId: secondary.serverId, + label: secondary.label, + port: secondary.port, }); const hostFilterTrigger = page.getByTestId("schedules-host-filter-trigger"); @@ -274,25 +263,17 @@ test.describe("Schedules project target", () => { const workspace = await seedWorkspace({ repoPrefix: "schedule-project-host-model-" }); cleanupTasks.push(() => workspace.cleanup()); const serverId = getServerId(); - const fakeHost = await buildFakeScheduleHostWorkspace(workspace); - const fakePort = String(59_000 + Math.floor(Math.random() * 900)); - - await installFakeScheduleHost({ - page, - port: fakePort, - serverId: fakeHost.serverId, - workspace: fakeHost.workspace, - project: fakeHost, - }); + const secondary = await createScheduleHost(); + cleanupTasks.push(() => secondary.cleanup()); await gotoAppShell(page); await waitForSidebarHydration(page); await page.goto(buildSchedulesRoute()); - await addFakeScheduleHostAndReload({ + await addScheduleHostAndReload({ page, - serverId: fakeHost.serverId, - label: "Fake host", - port: fakePort, + serverId: secondary.serverId, + label: secondary.label, + port: secondary.port, }); await expect(page.getByTestId("schedules-empty")).toBeVisible({ timeout: 30_000 }); await page.getByTestId("schedules-empty-new").click(); @@ -334,8 +315,8 @@ test.describe("Schedules project target", () => { await expectSettled(modeTrigger); await hostTrigger.click(); - await page.getByTestId(`schedule-host-option-${fakeHost.serverId}`).click(); - await expect(hostTrigger).toContainText("Fake host"); + await page.getByTestId(`schedule-host-option-${secondary.serverId}`).click(); + await expect(hostTrigger).toContainText(secondary.label); await expect(projectTrigger).toContainText(/select project/i); await expect(modelTrigger).toHaveCount(0); await expect(thinkingTrigger).toHaveCount(0); @@ -345,9 +326,9 @@ test.describe("Schedules project target", () => { await projectTrigger.click(); await page - .getByTestId(`schedule-project-option-${projectEquivalenceViewKey(fakeHost.projectKey)}`) + .getByTestId(`schedule-project-option-${projectEquivalenceViewKey(secondary.projectKey)}`) .click(); - await expect(projectTrigger).toContainText(fakeHost.projectDisplayName); + await expect(projectTrigger).toContainText(secondary.projectDisplayName); await expectSettled(projectTrigger); await expect(modelTrigger).toContainText(/select model/i); await expectSettled(modelTrigger); @@ -355,7 +336,7 @@ test.describe("Schedules project target", () => { await expect(modeTrigger).toHaveCount(0); await page.getByLabel("Schedule name").fill(`Cross host model ${Date.now()}`); - await page.getByLabel("Prompt").fill("Run on the fake host project."); + await page.getByLabel("Prompt").fill("Run on the secondary host project."); await expect(page.getByRole("button", { name: "Create schedule" })).toBeDisabled(); }); diff --git a/packages/app/e2e/browser/workspace-setup-streaming.spec.ts b/packages/app/e2e/browser/workspace-setup-streaming.spec.ts index 0763bfb332..875ec5197d 100644 --- a/packages/app/e2e/browser/workspace-setup-streaming.spec.ts +++ b/packages/app/e2e/browser/workspace-setup-streaming.spec.ts @@ -87,8 +87,7 @@ test.describe("Workspace setup streaming", () => { try { await seedProjectForWorkspaceSetup(client, repo.path); - // Wait for setup completion via daemon (setup snapshots are per-session, - // so the browser session won't receive progress events). + // The setup client owns progress before creating the workspace. const completed = waitForWorkspaceSetupProgress( client, (payload) => diff --git a/packages/app/e2e/browser/worktree-restore.spec.ts b/packages/app/e2e/browser/worktree-restore.spec.ts index d8bac26fe4..4f6096ed07 100644 --- a/packages/app/e2e/browser/worktree-restore.spec.ts +++ b/packages/app/e2e/browser/worktree-restore.spec.ts @@ -205,7 +205,7 @@ test.describe("Worktree restore", () => { "restore-success", ); await worktreeClient.fetchWorkspaces({ - subscribe: { subscriptionId: `restore-secondary-${randomUUID()}` }, + subscribe: {}, }); let updateTimeout: ReturnType | null = null; let unsubscribeSecondaryWorkspaceUpdate = () => {}; diff --git a/packages/app/e2e/support/helpers/plugin-buttons.ts b/packages/app/e2e/support/helpers/plugin-buttons.ts index 0e66a395dc..e3f0b1bba6 100644 --- a/packages/app/e2e/support/helpers/plugin-buttons.ts +++ b/packages/app/e2e/support/helpers/plugin-buttons.ts @@ -18,7 +18,7 @@ const COMPACT = { width: 390, height: 844 }; function clientSource(workspaceId: string, agentId: string): string { return `import React from "react"; import { Text, View, Pressable } from "react-native"; -import { useWorkspace, useRpc } from "@getpaseo/plugin/client"; +import { useWorkspace, useRpc, usePaseo } from "@getpaseo/plugin/client"; import { useQuery } from "@tanstack/react-query"; import { summary } from "./shared/rpc"; @@ -28,9 +28,22 @@ function StatusIcon({ size, theme }) { function Details({ workspaceId, theme, layout, close }) { const workspace = useWorkspace(workspaceId, (workspace) => workspace.name); + const paseo = usePaseo(); + const [ownerId, setOwnerId] = React.useState(""); + const [updates, setUpdates] = React.useState(0); + const [failed, setFailed] = React.useState(false); + React.useEffect(() => { + const owner = paseo.observeEvents(["project.update"]); + owner.subscribe({ snapshot(value) { setOwnerId(value.subscriptionId); }, update() { setUpdates((n) => n + 1); } }); + // Deliberately leave this observation to the mounted host scope. + }, [paseo]); const rpc = useRpc(summary); const query = useQuery({ queryKey: ["summary"], queryFn: () => rpc({}) }); + if (failed) throw new Error("Button content failed"); return + {ownerId} + Observed updates: {updates} + setFailed(true)}>Fail content Deployment status {workspace} {query.data?.message ?? "Loading deployment..."} @@ -83,12 +96,19 @@ export default function contribute(client) { command("icon-only", "Use icon-only header", () => deploy.update({ label: undefined })); command("hide-menu", "Hide composer menu", () => pillMenu.update({ visible: false })); command("show-menu", "Show composer menu", () => pillMenu.update({ visible: true })); - const unsubscribe = client.paseo.workspaces.ref(workspaceId).subscribe((update) => { - if (update.kind !== "upsert") return; - deploy.update({ visible: update.workspace.name !== "Compact checks" }); - pillMenu.update({ visible: update.workspace.name !== "Hide composer menu" }); - }); - return () => { unsubscribe(); finish(); finishReview(); for (const registration of registrations) registration.remove(); }; + const lifetime = new AbortController(); + const applyWorkspace = (workspace) => { + if (workspace.id !== workspaceId) return; + deploy.update({ visible: workspace.name !== "Compact checks" }); + pillMenu.update({ visible: workspace.name !== "Hide composer menu" }); + }; + void client.paseo.workspaces.list({ subscribe: {}, signal: lifetime.signal }).then(({ subscription }) => { + subscription.subscribe({ + snapshot({ entries }) { for (const workspace of entries) applyWorkspace(workspace); }, + update(message) { if (message.type === "workspace_update" && message.payload.kind === "upsert") applyWorkspace(message.payload.workspace); }, + }); + }).catch((error) => { if (!lifetime.signal.aborted) console.error(error); }); + return () => { lifetime.abort(); finish(); finishReview(); for (const registration of registrations) registration.remove(); }; }`; } @@ -222,8 +242,18 @@ export async function withButtonShowcase( runAndUpdateActions(): Promise; openCompactSheets(): Promise; hideAndUnloadOpenButtons(): Promise; + verifyObservationLifetime(): Promise; }) => Promise, ) { + const released = new Set(); + page.on("websocket", (socket) => + socket.on("framereceived", ({ payload }) => { + if (typeof payload !== "string") return; + const frame = JSON.parse(payload); + if (frame.message?.type === "subscription.release.response") + released.add(frame.message.payload.subscriptionId); + }), + ); const workspace = await seedWorkspace({ repoPrefix: "plugin-buttons-" }); const agent = await workspace.client.createAgent({ provider: "mock", @@ -241,6 +271,45 @@ export async function withButtonShowcase( await expect(page.getByRole("button", { name: "Run review", exact: true })).toBeVisible(); await run({ + verifyObservationLifetime: () => + test.step("closing and crashing content release its owner while sibling buttons and app remain live", async () => { + await press(page, "Header status"); + await expectDetails(page); + const observation = page.getByLabel("Content observation", { exact: true }); + await expect(observation).not.toHaveText(""); + const first = (await observation.textContent())!; + await press(page, "Close deployment details"); + await expect.poll(() => released.has(first)).toBe(true); + await press(page, "Composer status"); + await expectDetails(page); + const second = (await observation.textContent())!; + expect(second).not.toBe(first); + await workspace.client.renameProject(workspace.projectId, "Observer control"); + await expect(page.getByText("Observed updates: 1", { exact: true })).toBeVisible(); + await press(page, "Fail button content"); + await expect( + page.getByText("Plugin failed: Button content failed", { exact: true }), + ).toBeVisible(); + await expect.poll(() => released.has(second)).toBe(true); + await page.keyboard.press("Escape"); + await press(page, "Header status"); + await expectDetails(page); + const recovered = (await observation.textContent())!; + expect([first, second]).not.toContain(recovered); + await showcase.setTitle("Healthy app after button failure"); + await expect(page.getByTestId("workspace-header-title")).toHaveText( + "Healthy app after button failure", + ); + await showcase.disable(); + await expect.poll(() => released.has(recovered)).toBe(true); + await expect(page.getByRole("button", { name: "Run review", exact: true })).toHaveCount( + 0, + ); + await showcase.setTitle("Healthy app after plugin unload"); + await expect(page.getByTestId("workspace-header-title")).toHaveText( + "Healthy app after plugin unload", + ); + }), openWideMenusAndPopovers: () => test.step("wide buttons have placement-owned labels and chevrons", async () => { await expect( diff --git a/packages/app/e2e/support/helpers/schedule-fake-host.ts b/packages/app/e2e/support/helpers/schedule-fake-host.ts deleted file mode 100644 index df54741fc0..0000000000 --- a/packages/app/e2e/support/helpers/schedule-fake-host.ts +++ /dev/null @@ -1,304 +0,0 @@ -import { type Page } from "@playwright/test"; -import { buildSeededHost } from "./daemon-registry"; -import { wsRoutePatternForPort } from "./daemon-port"; -import { type SeededWorkspace } from "./seed-client"; - -const REGISTRY_KEY = "@paseo:daemon-registry"; -const SEED_NONCE_KEY = "@paseo:e2e-seed-nonce"; -const EXTRA_HOSTS_KEY = "@paseo:e2e-extra-hosts"; -export const FAKE_HOST_MODEL_ID = "fake-host-model"; -export const FAKE_HOST_MODEL_LABEL = "Fake host model"; -export const FAKE_HOST_PROJECT_DISPLAY_NAME = "Fake host project"; - -type WebSocketMessage = string | Buffer; -type SessionRequest = Record & { type?: string; requestId?: string }; - -export interface FakeScheduleHostWorkspace { - serverId: string; - projectId: string; - projectKey: string; - projectDisplayName: string; - workspace: Record; -} - -interface FakeScheduleSummary { - id: string; - name: string | null; - prompt: string; - cadence: { type: "cron"; expression: string }; - target: { - type: "new-agent"; - config: { - provider: "mock"; - cwd: string; - model: string; - modeId: string; - title?: string; - }; - }; - status: "active"; - createdAt: string; - updatedAt: string; - nextRunAt: string | null; - lastRunAt: string | null; - pausedAt: string | null; - expiresAt: string | null; - maxRuns: number | null; -} - -function parseJson(message: WebSocketMessage): unknown { - const raw = typeof message === "string" ? message : message.toString("utf8"); - try { - return JSON.parse(raw); - } catch { - return null; - } -} - -function buildSessionMessage(type: string, payload: Record) { - return JSON.stringify({ - type: "session", - message: { - type, - payload, - }, - }); -} - -function buildFakeProviderEntries(nowIso: string) { - return [ - { - provider: "mock", - label: "Mock", - status: "ready", - enabled: true, - fetchedAt: nowIso, - models: [ - { - provider: "mock", - id: FAKE_HOST_MODEL_ID, - label: FAKE_HOST_MODEL_LABEL, - isDefault: true, - }, - ], - modes: [{ id: "load-test", label: "Load test" }], - defaultModeId: "load-test", - }, - ]; -} - -function readSessionRequest(message: WebSocketMessage): SessionRequest | null { - const parsed = parseJson(message); - if (!parsed || typeof parsed !== "object") { - return null; - } - - const envelope = parsed as { type?: string; message?: SessionRequest }; - if (envelope.type !== "session" || !envelope.message) { - return null; - } - - return envelope.message; -} - -function getRequestId(request: SessionRequest): string { - return typeof request.requestId === "string" ? request.requestId : "fake-request"; -} - -export async function buildFakeScheduleHostWorkspace( - workspace: SeededWorkspace, -): Promise { - const workspaceList = await workspace.client.fetchWorkspaces({ - filter: { projectId: workspace.projectId }, - }); - const baseWorkspace = workspaceList.entries.find((entry) => entry.id === workspace.workspaceId); - if (!baseWorkspace) { - throw new Error(`Failed to load seeded workspace descriptor ${workspace.workspaceId}`); - } - - const projectId = `${workspace.projectId}-fake-host`; - const cwd = `${workspace.repoPath}-fake-host`; - return { - serverId: "schedule-fake-host", - projectId, - projectKey: workspace.projectKey, - projectDisplayName: FAKE_HOST_PROJECT_DISPLAY_NAME, - workspace: { - ...baseWorkspace, - id: `${baseWorkspace.id}-fake-host`, - projectId, - projectDisplayName: FAKE_HOST_PROJECT_DISPLAY_NAME, - projectRootPath: cwd, - workspaceDirectory: cwd, - name: FAKE_HOST_PROJECT_DISPLAY_NAME, - project: undefined, - }, - }; -} - -export async function installFakeScheduleHost(input: { - page: Page; - port: string; - serverId: string; - workspace: Record; - project: Pick; - schedules?: FakeScheduleSummary[]; -}): Promise { - await input.page.routeWebSocket(wsRoutePatternForPort(input.port), (ws) => { - ws.onMessage((message) => { - const parsed = parseJson(message); - if (parsed && typeof parsed === "object" && (parsed as { type?: string }).type === "hello") { - ws.send( - buildSessionMessage("status", { - status: "server_info", - serverId: input.serverId, - hostname: "fake-schedule-host", - version: "0.0.0-e2e", - features: { - providersSnapshot: true, - workspaceMultiplicity: true, - projectList: true, - projectAdd: true, - projectRemove: true, - workspaceRecovery: true, - }, - }), - ); - return; - } - - if (parsed && typeof parsed === "object" && (parsed as { type?: string }).type === "ping") { - ws.send(JSON.stringify({ type: "pong" })); - return; - } - - const request = readSessionRequest(message); - if (!request) { - return; - } - - const requestId = getRequestId(request); - const now = Date.now(); - const nowIso = new Date(now).toISOString(); - switch (request.type) { - case "ping": - ws.send( - buildSessionMessage("pong", { - requestId, - clientSentAt: typeof request.clientSentAt === "number" ? request.clientSentAt : now, - serverReceivedAt: now, - serverSentAt: now, - }), - ); - return; - case "fetch_workspaces_request": - ws.send( - buildSessionMessage("fetch_workspaces_response", { - requestId, - entries: [input.workspace], - emptyProjects: [], - pageInfo: { nextCursor: null, prevCursor: null, hasMore: false }, - }), - ); - return; - case "project.list.request": - ws.send( - buildSessionMessage("project.list.response", { - requestId, - projects: [ - { - projectId: input.project.projectId, - projectKey: input.project.projectKey, - projectDisplayName: input.project.projectDisplayName, - projectRootPath: String(input.workspace.projectRootPath), - projectKind: "git", - }, - ], - }), - ); - return; - case "fetch_agents_request": - ws.send( - buildSessionMessage("fetch_agents_response", { - requestId, - entries: [], - pageInfo: { nextCursor: null, prevCursor: null, hasMore: false }, - }), - ); - return; - case "get_providers_snapshot_request": - ws.send( - buildSessionMessage("get_providers_snapshot_response", { - requestId, - entries: buildFakeProviderEntries(nowIso), - generatedAt: nowIso, - }), - ); - return; - case "refresh_providers_snapshot_request": - ws.send( - buildSessionMessage("refresh_providers_snapshot_response", { - requestId, - acknowledged: true, - }), - ); - return; - case "schedule/list": - ws.send( - buildSessionMessage("schedule/list/response", { - requestId, - schedules: input.schedules ?? [], - error: null, - }), - ); - return; - } - }); - }); -} - -export async function addFakeScheduleHostAndReload(input: { - page: Page; - serverId: string; - label: string; - port: string; -}): Promise { - const host = buildSeededHost({ - serverId: input.serverId, - label: input.label, - endpoint: `127.0.0.1:${input.port}`, - nowIso: new Date().toISOString(), - }); - - await input.page.evaluate( - ({ seededHost, keys }) => { - const nonce = localStorage.getItem(keys.nonce); - if (!nonce) { - throw new Error("Expected the e2e seed nonce before overriding the host registry."); - } - const raw = localStorage.getItem(keys.registry); - const registry: Array<{ serverId: string }> = raw ? JSON.parse(raw) : []; - const nextRegistry = registry.filter((entry) => entry.serverId !== seededHost.serverId); - nextRegistry.push(seededHost); - localStorage.setItem(keys.registry, JSON.stringify(nextRegistry)); - - const rawExtraHosts = localStorage.getItem(keys.extraHosts); - const extraHosts: Array<{ serverId: string }> = rawExtraHosts - ? JSON.parse(rawExtraHosts) - : []; - const nextExtraHosts = extraHosts.filter((entry) => entry.serverId !== seededHost.serverId); - nextExtraHosts.push(seededHost); - localStorage.setItem(keys.extraHosts, JSON.stringify(nextExtraHosts)); - }, - { - seededHost: host, - keys: { - registry: REGISTRY_KEY, - nonce: SEED_NONCE_KEY, - extraHosts: EXTRA_HOSTS_KEY, - }, - }, - ); - - await input.page.reload(); -} diff --git a/packages/app/e2e/support/helpers/schedule-host.ts b/packages/app/e2e/support/helpers/schedule-host.ts new file mode 100644 index 0000000000..6c01c9650e --- /dev/null +++ b/packages/app/e2e/support/helpers/schedule-host.ts @@ -0,0 +1,81 @@ +import { type Page } from "@playwright/test"; +import { buildSeededHost } from "./daemon-registry"; +import { startIsolatedHostDaemon } from "./isolated-host-daemon"; +import { seedWorkspace, type SeededWorkspace } from "./seed-client"; + +const REGISTRY_KEY = "@paseo:daemon-registry"; +const SEED_NONCE_KEY = "@paseo:e2e-seed-nonce"; +const EXTRA_HOSTS_KEY = "@paseo:e2e-extra-hosts"; +export const SECONDARY_MODEL_ID = "one-minute-stream"; +export const SECONDARY_MODEL_LABEL = "One minute stream"; + +export async function createScheduleHost() { + const daemon = await startIsolatedHostDaemon("schedule-secondary-host"); + let workspace: SeededWorkspace | undefined; + try { + workspace = await seedWorkspace({ repoPrefix: "schedule-secondary-", port: daemon.port }); + const seeded = workspace; + await seeded.client.renameProject(seeded.projectId, "Secondary project"); + return { + ...seeded, + projectDisplayName: "Secondary project", + serverId: daemon.serverId, + port: daemon.port, + label: "Secondary host", + cleanup: async () => { + await seeded.cleanup(); + await daemon.close(); + }, + }; + } catch (error) { + await workspace?.cleanup(); + await daemon.close(); + throw error; + } +} + +export async function addScheduleHostAndReload(input: { + page: Page; + serverId: string; + label: string; + port: number; +}): Promise { + const host = buildSeededHost({ + serverId: input.serverId, + label: input.label, + endpoint: `127.0.0.1:${input.port}`, + nowIso: new Date().toISOString(), + }); + + await input.page.evaluate( + ({ seededHost, keys }) => { + const nonce = localStorage.getItem(keys.nonce); + if (!nonce) { + throw new Error("Expected the e2e seed nonce before overriding the host registry."); + } + const raw = localStorage.getItem(keys.registry); + const registry: Array<{ serverId: string }> = raw ? JSON.parse(raw) : []; + const nextRegistry = registry.filter((entry) => entry.serverId !== seededHost.serverId); + nextRegistry.push(seededHost); + localStorage.setItem(keys.registry, JSON.stringify(nextRegistry)); + + const rawExtraHosts = localStorage.getItem(keys.extraHosts); + const extraHosts: Array<{ serverId: string }> = rawExtraHosts + ? JSON.parse(rawExtraHosts) + : []; + const nextExtraHosts = extraHosts.filter((entry) => entry.serverId !== seededHost.serverId); + nextExtraHosts.push(seededHost); + localStorage.setItem(keys.extraHosts, JSON.stringify(nextExtraHosts)); + }, + { + seededHost: host, + keys: { + registry: REGISTRY_KEY, + nonce: SEED_NONCE_KEY, + extraHosts: EXTRA_HOSTS_KEY, + }, + }, + ); + + await input.page.reload(); +} diff --git a/packages/app/e2e/support/helpers/workspace-setup.ts b/packages/app/e2e/support/helpers/workspace-setup.ts index f990e52e86..ee706d7ad7 100644 --- a/packages/app/e2e/support/helpers/workspace-setup.ts +++ b/packages/app/e2e/support/helpers/workspace-setup.ts @@ -20,6 +20,7 @@ type WorkspaceSetupDaemonClient = Pick< | "fetchAgents" | "fetchWorkspaces" | "listTerminals" + | "observeEvents" | "removeProject" | "subscribeRawMessages" >; @@ -35,6 +36,13 @@ export async function connectWorkspaceSetupClient(): Promise({ clientIdPrefix: "workspace-setup", }); + // Establish demand before a caller can launch setup. Client close releases it. + try { + await client.observeEvents(["workspace_setup_progress"]).ready; + } catch (error) { + await client.close(); + throw error; + } return withProjectOwnership(client); } diff --git a/packages/app/src/agent-stream/chat-outline/use-chat-outline.test.tsx b/packages/app/src/agent-stream/chat-outline/use-chat-outline.test.tsx index 6200b8b0f9..6b64d992eb 100644 --- a/packages/app/src/agent-stream/chat-outline/use-chat-outline.test.tsx +++ b/packages/app/src/agent-stream/chat-outline/use-chat-outline.test.tsx @@ -6,20 +6,12 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; import type { StreamViewportHandle } from "../strategy"; import { useChatOutline } from "./use-chat-outline"; -interface RefreshMessage { - type: "agent_stream"; - payload: { - agentId: string; - event: { type: "timeline"; item: { type: "user_message" } }; - }; -} - const runtime = vi.hoisted(() => ({ listAgentTimelinePrompts: vi.fn(), fetchAgentTimeline: vi.fn(), - on: vi.fn<(event: string, listener: (message: RefreshMessage) => void) => () => void>( - () => () => undefined, - ), + subscribeAgentTimeline: vi.fn(() => { + throw new Error("The outline must reuse the viewed timeline"); + }), })); vi.mock("@/constants/platform", () => ({ isWeb: true })); @@ -42,7 +34,7 @@ describe("useChatOutline", () => { beforeEach(() => { runtime.listAgentTimelinePrompts.mockReset(); runtime.fetchAgentTimeline.mockReset(); - runtime.on.mockClear(); + runtime.subscribeAgentTimeline.mockClear(); }); it("drops a late prompt index after the authoritative timeline epoch changes", async () => { @@ -88,7 +80,7 @@ describe("useChatOutline", () => { expect(result.current.prompts[0]?.seq).toBe(2); }); - it("keeps the newest prompt index response within one epoch", async () => { + it("refreshes from viewed user messages without another stream and keeps the newest index", async () => { const older = deferred<{ epoch: string; prompts: Array<{ seq: number; timestamp: string; preview: string }>; @@ -102,36 +94,31 @@ describe("useChatOutline", () => { .mockReturnValueOnce(older.promise) .mockReturnValueOnce(newer.promise); const viewportRef = createRef(); - const { result } = renderHook(() => - useChatOutline({ - agentId: "agent-1", - serverId: "server-1", - timelineEpoch: "epoch-1", - tail: [], - head: [], - enabled: true, - viewportRef, - onJumpError: vi.fn(), - }), + const { result, rerender } = renderHook( + ({ tail }) => + useChatOutline({ + agentId: "agent-1", + serverId: "server-1", + timelineEpoch: "epoch-1", + tail, + head: [], + enabled: true, + viewportRef, + onJumpError: vi.fn(), + }), + { initialProps: { tail: [] as import("@/types/stream").StreamItem[] } }, ); await waitFor(() => expect(runtime.listAgentTimelinePrompts).toHaveBeenCalledTimes(1)); - const refresh = runtime.on.mock.calls[0]?.[1]; - await act(async () => { - refresh?.({ - type: "agent_stream", - payload: { - agentId: "agent-1", - event: { type: "timeline", item: { type: "user_message" } }, - }, - }); - refresh?.({ - type: "agent_stream", - payload: { - agentId: "agent-1", - event: { type: "timeline", item: { type: "user_message" } }, - }, - }); + const loadedPrompt = (seq: number): import("@/types/stream").StreamItem => ({ + id: `prompt-${seq}`, + kind: "user_message", + text: "live prompt", + timestamp: new Date(seq), + timelineCursor: { epoch: "epoch-1", seq }, }); + rerender({ tail: [loadedPrompt(2)] }); + await waitFor(() => expect(runtime.listAgentTimelinePrompts).toHaveBeenCalledTimes(2)); + rerender({ tail: [loadedPrompt(2), loadedPrompt(3)] }); await waitFor(() => expect(runtime.listAgentTimelinePrompts).toHaveBeenCalledTimes(3)); await act(async () => newer.resolve({ @@ -147,6 +134,74 @@ describe("useChatOutline", () => { ); expect(result.current.prompts.map((prompt) => prompt.seq)).toEqual([3]); + expect(runtime.subscribeAgentTimeline).not.toHaveBeenCalled(); + }); + + it("refreshes after reconnect catch-up and visibility changes without reacting to assistant chunks", async () => { + runtime.listAgentTimelinePrompts.mockResolvedValue({ + epoch: "epoch-1", + prompts: [{ seq: 1, timestamp: new Date(1).toISOString(), preview: "Unloaded prompt" }], + }); + const viewportRef = createRef(); + const { result, rerender } = renderHook( + ({ enabled, head, timelineEpoch }) => + useChatOutline({ + agentId: "agent-1", + serverId: "server-1", + tail: [], + head, + timelineEpoch, + enabled, + viewportRef, + visibleItemIds: new Set(), + onJumpError: vi.fn(), + }), + { + initialProps: { + enabled: true, + head: [] as import("@/types/stream").StreamItem[], + timelineEpoch: "epoch-1", + }, + }, + ); + await waitFor(() => expect(result.current.prompts[0]?.preview).toBe("Unloaded prompt")); + rerender({ + enabled: true, + head: [ + { + id: "assistant", + kind: "assistant_message", + text: "chunk", + timestamp: new Date(), + timelineCursor: { epoch: "epoch-1", seq: 2 }, + }, + ], + timelineEpoch: "epoch-1", + }); + expect(runtime.listAgentTimelinePrompts).toHaveBeenCalledTimes(1); + rerender({ enabled: false, head: [], timelineEpoch: "epoch-1" }); + expect(result.current.prompts).toEqual([]); + // The viewed model publishes the catch-up from the reconnected transport. + runtime.listAgentTimelinePrompts.mockResolvedValue({ + epoch: "epoch-2", + prompts: [{ seq: 1, timestamp: new Date(2).toISOString(), preview: "Replaced conversation" }], + }); + rerender({ + enabled: true, + head: [ + { + id: "reconnected-user", + kind: "user_message", + text: "Replaced conversation", + timestamp: new Date(2), + timelineCursor: { epoch: "epoch-2", seq: 1 }, + }, + ], + timelineEpoch: "epoch-2", + }); + await waitFor(() => expect(result.current.prompts[0]?.preview).toBe("Replaced conversation")); + expect(runtime.listAgentTimelinePrompts).toHaveBeenCalledTimes(2); + expect(runtime.subscribeAgentTimeline).not.toHaveBeenCalled(); }); it("reports a failed unloaded prompt jump", async () => { diff --git a/packages/app/src/agent-stream/chat-outline/use-chat-outline.ts b/packages/app/src/agent-stream/chat-outline/use-chat-outline.ts index b88876ef49..6d66ec4379 100644 --- a/packages/app/src/agent-stream/chat-outline/use-chat-outline.ts +++ b/packages/app/src/agent-stream/chat-outline/use-chat-outline.ts @@ -65,12 +65,23 @@ export function useChatOutline({ const loadedItems = useMemo(() => [...tail, ...(head ?? NO_STREAM_ITEMS)], [head, tail]); const prompts = enabled ? (index?.prompts ?? NO_PROMPTS) : NO_PROMPTS; + // The viewed timeline already owns live delivery and reconnect catch-up. Its complete + // loaded items (including rows outside the mounted window) invalidate the prompt index. + const latestPromptSeq = loadedItems.reduce( + (latest, item) => + item.kind === "user_message" && item.timelineCursor?.epoch === timelineEpoch + ? Math.max(latest, item.timelineCursor.seq) + : latest, + -1, + ); + + useEffect(() => setIndex(null), [agentId, enabled, serverId, timelineEpoch]); + useEffect(() => { if (!isWeb || !enabled) { setIndex(null); return; } - setIndex(null); const client = getHostRuntimeStore().getClient(serverId); if (!client) return; let active = true; @@ -91,21 +102,10 @@ export function useChatOutline({ .catch(() => undefined); }; refresh(); - const unsubscribe = client.on("agent_stream", (message) => { - if ( - message.type === "agent_stream" && - message.payload.agentId === agentId && - message.payload.event.type === "timeline" && - message.payload.event.item.type === "user_message" - ) { - refresh(); - } - }); return () => { active = false; - unsubscribe(); }; - }, [agentId, enabled, serverId, timelineEpoch]); + }, [agentId, enabled, serverId, timelineEpoch, latestPromptSeq]); // The transcript names the row it is showing; the outline turns that into a prompt using the // complete index, so unloaded rows never have to exist in the DOM to be marked. diff --git a/packages/app/src/components/terminal-pane.tsx b/packages/app/src/components/terminal-pane.tsx index 3e98799035..ad87c5f8eb 100644 --- a/packages/app/src/components/terminal-pane.tsx +++ b/packages/app/src/components/terminal-pane.tsx @@ -449,31 +449,11 @@ export function TerminalPane({ [handleKeyboardChange], ); - useEffect(() => { - if (!client || !isConnected || !isWorkspaceFocused) { - return; - } - - return client.on("terminal_stream_exit", (message) => { - if (message.type !== "terminal_stream_exit") { - return; - } - - const exitedTerminalId = message.payload.terminalId; - if (!exitedTerminalId) { - return; - } - - workspaceTerminalSession.snapshots.clear({ terminalId: exitedTerminalId }); - if (terminalIdRef.current === exitedTerminalId) { - emulatorRef.current?.clear(); - } - streamControllerRef.current?.handleTerminalExit({ - terminalId: exitedTerminalId, - }); - setModifiers({ ...EMPTY_MODIFIERS }); - }); - }, [client, isConnected, isWorkspaceFocused, workspaceTerminalSession.snapshots]); + const handleStreamExit = useStableEvent((exitedTerminalId: string) => { + workspaceTerminalSession.snapshots.clear({ terminalId: exitedTerminalId }); + if (terminalIdRef.current === exitedTerminalId) emulatorRef.current?.clear(); + setModifiers({ ...EMPTY_MODIFIERS }); + }); useEffect(() => { measuredTerminalSizeRef.current = null; @@ -561,6 +541,7 @@ export function TerminalPane({ onOutput: handleStreamOutput, onRestore: handleStreamRestore, onSnapshot: handleStreamSnapshot, + onExit: handleStreamExit, getRestoreOptions: getStreamRestoreOptions, onStatusChange: handleStreamControllerStatus, }); @@ -581,6 +562,7 @@ export function TerminalPane({ handleStreamOutput, handleStreamRestore, handleStreamSnapshot, + handleStreamExit, isConnected, ]); diff --git a/packages/app/src/contexts/session-context.tsx b/packages/app/src/contexts/session-context.tsx index fff3a0f496..8adf2a188a 100644 --- a/packages/app/src/contexts/session-context.tsx +++ b/packages/app/src/contexts/session-context.tsx @@ -14,12 +14,7 @@ import type { StreamItem } from "@/types/stream"; import { deriveAgentStreamTurnLiveness } from "@/timeline/session-stream-reducers"; import { planTimelineTailFetch } from "@/timeline/timeline-sync-plan"; import { requestTimelineReplacement } from "@/timeline/timeline-replacement"; -import { - consumeForcedTimelineTailReplacement, - type TimelineDeliveryMode, - type TimelineResponsePayload, - type ViewedTimelineOwner, -} from "@/timeline/viewed-timeline-sync"; +import { type ViewedTimelineOwner } from "@/timeline/viewed-timeline-sync"; import type { AgentAttachment, SessionOutboundMessage } from "@getpaseo/protocol/messages"; import { parseServerInfoStatusPayload } from "@getpaseo/protocol/messages"; import { @@ -81,11 +76,6 @@ interface BufferedAudioChunk { id: string; } -// COMPAT(selectiveAgentTimeline): added in v0.1.106, remove after 2027-01-12. -function getTimelineDeliveryMode(selectiveAgentTimeline?: boolean): TimelineDeliveryMode { - return selectiveAgentTimeline ? "selective" : "legacy"; -} - function decodeBase64Chunk(base64: string): Uint8Array { return Buffer.from(base64, "base64"); } @@ -251,7 +241,6 @@ function SessionProviderInternal({ children, serverId, client }: SessionProvider const _sessionStateTimeoutRef = useRef | null>(null); const attentionNotifiedRef = useRef>(new Map()); const appStateRef = useRef(AppState.currentState); - const forcedTimelineTailReplacements = useRef(new Set()); const viewedTimelineSyncRef = useRef(null); const audioOutputBuffersRef = useRef>(new Map()); const activeAudioGroupsRef = useRef>(new Set()); @@ -417,24 +406,62 @@ function SessionProviderInternal({ children, serverId, client }: SessionProvider [serverId, upsertWorkspaceSetupProgress], ); - const applyTimelineResponse = useCallback((receivedPayload: TimelineResponsePayload) => { - const payload = consumeForcedTimelineTailReplacement( - receivedPayload, - forcedTimelineTailReplacements.current, - ); - const owner = viewedTimelineSyncRef.current; - if (!owner) throw new Error("Viewed timeline owner is unavailable"); - owner.applyTimelineResponse(payload); - }, []); - useEffect(() => { const setAgentInitializing = createSetAgentInitializing(serverId, setInitializingAgents); - const initialDeliveryMode = getTimelineDeliveryMode( - client.getLastServerInfoMessage()?.features?.selectiveAgentTimeline, - ); + const onStream = (message: SessionOutboundMessage) => { + if (message.type !== "agent_stream") return; + const { agentId, event, timestamp, seq, epoch } = message.payload; + const parsedTimestamp = new Date(timestamp); + const streamEvent = event; + if ( + event.type === "turn_started" || + event.type === "turn_completed" || + event.type === "turn_failed" || + event.type === "turn_canceled" + ) { + voiceRuntime?.onTurnEvent(serverId, agentId, event.type); + } + const turnLiveness = deriveAgentStreamTurnLiveness([ + { event: streamEvent, seq, epoch, timestamp: parsedTimestamp }, + ]); + if (turnLiveness.length > 0) { + getHostRuntimeStore().applyAgentTurnLiveness(serverId, agentId, turnLiveness); + } + sync.enqueueStreamEvent(agentId, { + event: streamEvent, + seq, + epoch, + timestamp: parsedTimestamp, + }); + + // NOTE: We don't update lastActivityAt on every stream event to prevent + // cascading rerenders. The agent_update handler updates agent.lastActivityAt + // on status changes, which is sufficient for sorting and display purposes. + }; + const onReplacement = (message: SessionOutboundMessage) => { + if (message.type !== "agent.timeline.replacement") return; + void requestTimelineReplacement( + { + fetchAgentTimeline: (agentId, request) => + getHostRuntimeStore().fetchAgentTimeline(serverId, agentId, request), + }, + message.payload.agentId, + ).catch((error: unknown) => { + console.warn("[Session] timeline replacement refresh failed", { serverId, error }); + }); + }; const sync = getHostRuntimeStore().createViewedTimelineOwner(serverId, { - initialDeliveryMode, - setSubscription: (agentIds) => client.setAgentTimelineSubscription(agentIds), + observe: (agentIds) => { + const observation = client.observeTimeline(agentIds); + observation.subscribe({ + snapshot: () => {}, + update: (message) => { + if (message.type === "agent_stream") onStream(message); + else if (message.type === "agent.timeline.replacement") onReplacement(message); + }, + }); + return observation; + }, readCursor: (agentId) => { const timeline = selectAgentTimelineState( useSessionStore.getState().sessions[serverId], @@ -474,18 +501,8 @@ function SessionProviderInternal({ children, serverId, client }: SessionProvider throw error; } }, - fetchLatestTail: async (agentId) => { - forcedTimelineTailReplacements.current.add(agentId); - try { - return await getHostRuntimeStore().fetchAgentTimeline( - serverId, - agentId, - planTimelineTailFetch(), - ); - } finally { - forcedTimelineTailReplacements.current.delete(agentId); - } - }, + fetchLatestTail: (agentId) => + getHostRuntimeStore().fetchAgentTimeline(serverId, agentId, planTimelineTailFetch()), reportError: (error) => { console.warn("[Session] viewed timeline synchronization failed", { serverId, error }); }, @@ -505,7 +522,7 @@ function SessionProviderInternal({ children, serverId, client }: SessionProvider setViewedTimelineSync(serverId, null); sync.dispose(); }; - }, [client, serverId, setInitializingAgents, setViewedTimelineSync]); + }, [client, serverId, setInitializingAgents, setViewedTimelineSync, voiceRuntime]); useEffect(() => { viewedTimelineSyncRef.current?.setConnected(isConnected); @@ -513,98 +530,57 @@ function SessionProviderInternal({ children, serverId, client }: SessionProvider // Daemon message handlers - directly update Zustand store useEffect(() => { - const owner = viewedTimelineSyncRef.current; - if (!owner) throw new Error("Viewed timeline owner is unavailable"); - - const unsubAgentStream = client.on("agent_stream", (message) => { - if (message.type !== "agent_stream") return; - const { agentId, event, timestamp, seq, epoch } = message.payload; - const parsedTimestamp = new Date(timestamp); - const streamEvent = event; - if ( - event.type === "turn_started" || - event.type === "turn_completed" || - event.type === "turn_failed" || - event.type === "turn_canceled" - ) { - voiceRuntime?.onTurnEvent(serverId, agentId, event.type); - } - const turnLiveness = deriveAgentStreamTurnLiveness([ - { event: streamEvent, seq, epoch, timestamp: parsedTimestamp }, - ]); - if (turnLiveness.length > 0) { - getHostRuntimeStore().applyAgentTurnLiveness(serverId, agentId, turnLiveness); - } - owner.enqueueStreamEvent(agentId, { - event: streamEvent, - seq, - epoch, - timestamp: parsedTimestamp, + const feeds = client.observeEvents( + [ + "agent_attention_required", + "terminal_attention_required", + "agent_permission_request", + "agent_permission_resolved", + "agent.provider_subagents.update", + "checkout_status_update", + "workspace_setup_progress", + "status.server_info", + ], + { notifications: true }, + ); + const onFeed = ( + type: SessionOutboundMessage["type"], + handler: (message: SessionOutboundMessage) => void, + ) => + feeds.subscribe({ + snapshot: () => {}, + update: (message) => { + if (message.type === type) handler(message); + }, }); - // NOTE: We don't update lastActivityAt on every stream event to prevent - // cascading rerenders. The agent_update handler updates agent.lastActivityAt - // on status changes, which is sufficient for sorting and display purposes. - }); - - const unsubAgentAttention = client.onAgentAttentionRequired((notification) => { + const unsubAgentAttention = onFeed("agent_attention_required", (message) => { + if (message.type !== "agent_attention_required") return; + const notification = message.payload; if (notification.shouldNotify) { notifyAgentAttention(notification); } }); - const unsubAgentTimeline = client.on("fetch_agent_timeline_response", (message) => { - if (message.type !== "fetch_agent_timeline_response") return; - owner.flushStreamAgent(message.payload.agentId); - applyTimelineResponse(message.payload); - }); - - const unsubTimelineReplacement = client.on("agent.timeline.replacement", (message) => { - if (message.type !== "agent.timeline.replacement") return; - void requestTimelineReplacement( - { - fetchAgentTimeline: (agentId, request) => - getHostRuntimeStore().fetchAgentTimeline(serverId, agentId, request), - }, - message.payload.agentId, - ).catch((error: unknown) => { - console.warn("[Session] timeline replacement refresh failed", { serverId, error }); - }); - }); - - const unsubProviderSubagentUpdate = client.on("agent.provider_subagents.update", (message) => { + const unsubProviderSubagentUpdate = onFeed("agent.provider_subagents.update", (message) => { if (message.type !== "agent.provider_subagents.update") return; useProviderSubagentStore.getState().applyUpdate(serverId, message.payload); }); - const unsubCheckoutStatusUpdate = client.on("checkout_status_update", (message) => { + const unsubCheckoutStatusUpdate = onFeed("checkout_status_update", (message) => { if (message.type !== "checkout_status_update") return; applyCheckoutStatusUpdateFromEvent({ queryClient, serverId, message }); }); - const unsubWorkspaceSetupProgress = client.on("workspace_setup_progress", (message) => { + const unsubWorkspaceSetupProgress = onFeed("workspace_setup_progress", (message) => { if (message.type !== "workspace_setup_progress") return; applyWorkspaceSetupProgress(message.payload); }); - const unsubWorkspaceSetupStatusResponse = client.on( - "workspace_setup_status_response", - (message) => { - if (message.type !== "workspace_setup_status_response") return; - const { workspaceId, snapshot } = message.payload; - if (snapshot) { - applyWorkspaceSetupProgress({ workspaceId, ...snapshot }); - } - }, - ); - - const unsubStatus = client.on("status", (message) => { + const unsubStatus = onFeed("status", (message) => { if (message.type !== "status") return; const serverInfo = parseServerInfoStatusPayload(message.payload); if (serverInfo) { - viewedTimelineSyncRef.current?.setDeliveryMode( - getTimelineDeliveryMode(serverInfo.features?.selectiveAgentTimeline), - ); updateSessionServerInfo(serverId, { serverId: serverInfo.serverId, hostname: serverInfo.hostname, @@ -619,7 +595,7 @@ function SessionProviderInternal({ children, serverId, client }: SessionProvider } }); - const unsubPermissionRequest = client.on("agent_permission_request", (message) => { + const unsubPermissionRequest = onFeed("agent_permission_request", (message) => { if (message.type !== "agent_permission_request") return; const { agentId, request } = message.payload; @@ -631,7 +607,7 @@ function SessionProviderInternal({ children, serverId, client }: SessionProvider }); }); - const unsubPermissionResolved = client.on("agent_permission_resolved", (message) => { + const unsubPermissionResolved = onFeed("agent_permission_resolved", (message) => { if (message.type !== "agent_permission_resolved") return; const { requestId, agentId } = message.payload; @@ -740,7 +716,7 @@ function SessionProviderInternal({ children, serverId, client }: SessionProvider voiceRuntime?.onServerSpeechStateChanged(serverId, message.payload.isSpeaking); }); - const unsubTerminalAttention = client.on("terminal_attention_required", (message) => { + const unsubTerminalAttention = onFeed("terminal_attention_required", (message) => { if (message.type !== "terminal_attention_required") { return; } @@ -762,14 +738,13 @@ function SessionProviderInternal({ children, serverId, client }: SessionProvider }); return () => { - unsubTimelineReplacement(); - unsubAgentStream(); - unsubAgentTimeline(); + void feeds + .release() + .catch((error) => console.warn("[Session] Failed to release feeds", error)); unsubProviderSubagentUpdate(); unsubAgentAttention(); unsubCheckoutStatusUpdate(); unsubWorkspaceSetupProgress(); - unsubWorkspaceSetupStatusResponse(); unsubStatus(); unsubPermissionRequest(); unsubPermissionResolved(); @@ -792,7 +767,6 @@ function SessionProviderInternal({ children, serverId, client }: SessionProvider setPendingPermissions, notifyAgentAttention, applyWorkspaceSetupProgress, - applyTimelineResponse, updateSessionServerInfo, toast, voiceRuntime, diff --git a/packages/app/src/data/push-router.test.ts b/packages/app/src/data/push-router.test.ts index ae99c5dfbc..01fba418a9 100644 --- a/packages/app/src/data/push-router.test.ts +++ b/packages/app/src/data/push-router.test.ts @@ -1,3 +1,4 @@ +import type { OwnedSubscription, SubscriptionObserver } from "@getpaseo/client"; import { QueryClient, QueryObserver, skipToken } from "@tanstack/react-query"; import { describe, expect, it } from "vitest"; import type { MutableDaemonConfig, SessionOutboundMessage } from "@getpaseo/protocol/messages"; @@ -94,37 +95,89 @@ function createFakeClient(config: { rejectCheckoutDiffSubscribe?: boolean } = {} } } + function observe( + snapshot: T, + types: RouterMessageType[], + released: () => void, + matches: (message: RouterMessage) => boolean = () => true, + ): OwnedSubscription { + const observers = new Set>(); + let active = true; + const cleanups = types.map((type) => + on(type, (message) => { + if (!matches(message)) return; + for (const observer of observers) { + if (message.type === "subscribe_checkout_diff_response") + observer.snapshot({ ...snapshot, ...message.payload }); + else observer.update(message); + } + }), + ); + return { + subscriptionId: snapshot.subscriptionId, + ready: Promise.resolve(snapshot), + subscribe: (observer) => { + observers.add(observer); + observer.snapshot(snapshot); + return () => { + observers.delete(observer); + }; + }, + release: async () => { + if (!active) return; + active = false; + cleanups.forEach((cleanup) => cleanup()); + observers.clear(); + released(); + }, + }; + } + return { client: { async getProvidersSnapshot() { throw new Error("Unexpected snapshot pull"); }, - on, - async subscribeCheckoutDiff(cwd, compare, requestOptions) { - subscribeCheckoutDiffCalls.push({ - cwd, - compare, - subscriptionId: requestOptions.subscriptionId, - }); - if (config.rejectCheckoutDiffSubscribe) { - throw new Error("subscribe failed"); - } + observeEvents: () => + observe( + { subscriptionId: "events", requestId: "events", events: [] }, + ["status", "providers_snapshot_update"], + () => {}, + ), + observeCheckoutDiff(cwd, compare) { + const subscriptionId = `server-diff-${subscribeCheckoutDiffCalls.length + 1}`; + subscribeCheckoutDiffCalls.push({ cwd, compare, subscriptionId }); + const handle = observe( + { subscriptionId, cwd, files: [], error: null, requestId: "subscribe-checkout-diff" }, + ["checkout_diff_update", "subscribe_checkout_diff_response"], + () => unsubscribeCheckoutDiffCalls.push(subscriptionId), + (message) => + "subscriptionId" in message.payload && + message.payload.subscriptionId === subscriptionId, + ); + if (!config.rejectCheckoutDiffSubscribe) return handle; return { - subscriptionId: requestOptions.subscriptionId, - cwd, - files: [], - error: null, - requestId: requestOptions.requestId ?? "subscribe-checkout-diff", + ...handle, + subscribe: (observer) => { + queueMicrotask(() => observer.error?.(new Error("subscribe failed"))); + return () => {}; + }, }; }, - unsubscribeCheckoutDiff(subscriptionId) { - unsubscribeCheckoutDiffCalls.push(subscriptionId); - }, - subscribeTerminals(subscription) { - subscribeTerminalCalls.push(subscription); - }, - unsubscribeTerminals(subscription) { - unsubscribeTerminalCalls.push(subscription); + observeTerminals(input) { + const { signal: _signal, ...query } = input; + subscribeTerminalCalls.push(query); + return observe( + { + ...query, + subscriptionId: `terminal-${subscribeTerminalCalls.length}`, + requestId: "terminals", + terminals: [], + }, + ["terminals_changed"], + () => unsubscribeTerminalCalls.push(query), + (message) => message.type === "terminals_changed" && message.payload.cwd === query.cwd, + ); }, }, emit, @@ -153,17 +206,18 @@ describe("server data push router", () => { const cwd = "/repo"; const queryKey = checkoutDiffQueryKey(serverId, cwd, "uncommitted", undefined, false); const subscriptionId = "diff-sharing"; - queryClient.getQueryCache().build(queryClient, { + const observer = new QueryObserver(queryClient, { queryKey, queryFn: skipToken, meta: checkoutDiffPushRoute({ - enabled: false, + enabled: true, serverId, cwd, subscriptionId, compare: { mode: "uncommitted" }, }), }); + const unsubscribe = observer.subscribe(() => {}); const unmount = mountServerDataPushRouter({ queryClient, client: fake.client, serverId }); type Payload = SubscribeCheckoutDiffResponseMessage["payload"]; const files: Payload["files"] = ["a.ts", "b.ts"].map((path) => ({ @@ -195,7 +249,13 @@ describe("server data push router", () => { const publish = (incomingFiles: Payload["files"], requestId: string) => { fake.emit({ type: "subscribe_checkout_diff_response", - payload: { subscriptionId, cwd, files: incomingFiles, requestId, error: null }, + payload: { + subscriptionId: fake.subscribeCheckoutDiffCalls[0]!.subscriptionId, + cwd, + files: incomingFiles, + requestId, + error: null, + }, }); return queryClient.getQueryData(queryKey)!; }; @@ -212,6 +272,7 @@ describe("server data push router", () => { expect(next.files[0]!.hunks[0]!.lines[0]!.tokens![0]!.style).toBe("variable"); expect(publish([], "deleted").files).toEqual([]); } finally { + unsubscribe(); unmount(); queryClient.clear(); } @@ -277,18 +338,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 +369,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 +383,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 +479,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 +543,10 @@ describe("server data push router", () => { { cwd, compare: { mode: "base", baseRef: "main", ignoreWhitespace: true }, - subscriptionId: checkoutDiffSubscriptionId, - }, - { - cwd, - compare: { mode: "base", baseRef: "main", ignoreWhitespace: true }, - subscriptionId: checkoutDiffSubscriptionId, + subscriptionId: "server-diff-1", }, ]); - expect(fake.subscribeTerminalCalls).toEqual([ - { cwd, workspaceId }, - { cwd, workspaceId }, - ]); + expect(fake.subscribeTerminalCalls).toEqual([{ cwd, workspaceId }]); fake.emit({ type: "terminals_changed", diff --git a/packages/app/src/data/push-router.ts b/packages/app/src/data/push-router.ts index 72c8ef20b2..fc6d51ce4f 100644 --- a/packages/app/src/data/push-router.ts +++ b/packages/app/src/data/push-router.ts @@ -1,3 +1,4 @@ +import type { OwnedSubscription } from "@getpaseo/client"; import type { Query, QueryCacheNotifyEvent, QueryClient, QueryKey } from "@tanstack/react-query"; import type { ListTerminalsResponse, @@ -21,19 +22,12 @@ type ProvidersSnapshotUpdateMessage = Extract< SessionOutboundMessage, { type: "providers_snapshot_update" } >; -type CheckoutDiffUpdateMessage = Extract; type SubscribeCheckoutDiffResponseMessage = Extract< SessionOutboundMessage, { type: "subscribe_checkout_diff_response" } >; type StatusMessage = Extract; type TerminalsChangedMessage = Extract; -type ServerDataEventType = - | "providers_snapshot_update" - | "checkout_diff_update" - | "subscribe_checkout_diff_response" - | "status" - | "terminals_changed"; type CheckoutDiffResponsePayload = SubscribeCheckoutDiffResponseMessage["payload"]; type CheckoutDiffCachePayload = Omit; type ListTerminalsPayload = ListTerminalsResponse["payload"]; @@ -53,6 +47,10 @@ interface CheckoutDiffRoute { compare: CheckoutDiffCompare; } +interface CheckoutDiffRegistration extends CheckoutDiffRoute { + subscription: OwnedSubscription; +} + interface WorkspaceTerminalsRoute { domain: "workspaceTerminals"; enabled: boolean; @@ -61,6 +59,10 @@ interface WorkspaceTerminalsRoute { workspaceId?: string; } +interface WorkspaceTerminalsRegistration extends WorkspaceTerminalsRoute { + subscription: OwnedSubscription; +} + type ServerDataRoute = CheckoutDiffRoute | WorkspaceTerminalsRoute; export interface ServerDataQueryMeta extends Record { @@ -71,18 +73,9 @@ export type ProvidersSnapshotUpdate = ProvidersSnapshotUpdateMessage; interface ServerDataPushClient { getProvidersSnapshot: import("@getpaseo/client/internal/daemon-client").DaemonClient["getProvidersSnapshot"]; - on( - type: TType, - handler: (message: Extract) => void, - ): () => void; - subscribeCheckoutDiff( - cwd: string, - compare: CheckoutDiffCompare, - options: { subscriptionId: string; requestId?: string }, - ): Promise; - unsubscribeCheckoutDiff(subscriptionId: string): void; - subscribeTerminals(input: { cwd: string; workspaceId?: string }): void; - unsubscribeTerminals(input: { cwd: string; workspaceId?: string }): void; + observeEvents: import("@getpaseo/client/internal/daemon-client").DaemonClient["observeEvents"]; + observeCheckoutDiff: import("@getpaseo/client/internal/daemon-client").DaemonClient["observeCheckoutDiff"]; + observeTerminals: import("@getpaseo/client/internal/daemon-client").DaemonClient["observeTerminals"]; } interface PushRouterInput { @@ -137,7 +130,6 @@ const RECONNECT_REPAIR_POLICIES: ReconnectRepairPolicy[] = [ }, }, ]; -const reconnectSubscriptionRepairsByServerId = new Map void>>(); export function checkoutDiffPushRoute(input: { enabled: boolean; @@ -182,10 +174,6 @@ export function invalidateServerDataQueriesAfterReconnect(input: { for (const policy of RECONNECT_REPAIR_POLICIES) { policy.invalidate(input); } - for (const repairSubscriptions of reconnectSubscriptionRepairsByServerId.get(input.serverId) ?? - []) { - repairSubscriptions(); - } } export async function applyProvidersSnapshotUpdate(input: { @@ -226,8 +214,8 @@ export async function applyProvidersSnapshotUpdate(input: { } export function mountServerDataPushRouter(input: PushRouterInput): () => void { - const activeCheckoutDiffSubscriptions = new Map(); - const activeTerminalSubscriptions = new Map(); + const activeCheckoutDiffSubscriptions = new Map(); + const activeTerminalSubscriptions = new Map(); let disposed = false; function reconcileSubscriptions( @@ -262,24 +250,24 @@ export function mountServerDataPushRouter(input: PushRouterInput): () => void { client: input.client, desired: desiredCheckoutDiffSubscriptions, serverId: input.serverId, + queryClient: input.queryClient, }); reconcileTerminalSubscriptions({ active: activeTerminalSubscriptions, client: input.client, desired: desiredTerminalSubscriptions, + apply: (route, payload) => + applyTerminalsChanged({ + activeCheckoutDiffSubscriptions, + activeTerminalSubscriptions, + queryClient: input.queryClient, + serverId: input.serverId, + route, + message: { type: "terminals_changed", payload }, + }), }); } - function resetSubscriptionsAfterReconnect(): void { - const fallbackActive = { - checkoutDiff: new Map(activeCheckoutDiffSubscriptions), - workspaceTerminals: new Map(activeTerminalSubscriptions), - }; - activeCheckoutDiffSubscriptions.clear(); - activeTerminalSubscriptions.clear(); - reconcileSubscriptions(fallbackActive); - } - const unsubscribeQueryCache = input.queryClient.getQueryCache().subscribe((event) => { if ( !shouldReconcileSubscriptionsForCacheEvent(event, input.serverId, { @@ -291,137 +279,131 @@ export function mountServerDataPushRouter(input: PushRouterInput): () => void { } reconcileSubscriptions(); }); - const unsubscribeProviders = input.client.on("providers_snapshot_update", (message) => { - void applyProvidersSnapshotUpdate({ - client: input.client, - queryClient: input.queryClient, - serverId: input.serverId, - message, - }).catch(() => { - /* Query state owns fetch failures; reconnect/refetch repairs them. */ - }); - }); - const unsubscribeDaemonConfig = input.client.on("status", (message) => { - applyDaemonConfigStatus({ queryClient: input.queryClient, serverId: input.serverId, message }); - }); - const unsubscribeCheckoutDiffUpdate = input.client.on("checkout_diff_update", (message) => { - applyCheckoutDiffUpdate({ - activeCheckoutDiffSubscriptions, - queryClient: input.queryClient, - serverId: input.serverId, - message, - }); - }); - const unsubscribeCheckoutDiffResponse = input.client.on( - "subscribe_checkout_diff_response", - (message) => { - applyCheckoutDiffSubscribeResponse({ - activeCheckoutDiffSubscriptions, - queryClient: input.queryClient, - serverId: input.serverId, - message, - }); + const events = input.client.observeEvents([ + "providers_snapshot_update", + "status.daemon_config_changed", + ]); + events.subscribe({ + snapshot: () => {}, + update: (message) => { + if (message.type === "providers_snapshot_update") { + void applyProvidersSnapshotUpdate({ + client: input.client, + queryClient: input.queryClient, + serverId: input.serverId, + message, + }).catch(() => { + /* Query state owns fetch failures; reconnect/refetch repairs them. */ + }); + } + if (message.type === "status") + applyDaemonConfigStatus({ + queryClient: input.queryClient, + serverId: input.serverId, + message, + }); }, - ); - const unsubscribeTerminalsChanged = input.client.on("terminals_changed", (message) => { - applyTerminalsChanged({ - activeCheckoutDiffSubscriptions, - activeTerminalSubscriptions, - queryClient: input.queryClient, - serverId: input.serverId, - message, - }); }); - let reconnectSubscriptionRepairs = reconnectSubscriptionRepairsByServerId.get(input.serverId); - if (!reconnectSubscriptionRepairs) { - reconnectSubscriptionRepairs = new Set(); - reconnectSubscriptionRepairsByServerId.set(input.serverId, reconnectSubscriptionRepairs); - } - reconnectSubscriptionRepairs.add(resetSubscriptionsAfterReconnect); reconcileSubscriptions(); return () => { disposed = true; - reconnectSubscriptionRepairs.delete(resetSubscriptionsAfterReconnect); - if (reconnectSubscriptionRepairs.size === 0) { - reconnectSubscriptionRepairsByServerId.delete(input.serverId); - } unsubscribeQueryCache(); - unsubscribeProviders(); - unsubscribeDaemonConfig(); - unsubscribeCheckoutDiffUpdate(); - unsubscribeCheckoutDiffResponse(); - unsubscribeTerminalsChanged(); - for (const subscriptionId of activeCheckoutDiffSubscriptions.keys()) { - unsubscribeCheckoutDiff(input.client, subscriptionId); + void events.release().catch(console.error); + for (const current of activeCheckoutDiffSubscriptions.values()) { + void current.subscription.release().catch(console.error); } activeCheckoutDiffSubscriptions.clear(); for (const route of activeTerminalSubscriptions.values()) { - input.client.unsubscribeTerminals(workspaceTerminalSubscriptionInput(route)); + void route.subscription.release().catch(console.error); } activeTerminalSubscriptions.clear(); }; } function reconcileCheckoutDiffSubscriptions(input: { - active: Map; + active: Map; client: ServerDataPushClient; desired: Map; serverId: string; + queryClient: QueryClient; }): void { - for (const [subscriptionId, current] of input.active) { - const desired = input.desired.get(subscriptionId); - if (desired && areCheckoutDiffRoutesEqual(current, desired)) { - continue; - } - unsubscribeCheckoutDiff(input.client, subscriptionId); - input.active.delete(subscriptionId); + for (const [key, current] of input.active) { + const desired = input.desired.get(key); + if (desired && areCheckoutDiffRoutesEqual(current, desired)) continue; + input.active.delete(key); + void current.subscription.release().catch(console.error); } - - for (const [subscriptionId, desired] of input.desired) { - if (input.active.has(subscriptionId)) { - continue; - } - input.active.set(subscriptionId, desired); - void input.client - .subscribeCheckoutDiff(desired.cwd, desired.compare, { - subscriptionId, - requestId: `push-router:${input.serverId}:${subscriptionId}`, - }) - .catch((error) => { - if (areCheckoutDiffRoutesEqual(input.active.get(subscriptionId), desired)) { - input.active.delete(subscriptionId); - } - console.error("[server-data] subscribeCheckoutDiff failed", { + for (const [key, desired] of input.desired) { + if (input.active.has(key)) continue; + const subscription = input.client.observeCheckoutDiff(desired.cwd, desired.compare); + const registration = { ...desired, subscription }; + input.active.set(key, registration); + const apply = ( + payload: Omit & { requestId?: string }, + ) => { + if (input.active.get(key) !== registration) return; + const { subscriptionId, ...snapshot } = payload; + setCheckoutDiffPayload({ + activeCheckoutDiffSubscriptions: input.active, + queryClient: input.queryClient, + serverId: input.serverId, + subscriptionId: key, + payload: { + ...snapshot, + files: orderCheckoutDiffFiles(payload.files), + requestId: payload.requestId ?? `subscription:${subscriptionId}`, + }, + }); + }; + subscription.subscribe({ + snapshot: apply, + update: (message) => { + if (message.type === "checkout_diff_update") apply(message.payload); + }, + error: (error) => { + if (input.active.get(key) === registration) input.active.delete(key); + console.error("[server-data] observeCheckoutDiff failed", { serverId: input.serverId, cwd: desired.cwd, error, }); - }); + }, + }); } } function reconcileTerminalSubscriptions(input: { - active: Map; + active: Map; client: ServerDataPushClient; desired: Map; + apply(route: WorkspaceTerminalsRoute, payload: TerminalsChangedMessage["payload"]): void; }): void { for (const [key, current] of input.active) { const desired = input.desired.get(key); - if (desired && areWorkspaceTerminalsRoutesEqual(current, desired)) { - continue; - } - input.client.unsubscribeTerminals(workspaceTerminalSubscriptionInput(current)); + if (desired && areWorkspaceTerminalsRoutesEqual(current, desired)) continue; input.active.delete(key); + void current.subscription.release().catch(console.error); } - for (const [key, desired] of input.desired) { - if (input.active.has(key)) { - continue; - } - input.active.set(key, desired); - input.client.subscribeTerminals(workspaceTerminalSubscriptionInput(desired)); + if (input.active.has(key)) continue; + const subscription = input.client.observeTerminals(workspaceTerminalSubscriptionInput(desired)); + const registration = { ...desired, subscription }; + input.active.set(key, registration); + const apply = (payload: TerminalsChangedMessage["payload"]) => { + if (input.active.get(key) === registration) input.apply(desired, payload); + }; + subscription.subscribe({ + snapshot: apply, + update: (message) => { + if (message.type === "terminals_changed") apply(message.payload); + }, + error: (error) => { + if (input.active.get(key) === registration) input.active.delete(key); + console.error("[server-data] observeTerminals failed", { cwd: desired.cwd, error }); + }, + }); } } @@ -443,52 +425,6 @@ function applyDaemonConfigStatus(input: { }); } -function applyCheckoutDiffUpdate(input: { - activeCheckoutDiffSubscriptions: Map; - queryClient: QueryClient; - serverId: string; - message: CheckoutDiffUpdateMessage; -}): void { - setCheckoutDiffPayload({ - activeCheckoutDiffSubscriptions: input.activeCheckoutDiffSubscriptions, - queryClient: input.queryClient, - serverId: input.serverId, - subscriptionId: input.message.payload.subscriptionId, - payload: { - cwd: input.message.payload.cwd, - files: orderCheckoutDiffFiles(input.message.payload.files), - error: input.message.payload.error, - ...(input.message.payload.diffTooLarge !== undefined - ? { diffTooLarge: input.message.payload.diffTooLarge } - : {}), - requestId: `subscription:${input.message.payload.subscriptionId}`, - }, - }); -} - -function applyCheckoutDiffSubscribeResponse(input: { - activeCheckoutDiffSubscriptions: Map; - queryClient: QueryClient; - serverId: string; - message: SubscribeCheckoutDiffResponseMessage; -}): void { - setCheckoutDiffPayload({ - activeCheckoutDiffSubscriptions: input.activeCheckoutDiffSubscriptions, - queryClient: input.queryClient, - serverId: input.serverId, - subscriptionId: input.message.payload.subscriptionId, - payload: { - cwd: input.message.payload.cwd, - files: orderCheckoutDiffFiles(input.message.payload.files), - error: input.message.payload.error, - ...(input.message.payload.diffTooLarge !== undefined - ? { diffTooLarge: input.message.payload.diffTooLarge } - : {}), - requestId: input.message.payload.requestId, - }, - }); -} - function setCheckoutDiffPayload(input: { activeCheckoutDiffSubscriptions: Map; queryClient: QueryClient; @@ -523,6 +459,7 @@ function applyTerminalsChanged(input: { queryClient: QueryClient; serverId: string; message: TerminalsChangedMessage; + route: WorkspaceTerminalsRoute; }): void { for (const query of input.queryClient.getQueryCache().getAll()) { const route = getActiveServerDataRoute(query, input.serverId, { @@ -532,7 +469,7 @@ function applyTerminalsChanged(input: { if ( !route || route.domain !== "workspaceTerminals" || - route.cwd !== input.message.payload.cwd + !areWorkspaceTerminalsRoutesEqual(route, input.route) ) { continue; } @@ -776,14 +713,6 @@ function workspaceTerminalSubscriptionInput(route: WorkspaceTerminalsRoute): { }; } -function unsubscribeCheckoutDiff(client: ServerDataPushClient, subscriptionId: string): void { - try { - client.unsubscribeCheckoutDiff(subscriptionId); - } catch { - // Disconnect cleanup can race with explicit subscription teardown. - } -} - function isQueryForServer(queryKey: QueryKey, kind: string, serverId: string): boolean { return queryKey.length >= 2 && queryKey[0] === kind && queryKey[1] === serverId; } diff --git a/packages/app/src/desktop/browser/automation/handler.ts b/packages/app/src/desktop/browser/automation/handler.ts index bc30fdadb4..08bb9927e5 100644 --- a/packages/app/src/desktop/browser/automation/handler.ts +++ b/packages/app/src/desktop/browser/automation/handler.ts @@ -1,3 +1,4 @@ +import { BROWSER_AUTOMATION_COMMAND_NAMES } from "@getpaseo/protocol/browser-automation/rpc-schemas"; import type { SessionInboundMessage, SessionOutboundMessage } from "@getpaseo/protocol/messages"; import { getDesktopHost, type DesktopHostBridge } from "@/desktop/host"; import { @@ -69,13 +70,33 @@ export function mountBrowserAutomationHandler( } export function mountBrowserAutomationDaemonClientHandler( - client: unknown, + client: import("@getpaseo/client/internal/daemon-client").DaemonClient, options?: { serverId?: string }, ): () => void { - return mountBrowserAutomationHandler({ - client: client as BrowserAutomationClient, + const observation = client.registerBrowserHost({ + hostKind: "desktop app", + supportedCommands: [...BROWSER_AUTOMATION_COMMAND_NAMES], + }); + const unmount = mountBrowserAutomationHandler({ + client: { + on: (_type, handler) => + observation.subscribe({ + snapshot: () => {}, + update: (message) => { + if (message.type === "browser.automation.execute.request") handler(message); + }, + }), + sendBrowserAutomationExecuteResponse: (response) => + client.sendBrowserAutomationExecuteResponse(response), + }, ...(options?.serverId ? { serverId: options.serverId } : {}), }); + return () => { + unmount(); + void observation + .release() + .catch((error) => console.warn("Failed to release browser host", error)); + }; } async function handleBrowserAutomationRequest(params: { diff --git a/packages/app/src/plugins/buttons/model.test.ts b/packages/app/src/plugins/buttons/model.test.ts index b6ea249337..028114c862 100644 --- a/packages/app/src/plugins/buttons/model.test.ts +++ b/packages/app/src/plugins/buttons/model.test.ts @@ -6,6 +6,7 @@ import { PluginButtonStore, buttonMatches } from "./model"; function installation(): InstalledPlugin { return { + lifetime: new AbortController(), id: "review", serverId: "host-a", clientBundle: "bundle", diff --git a/packages/app/src/plugins/buttons/view.tsx b/packages/app/src/plugins/buttons/view.tsx index 42067043c2..b737b3ebf0 100644 --- a/packages/app/src/plugins/buttons/view.tsx +++ b/packages/app/src/plugins/buttons/view.tsx @@ -36,7 +36,6 @@ import type { Theme } from "@/styles/theme"; import { createPluginClientStateSource } from "../client-state/source"; import { Icon } from "../icons"; import { PluginRuntimeBoundary } from "../runtime-boundary"; -import { createPluginSurfaceRuntime, type PluginSurfaceRuntime } from "../surface-runtime"; import { SurfaceErrorBoundary } from "../surface-error-boundary"; import { toPluginTheme } from "../theme"; import { buttonMatches, type RegisteredPluginButton } from "./model"; @@ -45,7 +44,7 @@ import { pluginButtonStore } from "./store"; interface ButtonView { entry: RegisteredPluginButton; props: PluginHostProps & RegisteredPluginButton["context"]; - runtime: PluginSurfaceRuntime; + client: NonNullable>; state: ReturnType; toast: ReturnType; } @@ -79,7 +78,7 @@ function resolvePlatform(): PluginHostProps["layout"]["platform"] { function ButtonEnvironment({ view, children }: { view: ButtonView; children: ReactNode }) { return ( - + {children} @@ -219,11 +218,11 @@ function ButtonSurfaceBody({ path: readonly string[]; }) { return ( - - + + - - + + ); } @@ -396,11 +395,10 @@ function createButtonView({ hostLabel: string; theme: PluginTheme; }): ButtonView | null { - const runtime = createPluginSurfaceRuntime(client, entry.installation.id); - if (!runtime) return null; + if (!client) return null; return { entry, - runtime, + client, toast, state: createPluginClientStateSource(entry.installation.serverId), props: { @@ -486,11 +484,15 @@ function OverflowPages({ const view = createButtonView({ entry, client, toast, compact, hostLabel, theme }); if (!view) continue; rows.push( - - + + - - , + + , ); if (entry.button.behavior.kind === "action") continue; pages.push( diff --git a/packages/app/src/plugins/catalog-sync.tsx b/packages/app/src/plugins/catalog-sync.tsx index a7300d75c2..484124971e 100644 --- a/packages/app/src/plugins/catalog-sync.tsx +++ b/packages/app/src/plugins/catalog-sync.tsx @@ -48,25 +48,36 @@ export function PluginCatalogSync({ ); return refreshQueue; }; - void refresh(); - const unsubscribe = client.on("status", (message) => { - if (message.payload.status === "plugin_settings_changed") { - const { pluginId, settingsId } = message.payload; - if (typeof settingsId === "string") { - const plugin = pluginRegistry - .getSnapshot() - .find((item) => item.serverId === serverId && item.id === pluginId); - void plugin?.queryClient.invalidateQueries({ queryKey: pluginSettingsKey(settingsId) }); + const observation = client.observeEvents([ + "status.plugin_catalog_changed", + "status.plugin_settings_changed", + ]); + observation.subscribe({ + snapshot: () => { + void refresh(); + }, + update: (message) => { + if (message.type !== "status") return; + if (message.payload.status === "plugin_settings_changed") { + const { pluginId, settingsId } = message.payload; + if (typeof settingsId === "string") { + const plugin = pluginRegistry + .getSnapshot() + .find((item) => item.serverId === serverId && item.id === pluginId); + void plugin?.queryClient.invalidateQueries({ queryKey: pluginSettingsKey(settingsId) }); + } } - } - if (message.payload.status === "plugin_catalog_changed") { - const pluginId = message.payload.pluginId; - if (typeof pluginId === "string") void refresh(pluginId); - } + if (message.payload.status === "plugin_catalog_changed") { + const pluginId = message.payload.pluginId; + if (typeof pluginId === "string") void refresh(pluginId); + } + }, }); return () => { cancelled = true; - unsubscribe(); + void observation + .release() + .catch((error) => console.warn("[Plugins] Failed to release catalog", error)); }; }, [client, connected, serverId, supported]); diff --git a/packages/app/src/plugins/client-runtime.ts b/packages/app/src/plugins/client-runtime.ts index cb953d16b2..4424c240fa 100644 --- a/packages/app/src/plugins/client-runtime.ts +++ b/packages/app/src/plugins/client-runtime.ts @@ -16,7 +16,7 @@ export function createPluginClientRuntime( installation: InstalledPlugin, daemonClient: DaemonClient, ): PluginClientRuntime { - const runtime = createPluginSurfaceRuntime(daemonClient, installation.id); + const runtime = createPluginSurfaceRuntime(daemonClient, installation); if (!runtime) throw new Error("Plugin host is offline"); const state = createPluginClientStateSource(installation.serverId); const capabilities = createPluginCapabilities( diff --git a/packages/app/src/plugins/client-slash-commands/index.ts b/packages/app/src/plugins/client-slash-commands/index.ts index a99a0fa538..57d205a7b9 100644 --- a/packages/app/src/plugins/client-slash-commands/index.ts +++ b/packages/app/src/plugins/client-slash-commands/index.ts @@ -31,28 +31,10 @@ export function usePluginClientSlashCommands(input: { }); const commands = installed .filter((plugin) => plugin.serverId === input.serverId) - .flatMap((plugin) => { - const runtime = createPluginSurfaceRuntime(client, plugin.id); - if (!runtime) return []; - return (plugin.clientSlashCommands ?? []).flatMap((contribution) => { - const context = - contribution.context === "agent" - ? createPluginAgentActionContext({ - plugin, - runtime, - navigation, - state, - workspaceId, - agentId: input.agentId, - }) - : createPluginWorkspaceActionContext({ - plugin, - runtime, - navigation, - state, - workspaceId, - }); - if (!context) return []; + .flatMap((plugin) => + plugin.clientSlashCommands.flatMap((contribution) => { + if (contribution.context === "agent" && !state.getAgent(input.agentId)) return []; + if (!state.getWorkspace(workspaceId)) return []; return [ { pluginId: plugin.id, @@ -60,19 +42,38 @@ export function usePluginClientSlashCommands(input: { description: contribution.description, argumentHint: contribution.argumentHint, async run(args: string) { - if (contribution.context === "agent" && context.context === "agent") { - await contribution.onSubmit({ ...context, args }); - } else if ( - contribution.context === "workspace" && - context.context === "workspace" - ) { - await contribution.onSubmit({ ...context, args }); + const runtime = createPluginSurfaceRuntime(client, plugin); + if (!runtime) return; + try { + const context = + contribution.context === "agent" + ? createPluginAgentActionContext({ + plugin, + runtime, + navigation, + state, + workspaceId, + agentId: input.agentId, + }) + : createPluginWorkspaceActionContext({ + plugin, + runtime, + navigation, + state, + workspaceId, + }); + if (contribution.context === "agent" && context?.context === "agent") + await contribution.onSubmit({ ...context, args }); + else if (contribution.context === "workspace" && context?.context === "workspace") + await contribution.onSubmit({ ...context, args }); + } finally { + await runtime.paseo.dispose(); } }, }, ]; - }); - }); + }), + ); return commands; }, [client, input.agentId, input.serverId, input.workspaceId, installed]); } diff --git a/packages/app/src/plugins/command-center/contributions.test.ts b/packages/app/src/plugins/command-center/contributions.test.ts index dc40b342b9..89313f907c 100644 --- a/packages/app/src/plugins/command-center/contributions.test.ts +++ b/packages/app/src/plugins/command-center/contributions.test.ts @@ -60,6 +60,7 @@ function plugin(onAgentSelect: AgentCommandItem["onSelect"]): InstalledPlugin { id: "review", serverId: "host-1", clientBundle: "bundle", + lifetime: new AbortController(), queryClient: new QueryClient(), cleanup: () => {}, settingsScreens: [], @@ -120,7 +121,7 @@ function stateSource() { }; } -function createRuntime(pluginId: string) { +function createRuntime(installed: InstalledPlugin) { const client = new DaemonClient({ url: "ws://127.0.0.1:1", clientId: "plugin-command-test", @@ -129,7 +130,7 @@ function createRuntime(pluginId: string) { return { paseo: createPaseoApi(client), invoke: async (method: string, input: unknown) => { - expect(pluginId).toBe("review"); + expect(installed.id).toBe("review"); expect(method).toBe("review.inspect"); return { value: inspect.input.parse(input).value + 1 }; }, @@ -187,7 +188,7 @@ describe("plugin Command Center contributions", () => { context.openSurface("main"); context.openPanel("details", { location: "explorer" }); }); - const runtime = createRuntime("review"); + const runtime = createRuntime(installed); const actions = buildPluginCommandCenterContributions({ plugins: [installed], runtime: () => runtime, diff --git a/packages/app/src/plugins/command-center/contributions.ts b/packages/app/src/plugins/command-center/contributions.ts index 448b0068cb..9d40ee57e2 100644 --- a/packages/app/src/plugins/command-center/contributions.ts +++ b/packages/app/src/plugins/command-center/contributions.ts @@ -9,7 +9,7 @@ import { createPluginCapabilities, type PluginNavigation } from "../actions"; export interface PluginCommandCenterSource { plugins: readonly InstalledPlugin[]; - runtime(pluginId: string): PluginSurfaceRuntime; + runtime(plugin: InstalledPlugin): PluginSurfaceRuntime; state: PluginClientStateSource; workspaceId: string | null; agentId: string | null; @@ -22,12 +22,12 @@ export function buildPluginCommandCenterContributions( ): CommandCenterContribution[] { const contributions: CommandCenterContribution[] = []; for (const plugin of source.plugins) { - const runtime = source.runtime(plugin.id); - const common = createPluginCapabilities(plugin, runtime, source.navigation); for (const [rank, item] of plugin.commandCenterItems.entries()) { if (item.context === "workspace" && !source.workspaceId) continue; if (item.context === "agent" && (!source.workspaceId || !source.agentId)) continue; const run = async () => { + const runtime = source.runtime(plugin); + const common = createPluginCapabilities(plugin, runtime, source.navigation); try { if (item.context === "global") { await item.onSelect({ context: "global", ...common }); @@ -73,6 +73,8 @@ export function buildPluginCommandCenterContributions( }); } catch (error) { source.reportError(error); + } finally { + await runtime.paseo.dispose().catch(source.reportError); } }; contributions.push({ diff --git a/packages/app/src/plugins/command-center/registration.tsx b/packages/app/src/plugins/command-center/registration.tsx index 2ee0d24ff2..cd85f9bff6 100644 --- a/packages/app/src/plugins/command-center/registration.tsx +++ b/packages/app/src/plugins/command-center/registration.tsx @@ -53,8 +53,8 @@ export function PluginCommandCenterActions() { if (!client || !serverId || !stateSource) return []; return buildPluginCommandCenterContributions({ plugins, - runtime(pluginId) { - const runtime = createPluginSurfaceRuntime(client, pluginId); + runtime(plugin) { + const runtime = createPluginSurfaceRuntime(client, plugin); if (!runtime) throw new Error("Plugin host is offline"); return runtime; }, diff --git a/packages/app/src/plugins/evaluate.ts b/packages/app/src/plugins/evaluate.ts index e56eb0e5be..d3cb17b150 100644 --- a/packages/app/src/plugins/evaluate.ts +++ b/packages/app/src/plugins/evaluate.ts @@ -392,19 +392,11 @@ export function runPluginClientBundle( if (typeof setup !== "function") { throw new Error(`Plugin ${id} must default export a function`); } - let entryCleanup: PluginCleanup; + let entryCleanup: PluginCleanup | undefined; try { entryCleanup = setup(pluginContext); - if (typeof entryCleanup !== "function") { + if (typeof entryCleanup !== "function") throw new Error(`Plugin ${id} contribution must return a cleanup function`); - } - } catch (error) { - stopped = true; - for (const remove of removals) remove(); - throw error; - } - - try { for (const item of collector.sidebarItems) { if (!surfaceIds.has(item.surface)) { throw new Error(`Sidebar item ${item.id} references missing surface ${item.surface}`); @@ -412,22 +404,26 @@ export function runPluginClientBundle( } } catch (error) { stopped = true; - for (const remove of removals) remove(); try { - void Promise.resolve(entryCleanup()).catch((cleanupError) => { + void Promise.resolve( + typeof entryCleanup === "function" ? entryCleanup() : entryCleanup, + ).catch((cleanupError) => { console.warn(`[Plugins] Cleanup failed after setup error for ${id}`, cleanupError); }); } catch (cleanupError) { console.warn(`[Plugins] Cleanup failed after setup error for ${id}`, cleanupError); + } finally { + for (const remove of removals) remove(); } throw error; } setupComplete = true; + const cleanupEntry = entryCleanup; const cleanup = async () => { if (stopped) return; stopped = true; try { - await entryCleanup(); + await cleanupEntry(); } finally { for (const remove of removals) remove(); } diff --git a/packages/app/src/plugins/registry-lifecycle.test.ts b/packages/app/src/plugins/registry-lifecycle.test.ts new file mode 100644 index 0000000000..1a239a3d30 --- /dev/null +++ b/packages/app/src/plugins/registry-lifecycle.test.ts @@ -0,0 +1,74 @@ +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: () => ({ update() {}, remove() {} }), + addHeaderButton: () => ({ update() {}, remove() {} }), + }; + }, + }); + return { client, released, plugins }; +} + +function catalog(id: string, body: string) { + return { + id, + requirements: { paseo: ">=0.8.0" }, + clientBundle: `(function() { return { default: function(plugin) { ${body} } }; })`, + }; +} + +test("failed plugin initialization disposes its API scope", async () => { + const h = registry(); + h.plugins.installCatalog("host", [catalog("failed", 'throw new Error("setup failed");')], { + client: h.client, + }); + await expect.poll(() => h.released).toEqual(["failed"]); + expect(h.plugins.getSnapshot()).toEqual([]); +}); + +test("unloading releases the API even when plugin cleanup throws, preserving another plugin", async () => { + const h = registry(); + const failing = catalog("failing", 'return function() { throw new Error("cleanup failed"); };'); + const surviving = catalog("surviving", "return function() {};"); + h.plugins.installCatalog("host", [failing, surviving], { client: h.client }); + h.plugins.installCatalog("host", [surviving], { client: h.client }); + await expect.poll(() => h.released).toEqual(["failing"]); + expect(h.plugins.getSnapshot().map((plugin) => plugin.id)).toEqual(["surviving"]); + h.plugins.removeHost("host"); + await expect.poll(() => h.released).toEqual(["failing", "surviving"]); +}); + +test("an invalid async client entry is disposed and its rejected continuation is observed", async () => { + const h = registry(); + h.plugins.installCatalog( + "host", + [catalog("async-entry", 'return Promise.reject(new Error("asynchronous setup failed"));')], + { client: h.client }, + ); + await expect.poll(() => h.released).toEqual(["async-entry"]); + await new Promise((resolve) => setTimeout(resolve, 0)); + expect(h.plugins.getSnapshot()).toEqual([]); +}); diff --git a/packages/app/src/plugins/registry.test.ts b/packages/app/src/plugins/registry.test.ts index 918798f7e4..45e1ba546f 100644 --- a/packages/app/src/plugins/registry.test.ts +++ b/packages/app/src/plugins/registry.test.ts @@ -8,7 +8,7 @@ vi.mock("./navigation", () => ({ })); vi.mock("./client-runtime", () => ({ createPluginClientRuntime: () => ({ - paseo: {}, + paseo: { dispose: async () => {} }, rpc: async () => undefined, openSurface: () => undefined, openPanel: () => undefined, diff --git a/packages/app/src/plugins/registry.ts b/packages/app/src/plugins/registry.ts index f228c7202f..2894784fc7 100644 --- a/packages/app/src/plugins/registry.ts +++ b/packages/app/src/plugins/registry.ts @@ -4,7 +4,7 @@ import type { DaemonClient } from "@getpaseo/client/internal/daemon-client"; import { assertPluginCompatibility } from "@getpaseo/protocol/plugin-requirements"; import { resolveAppVersion } from "@/utils/app-version"; import { createPluginClientRuntime } from "./client-runtime"; -import { runPluginClientBundle } from "./evaluate"; +import { runPluginClientBundle, type PluginClientRuntime } from "./evaluate"; import type { InstalledPlugin } from "./types"; type CatalogPlugin = Awaited>[number]; @@ -64,6 +64,8 @@ export class PluginRegistry { } const installed = catalog.flatMap((entry) => { const key = `${serverId}/${entry.id}`; + let runtime: PluginClientRuntime | undefined; + let lifetime: AbortController | undefined; try { if (!entry.clientBundle) return []; assertPluginCompatibility({ ...entry, version: this.dependencies.version, runtime: "app" }); @@ -74,7 +76,9 @@ export class PluginRegistry { this.evaluationErrors.delete(key); return [existing]; } + lifetime = new AbortController(); const installation: InstalledPlugin = { + lifetime, id: entry.id, serverId, clientBundle: entry.clientBundle, @@ -92,16 +96,28 @@ 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(), 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 +154,7 @@ export class PluginRegistry { private dispose(plugin: InstalledPlugin): void { if (this.disposed.has(plugin)) return; this.disposed.add(plugin); + plugin.lifetime.abort(); plugin.queryClient.clear(); try { void Promise.resolve(plugin.cleanup()).catch((error) => { diff --git a/packages/app/src/plugins/runtime-boundary.tsx b/packages/app/src/plugins/runtime-boundary.tsx index eaed2e5d54..7ae51af2b9 100644 --- a/packages/app/src/plugins/runtime-boundary.tsx +++ b/packages/app/src/plugins/runtime-boundary.tsx @@ -2,17 +2,20 @@ import { QueryClientProvider } from "@tanstack/react-query"; import { PaseoApiProvider, PluginRpcProvider } from "@getpaseo/plugin/client/host"; import type { ReactNode } from "react"; import type { InstalledPlugin } from "./types"; -import type { PluginSurfaceRuntime } from "./surface-runtime"; +import { usePluginSurfaceRuntime } from "./surface-runtime"; +import type { DaemonClient } from "@getpaseo/client/internal/daemon-client"; export function PluginRuntimeBoundary({ plugin, - runtime, + client, children, }: { plugin: InstalledPlugin; - runtime: PluginSurfaceRuntime; + client: DaemonClient; children: ReactNode; }) { + const runtime = usePluginSurfaceRuntime(client, plugin); + if (!runtime) return null; return ( diff --git a/packages/app/src/plugins/settings/index.tsx b/packages/app/src/plugins/settings/index.tsx index dbf9dec18d..f4c66c3b32 100644 --- a/packages/app/src/plugins/settings/index.tsx +++ b/packages/app/src/plugins/settings/index.tsx @@ -13,7 +13,6 @@ import type { Theme } from "@/styles/theme"; import { useInstalledPlugin } from "../registry"; import { PluginRuntimeBoundary } from "../runtime-boundary"; import { SurfaceErrorBoundary } from "../surface-error-boundary"; -import { createPluginSurfaceRuntime } from "../surface-runtime"; import { toPluginTheme } from "../theme"; import { buildPluginSettingsRoute } from "./routes"; @@ -101,8 +100,7 @@ function SettingsContent({ return {t("settings.plugins.screens.offline")}; // COMPAT(pluginSettings): added in v0.8, remove after 2027-03-05. if (!supported) return {t("settings.plugins.screens.update")}; - const runtime = createPluginSurfaceRuntime(client, pluginId); - if (!plugin || !screen || !runtime) + if (!plugin || !screen || !client) return {t("settings.plugins.screens.unavailable")}; const Component = screen.Component; return ( @@ -113,7 +111,7 @@ function SettingsContent({ resetKey={attempt} renderError={renderError} > - + diff --git a/packages/app/src/plugins/sidebar-groups.test.ts b/packages/app/src/plugins/sidebar-groups.test.ts index eb9ff4fa45..7a38bb8eb5 100644 --- a/packages/app/src/plugins/sidebar-groups.test.ts +++ b/packages/app/src/plugins/sidebar-groups.test.ts @@ -9,6 +9,7 @@ function installed(serverId: string, contributionId = "main"): InstalledPlugin { cleanup: () => undefined, serverId, clientBundle: serverId, + lifetime: new AbortController(), queryClient: new QueryClient(), settingsScreens: [], surfaces: [{ id: "surface", Component: () => null }], diff --git a/packages/app/src/plugins/surface-contribution.test.ts b/packages/app/src/plugins/surface-contribution.test.ts index 685b57d3c2..e6b2eea757 100644 --- a/packages/app/src/plugins/surface-contribution.test.ts +++ b/packages/app/src/plugins/surface-contribution.test.ts @@ -16,6 +16,7 @@ function installation( id: pluginId, serverId, clientBundle: serverId, + lifetime: new AbortController(), queryClient: new QueryClient(), cleanup: () => undefined, settingsScreens: [], diff --git a/packages/app/src/plugins/surface-runtime.test.tsx b/packages/app/src/plugins/surface-runtime.test.tsx index 88d8f694ec..30b2a7f76b 100644 --- a/packages/app/src/plugins/surface-runtime.test.tsx +++ b/packages/app/src/plugins/surface-runtime.test.tsx @@ -52,7 +52,10 @@ function borrowFromAppProvider(paseo: PaseoApi): PaseoApi { describe("plugin surface host runtime", () => { it("creates a PR worktree and agent through usePaseo on the selected app host", async () => { const selected = clientWithWorkspace("workspace-a"); - const runtime = createPluginSurfaceRuntime(selected.client, "workspace-plugin"); + const runtime = createPluginSurfaceRuntime(selected.client, { + id: "workspace-plugin", + lifetime: new AbortController(), + }); if (!runtime) throw new Error("Expected selected host runtime"); const paseo = borrowFromAppProvider(runtime.paseo); @@ -78,8 +81,14 @@ describe("plugin surface host runtime", () => { it("switches all plugin calls when the selected host changes", async () => { const hostA = clientWithWorkspace("workspace-a"); const hostB = clientWithWorkspace("workspace-b"); - const first = createPluginSurfaceRuntime(hostA.client, "same-plugin"); - const second = createPluginSurfaceRuntime(hostB.client, "same-plugin"); + const first = createPluginSurfaceRuntime(hostA.client, { + id: "same-plugin", + lifetime: new AbortController(), + }); + const second = createPluginSurfaceRuntime(hostB.client, { + id: "same-plugin", + lifetime: new AbortController(), + }); if (!first || !second) throw new Error("Expected online host runtimes"); await first.invoke("host", {}); @@ -95,7 +104,9 @@ describe("plugin surface host runtime", () => { it("keeps an offline selected host unavailable instead of borrowing another host", () => { const otherHost = clientWithWorkspace("workspace-online"); - expect(createPluginSurfaceRuntime(null, "same-plugin")).toBeNull(); + expect( + createPluginSurfaceRuntime(null, { id: "same-plugin", lifetime: new AbortController() }), + ).toBeNull(); expect(otherHost.createWorkspace).not.toHaveBeenCalled(); }); }); diff --git a/packages/app/src/plugins/surface-runtime.ts b/packages/app/src/plugins/surface-runtime.ts index b3635b9122..3c99e38aba 100644 --- a/packages/app/src/plugins/surface-runtime.ts +++ b/packages/app/src/plugins/surface-runtime.ts @@ -1,3 +1,5 @@ +import { useEffect, useState } from "react"; +import type { InstalledPlugin } from "./types"; import { createPaseoApi, type PaseoApi } from "@getpaseo/client"; import type { DaemonClient } from "@getpaseo/client/internal/daemon-client"; @@ -8,11 +10,35 @@ export interface PluginSurfaceRuntime { export function createPluginSurfaceRuntime( client: DaemonClient | null, - pluginId: string, + plugin: Pick, ): PluginSurfaceRuntime | null { - if (!client) return null; + if (!client || plugin.lifetime.signal.aborted) return null; return { - paseo: createPaseoApi(client), - invoke: (method, input) => client.invokePluginRpc(pluginId, method, input), + paseo: createPaseoApi(client, { signal: plugin.lifetime.signal }), + invoke: (method, input) => client.invokePluginRpc(plugin.id, method, input), }; } + +/** A mounted surface owns its API; creating a React element creates no server demand. */ +export function usePluginSurfaceRuntime( + client: DaemonClient | null, + plugin: InstalledPlugin | null | undefined, +): PluginSurfaceRuntime | null { + const [mounted, setMounted] = useState<{ + client: DaemonClient; + plugin: InstalledPlugin; + runtime: PluginSurfaceRuntime; + } | null>(null); + useEffect(() => { + if (!client || !plugin) return; + const runtime = createPluginSurfaceRuntime(client, plugin); + if (!runtime) return; + setMounted({ client, plugin, runtime }); + return () => { + void runtime.paseo + .dispose() + .catch((error) => console.warn(`[Plugins] Surface cleanup failed for ${plugin.id}`, error)); + }; + }, [client, plugin]); + return mounted?.client === client && mounted.plugin === plugin ? mounted.runtime : null; +} diff --git a/packages/app/src/plugins/surface-screen.tsx b/packages/app/src/plugins/surface-screen.tsx index 6139897c5b..c827cf158b 100644 --- a/packages/app/src/plugins/surface-screen.tsx +++ b/packages/app/src/plugins/surface-screen.tsx @@ -21,7 +21,7 @@ import { useInstalledPlugin, usePluginInstallations } from "./registry"; import { buildPluginSurfaceRoute } from "./routes"; import { rememberPluginContributionHost } from "./contribution-host"; import { SurfaceErrorBoundary } from "./surface-error-boundary"; -import { createPluginSurfaceRuntime } from "./surface-runtime"; +import type { DaemonClient } from "@getpaseo/client/internal/daemon-client"; import { PluginRuntimeBoundary } from "./runtime-boundary"; import { getPluginSurfaceContributionServerIds, @@ -55,14 +55,14 @@ const ThemedPluginHeaderIcon = withUnistyles(PluginHeaderIcon); function SurfaceRenderer({ Surface, - runtime, + client, plugin, layout, host, theme, }: { Surface: ComponentType; - runtime: NonNullable>; + client: DaemonClient; plugin: NonNullable>; layout: PluginSurfaceProps["layout"]; host: PluginSurfaceProps["host"]; @@ -70,7 +70,7 @@ function SurfaceRenderer({ }) { const navigation = usePluginHostNavigation(host.id); return ( - + ); @@ -162,7 +162,6 @@ export function PluginSurfaceScreen() { const installations = usePluginInstallations(pluginId); const hosts = useHosts(); const client = useHostRuntimeClient(serverId); - const runtime = useMemo(() => createPluginSurfaceRuntime(client, pluginId), [client, pluginId]); const compact = useIsCompactFormFactor(); const { sidebarItem, surface } = useMemo( () => resolvePluginSurfaceContribution(plugin, identity), @@ -225,7 +224,7 @@ export function PluginSurfaceScreen() { - {plugin && surface && runtime ? ( + {plugin && surface && client ? ( undefined, serverId, clientBundle: serverId, + lifetime: new AbortController(), queryClient: new QueryClient(), settingsScreens: [], surfaces: [], diff --git a/packages/app/src/plugins/timeline/model.test.ts b/packages/app/src/plugins/timeline/model.test.ts index 5db3d3b327..68e1378199 100644 --- a/packages/app/src/plugins/timeline/model.test.ts +++ b/packages/app/src/plugins/timeline/model.test.ts @@ -11,6 +11,7 @@ function plugin(input: { id: input.id, serverId: "host-1", clientBundle: "bundle", + lifetime: new AbortController(), queryClient: new QueryClient(), cleanup: () => {}, settingsScreens: [], diff --git a/packages/app/src/plugins/timeline/view.test.tsx b/packages/app/src/plugins/timeline/view.test.tsx index 9118a29a15..59d7c3ff36 100644 --- a/packages/app/src/plugins/timeline/view.test.tsx +++ b/packages/app/src/plugins/timeline/view.test.tsx @@ -2,14 +2,15 @@ * @vitest-environment jsdom */ import appPackage from "../../../package.json"; -import type { DaemonClient } from "@getpaseo/client/internal/daemon-client"; -import React, { act } from "react"; +import { DaemonClient, type DaemonTransport } from "@getpaseo/client/internal/daemon-client"; +import React, { act, StrictMode } from "react"; import { createRoot } from "react-dom/client"; -import { renderToStaticMarkup } from "react-dom/server"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +const hostClient = vi.hoisted(() => ({ invokePluginRpc: async () => null })); +const selectedHost = vi.hoisted(() => ({ client: null as DaemonClient | null })); vi.mock("@/runtime/host-runtime", () => ({ - useHostRuntimeClient: () => ({ invokePluginRpc: async () => null }) as unknown as DaemonClient, + useHostRuntimeClient: () => selectedHost.client ?? (hostClient as unknown as DaemonClient), useHosts: () => [{ serverId: "host-1", label: "Local" }], })); vi.mock("@/constants/layout", () => ({ @@ -21,6 +22,7 @@ vi.mock("../navigation", () => ({ vi.mock("../client-runtime", () => ({ createPluginClientRuntime: () => ({ paseo: {}, + dispose: () => {}, rpc: async () => undefined, openSurface: () => undefined, openPanel: () => undefined, @@ -107,12 +109,13 @@ afterEach(() => { for (const root of roots.splice(0)) act(() => root.unmount()); for (const container of containers.splice(0)) container.remove(); pluginRegistry.removeHost("host-1"); + selectedHost.client = null; vi.restoreAllMocks(); vi.unstubAllGlobals(); }); describe("PluginTimelineItemView", () => { - it("validates and renders the matching plugin component", () => { + it("validates and renders the matching plugin component", async () => { pluginRegistry.installCatalog( "host-1", [{ id: "reports", requirements: { paseo: `>=${appPackage.version}` }, clientBundle: bundle }], @@ -121,11 +124,16 @@ describe("PluginTimelineItemView", () => { }, ); - const markup = renderToStaticMarkup( - , + const container = document.createElement("div"); + containers.push(container); + const root = createRoot(container); + roots.push(root); + await act(async () => + root.render( + , + ), ); - - expect(markup).toContain("Four tests passed"); + expect(container.textContent).toContain("Four tests passed"); }); it("contains renderer crashes to one timeline item", async () => { @@ -201,3 +209,144 @@ describe("PluginTimelineItemView", () => { expect(container.textContent).toBe("Recovered"); }); }); + +it("releases a crashed renderer's observations and recovers a fresh scope in StrictMode", async () => { + let receive: Parameters[0] = () => {}; + let opened: () => void = () => {}; + let nextId = 0; + const retained = new Set(); + const released: string[] = []; + const deliver = (message: unknown) => + receive(JSON.stringify({ type: "session", message }), false); + const client = new DaemonClient({ + url: "ws://surface-observation.test", + clientId: "surface-lifetime-test", + transportFactory: () => ({ + send(data) { + if (typeof data !== "string") throw new Error("Expected a JSON request"); + const frame = JSON.parse(data) as { + type: string; + message?: { type: string; requestId: string; subscriptionId: string }; + }; + if (frame.type === "hello") + deliver({ + type: "status", + payload: { + status: "server_info", + serverId: "host-1", + hostname: null, + version: null, + features: { ownedSubscriptions: true }, + }, + }); + const request = frame.message; + if (request?.type === "session.events.set_subscription.request") { + const id = `surface-${++nextId}`; + retained.add(id); + deliver({ + type: "session.events.set_subscription.response", + payload: { + requestId: request.requestId, + subscriptionId: id, + events: ["project.update"], + snapshots: [], + }, + }); + } else if (request?.type === "subscription.release.request") { + retained.delete(request.subscriptionId); + released.push(request.subscriptionId); + deliver({ type: "subscription.release.response", payload: request }); + } + }, + close() {}, + onMessage(handler) { + receive = handler; + return () => {}; + }, + onOpen(handler) { + opened = handler; + return () => {}; + }, + onClose() { + return () => {}; + }, + onError() { + return () => {}; + }, + }), + }); + const connecting = client.connect(); + opened(); + await connecting; + selectedHost.client = client; + const liveBundle = `(function(require) { + const React = require("react"); + const { usePaseo } = require("@getpaseo/plugin/client"); + return { default(plugin) { + function Card(props) { + const paseo = usePaseo(); + const failed = React.useRef(false); + failed.current = props.item.data.label === "explode"; + React.useEffect(() => { + const owner = paseo.observeEvents(["project.update"]); + return () => { if (failed.current) throw new Error("plugin cleanup failed"); void owner.release(); }; + }, [paseo]); + if (props.item.data.label === "explode") throw new Error("owned renderer failed"); + return React.createElement("span", null, props.item.data.label); + } + plugin.addTimelineRenderer({ kind:"test-report", version:1, schema:{safeParse(data){return {success:true,data};}}, Component:Card }); + return () => {}; + }}; + })`; + const survivor = client.observeEvents(["project.update"]); + const updates: unknown[] = []; + survivor.subscribe({ snapshot: () => {}, update: (message) => updates.push(message) }); + await survivor.ready; + pluginRegistry.installCatalog( + "host-1", + [ + { + id: "reports", + requirements: { paseo: `>=${appPackage.version}` }, + clientBundle: liveBundle, + }, + ], + { client }, + ); + const container = document.createElement("div"); + containers.push(container); + const root = createRoot(container); + roots.push(root); + const render = (item: typeof timelineItem) => + act(async () => + root.render( + + + , + ), + ); + try { + await render(timelineItem); + expect(retained.size).toBe(2); + const initialIds = new Set(retained); + await render(failingTimelineItem); + expect(container.textContent).toContain("Plugin failed:"); + expect([...retained]).toEqual([survivor.subscriptionId]); + expect(released.some((id) => initialIds.has(id))).toBe(true); + deliver({ + type: "project.update", + payload: { subscriptionId: survivor.subscriptionId, kind: "remove", projectId: "still-live" }, + }); + expect(updates).toHaveLength(1); + await render(recoveredTimelineItem); + expect(container.textContent).toContain("Recovered"); + expect(retained.size).toBe(2); + expect([...retained].filter((id) => !initialIds.has(id))).toHaveLength(1); + await act(async () => root.unmount()); + roots.splice(roots.indexOf(root), 1); + expect([...retained]).toEqual([survivor.subscriptionId]); + } finally { + await survivor.release(); + await client.close(); + } +}); diff --git a/packages/app/src/plugins/timeline/view.tsx b/packages/app/src/plugins/timeline/view.tsx index 6eba06c32b..1490d8e14e 100644 --- a/packages/app/src/plugins/timeline/view.tsx +++ b/packages/app/src/plugins/timeline/view.tsx @@ -11,7 +11,6 @@ import type { PluginTimelineStreamItem } from "@/types/stream"; import { createPluginClientStateSource } from "../client-state/source"; import { useInstalledPlugin } from "../registry"; import { PluginRuntimeBoundary } from "../runtime-boundary"; -import { createPluginSurfaceRuntime } from "../surface-runtime"; import { SurfaceErrorBoundary } from "../surface-error-boundary"; import { toPluginTheme } from "../theme"; @@ -60,10 +59,6 @@ function PluginTimelineItemBody({ ); const parsed = parseRendererData(renderer, item.data); const client = useHostRuntimeClient(serverId); - const runtime = useMemo( - () => createPluginSurfaceRuntime(client, item.pluginId), - [client, item.pluginId], - ); const compact = useIsCompactFormFactor(); const hosts = useHosts(); const hostLabel = hosts.find((host) => host.serverId === serverId)?.label ?? serverId; @@ -71,7 +66,7 @@ function PluginTimelineItemBody({ const layout = useMemo(() => ({ compact, platform: resolvePlatform() }), [compact]); const stateSource = useMemo(() => createPluginClientStateSource(serverId), [serverId]); - if (!plugin || !renderer || !parsed || !runtime) { + if (!plugin || !renderer || !parsed || !client) { return ; } @@ -91,7 +86,7 @@ function PluginTimelineItemBody({ }; return ( - + diff --git a/packages/app/src/plugins/types.ts b/packages/app/src/plugins/types.ts index 651e5f7b28..9305cc3ba8 100644 --- a/packages/app/src/plugins/types.ts +++ b/packages/app/src/plugins/types.ts @@ -38,6 +38,7 @@ export interface EvaluatedPlugin { } export interface InstalledPlugin extends EvaluatedPlugin { + lifetime: AbortController; serverId: string; requirements?: PluginRequirements; clientBundle: string; diff --git a/packages/app/src/plugins/workspace-panels/panel.tsx b/packages/app/src/plugins/workspace-panels/panel.tsx index bf652140f5..78a8bb462a 100644 --- a/packages/app/src/plugins/workspace-panels/panel.tsx +++ b/packages/app/src/plugins/workspace-panels/panel.tsx @@ -24,7 +24,6 @@ import { toPluginTheme } from "../theme"; import { resolvePluginIcon } from "../icons"; import { useInstalledPlugin } from "../registry"; import { PluginRuntimeBoundary } from "../runtime-boundary"; -import { createPluginSurfaceRuntime } from "../surface-runtime"; import { SurfaceErrorBoundary } from "../surface-error-boundary"; import { resolvePluginWorkspacePanel } from "./resolution"; @@ -54,10 +53,6 @@ function PluginPanelBody({ theme }: { theme: PluginTheme }) { ); }); const client = useHostRuntimeClient(serverId); - const runtime = useMemo( - () => createPluginSurfaceRuntime(client, target.pluginId), - [client, target.pluginId], - ); const compact = useIsCompactFormFactor(); const hosts = useHosts(); const hostLabel = hosts.find((host) => host.serverId === serverId)?.label ?? serverId; @@ -69,7 +64,7 @@ function PluginPanelBody({ theme }: { theme: PluginTheme }) { if (!plugin || !contribution || !workspaceExists) { return ; } - if (!runtime) { + if (!client) { return ; } @@ -110,7 +105,7 @@ function PluginPanelBody({ theme }: { theme: PluginTheme }) { Surface={Surface} key={`${serverId}/${target.pluginId}/${target.panelId}/${target.context}`} > - + {panel} diff --git a/packages/app/src/plugins/workspace-panels/resolution.test.ts b/packages/app/src/plugins/workspace-panels/resolution.test.ts index 36f1e35282..c4ad2ef302 100644 --- a/packages/app/src/plugins/workspace-panels/resolution.test.ts +++ b/packages/app/src/plugins/workspace-panels/resolution.test.ts @@ -8,6 +8,7 @@ function installed(): InstalledPlugin { id: "review", serverId: "host-1", clientBundle: "bundle", + lifetime: new AbortController(), queryClient: new QueryClient(), cleanup: () => {}, settingsScreens: [], diff --git a/packages/app/src/runtime/directory-sync/index.test.ts b/packages/app/src/runtime/directory-sync/index.test.ts index 5fbeb3d3e9..9f64a4fc60 100644 --- a/packages/app/src/runtime/directory-sync/index.test.ts +++ b/packages/app/src/runtime/directory-sync/index.test.ts @@ -22,6 +22,8 @@ import { type DirectoryCheckpointStorage, } from "./index"; +import { subscriptionFixture } from "../subscription-fixture"; + type WorkspaceFetchResult = Awaited>; type ProjectListResult = Awaited>; type AgentFetchResult = Awaited>; @@ -123,6 +125,25 @@ class FakeDirectoryClient { }; } + observeAgents(options: Parameters[0]) { + return subscriptionFixture(this.fetchAgents({ ...options, subscribe: {} }), (receive) => + this.on("agent_update", receive), + ); + } + + observeWorkspaces(options: Parameters[0]) { + return subscriptionFixture(this.fetchWorkspaces(options), (receive) => + this.on("workspace_update", receive), + ); + } + + observeEvents(events: readonly SessionOutboundMessage["type"][]) { + return subscriptionFixture(Promise.resolve({ events }), (receive) => { + const stops = events.map((event) => this.on(event, receive)); + return () => stops.forEach((stop) => stop()); + }); + } + getLastServerInfoMessage(): null { return null; } @@ -304,7 +325,7 @@ describe("DirectorySync session readiness", () => { directory.dispose(); }); - it("persists accepted script status updates through the directory owner", () => { + it("persists accepted script status updates through the directory owner", async () => { const serverId = "script-status-owner"; serverIds.add(serverId); const client = new FakeDirectoryClient(); @@ -351,6 +372,14 @@ describe("DirectorySync session readiness", () => { }); const store = useSessionStore.getState(); store.initializeSession(serverId, client as unknown as DaemonClient, 1); + store.updateSessionServerInfo(serverId, { + serverId, + hostname: null, + version: "test", + features: { workspaceMultiplicity: true }, + }); + directory.setDemand({}, true); + await directory.refreshDemand(); directory.acceptWorkspaces([workspace]); commits.length = 0; @@ -652,6 +681,8 @@ describe("DirectorySync session readiness", () => { features: { workspaceMultiplicity: true, directorySync: true }, }); + directory.setAgentRouteDemand(["agent-1"]); + await directory.refreshDemand(); const refresh = directory.refreshWorkspaces(); await Promise.resolve(); client.emit({ @@ -735,10 +766,7 @@ describe("DirectorySync session readiness", () => { useSessionStore.getState().initializeSession(serverId, client as unknown as DaemonClient, 1); const load = directory.loadCachedAgent(cachedAgent.id); - client.emit({ - type: "agent_deleted", - payload: { agentId: cachedAgent.id, requestId: "delete-live" }, - }); + directory.removeAgent(cachedAgent.id); releaseAgent(cachedAgent); await load; @@ -843,14 +871,14 @@ describe("DirectorySync session readiness", () => { }); await directory.refreshAgents({ - subscribe: { subscriptionId: `app:${serverId}` }, + subscribe: {}, page: { limit: 200 }, }); expect(client.lastAgentOptions).toEqual({ scope: "active", sort: [{ key: "updated_at", direction: "desc" }], - subscribe: { subscriptionId: `app:${serverId}` }, + subscribe: {}, page: { limit: 200 }, sync: { generation: "generation", afterSeq: 12 }, }); @@ -1045,8 +1073,9 @@ describe("DirectorySync session readiness", () => { }); const completeFetch = client.holdWorkspaceFetch(); - const refresh = directory.refreshWorkspaces({ subscribe: true }); - await Promise.resolve(); + directory.setDemand({}, true); + const refresh = directory.refreshDemand(); + await expect.poll(() => client.fetchWorkspacesCalls).toBe(1); client.emit({ type: "workspace_update", payload: { @@ -1100,7 +1129,7 @@ describe("DirectorySync session readiness", () => { directory.dispose(); }); - it("buffers project updates from the online epoch before workspace hydration starts", async () => { + it("ignores passive project events before directory demand", async () => { const serverId = "project-before-workspace-hydration"; const { client, directory } = createDirectory(serverId); const store = useSessionStore.getState(); @@ -1130,12 +1159,9 @@ describe("DirectorySync session readiness", () => { await directory.refreshWorkspaces({ subscribe: true }); expect(useSessionStore.getState().sessions[serverId]?.hasHydratedWorkspaces).toBe(true); - expect( - useSessionStore.getState().sessions[serverId]?.projects.get("early-project"), - ).toMatchObject({ - projectDisplayName: "Early project", - projectRootPath: "/repo/early-project", - }); + expect(useSessionStore.getState().sessions[serverId]?.projects.has("early-project")).toBe( + false, + ); directory.dispose(); }); }); @@ -1229,6 +1255,14 @@ it("fills every cached workspace beneath live updates received during the SQLite status: "online", source: { clientGeneration: 1, connectionEpoch: 1 }, }); + useSessionStore.getState().updateSessionServerInfo(serverId, { + serverId, + hostname: null, + version: "test", + features: { workspaceMultiplicity: true }, + }); + directory.setAgentRouteDemand(["agent"]); + await directory.refreshDemand(); let updated = false; holdRead(async () => { if (updated) return; diff --git a/packages/app/src/runtime/directory-sync/index.ts b/packages/app/src/runtime/directory-sync/index.ts index a490421ceb..9d457f63b2 100644 --- a/packages/app/src/runtime/directory-sync/index.ts +++ b/packages/app/src/runtime/directory-sync/index.ts @@ -1,3 +1,4 @@ +import type { OwnedSubscription } from "@getpaseo/client"; import type { DaemonClient, FetchAgentsEntry, @@ -12,12 +13,6 @@ import { type ProjectDescriptor, type WorkspaceDescriptor, } from "@/stores/session-store"; -import { - readLegacyDaemonWorkspaceDirectory, - buildLegacyWorkspaces, - shouldUseLegacyDaemonWorkspaceDirectory, - stampLegacyWorkspaceIds, -} from "@/workspace/legacy-daemon-workspaces"; import type { AgentDirectoryDelta } from "@/utils/agent-directory-sync"; import { AgentDirectoryReplica } from "./agent-replica"; import { @@ -40,6 +35,9 @@ import type { } from "@/runtime/replica-cache"; import type { TurnLivenessTransition } from "@/timeline/turn-liveness"; +type FetchAgentsPayload = Awaited>; +type FetchWorkspacesPayload = Awaited>; + const PAGE_LIMIT = 200; const AGENT_SORT: NonNullable = [ { key: "updated_at", direction: "desc" }, @@ -58,7 +56,6 @@ function resolveAgentNextPage(pageInfo: AgentPageInfo): { interface AgentSnapshot { entries: FetchAgentsEntry[]; subscriptionId: string | null; - legacy: boolean; syncMode?: "snapshot" | "changes"; syncCursor?: DirectoryCursor; syncRemovals: Array<{ id: string; seq: number }>; @@ -146,7 +143,9 @@ export class DirectorySync { status: "offline", source: { clientGeneration: 0, connectionEpoch: 0 }, }; - private unsubscribe: (() => void) | null = null; + private agentSubscription: OwnedSubscription | null = null; + private workspaceSubscription: OwnedSubscription | null = null; + private eventSubscription: ReturnType | null = null; private readonly abortSessionWaits = new Set<() => void>(); private cacheLoad: Promise | null = null; private cacheAccepted = false; @@ -185,79 +184,11 @@ export class DirectorySync { return false; } this.flushAbortedTransactions(); - this.unsubscribe?.(); - this.unsubscribe = null; + this.releaseSubscriptions(); workspaceLabels.disconnect(this.serverId); this.connection = connection; this.abortPendingSessionWaits(); if (!connection.client || connection.status !== "online") return true; - const client = connection.client; - const source = connection.source; - const subscriptions = [ - client.on("agent_update", (message) => { - if (message.type !== "agent_update" || !this.isCurrent(client, source)) return; - this.revision += 1; - const recorded = this.agentTransactions.record(source, message.payload); - if (!recorded) { - this.agents.applyDelta(message.payload); - this.noteLiveCursor("agents", message.payload); - this.persistCheckpoint(); - } - }), - client.on("workspace_update", (message) => { - if (message.type !== "workspace_update" || !this.isCurrent(client, source)) return; - this.revision += 1; - this.workspaceRevision += 1; - const recorded = this.workspaceTransactions.record(source, message.payload); - if (!recorded) { - this.applyWorkspaceDelta(message.payload); - this.noteLiveCursor("workspaces", message.payload); - this.persistCheckpoint(); - } - }), - client.on("project.update", (message) => { - if (message.type !== "project.update" || !this.isCurrent(client, source)) return; - this.revision += 1; - this.workspaceRevision += 1; - const recorded = this.workspaceTransactions.record(source, message.payload); - if (!recorded) { - this.applyWorkspaceDelta(message.payload); - this.noteLiveCursor("projects", message.payload); - this.persistCheckpoint(); - } - }), - client.on("script_status_update", (message) => { - if (message.type !== "script_status_update" || !this.isCurrent(client, source)) return; - this.revision += 1; - this.workspaceRevision += 1; - const delta: WorkspaceDirectoryDelta = { - kind: "script_status", - update: message.payload, - }; - const recorded = this.workspaceTransactions.record(source, delta); - if (!recorded) { - this.applyWorkspaceDelta(delta); - this.persistCheckpoint(); - } - }), - client.on("agent_deleted", (message) => { - if (message.type === "agent_deleted" && this.isCurrent(client, source)) { - this.revision += 1; - this.agents.remove(message.payload.agentId); - this.persistCheckpoint(); - } - }), - client.on("agent_archived", (message) => { - if (message.type === "agent_archived" && this.isCurrent(client, source)) { - this.revision += 1; - this.agents.archive(message.payload.agentId, message.payload.archivedAt); - this.persistCheckpoint(); - } - }), - ]; - this.unsubscribe = () => { - for (const unsubscribe of subscriptions) unsubscribe(); - }; if (this.hasDemand()) void this.requestDemandRefresh().catch(() => undefined); return true; } @@ -266,6 +197,7 @@ export class DirectorySync { const wasDemanded = this.fullDemandSources.size > 0; if (demanded) this.fullDemandSources.add(source); else this.fullDemandSources.delete(source); + if (!this.hasDemand()) this.releaseSubscriptions(); if (!wasDemanded && this.fullDemandSources.size > 0) { void this.loadCachedDirectory().catch(() => undefined); if (this.getOnlineConnection()) void this.requestDemandRefresh().catch(() => undefined); @@ -282,6 +214,7 @@ export class DirectorySync { } this.routeDemandIds.clear(); for (const agentId of next) this.routeDemandIds.add(agentId); + if (!this.hasDemand()) this.releaseSubscriptions(); if (this.routeDemandIds.size > 0 && this.getOnlineConnection()) { void this.requestDemandRefresh().catch(() => undefined); } @@ -290,13 +223,65 @@ export class DirectorySync { dispose(): void { this.flushAbortedTransactions(); this.abortPendingSessionWaits(); - this.unsubscribe?.(); - this.unsubscribe = null; + this.releaseSubscriptions(); this.fullDemandSources.clear(); this.routeDemandIds.clear(); workspaceLabels.disconnect(this.serverId); } + private releaseSubscriptions(): void { + for (const subscription of [ + this.agentSubscription, + this.workspaceSubscription, + this.eventSubscription, + ]) { + void subscription?.release().catch(() => undefined); + } + this.agentSubscription = null; + this.workspaceSubscription = null; + this.eventSubscription = null; + this.satisfiedDemandSource = null; + } + + private receiveAgentDelta(source: DirectorySourceToken, delta: AgentDirectoryDelta): void { + this.revision += 1; + if (this.agentTransactions.record(source, delta)) return; + this.agents.applyDelta(delta); + this.noteLiveCursor("agents", delta); + this.persistCheckpoint(); + } + + private receiveWorkspaceDelta( + source: DirectorySourceToken, + delta: WorkspaceDirectoryDelta, + ): void { + this.revision += 1; + this.workspaceRevision += 1; + if (this.workspaceTransactions.record(source, delta)) return; + this.applyWorkspaceDelta(delta); + if (delta.kind !== "script_status") + this.noteLiveCursor( + "projectId" in delta || "project" in delta ? "projects" : "workspaces", + delta, + ); + this.persistCheckpoint(); + } + + private observeDirectoryEvents(): void { + if (this.eventSubscription) return; + const { client, source } = this.requireOnline(); + this.eventSubscription = client.observeEvents(["project.update", "script_status_update"]); + this.eventSubscription.subscribe({ + snapshot: () => {}, + update: (message) => { + if (!this.isCurrent(client, source)) return; + if (message.type === "project.update") this.receiveWorkspaceDelta(source, message.payload); + if (message.type === "script_status_update") + this.receiveWorkspaceDelta(source, { kind: "script_status", update: message.payload }); + }, + }); + } + private hasDemand(): boolean { return this.fullDemandSources.size > 0 || this.routeDemandIds.size > 0; } @@ -307,6 +292,7 @@ export class DirectorySync { if (!this.getOnlineConnection()) { return this.fullDemandSources.size > 0 ? this.loadCachedDirectory() : Promise.resolve(); } + this.observeDirectoryEvents(); const source = this.connection.source; if ( !force && @@ -449,32 +435,12 @@ export class DirectorySync { const transaction = this.agentTransactions.begin(source, () => ({ entries: [], subscriptionId: null, - legacy: false, syncRemovals: [], })); this.callbacks.markAgentLoading(); try { await this.waitForSession(client, source); - const session = useSessionStore.getState().sessions[this.serverId]; - if (!input.filter && shouldUseLegacyDaemonWorkspaceDirectory(session?.serverInfo)) { - const directory = await readLegacyDaemonWorkspaceDirectory({ - client, - subscribe: input.subscribe, - page: input.page, - }); - if ( - !directory || - !this.agentTransactions.isCurrent(transaction) || - !this.isCurrent(client, source) - ) { - throw new DirectoryRefreshSupersededError("legacy fetch no longer current"); - } - transaction.snapshot.entries.push(...stampLegacyWorkspaceIds(directory.entries)); - transaction.snapshot.subscriptionId = directory.subscriptionId; - transaction.snapshot.legacy = true; - } else { - await this.fetchAgents(client, source, transaction, input); - } + await this.fetchAgents(client, source, transaction, input); if (!this.isCurrent(client, source) || !this.hasMatchingSession(client, source)) { throw new DirectoryRefreshSupersededError("agent completion no longer current"); } @@ -482,36 +448,8 @@ export class DirectorySync { if (completion.kind === "stale") { throw new DirectoryRefreshSupersededError("agent completion was superseded"); } - if (completion.snapshot.legacy) { - const workspaces = buildLegacyWorkspaces(completion.snapshot.entries); - const previous = this.readWorkspaceState(); - this.workspaces.commitSnapshot( - { - workspaces, - projects: new Map( - Array.from(workspaces.values(), legacyProjectDescriptorFromWorkspace).map( - (project) => [project.projectId, project], - ), - ), - }, - [], - ); - const next = this.workspaces.snapshot(); - this.persistWorkspaceChanges( - previous, - new Set([...previous.workspaces.keys(), ...next.workspaces.keys()]), - new Set([...previous.projects.keys(), ...next.projects.keys()]), - ); - } - const deltas = completion.snapshot.legacy - ? completion.deltas.map((delta) => - delta.kind === "upsert" - ? { ...delta, agent: { ...delta.agent, workspaceId: delta.agent.cwd } } - : delta, - ) - : completion.deltas; this.revision += 1; - const agents = this.commitAgentSnapshot(completion.snapshot, deltas); + const agents = this.commitAgentSnapshot(completion.snapshot, completion.deltas); this.persistAgentCursors(completion.snapshot, completion.deltas); this.persistCheckpoint(); this.callbacks.markAgentReady(); @@ -589,12 +527,25 @@ export class DirectorySync { let cursor: string | null = null; let subscribe = initialSubscribe; while (true) { - const payload = await client.fetchWorkspaces({ + const query: Parameters[0] = { sort: [{ key: "activity_at", direction: "desc" }], - ...(subscribe ? { subscribe: {} } : {}), page: cursor ? { limit: PAGE_LIMIT, cursor } : { limit: PAGE_LIMIT }, ...(supportsDirectorySync ? { sync: this.readCursors().workspaces ?? {} } : {}), - }); + }; + let payload: FetchWorkspacesPayload; + if (subscribe) { + void this.workspaceSubscription?.release().catch(() => undefined); + const subscription = client.observeWorkspaces(query); + this.workspaceSubscription = subscription; + subscription.subscribe({ + snapshot: () => {}, + update: (message) => { + if (message.type === "workspace_update" && this.isCurrent(client, source)) + this.receiveWorkspaceDelta(source, message.payload); + }, + }); + payload = await subscription.ready; + } else payload = await client.fetchWorkspaces(query); this.assertWorkspaceTransactionCurrent(client, source, transaction); applyWorkspaceSnapshotPage(transaction.snapshot, payload, cursor === null); if (!payload.pageInfo.hasMore || !payload.pageInfo.nextCursor) return; @@ -672,15 +623,29 @@ export class DirectorySync { let subscribe = input.subscribe; while (true) { const limit = input.page?.limit ?? PAGE_LIMIT; - const payload = await client.fetchAgents({ + const query: Omit = { ...(input.filter ? { filter: input.filter } : { scope: "active" as const }), sort: AGENT_SORT, - ...(subscribe ? { subscribe } : {}), page: cursor ? { limit, cursor } : { limit }, ...(!input.filter && cursor === null && this.supportsDirectorySync() ? { sync: this.readCursors().agents ?? {} } : {}), - }); + }; + let payload: FetchAgentsPayload; + if (subscribe) { + if (subscribe.subscriptionId) throw new Error("Subscription IDs are assigned by the host"); + void this.agentSubscription?.release().catch(() => undefined); + const subscription = client.observeAgents(query); + this.agentSubscription = subscription; + subscription.subscribe({ + snapshot: () => {}, + update: (message) => { + if (message.type === "agent_update" && this.isCurrent(client, source)) + this.receiveAgentDelta(source, message.payload); + }, + }); + payload = await subscription.ready; + } else payload = await client.fetchAgents(query); this.assertAgentTransactionCurrent(client, source, transaction); transaction.snapshot.entries.push(...payload.entries); transaction.snapshot.subscriptionId ??= payload.subscriptionId ?? null; @@ -731,7 +696,6 @@ export class DirectorySync { this.agentTransactions.begin(connection.source, () => ({ entries: [], subscriptionId: null, - legacy: false, syncRemovals: [], })); } diff --git a/packages/app/src/runtime/host-runtime.test.ts b/packages/app/src/runtime/host-runtime.test.ts index cd6be83fae..52686df9c6 100644 --- a/packages/app/src/runtime/host-runtime.test.ts +++ b/packages/app/src/runtime/host-runtime.test.ts @@ -25,6 +25,8 @@ import { } from "./host-runtime"; import type { ReplicaRow, ReplicaRowStore } from "./replica-cache/row-store"; +import { subscriptionFixture } from "./subscription-fixture"; + class FakeDaemonClient { private state: ConnectionState = { status: "idle" }; private listeners = new Set<(status: ConnectionState) => void>(); @@ -58,6 +60,16 @@ class FakeDaemonClient { return () => this.agentUpdateListeners.delete(listener); } + observeAgents(options: Parameters[0]) { + return subscriptionFixture(this.fetchAgents({ ...options, subscribe: {} }), (receive) => + this.on("agent_update", receive), + ); + } + + observeEvents() { + return subscriptionFixture(Promise.resolve({}), () => () => {}); + } + async waitForAgentUpdates(): Promise { if (this.agentUpdateListeners.size > 0) return; await new Promise((resolve) => { @@ -119,8 +131,16 @@ class FakeDaemonClient { return this.state; } - getLastServerInfoMessage(): null { - return null; + public ownedSubscriptions = true; + + getLastServerInfoMessage(): ReturnType { + return { + status: "server_info", + serverId: "srv_test", + hostname: "test", + version: "0.8.0", + features: { ownedSubscriptions: this.ownedSubscriptions }, + }; } subscribeConnectionStatus(listener: (status: ConnectionState) => void): () => void { @@ -550,6 +570,42 @@ class BrowserClientLifecycle { } describe("HostRuntimeController", () => { + it("gates an old host before publishing the app client or mounting observations", async () => { + const host = makeHost(); + const client = new FakeDaemonClient(); + client.ownedSubscriptions = false; + let mounts = 0; + const controller = new HostRuntimeController({ + host, + deps: { + createClient: () => client as unknown as DaemonClient, + connectToDaemon: async () => ({ + client: client as unknown as DaemonClient, + serverId: host.serverId, + hostname: null, + }), + getClientId: async () => "app-version-gate", + mountClientHandlers: () => { + mounts++; + return () => {}; + }, + }, + }); + const publishedClients: Array = []; + const stop = controller.subscribe(() => publishedClients.push(controller.getSnapshot().client)); + await controller.activateConnection({ connectionId: host.connections[0]!.id }); + expect(controller.getSnapshot()).toMatchObject({ + connectionStatus: "error", + client: null, + lastError: "Update the host to use this version of Paseo.", + }); + expect(mounts).toBe(0); + expect(publishedClients.every((value) => value === null)).toBe(true); + expect(client.fetchAgentsCalls).toEqual([]); + stop(); + await controller.stop(); + }); + it("replaces the active relay client when re-pairing changes the daemon public key", async () => { const oldRelay: HostConnection = { id: "relay:wss:relay.paseo.sh:443", @@ -1952,6 +2008,7 @@ describe("HostRuntimeStore", () => { expect(fakeClient.fetchAgentsCalls).toHaveLength(1); expect(fakeClient.fetchAgentsCalls[0]).toEqual({ + scope: "active", sort: [{ key: "updated_at", direction: "desc" }], subscribe: {}, page: { limit: 200 }, @@ -1968,6 +2025,7 @@ describe("HostRuntimeStore", () => { await store.refreshAgentDirectory({ serverId: host.serverId }); expect(fakeClient.fetchAgentsCalls[2]).toEqual({ + scope: "active", sort: [{ key: "updated_at", direction: "desc" }], page: { limit: 200 }, }); @@ -2006,7 +2064,7 @@ describe("HostRuntimeStore", () => { await waitForHostOnline(store, host.serverId); const load = store.refreshAgentDirectory({ serverId: host.serverId, - subscribe: { subscriptionId: "app:srv_no_session" }, + subscribe: {}, page: { limit: 200 }, }); await fakeClient.waitForFetches(1); @@ -2019,7 +2077,7 @@ describe("HostRuntimeStore", () => { expect(fakeClient.fetchAgentsCalls[0]).toEqual({ scope: "active", sort: [{ key: "updated_at", direction: "desc" }], - subscribe: { subscriptionId: "app:srv_no_session" }, + subscribe: {}, page: { limit: 200 }, }); @@ -2027,83 +2085,7 @@ describe("HostRuntimeStore", () => { expect(useSessionStore.getState().sessions[host.serverId]).toBeUndefined(); }); - it("bootstraps legacy daemons from unscoped agents and creates path-backed workspaces", async () => { - const host = makeHost({ - serverId: "srv_legacy_workspace_daemon", - connections: [ - { - id: "direct:lan:6767", - type: "directTcp", - endpoint: "lan:6767", - }, - ], - }); - const fakeClient = new FakeDaemonClient(); - fakeClient.setConnectionState({ status: "connected" }); - fakeClient.fetchAgentsResponses.push( - makeFetchAgentsPayload({ - entries: [ - makeFetchAgentsEntry({ - id: "agent-legacy", - cwd: "/repo/legacy-app", - updatedAt: "2026-06-18T12:00:00.000Z", - title: "Legacy daemon agent", - }), - ], - subscriptionId: "app:srv_legacy_workspace_daemon", - }), - ); - const store = new HostRuntimeStore({ - deps: { - createClient: () => fakeClient as unknown as DaemonClient, - connectToDaemon: async ({ host: hostProfile }) => ({ - client: fakeClient as unknown as DaemonClient, - serverId: hostProfile.serverId, - hostname: hostProfile.label ?? null, - }), - getClientId: async () => "cid_test_runtime", - }, - }); - - const sessionStore = useSessionStore.getState(); - sessionStore.initializeSession(host.serverId, fakeClient as unknown as DaemonClient, 1); - sessionStore.updateSessionServerInfo(host.serverId, { - serverId: host.serverId, - hostname: null, - version: "0.1.96", - }); - store.syncHosts([host]); - await waitForHostOnline(store, host.serverId); - const load = store.refreshAgentDirectory({ - serverId: host.serverId, - subscribe: { subscriptionId: "app:srv_legacy_workspace_daemon" }, - page: { limit: 200 }, - }); - await fakeClient.waitForFetches(1); - await load; - - expect(fakeClient.fetchAgentsCalls).toEqual([ - { - sort: [{ key: "updated_at", direction: "desc" }], - subscribe: { subscriptionId: "app:srv_legacy_workspace_daemon" }, - page: { limit: 200 }, - }, - ]); - const session = useSessionStore.getState().sessions[host.serverId]; - expect(session?.agents.get("agent-legacy")?.workspaceId).toBe("/repo/legacy-app"); - expect(Array.from(session?.workspaces.values() ?? [])).toEqual([ - expect.objectContaining({ - id: "/repo/legacy-app", - workspaceDirectory: "/repo/legacy-app", - name: "legacy-app", - }), - ]); - - store.syncHosts([]); - useSessionStore.getState().clearSession(host.serverId); - }); - - it("drains legacy snapshot and buffered running transitions exactly once", async () => { + it("drains snapshot and buffered running transitions exactly once", async () => { const host = makeHost({ serverId: "srv_legacy_transitions", connections: [{ id: "direct:lan:6767", type: "directTcp", endpoint: "lan:6767" }], @@ -2190,7 +2172,7 @@ describe("HostRuntimeStore", () => { await waitForHostOnline(store, host.serverId); const load = store.refreshAgentDirectory({ serverId: host.serverId, - subscribe: { subscriptionId: `app:${host.serverId}` }, + subscribe: {}, page: { limit: 200 }, }); await fakeClient.waitForFetches(2); @@ -2212,8 +2194,8 @@ describe("HostRuntimeStore", () => { ({ id, status, workspaceId }) => [id, status, workspaceId], ), ).toEqual([ - ["legacy-snapshot", "idle", "/legacy/repo"], - ["legacy-buffered", "idle", "/legacy/repo"], + ["legacy-snapshot", "idle", snapshotAgent.agent.workspaceId], + ["legacy-buffered", "idle", bufferedAgent.agent.workspaceId], ]); store.syncHosts([]); @@ -2280,7 +2262,7 @@ describe("HostRuntimeStore", () => { await waitForHostOnline(store, host.serverId); const load = store.refreshAgentDirectory({ serverId: host.serverId, - subscribe: { subscriptionId: "app:srv_paged" }, + subscribe: {}, page: { limit: 200 }, }); await fakeClient.waitForFetches(2); @@ -2290,7 +2272,7 @@ describe("HostRuntimeStore", () => { expect(fakeClient.fetchAgentsCalls[0]).toEqual({ scope: "active", sort: [{ key: "updated_at", direction: "desc" }], - subscribe: { subscriptionId: "app:srv_paged" }, + subscribe: {}, page: { limit: 200 }, }); expect(fakeClient.fetchAgentsCalls[1]).toEqual({ @@ -2370,7 +2352,7 @@ describe("HostRuntimeStore", () => { await waitForHostOnline(store, host.serverId); const load = store.refreshAgentDirectory({ serverId: host.serverId, - subscribe: { subscriptionId: "app:srv_paged_delta" }, + subscribe: {}, page: { limit: 200 }, }); await fakeClient.waitForFetches(2); @@ -2457,12 +2439,13 @@ describe("HostRuntimeStore", () => { }); store.syncHosts([host]); - await fakeClient.waitForAgentUpdates(); + await waitForHostOnline(store, host.serverId); const load = store.refreshAgentDirectory({ serverId: host.serverId, - subscribe: { subscriptionId: `app:${host.serverId}` }, + subscribe: {}, page: { limit: 200 }, }); + await fakeClient.waitForAgentUpdates(); fakeClient.agentUpdate({ kind: "upsert", agent: { ...snapshotEntry.agent, title: "before-session" }, @@ -2508,7 +2491,7 @@ describe("HostRuntimeStore", () => { .initializeSession(host.serverId, fakeClient as unknown as DaemonClient, 1); store.syncHosts([host]); await waitForHostOnline(store, host.serverId); - await store.refreshAgentDirectory({ serverId: host.serverId }); + await store.refreshAgentDirectory({ serverId: host.serverId, subscribe: {} }); const olderPage = new Deferred>>(); fakeClient.fetchAgentsResponses.push(olderPage.promise); const olderRefresh = store.refreshAgentDirectory({ serverId: host.serverId }); @@ -2576,7 +2559,7 @@ describe("HostRuntimeStore", () => { .initializeSession(host.serverId, fakeClient as unknown as DaemonClient, 1); store.syncHosts([host]); await waitForHostOnline(store, host.serverId); - await store.refreshAgentDirectory({ serverId: host.serverId }); + await store.refreshAgentDirectory({ serverId: host.serverId, subscribe: {} }); const olderPage = new Deferred>>(); fakeClient.fetchAgentsResponses.push(olderPage.promise); @@ -2749,7 +2732,7 @@ describe("HostRuntimeStore", () => { ); store.syncHosts([host]); await waitForHostOnline(store, host.serverId); - const load = store.refreshAgentDirectory({ serverId: host.serverId }); + const load = store.refreshAgentDirectory({ serverId: host.serverId, subscribe: {} }); await fakeClient.waitForFetches(2); fakeClient.agentUpdate({ kind: "upsert", @@ -3051,7 +3034,7 @@ describe("HostRuntimeStore", () => { }); store.syncHosts([host]); await waitForHostOnline(store, host.serverId); - const load = store.refreshAgentDirectory({ serverId: host.serverId }); + const load = store.refreshAgentDirectory({ serverId: host.serverId, subscribe: {} }); await fakeClient.waitForFetches(2); fakeClient.agentUpdate({ kind: "upsert", @@ -3132,7 +3115,7 @@ describe("HostRuntimeStore", () => { await waitForHostOnline(store, host.serverId); await store.refreshAgentDirectory({ serverId: host.serverId, - subscribe: { subscriptionId: "app:srv_resubscribe" }, + subscribe: {}, page: { limit: 200 }, }); await fakeClient.waitForFetches(1); @@ -3150,7 +3133,7 @@ describe("HostRuntimeStore", () => { await waitForHostOnline(store, host.serverId); await store.refreshAgentDirectory({ serverId: host.serverId, - subscribe: { subscriptionId: "app:srv_resubscribe" }, + subscribe: {}, page: { limit: 200 }, }); await fakeClient.waitForFetches(2); @@ -3159,13 +3142,13 @@ describe("HostRuntimeStore", () => { { scope: "active", sort: [{ key: "updated_at", direction: "desc" }], - subscribe: { subscriptionId: "app:srv_resubscribe" }, + subscribe: {}, page: { limit: 200 }, }, { scope: "active", sort: [{ key: "updated_at", direction: "desc" }], - subscribe: { subscriptionId: "app:srv_resubscribe" }, + subscribe: {}, page: { limit: 200 }, }, ]); @@ -3242,7 +3225,7 @@ describe("HostRuntimeStore", () => { await waitForHostOnline(store, host.serverId); await store.refreshAgentDirectory({ serverId: host.serverId, - subscribe: { subscriptionId: "app:srv_archived_rehydrate" }, + subscribe: {}, page: { limit: 200 }, }); await fakeClient.waitForFetches(1); diff --git a/packages/app/src/runtime/host-runtime.ts b/packages/app/src/runtime/host-runtime.ts index 0dd8bf776e..6f4d4bd6ad 100644 --- a/packages/app/src/runtime/host-runtime.ts +++ b/packages/app/src/runtime/host-runtime.ts @@ -1244,56 +1244,62 @@ export class HostRuntimeController { } this.activeClient = client; - this.unsubscribeClientHandlers = - this.deps.mountClientHandlers?.({ client, host: this.host, connection }) ?? null; this.applyConnectionEvent({ type: "select_connection", connectionId: connection.id, connection: toActiveConnection(connection), }); - this.snapshot = { - ...this.snapshot, - serverId: this.host.serverId, + this.snapshot = { ...this.snapshot, clientGeneration: nextGeneration }; + this.updateSnapshot({ ...toSnapshotConnectionPatch(this.connectionMachineState, this.connectionEpoch), - client, - clientGeneration: nextGeneration, - }; - for (const listener of this.listeners) { - listener(); - } - - this.unsubscribeClientStatus = client.subscribeConnectionStatus((state) => { - if (!this.isCurrentSwitchRequest(requestVersion) || this.activeClient !== client) { - return; - } - this.applyConnectionEvent({ - type: "client_state", - state, - lastError: client.lastError, - }); - const patch: HostRuntimeSnapshotPatch = { - ...toSnapshotConnectionPatch(this.connectionMachineState, this.connectionEpoch), - ...this.buildAgentDirectoryStatusPatch(), - }; - this.updateSnapshot(patch); + client: null, }); - try { - if (!existingClient) { - await client.connect(); - } - } catch (error) { - if (!this.isCurrentSwitchRequest(requestVersion) || this.activeClient !== client) { - return; - } - const message = toErrorMessage(error); - this.applyConnectionEvent({ - type: "connect_failed", - message, - }); + const failConnection = async (error: unknown) => { + if (!this.isCurrentSwitchRequest(requestVersion) || this.activeClient !== client) return; + this.unsubscribeClientStatus?.(); + this.unsubscribeClientStatus = null; + this.unsubscribeClientHandlers?.(); + this.unsubscribeClientHandlers = null; + client.setReconnectEnabled(false); + this.applyConnectionEvent({ type: "connect_failed", message: toErrorMessage(error) }); this.updateSnapshot({ ...toSnapshotConnectionPatch(this.connectionMachineState, this.connectionEpoch), + client: null, }); + try { + await client.close(); + } catch { + /* Preserve the compatibility/connection error. */ + } + }; + const requireAppHost = () => { + if (client.getLastServerInfoMessage()?.features?.ownedSubscriptions !== true) + throw new Error("Update the host to use this version of Paseo."); + }; + try { + if (!existingClient) await client.connect(); + if (!this.isCurrentSwitchRequest(requestVersion) || this.activeClient !== client) return; + requireAppHost(); + this.unsubscribeClientHandlers = + this.deps.mountClientHandlers?.({ client, host: this.host, connection }) ?? null; + this.unsubscribeClientStatus = client.subscribeConnectionStatus((state) => { + if (!this.isCurrentSwitchRequest(requestVersion) || this.activeClient !== client) return; + try { + if (state.status === "connected") requireAppHost(); + } catch (error) { + void failConnection(error); + return; + } + this.applyConnectionEvent({ type: "client_state", state, lastError: client.lastError }); + this.updateSnapshot({ + ...toSnapshotConnectionPatch(this.connectionMachineState, this.connectionEpoch), + ...this.buildAgentDirectoryStatusPatch(), + client, + }); + }); + } catch (error) { + await failConnection(error); } } @@ -2397,14 +2403,20 @@ export class HostRuntimeStore { await replica.prepare(agentId); } - fetchAgentTimeline( + async fetchAgentTimeline( serverId: string, agentId: string, request: Parameters[1], ): Promise>> { const directory = this.directorySyncByServer.get(serverId); if (!directory) throw new Error(`Unknown host runtime for serverId ${serverId}`); - return directory.fetchTimeline(agentId, request); + const owner = useSessionStore.getState().sessions[serverId]?.viewedTimelineSync; + const page = await directory.fetchTimeline(agentId, request); + if (owner && useSessionStore.getState().sessions[serverId]?.viewedTimelineSync === owner) { + owner.flushStreamAgent(agentId); + owner.applyTimelineResponse(page); + } + return page; } createViewedTimelineOwner( diff --git a/packages/app/src/runtime/subscription-fixture.ts b/packages/app/src/runtime/subscription-fixture.ts new file mode 100644 index 0000000000..c6eb9f78fe --- /dev/null +++ b/packages/app/src/runtime/subscription-fixture.ts @@ -0,0 +1,43 @@ +import type { OwnedSubscription, SubscriptionObserver } from "@getpaseo/client"; +import type { SessionOutboundMessage } from "@getpaseo/protocol/messages"; + +/** Public observation dependency for the directory/host runtime tests. */ +export function subscriptionFixture( + snapshot: Promise, + listen: (receive: (message: SessionOutboundMessage) => void) => () => void, +): OwnedSubscription { + const subscriptionId = crypto.randomUUID(); + const observers = new Set>(); + const updates: SessionOutboundMessage[] = []; + let current: (T & { subscriptionId: string }) | undefined; + let released = false; + const stop = listen((message) => { + if (!current) updates.push(message); + else for (const observer of observers) observer.update(message); + }); + const ready = snapshot.then((value) => { + current = { ...value, subscriptionId }; + if (!released) { + for (const observer of observers) { + observer.snapshot(current); + for (const message of updates) observer.update(message); + } + } + return current; + }); + return { + subscriptionId, + ready, + subscribe(observer) { + if (released) return () => {}; + observers.add(observer); + if (current) observer.snapshot(current); + return () => observers.delete(observer); + }, + async release() { + released = true; + observers.clear(); + stop(); + }, + }; +} diff --git a/packages/app/src/screens/open-project-screen.tsx b/packages/app/src/screens/open-project-screen.tsx index 9cbe43d83e..4c340e0f15 100644 --- a/packages/app/src/screens/open-project-screen.tsx +++ b/packages/app/src/screens/open-project-screen.tsx @@ -1,3 +1,4 @@ +import { useHosts, useHostRuntimeLastError } from "@/runtime/host-runtime"; import { useCallback, useEffect, useState, type ComponentType } from "react"; import { useTranslation } from "react-i18next"; import { View, Text, Pressable } from "react-native"; @@ -24,6 +25,7 @@ import { buildSettingsHostSectionRoute } from "@/utils/host-routes"; export function OpenProjectScreen() { const { t } = useTranslation(); + const hosts = useHosts(); const router = useRouter(); const openDesktopAgentList = usePanelStore((s) => s.openDesktopAgentList); const openProjectPicker = useOpenAddProject(); @@ -64,6 +66,9 @@ export function OpenProjectScreen() { + {hosts.map((host) => ( + + ))} + {label}: {error} + + ) : null; +} + interface HomeTileProps { icon: ComponentType<{ size: number; color: string }>; title: string; @@ -178,6 +192,12 @@ const styles = StyleSheet.create((theme) => ({ logo: { marginBottom: theme.spacing[8], }, + hostError: { + color: theme.colors.destructive, + fontSize: theme.fontSize.base, + maxWidth: 452, + textAlign: "center", + }, tiles: { marginTop: { xs: theme.spacing[6], md: theme.spacing[12] }, width: "100%", diff --git a/packages/app/src/screens/settings/plugins-page.test.tsx b/packages/app/src/screens/settings/plugins-page.test.tsx index 8ec1e4df6b..f02cfa80a8 100644 --- a/packages/app/src/screens/settings/plugins-page.test.tsx +++ b/packages/app/src/screens/settings/plugins-page.test.tsx @@ -1,3 +1,4 @@ +import { subscriptionFixture } from "@/runtime/subscription-fixture"; /** * @vitest-environment jsdom */ @@ -103,7 +104,11 @@ function plugin(enabled = true): PluginListItem { function createClient() { return { - on: vi.fn(() => () => undefined), + observeEvents: () => + subscriptionFixture( + Promise.resolve({ events: ["status.plugin_catalog_changed"] }), + () => () => {}, + ), getDaemonConfig: vi.fn(async () => ({ config: { pluginsEnabled: true } })), patchDaemonConfig: vi.fn(async () => ({ config: { pluginsEnabled: true } })), listPlugins: vi.fn(async (): Promise => []), diff --git a/packages/app/src/screens/settings/plugins-page.tsx b/packages/app/src/screens/settings/plugins-page.tsx index 086b1f1a53..60a797b951 100644 --- a/packages/app/src/screens/settings/plugins-page.tsx +++ b/packages/app/src/screens/settings/plugins-page.tsx @@ -227,11 +227,21 @@ export function HostPluginsPage({ serverId }: { serverId: string }) { await refreshQueue.current; }, [client, refetchPlugins]); useEffect(() => { - if (!client) return; - return client.on("status", (message) => { - if (message.payload.status === "plugin_catalog_changed") void refresh(); + if (!client || !connected || !supported) return; + const observation = client.observeEvents(["status.plugin_catalog_changed"]); + observation.subscribe({ + snapshot: () => {}, + update: (message) => { + if (message.type === "status" && message.payload.status === "plugin_catalog_changed") + void refresh(); + }, }); - }, [client, refresh]); + return () => { + void observation + .release() + .catch((error) => console.warn("[Plugins] Failed to release settings feed", error)); + }; + }, [client, connected, supported, refresh]); const mutation = useMutation({ mutationFn: async (operation: { action: string; diff --git a/packages/app/src/stores/session-store.ts b/packages/app/src/stores/session-store.ts index 3f1ae6ffcf..dd5469df26 100644 --- a/packages/app/src/stores/session-store.ts +++ b/packages/app/src/stores/session-store.ts @@ -2,7 +2,7 @@ import equal from "fast-deep-equal"; import { create } from "zustand"; import { subscribeWithSelector } from "zustand/middleware"; import type { DaemonClient } from "@getpaseo/client/internal/daemon-client"; -import type { ViewedTimelineUiBridge } from "@/timeline/viewed-timeline-sync"; +import type { ViewedTimelineOwner } from "@/timeline/viewed-timeline-sync"; import type { AgentDirectoryEntry } from "@/types/agent-directory"; import { appendSubmittedUserMessage, @@ -366,7 +366,7 @@ export interface SessionState { // Daemon client (immutable reference) client: DaemonClient | null; clientGeneration: number; - viewedTimelineSync: ViewedTimelineUiBridge | null; + viewedTimelineSync: ViewedTimelineOwner | null; // Server metadata (from server_info handshake) serverInfo: DaemonServerInfo | null; @@ -442,7 +442,7 @@ interface SessionStoreActions { clearSession: (serverId: string) => void; getSession: (serverId: string) => SessionState | undefined; updateSessionClient: (serverId: string, client: DaemonClient, clientGeneration?: number) => void; - setViewedTimelineSync: (serverId: string, sync: ViewedTimelineUiBridge | null) => void; + setViewedTimelineSync: (serverId: string, sync: ViewedTimelineOwner | null) => void; updateSessionServerInfo: (serverId: string, info: DaemonServerInfo) => void; // Audio state diff --git a/packages/app/src/terminal/runtime/terminal-stream-controller.test.ts b/packages/app/src/terminal/runtime/terminal-stream-controller.test.ts index ccac3ecc93..388484d79d 100644 --- a/packages/app/src/terminal/runtime/terminal-stream-controller.test.ts +++ b/packages/app/src/terminal/runtime/terminal-stream-controller.test.ts @@ -1,3 +1,4 @@ +import type { SessionOutboundMessage } from "@getpaseo/protocol/messages"; import { describe, expect, it } from "vitest"; import { @@ -36,6 +37,10 @@ type TerminalStreamEvent = | { terminalId: string; type: "restore"; data: Uint8Array }; class FakeTerminalStreamClient implements TerminalStreamControllerClient { + private readonly updates = new Set<(message: SessionOutboundMessage) => void>(); + emitUpdate(message: SessionOutboundMessage): void { + for (const receive of this.updates) receive(message); + } private readonly listeners = new Set<(event: TerminalStreamEvent) => void>(); public subscribeCalls: Array<{ terminalId: string; options?: unknown }> = []; public unsubscribeCalls: string[] = []; @@ -47,18 +52,46 @@ class FakeTerminalStreamClient implements TerminalStreamControllerClient { }> = []; public nextSubscribeResults: Array<{ terminalId: string; error?: string | null }> = []; - async subscribeTerminal(terminalId: string, options?: unknown) { + observeTerminal: TerminalStreamControllerClient["observeTerminal"] = ( + terminalId, + receive, + options, + ) => { this.subscribeCalls.push({ terminalId, ...(options ? { options } : {}) }); const result = this.nextSubscribeResults.shift(); - if (!result) { - throw new Error("Missing fake subscribe result"); - } - return result; - } - - unsubscribeTerminal(terminalId: string): void { - this.unsubscribeCalls.push(terminalId); - } + if (!result) throw new Error("Missing fake subscribe result"); + const subscriptionId = `terminal-${this.subscribeCalls.length}`; + const listener = (event: TerminalStreamEvent) => { + if (event.terminalId === terminalId) receive({ ...event, subscriptionId }); + }; + this.listeners.add(listener); + const ready = result.error + ? Promise.reject(new Error(result.error)) + : Promise.resolve({ + terminalId, + subscriptionId, + slot: this.subscribeCalls.length, + error: null, + requestId: subscriptionId, + }); + let released = false; + return { + subscriptionId, + ready, + subscribe: (observer) => { + this.updates.add(observer.update); + return () => { + this.updates.delete(observer.update); + }; + }, + release: async () => { + if (released) return; + released = true; + this.listeners.delete(listener); + this.unsubscribeCalls.push(terminalId); + }, + }; + }; sendTerminalInput( terminalId: string, @@ -72,13 +105,6 @@ class FakeTerminalStreamClient implements TerminalStreamControllerClient { }); } - onTerminalStreamEvent(handler: (event: TerminalStreamEvent) => void): () => void { - this.listeners.add(handler); - return () => { - this.listeners.delete(handler); - }; - } - emit(event: TerminalStreamEvent): void { for (const listener of this.listeners) { listener(event); @@ -92,8 +118,10 @@ function createHarness(input?: { client?: FakeTerminalStreamClient }) { const restores: Array<{ terminalId: string; data: Uint8Array }> = []; const snapshots: Array<{ terminalId: string; text: string }> = []; const statuses: TerminalStreamControllerStatus[] = []; + const exits: string[] = []; const controller = new TerminalStreamController({ client, + onExit: (terminalId) => exits.push(terminalId), getPreferredSize: () => ({ rows: 24, cols: 80 }), onOutput: (output) => { outputs.push(output); @@ -112,7 +140,7 @@ function createHarness(input?: { client?: FakeTerminalStreamClient }) { }, }); - return { client, controller, outputs, restores, snapshots, statuses }; + return { client, controller, outputs, restores, snapshots, statuses, exits }; } async function flushAsyncWork(): Promise { @@ -239,7 +267,9 @@ describe("terminal-stream-controller", () => { }, }, ]); - expect(client.resizeCalls).toEqual([]); + expect(client.resizeCalls).toEqual([ + { terminalId: "term-1", rows: 24, cols: 80, intent: "claim" }, + ]); expect(harness.restores[0]?.data).toBe(restoreData); expect(harness.restores).toEqual([{ terminalId: "term-1", data: terminalOutput("restored") }]); expect(harness.outputs).toEqual([]); @@ -264,3 +294,28 @@ describe("terminal-stream-controller", () => { }); }); }); + +it("reports observation failure without declaring the PTY exited, then allows reattach", async () => { + const h = createHarness(); + h.client.nextSubscribeResults.push({ terminalId: "term-1", error: null }); + h.controller.setTerminal({ terminalId: "term-1" }); + await flushAsyncWork(); + h.client.emitUpdate({ + type: "terminal_stream_exit", + payload: { terminalId: "term-1", subscriptionId: "terminal-1", error: "Snapshot read failed" }, + }); + expect(h.statuses.at(-1)).toEqual({ + terminalId: "term-1", + isAttaching: false, + error: "Snapshot read failed", + }); + expect(h.exits).toEqual([]); + expect(h.client.unsubscribeCalls).toEqual(["term-1"]); + h.client.nextSubscribeResults.push({ terminalId: "term-1", error: null }); + h.controller.setTerminal({ terminalId: "term-1" }); + await flushAsyncWork(); + h.client.emit({ terminalId: "term-1", type: "output", data: terminalOutput("STILL ALIVE") }); + expect(h.outputs).toHaveLength(1); + expect(h.statuses.at(-1)?.error).toBe(null); + h.controller.dispose(); +}); diff --git a/packages/app/src/terminal/runtime/terminal-stream-controller.ts b/packages/app/src/terminal/runtime/terminal-stream-controller.ts index 6b008a4ff0..90d54be4d4 100644 --- a/packages/app/src/terminal/runtime/terminal-stream-controller.ts +++ b/packages/app/src/terminal/runtime/terminal-stream-controller.ts @@ -1,29 +1,12 @@ +import type { DaemonClient } from "@getpaseo/client/internal/daemon-client"; import type { SubscribeTerminalRequest, TerminalState } from "@getpaseo/protocol/messages"; import type { TerminalOutputData } from "./terminal-emulator-runtime"; import { i18n } from "@/i18n/i18next"; -export interface TerminalStreamControllerClient { - subscribeTerminal: ( - terminalId: string, - options?: { restore?: SubscribeTerminalRequest["restore"] }, - ) => Promise<{ - terminalId: string; - error?: string | null; - }>; - unsubscribeTerminal: (terminalId: string) => void; - sendTerminalInput: ( - terminalId: string, - message: { type: "resize"; rows: number; cols: number; intent?: "claim" | "update" }, - ) => void; - onTerminalStreamEvent: ( - handler: ( - event: - | { terminalId: string; type: "output"; data: Uint8Array } - | { terminalId: string; type: "snapshot"; state: TerminalState } - | { terminalId: string; type: "restore"; data: Uint8Array }, - ) => void, - ) => () => void; -} +export type TerminalStreamControllerClient = Pick< + DaemonClient, + "observeTerminal" | "sendTerminalInput" +>; export interface TerminalStreamControllerSize { rows: number; @@ -41,6 +24,7 @@ export interface TerminalStreamControllerOptions { getPreferredSize: () => TerminalStreamControllerSize | null; onOutput: (input: { terminalId: string; data: TerminalOutputData }) => void; onSnapshot: (input: { terminalId: string; state: TerminalState }) => void; + onExit?: (terminalId: string) => void; onRestore?: (input: { terminalId: string; data: TerminalOutputData }) => void; getRestoreOptions?: () => SubscribeTerminalRequest["restore"] | undefined; onStatusChange?: (status: TerminalStreamControllerStatus) => void; @@ -49,51 +33,76 @@ export interface TerminalStreamControllerOptions { const TERMINAL_EXITED_ERROR = "Terminal exited"; export class TerminalStreamController { - private readonly unsubscribeStreamEvents: () => void; + private subscription: ReturnType | null = null; private terminalId: string | null = null; private disposed = false; - constructor(private readonly options: TerminalStreamControllerOptions) { - this.unsubscribeStreamEvents = this.options.client.onTerminalStreamEvent((event) => { - if (this.disposed || event.terminalId !== this.terminalId) { - return; - } - if (event.type === "snapshot") { - this.options.onSnapshot({ terminalId: event.terminalId, state: event.state }); - return; - } - if (event.type === "restore") { - if (event.data.length > 0) { - this.options.onRestore?.({ terminalId: event.terminalId, data: event.data }); - } - return; - } + constructor(private readonly options: TerminalStreamControllerOptions) {} + + private receive: Parameters[1] = (event) => { + if (this.disposed || event.terminalId !== this.terminalId) { + return; + } + if (event.type === "snapshot") { + this.options.onSnapshot({ terminalId: event.terminalId, state: event.state }); + return; + } + if (event.type === "restore") { if (event.data.length > 0) { - this.options.onOutput({ terminalId: event.terminalId, data: event.data }); + this.options.onRestore?.({ terminalId: event.terminalId, data: event.data }); } - }); - } + return; + } + if (event.data.length > 0) { + this.options.onOutput({ terminalId: event.terminalId, data: event.data }); + } + }; setTerminal(input: { terminalId: string | null }): void { if (this.disposed || input.terminalId === this.terminalId) { return; } const nextTerminalId = input.terminalId; - const previousTerminalId = this.terminalId; this.terminalId = nextTerminalId; - if (previousTerminalId) { - this.options.client.unsubscribeTerminal(previousTerminalId); - } + void this.subscription?.release().catch(console.error); + this.subscription = null; if (!nextTerminalId) { this.options.onStatusChange?.({ terminalId: null, isAttaching: false, error: null }); return; } const restore = this.options.getRestoreOptions?.(); this.options.onStatusChange?.({ terminalId: nextTerminalId, isAttaching: true, error: null }); - void this.options.client - .subscribeTerminal(nextTerminalId, restore ? { restore } : undefined) + let subscription: ReturnType; + try { + const preferredSize = restore?.size ?? this.options.getPreferredSize(); + if (preferredSize) + this.options.client.sendTerminalInput(nextTerminalId, { + type: "resize", + ...preferredSize, + intent: "claim", + }); + subscription = this.options.client.observeTerminal( + nextTerminalId, + this.receive, + restore ? { restore } : undefined, + ); + } catch (error) { + this.failAttach(nextTerminalId, error); + return; + } + this.subscription = subscription; + subscription.subscribe({ + snapshot: () => {}, + update: (message) => { + if (message.type !== "terminal_stream_exit" || this.subscription !== subscription) return; + if (message.payload.error) + this.failAttach(nextTerminalId, new Error(message.payload.error)); + else this.handleTerminalExit({ terminalId: nextTerminalId }); + }, + }); + void subscription.ready .then((payload) => { - if (this.disposed || this.terminalId !== nextTerminalId) { + if (this.disposed || this.subscription !== subscription) { return; } if (payload.error) { @@ -105,15 +114,6 @@ export class TerminalStreamController { }); return; } - const preferredSize = restore?.size ? null : this.options.getPreferredSize(); - if (preferredSize) { - this.options.client.sendTerminalInput(nextTerminalId, { - type: "resize", - rows: preferredSize.rows, - cols: preferredSize.cols, - intent: "claim", - }); - } this.options.onStatusChange?.({ terminalId: nextTerminalId, isAttaching: false, @@ -122,24 +122,33 @@ export class TerminalStreamController { return; }) .catch((error: unknown) => { - if (this.disposed || this.terminalId !== nextTerminalId) { + if (this.disposed || this.subscription !== subscription) { return; } - this.terminalId = null; - this.options.onStatusChange?.({ - terminalId: nextTerminalId, - isAttaching: false, - error: - error instanceof Error ? error.message : i18n.t("workspace.terminal.unableToSubscribe"), - }); + this.failAttach(nextTerminalId, error); }); } + private failAttach(terminalId: string, error: unknown): void { + this.terminalId = null; + void this.subscription?.release().catch(console.error); + this.subscription = null; + this.options.onStatusChange?.({ + terminalId, + isAttaching: false, + error: + error instanceof Error ? error.message : i18n.t("workspace.terminal.unableToSubscribe"), + }); + } + handleTerminalExit(input: { terminalId: string }): void { if (this.disposed || input.terminalId !== this.terminalId) { return; } + this.options.onExit?.(input.terminalId); this.terminalId = null; + void this.subscription?.release().catch(console.error); + this.subscription = null; this.options.onStatusChange?.({ terminalId: input.terminalId, isAttaching: false, @@ -152,12 +161,9 @@ export class TerminalStreamController { return; } this.disposed = true; - const terminalId = this.terminalId; this.terminalId = null; - if (terminalId) { - this.options.client.unsubscribeTerminal(terminalId); - } - this.unsubscribeStreamEvents(); + void this.subscription?.release().catch(console.error); + this.subscription = null; this.options.onStatusChange?.({ terminalId: null, isAttaching: false, error: null }); } } diff --git a/packages/app/src/timeline/replica.test.ts b/packages/app/src/timeline/replica.test.ts index e01b0f5ee6..f77e95b8c6 100644 --- a/packages/app/src/timeline/replica.test.ts +++ b/packages/app/src/timeline/replica.test.ts @@ -51,8 +51,7 @@ function createOwner(storage: TimelineReplicaStorage): ViewedTimelineOwner { replaceDemandedAgentIds: () => undefined, drainQueuedAgentMessage: () => undefined, ports: { - initialDeliveryMode: "legacy", - setSubscription: async () => undefined, + observe: () => ({ ready: Promise.resolve(), release: async () => undefined }), readCursor: () => undefined, fetchPage: async () => ({ hasNewer: false, endCursor: null }), fetchLatestTail: async () => ({ hasNewer: false, endCursor: null }), diff --git a/packages/app/src/timeline/viewed-timeline-sync.test.ts b/packages/app/src/timeline/viewed-timeline-sync.test.ts index 8c7c156e6f..9a77ab8a6a 100644 --- a/packages/app/src/timeline/viewed-timeline-sync.test.ts +++ b/packages/app/src/timeline/viewed-timeline-sync.test.ts @@ -1,9 +1,12 @@ import { expect, test, vi } from "vitest"; +import { DaemonClient, type DaemonTransport } from "@getpaseo/client/internal/daemon-client"; +import type { SessionInboundMessage, SessionOutboundMessage } from "@getpaseo/protocol/messages"; import type { ProjectedTimelineForwardFetchPlan } from "./timeline-sync-plan"; import { consumeForcedTimelineTailReplacement, createViewedTimelineSync, type TimelineResponsePayload, + type ViewedTimelineSyncPorts, } from "./viewed-timeline-sync"; interface Deferred { @@ -36,7 +39,9 @@ interface TimelineFetch { } class TimelineWorld { + constructor(private readonly observeMembership?: ViewedTimelineSyncPorts["observe"]) {} readonly errors: string[] = []; + readonly releasedMemberships: string[][] = []; readonly cursors = new Map(); readonly cacheRequests: string[] = []; readonly forcedTimelineTailReplacements = new Set(); @@ -48,8 +53,8 @@ class TimelineWorld { this.cacheRequestWaiters.shift()?.(agentId); await this.cacheGate?.promise; }, - initialDeliveryMode: "selective", - setSubscription: async (agentIds) => { + observe: (agentIds) => { + if (this.observeMembership) return this.observeMembership(agentIds); const result = deferred(); this.memberships.push({ agentIds, @@ -57,7 +62,12 @@ class TimelineWorld { fail: (message) => result.reject(new Error(message)), }); this.releaseMembershipWaiter(); - return result.promise; + return { + ready: result.promise, + release: async () => { + this.releasedMemberships.push(agentIds); + }, + }; }, readCursor: (agentId) => this.cursors.get(agentId), fetchPage: async (agentId, request) => { @@ -380,33 +390,112 @@ test("all acknowledged agents begin catch-up independently", async () => { expect(membership.agentIds).toEqual(["agent-a", "agent-b"]); }); -test("an eviction during acknowledgement never catches up the stale hot set", async () => { - const world = new TimelineWorld(); - world.sync.setConnected(true); - world.sync.replaceVisibleAgentIds("workspace", [ - "agent-a", - "agent-b", - "agent-c", - "agent-d", - "agent-e", - ]); - const staleMembership = await world.nextMembership(); - world.sync.replaceVisibleAgentIds("workspace", ["agent-f"]); - staleMembership.succeed(); - const currentMembership = await world.nextMembership(); - currentMembership.succeed(); - const currentCatchUps = await Promise.all( - ["agent-a", "agent-b", "agent-c", "agent-d", "agent-f"].map((agentId) => - world.nextFetch(agentId), - ), - ); - for (const catchUp of currentCatchUps) catchUp.respond({ hasNewer: false }); - - expect({ stale: staleMembership.agentIds, current: currentMembership.agentIds }).toEqual({ - stale: ["agent-a", "agent-b", "agent-c", "agent-d", "agent-e"], - current: ["agent-a", "agent-b", "agent-c", "agent-d", "agent-f"], +test("an eviction starts and acknowledges B before A returns its late subscription ID", async () => { + const requests: SessionInboundMessage[] = []; + let receive!: (data: unknown, isBinary: boolean) => void; + let open!: () => void; + const reply = (message: SessionOutboundMessage) => + receive(JSON.stringify({ type: "session", message }), false); + const transport: DaemonTransport = { + send(data) { + if (typeof data !== "string") throw new Error("No binary request expected"); + const frame = JSON.parse(data); + if (frame.type === "ping") receive(JSON.stringify({ type: "pong" }), false); + if (frame.type !== "session") return; + requests.push(frame.message); + if (frame.message.type === "subscription.release.request") + reply({ + type: "subscription.release.response", + payload: { + requestId: frame.message.requestId, + subscriptionId: frame.message.subscriptionId, + }, + }); + }, + close() {}, + onMessage(handler) { + receive = handler; + return () => {}; + }, + onOpen(handler) { + open = handler; + return () => {}; + }, + onClose() { + return () => {}; + }, + onError() { + return () => {}; + }, + }; + const client = new DaemonClient({ + url: "ws://timeline-fixture", + clientId: "timeline-replacement", + transportFactory: () => transport, + reconnect: { enabled: false }, }); - world.expectNoPendingFetch(); + const world = new TimelineWorld((agentIds) => client.observeTimeline(agentIds)); + const membershipRequests = () => + requests.filter((request) => request.type === "agent.timeline.set_subscription.request"); + try { + const connected = client.connect(); + open(); + reply({ + type: "status", + payload: { + status: "server_info", + serverId: "timeline-fixture", + hostname: null, + version: null, + features: { ownedSubscriptions: true }, + }, + }); + await connected; + world.sync.replaceVisibleAgentIds("workspace", [ + "agent-a", + "agent-b", + "agent-c", + "agent-d", + "agent-e", + ]); + world.sync.setConnected(true); + await vi.waitFor(() => expect(membershipRequests()).toHaveLength(1)); + const a = membershipRequests()[0]!; + world.sync.replaceVisibleAgentIds("workspace", ["agent-f"]); + await vi.waitFor(() => expect(membershipRequests()).toHaveLength(2)); + const b = membershipRequests()[1]!; + reply({ + type: "agent.timeline.set_subscription.response", + payload: { requestId: b.requestId, agentIds: b.agentIds, subscriptionId: "server-b" }, + }); + const catchUps = await Promise.all(b.agentIds.map((agentId) => world.nextFetch(agentId))); + for (const catchUp of catchUps) catchUp.respond({ hasNewer: false }); + await vi.waitFor(() => expect(world.sync.getAgentTimelineStatus("agent-f")).toBe("ready")); + reply({ + type: "agent.timeline.set_subscription.response", + payload: { requestId: a.requestId, agentIds: a.agentIds, subscriptionId: "server-a-late" }, + }); + await vi.waitFor(() => + expect(requests.filter((request) => request.type === "subscription.release.request")).toEqual( + [expect.objectContaining({ subscriptionId: "server-a-late" })], + ), + ); + expect(a.agentIds).toEqual(["agent-a", "agent-b", "agent-c", "agent-d", "agent-e"]); + expect(b.agentIds).toEqual(["agent-a", "agent-b", "agent-c", "agent-d", "agent-f"]); + world.expectNoPendingFetch(); + expect(world.errors).toEqual([]); + world.sync.dispose(); + await vi.waitFor(() => + expect( + requests + .filter((request) => request.type === "subscription.release.request") + .map((request) => request.subscriptionId), + ).toEqual(["server-a-late", "server-b"]), + ); + } finally { + world.sync.dispose(); + await client.close(); + } }); test("disconnect cancels paging and reconnect restores membership before fresh catch-up", async () => { @@ -877,75 +966,14 @@ test("disconnect clears hidden hot agents before reconnecting the visible set", expect(reopened.agentIds).toEqual(["agent-a", "agent-b"]); }); -test("legacy delivery skips subscription RPCs while retaining visibility catch-up and gap recovery", async () => { +test("disposing a view releases its pending observation before bootstrap completes", async () => { const world = new TimelineWorld(); - world.sync.setDeliveryMode("legacy"); world.sync.replaceVisibleAgentIds("workspace", ["agent-a"]); world.sync.setConnected(true); - - world.expectNoPendingMembership(); - const initial = await world.nextFetch("agent-a"); - initial.respond({ hasNewer: false }); - await vi.waitFor(() => expect(world.sync.getAgentTimelineStatus("agent-a")).toBe("ready")); - - world.sync.recoverGap("agent-a", { epoch: "epoch-agent-a", endSeq: 10 }); - const recovery = await world.nextFetch("agent-a"); - recovery.respond({ hasNewer: false }); - - expect(recovery.request).toEqual({ - direction: "after", - cursor: { epoch: "epoch-agent-a", seq: 10 }, - limit: 40, - projection: "projected", - }); -}); - -test("legacy delivery catches up after returning to a view or foreground", async () => { - const world = new TimelineWorld(); - world.sync.setDeliveryMode("legacy"); - world.sync.setConnected(true); - world.sync.replaceVisibleAgentIds("workspace", ["agent-a"]); - const firstAgentA = await world.nextFetch("agent-a"); - firstAgentA.respond({ hasNewer: false }); - await vi.waitFor(() => expect(world.sync.getAgentTimelineStatus("agent-a")).toBe("ready")); - - world.sync.replaceVisibleAgentIds("workspace", ["agent-b"]); - const agentB = await world.nextFetch("agent-b"); - agentB.respond({ hasNewer: false }); - await vi.waitFor(() => expect(world.sync.getAgentTimelineStatus("agent-b")).toBe("ready")); - - world.sync.replaceVisibleAgentIds("workspace", ["agent-a"]); - const secondAgentA = await world.nextFetch("agent-a"); - secondAgentA.respond({ hasNewer: false }); - await vi.waitFor(() => expect(world.sync.getAgentTimelineStatus("agent-a")).toBe("ready")); - - world.sync.setActive(false); - world.sync.setActive(true); - const foregroundAgentA = await world.nextFetch("agent-a"); - foregroundAgentA.respond({ hasNewer: false }); - - world.expectNoPendingMembership(); -}); - -test("switching from legacy to selective delivery publishes membership and catches up once", async () => { - const world = new TimelineWorld(); - world.sync.setDeliveryMode("legacy"); - world.sync.replaceVisibleAgentIds("workspace", ["agent-a"]); - world.sync.setConnected(true); - const legacyCatchUp = await world.nextFetch("agent-a"); - legacyCatchUp.respond({ hasNewer: false }); - await vi.waitFor(() => expect(world.sync.getAgentTimelineStatus("agent-a")).toBe("ready")); - - world.sync.setDeliveryMode("selective"); - expect(world.sync.getAgentTimelineStatus("agent-a")).toBe("pending"); - const membership = await world.nextMembership(); - membership.succeed(); - const catchUp = await world.nextFetch("agent-a"); - catchUp.respond({ hasNewer: false }); - - await vi.waitFor(() => expect(world.sync.getAgentTimelineStatus("agent-a")).toBe("ready")); - - expect(membership.agentIds).toEqual(["agent-a"]); - world.expectNoPendingMembership(); + const request = await world.nextMembership(); + world.sync.dispose(); + expect(world.releasedMemberships).toEqual([["agent-a"]]); + request.succeed(); + await Promise.resolve(); world.expectNoPendingFetch(); }); diff --git a/packages/app/src/timeline/viewed-timeline-sync.ts b/packages/app/src/timeline/viewed-timeline-sync.ts index d11084cdf2..ea73f9e8e2 100644 --- a/packages/app/src/timeline/viewed-timeline-sync.ts +++ b/packages/app/src/timeline/viewed-timeline-sync.ts @@ -308,10 +308,9 @@ function applyAuthoritativeTimelineResponse(input: { } export interface ViewedTimelineSyncPorts { - initialDeliveryMode: TimelineDeliveryMode; prepare(agentId: string): Promise; replaceDemandedAgentIds(agentIds: string[]): void; - setSubscription(agentIds: string[]): Promise; + observe(agentIds: string[]): { ready: Promise; release(): Promise }; readCursor(agentId: string): { epoch: string; endSeq: number } | undefined; fetchPage( agentId: string, @@ -322,7 +321,6 @@ export interface ViewedTimelineSyncPorts { schedule(task: () => void, delayMs: number): () => void; } -export type TimelineDeliveryMode = "legacy" | "selective"; export type ViewedTimelineStatus = "ready" | "pending" | "error" | "retrying"; export interface ViewedTimelineUiBridge { @@ -336,7 +334,6 @@ export interface ViewedTimelineUiBridge { export interface ViewedTimelineSync extends ViewedTimelineUiBridge { setActive(active: boolean): void; setConnected(connected: boolean): void; - setDeliveryMode(mode: TimelineDeliveryMode): void; recoverGap(agentId: string, cursor: { epoch: string; endSeq: number }): void; dispose(): void; } @@ -359,8 +356,17 @@ export function createViewedTimelineOwner(input: { drainQueuedAgentMessage: (agentId: string) => void; ports: ViewedTimelineOwnerPorts; }): ViewedTimelineOwner { + const forcedTailReplacements = new Set(); const sync = createViewedTimelineSync({ ...input.ports, + fetchLatestTail: async (agentId) => { + forcedTailReplacements.add(agentId); + try { + return await input.ports.fetchLatestTail(agentId); + } finally { + forcedTailReplacements.delete(agentId); + } + }, prepare: (agentId) => input.replica.prepare(agentId), readCursor: (agentId) => input.replica.readCursor(agentId) ?? input.ports.readCursor(agentId), replaceDemandedAgentIds: input.replaceDemandedAgentIds, @@ -374,7 +380,8 @@ export function createViewedTimelineOwner(input: { }); return { ...sync, - applyTimelineResponse(payload) { + applyTimelineResponse(receivedPayload) { + const payload = consumeForcedTimelineTailReplacement(receivedPayload, forcedTailReplacements); const accepted = applyAuthoritativeTimelineResponse({ serverId: input.serverId, payload, @@ -477,13 +484,14 @@ export function createViewedTimelineSync(ports: ViewedTimelineSyncPorts): Viewed const listeners = new Set<() => void>(); let active = true; let connected = false; - let deliveryMode = ports.initialDeliveryMode; + let observation: { + handle: ReturnType; + agentIds: string[]; + } | null = null; let disposed = false; let desired: string[] = []; let acknowledged: string[] = []; let membershipGeneration = 0; - let reconciling = false; - let reconcileRequested = false; let membershipNeedsRetry = false; let membershipRetryDelayMs: number | undefined; let cancelMembershipRetry: (() => void) | null = null; @@ -678,78 +686,46 @@ export function createViewedTimelineSync(ports: ViewedTimelineSyncPorts): Viewed } }; - const reconcileLatestMembership = async (): Promise => { - if (disposed || !connected || deliveryMode !== "selective") return; + const reconcileMembership = async (): Promise => { + if (disposed || !connected) return; const generation = membershipGeneration; const requested = desired; - if (!membershipNeedsRetry && sameAgentIds(requested, acknowledged)) return; + if (!membershipNeedsRetry && sameAgentIds(requested, observation?.agentIds ?? acknowledged)) + return; membershipNeedsRetry = false; try { - await ports.setSubscription(requested); + const previous = observation; + const handle = ports.observe(requested); + observation = { handle, agentIds: requested }; + void previous?.handle.release().catch(ports.reportError); + await handle.ready; } catch (error) { + if (disposed || !connected || generation !== membershipGeneration) return; membershipNeedsRetry = true; setVisibilityCatchUpError(requested, error); cancelMembershipRetry?.(); const nextRetryDelayMs = getNextRetryDelayMs(membershipRetryDelayMs); cancelMembershipRetry = ports.schedule(() => { cancelMembershipRetry = null; - if ( - disposed || - !connected || - membershipGeneration !== generation || - !sameAgentIds(desired, requested) - ) { - return; - } + if (disposed || !connected || membershipGeneration !== generation) return; void reconcileMembership(); }, nextRetryDelayMs); membershipRetryDelayMs = nextRetryDelayMs; ports.reportError(error); return; } + if (disposed || !connected || generation !== membershipGeneration) return; cancelMembershipRetry?.(); cancelMembershipRetry = null; membershipRetryDelayMs = undefined; - if (disposed || !connected || deliveryMode !== "selective") return; acknowledged = requested; - if (generation !== membershipGeneration) { - await reconcileLatestMembership(); - return; - } startAcknowledgedCatchUps(); - if (!sameAgentIds(desired, acknowledged)) await reconcileLatestMembership(); - }; - - const reconcileMembership = async () => { - if (reconciling) { - reconcileRequested = true; - return; - } - if (disposed || !connected) return; - reconciling = true; - try { - await reconcileLatestMembership(); - } finally { - reconciling = false; - if (reconcileRequested && !disposed && connected && deliveryMode === "selective") { - reconcileRequested = false; - void reconcileMembership(); - } else if ( - !disposed && - connected && - deliveryMode === "selective" && - !membershipNeedsRetry && - !sameAgentIds(desired, acknowledged) - ) { - void reconcileMembership(); - } - } }; const retryVisibleAgentTimeline = (agentId: string) => { if (!isDesired(agentId) || manualRetries.has(agentId)) return; const catchUp = catchUps.get(agentId); - const membershipRetryable = deliveryMode === "selective" && membershipNeedsRetry && connected; + const membershipRetryable = membershipNeedsRetry && connected; if (catchUp?.status !== "error" && !membershipRetryable) return; manualRetries.add(agentId); notifyListeners(); @@ -761,7 +737,6 @@ export function createViewedTimelineSync(ports: ViewedTimelineSyncPorts): Viewed cancelMembershipRetry?.(); cancelMembershipRetry = null; membershipRetryDelayMs = undefined; - membershipNeedsRetry = false; void reconcileMembership(); }; @@ -807,17 +782,12 @@ export function createViewedTimelineSync(ports: ViewedTimelineSyncPorts): Viewed ports.replaceDemandedAgentIds(desired); membershipGeneration += 1; notifyListeners(); - if (deliveryMode === "legacy") { - acknowledged = connected ? desired : []; - if (connected) startAcknowledgedCatchUps(); - return; - } void reconcileMembership(); }; const publishVisibleMembership = () => { const visible = visibleAgentIds(); - if (!connected || deliveryMode !== "selective") { + if (!connected) { const activeVisible = active ? visible : []; recentlyViewedAgentIds = activeVisible; commitDesiredMembership(activeVisible); @@ -862,40 +832,15 @@ export function createViewedTimelineSync(ports: ViewedTimelineSyncPorts): Viewed cancelMembershipRetry?.(); cancelMembershipRetry = null; membershipRetryDelayMs = undefined; + void observation?.handle.release().catch(ports.reportError); + observation = null; acknowledged = []; membershipGeneration += 1; for (const agentId of desired) cancelCatchUp(agentId); return; } membershipGeneration += 1; - if (deliveryMode === "legacy") { - acknowledged = desired; - startAcknowledgedCatchUps(); - } else { - void reconcileMembership(); - } - }, - setDeliveryMode(nextMode) { - if (deliveryMode === nextMode) return; - deliveryMode = nextMode; - cancelMembershipRetry?.(); - cancelMembershipRetry = null; - membershipRetryDelayMs = undefined; - membershipNeedsRetry = false; - membershipGeneration += 1; - for (const agentId of desired) cancelCatchUp(agentId); - const visible = active ? visibleAgentIds() : []; - recentlyViewedAgentIds = visible; - desired = visible; - ports.replaceDemandedAgentIds(desired); - visibilityCatchUpPending.clear(); - visibilityCatchUpErrors.clear(); - manualRetries.clear(); - for (const agentId of desired) visibilityCatchUpPending.add(agentId); - acknowledged = deliveryMode === "legacy" && connected ? desired : []; - notifyListeners(); - if (deliveryMode === "selective" && connected) void reconcileMembership(); - else if (connected) startAcknowledgedCatchUps(); + void reconcileMembership(); }, recoverGap(agentId, cursor) { if (!isDesired(agentId)) return; @@ -906,6 +851,8 @@ export function createViewedTimelineSync(ports: ViewedTimelineSyncPorts): Viewed }, dispose() { disposed = true; + void observation?.handle.release().catch(ports.reportError); + observation = null; cancelMembershipRetry?.(); cancelMembershipRetry = null; membershipNeedsRetry = false; diff --git a/packages/app/src/workspace-labels/index.ts b/packages/app/src/workspace-labels/index.ts index b2c46ab748..49a2e6b1a9 100644 --- a/packages/app/src/workspace-labels/index.ts +++ b/packages/app/src/workspace-labels/index.ts @@ -155,18 +155,38 @@ class WorkspaceLabelsController { return; } - let connection: HostConnection | undefined; - const unsubscribe = input.client.on("workspace.label.update", (message) => { - if (this.connections.get(input.serverId) !== connection) return; - if (!replica.applyUpdate(message)) { - void this.refresh(input.serverId).catch(() => undefined); - return; - } - this.publish(input.serverId, replica, "online", null); - }); - connection = { client: input.client, replica, unsubscribe }; + const subscription = input.client.observeWorkspaceLabels(); + const connection: HostConnection = { + client: input.client, + replica, + unsubscribe: () => { + void subscription.release().catch(() => undefined); + }, + }; this.connections.set(input.serverId, connection); - await this.refresh(input.serverId); + subscription.subscribe({ + snapshot: (payload) => { + replica.applyList(payload); + this.publish(input.serverId, replica, "online", null); + }, + update: (message) => { + if (message.type !== "workspace.label.update") return; + if (!replica.applyUpdate(message)) { + void this.refresh(input.serverId).catch(() => undefined); + return; + } + this.publish(input.serverId, replica, "online", null); + }, + error: (error) => { + this.publish( + input.serverId, + replica, + "online", + error instanceof Error ? error.message : i18n.t("workspaceLabels.errors.load"), + ); + }, + }); + await subscription.ready; } disconnect(serverId: string): void { @@ -207,7 +227,6 @@ class WorkspaceLabelsController { if (!connection) return; try { const payload = await connection.client.listWorkspaceLabels({ - subscriptionId: `workspace-labels:${serverId}`, sync: connection.replica.snapshot().cursor, }); if (this.connections.get(serverId) !== connection) return; diff --git a/packages/app/src/workspace/legacy-daemon-workspaces.test.ts b/packages/app/src/workspace/legacy-daemon-workspaces.test.ts index 50dc1a8ca1..263596badc 100644 --- a/packages/app/src/workspace/legacy-daemon-workspaces.test.ts +++ b/packages/app/src/workspace/legacy-daemon-workspaces.test.ts @@ -1,26 +1,12 @@ import { afterEach, describe, expect, it } from "vitest"; import type { DaemonClient, FetchAgentsEntry } from "@getpaseo/client/internal/daemon-client"; -import { useSessionStore, type Agent, type WorkspaceDescriptor } from "@/stores/session-store"; +import { useSessionStore, type Agent } from "@/stores/session-store"; import { deriveWorkspaceAgentVisibility } from "@/workspace-tabs/agent-visibility"; -import { buildWorkspaceStructureProjects } from "@/projects/workspace-structure"; -import { - applyLegacyDaemonWorkspaceOwnership, - buildLegacyDaemonWorkspaceSnapshot, -} from "./legacy-daemon-workspaces"; +import { buildAgentDirectoryState } from "@/utils/agent-directory-sync"; +import { applyLegacyDaemonWorkspaceOwnership } from "./legacy-daemon-workspaces"; const SERVER_ID = "srv_legacy"; -function legacyProjectFromWorkspace(workspace: WorkspaceDescriptor) { - return { - projectId: workspace.projectId, - projectKey: null, - projectDisplayName: workspace.projectDisplayName, - projectCustomName: workspace.projectCustomName ?? null, - projectRootPath: workspace.projectRootPath, - projectKind: workspace.projectKind, - }; -} - function legacyAgent(input: { id: string; cwd: string; @@ -82,87 +68,9 @@ afterEach(() => { useSessionStore.getState().clearSession(SERVER_ID); }); -describe("buildLegacyDaemonWorkspaceSnapshot", () => { - it("creates path-backed workspace rows and stamps legacy agents with their workspace id", () => { - const snapshot = buildLegacyDaemonWorkspaceSnapshot({ - serverId: SERVER_ID, - entries: [ - legacyAgent({ id: "agent-running", cwd: "/repo/app", status: "running" }), - legacyAgent({ id: "agent-idle", cwd: "/repo/app", status: "idle" }), - ], - }); - - expect(Array.from(snapshot.workspaces.values())).toEqual([ - expect.objectContaining({ - id: "/repo/app", - projectId: "/repo", - projectDisplayName: "repo", - projectRootPath: "/repo", - workspaceDirectory: "/repo/app", - projectKind: "git", - workspaceKind: "checkout", - name: "app", - status: "running", - scripts: [], - }), - ]); - expect( - Array.from(snapshot.agents.values()).map((agent) => ({ - id: agent.id, - serverId: agent.serverId, - cwd: agent.cwd, - workspaceId: agent.workspaceId, - })), - ).toEqual([ - { - id: "agent-running", - serverId: SERVER_ID, - cwd: "/repo/app", - workspaceId: "/repo/app", - }, - { - id: "agent-idle", - serverId: SERVER_ID, - cwd: "/repo/app", - workspaceId: "/repo/app", - }, - ]); - }); - - it("keeps matching legacy path projects separate across hosts", () => { - const first = buildLegacyDaemonWorkspaceSnapshot({ - serverId: "host-a", - entries: [legacyAgent({ id: "agent-a", cwd: "/repo/app" })], - }); - const second = buildLegacyDaemonWorkspaceSnapshot({ - serverId: "host-b", - entries: [legacyAgent({ id: "agent-b", cwd: "/repo/app" })], - }); - - const projects = buildWorkspaceStructureProjects({ - sessions: [ - { - serverId: "host-a", - projects: Array.from(first.workspaces.values(), legacyProjectFromWorkspace), - workspaces: first.workspaces.values(), - }, - { - serverId: "host-b", - projects: Array.from(second.workspaces.values(), legacyProjectFromWorkspace), - workspaces: second.workspaces.values(), - }, - ], - }); - - expect(projects).toHaveLength(2); - expect(projects.map((project) => project.hosts[0]?.serverId).sort()).toEqual([ - "host-a", - "host-b", - ]); - }); - +describe("applyLegacyDaemonWorkspaceOwnership", () => { it("keeps old-daemon agent updates attached to the path-backed workspace", () => { - const snapshot = buildLegacyDaemonWorkspaceSnapshot({ + const snapshot = buildAgentDirectoryState({ serverId: SERVER_ID, entries: [legacyAgent({ id: "agent-running", cwd: "/repo/app", status: "running" })], }); @@ -173,7 +81,8 @@ describe("buildLegacyDaemonWorkspaceSnapshot", () => { hostname: null, version: "0.1.96", }); - store.setWorkspaces(SERVER_ID, snapshot.workspaces); + const cachedAgent = getSnapshotAgent(snapshot, "agent-running"); + snapshot.agents.set(cachedAgent.id, { ...cachedAgent, workspaceId: "/repo/app" }); store.setAgents(SERVER_ID, snapshot.agents); const existingAgent = getSnapshotAgent(snapshot, "agent-running"); diff --git a/packages/app/src/workspace/legacy-daemon-workspaces.ts b/packages/app/src/workspace/legacy-daemon-workspaces.ts index 8303cfadec..08c0ddf3ee 100644 --- a/packages/app/src/workspace/legacy-daemon-workspaces.ts +++ b/packages/app/src/workspace/legacy-daemon-workspaces.ts @@ -1,113 +1,19 @@ -import type { - DaemonClient, - FetchAgentsEntry, - FetchAgentsOptions, -} from "@getpaseo/client/internal/daemon-client"; -import { - deriveAgentStateBucket, - getWorkspaceStateBucketPriority, -} from "@getpaseo/protocol/agent-state-bucket"; import { useSessionStore, type Agent, type DaemonServerInfo, type WorkspaceDescriptor, } from "@/stores/session-store"; -import { buildAgentDirectoryState } from "@/utils/agent-directory-sync"; import { normalizeWorkspacePath } from "@/utils/workspace-identity"; -export interface LegacyDaemonWorkspaceSnapshot { - agents: Map; - workspaces: Map; -} - -interface LegacyDaemonWorkspaceDirectoryReadResult { - entries: FetchAgentsEntry[]; - subscriptionId: string | null; -} - -const LEGACY_AGENT_DIRECTORY_SORT: NonNullable = [ - { key: "updated_at", direction: "desc" }, -]; - -// COMPAT(legacyWorkspaceDaemon): v0.1.97 app talking to <=v0.1.96 daemons. -// Older daemons expose agents by cwd but may have no workspace registry rows. -// Keep all cwd -> synthetic workspace behavior in this file so the shim is -// deleted by removing this module and its call sites once the daemon floor is v0.1.97. -export function buildLegacyDaemonWorkspaceSnapshot(input: { - serverId: string; - entries: FetchAgentsEntry[]; -}): LegacyDaemonWorkspaceSnapshot { - const entries = stampLegacyWorkspaceIds(input.entries); - const { agents } = buildAgentDirectoryState({ - serverId: input.serverId, - entries, - }); - - return { - agents, - workspaces: buildLegacyWorkspaces(entries), - }; -} - -export function shouldUseLegacyDaemonWorkspaceDirectory( - serverInfo: DaemonServerInfo | null | undefined, -): boolean { - return ( - serverInfo !== null && - serverInfo !== undefined && - serverInfo.features?.workspaceMultiplicity !== true - ); -} - +// COMPAT(legacyWorkspaceDaemon): added in v0.1.97, remove after 2027-03-09. +// Cached or partial agent records can predate workspace IDs; preserve their placement. function shouldBackfillLegacyDaemonWorkspaceDirectory( serverInfo: DaemonServerInfo | null | undefined, ): boolean { return serverInfo?.features?.workspaceMultiplicity !== true; } -export async function readLegacyDaemonWorkspaceDirectory(input: { - client: Pick; - subscribe?: FetchAgentsOptions["subscribe"]; - page?: FetchAgentsOptions["page"]; - isCancelled?: () => boolean; -}): Promise { - const entries: FetchAgentsEntry[] = []; - let cursor = input.page?.cursor ?? null; - let includeSubscribe = true; - let subscriptionId: string | null = null; - const pageLimit = input.page?.limit ?? 200; - - while (true) { - if (input.isCancelled?.()) { - return null; - } - - const payload = await input.client.fetchAgents({ - sort: LEGACY_AGENT_DIRECTORY_SORT, - ...(includeSubscribe && input.subscribe ? { subscribe: input.subscribe } : {}), - page: cursor ? { limit: pageLimit, cursor } : { limit: pageLimit }, - }); - if (input.isCancelled?.()) { - return null; - } - - entries.push(...payload.entries); - subscriptionId = subscriptionId ?? payload.subscriptionId ?? null; - includeSubscribe = false; - if (!readFetchAgentsHasMore(payload.pageInfo)) { - break; - } - const nextCursor = readFetchAgentsNextCursor(payload.pageInfo); - if (!nextCursor) { - break; - } - cursor = nextCursor; - } - - return { entries, subscriptionId }; -} - export function applyLegacyDaemonWorkspaceOwnership(input: { serverId: string; agent: Agent; @@ -139,144 +45,6 @@ export function applyLegacyDaemonWorkspaceOwnership(input: { }; } -function readFetchAgentsHasMore( - pageInfo: Awaited>["pageInfo"], -): boolean { - const page = pageInfo as { - hasMore?: boolean; - hasMoreAfter?: boolean; - }; - if (typeof page.hasMore === "boolean") { - return page.hasMore; - } - if (typeof page.hasMoreAfter === "boolean") { - return page.hasMoreAfter; - } - return false; -} - -function readFetchAgentsNextCursor( - pageInfo: Awaited>["pageInfo"], -): string | null { - const page = pageInfo as { - nextCursor?: string | null; - afterCursor?: string | null; - }; - if (typeof page.nextCursor === "string") { - return page.nextCursor; - } - if (typeof page.afterCursor === "string") { - return page.afterCursor; - } - return null; -} - -export function stampLegacyWorkspaceIds(entries: FetchAgentsEntry[]): FetchAgentsEntry[] { - return entries.map((entry) => { - const workspaceId = resolveLegacyWorkspaceId(entry); - return { - ...entry, - agent: { - ...entry.agent, - workspaceId, - }, - }; - }); -} - -export function buildLegacyWorkspaces( - entries: FetchAgentsEntry[], -): Map { - const workspaces = new Map(); - for (const entry of entries) { - const workspaceId = entry.agent.workspaceId ?? resolveLegacyWorkspaceId(entry); - const status = deriveAgentStateBucket({ - status: entry.agent.status, - pendingPermissionCount: entry.agent.pendingPermissions.length, - requiresAttention: entry.agent.requiresAttention, - attentionReason: entry.agent.attentionReason, - }); - const statusEnteredAt = parseLegacyAgentTimestamp(entry); - const existing = workspaces.get(workspaceId); - if (!existing) { - workspaces.set(workspaceId, createLegacyWorkspace(entry, status, statusEnteredAt)); - continue; - } - if ( - getWorkspaceStateBucketPriority(status) < getWorkspaceStateBucketPriority(existing.status) - ) { - workspaces.set(workspaceId, { - ...existing, - status, - statusEnteredAt, - }); - } - } - return workspaces; -} - -function createLegacyWorkspace( - entry: FetchAgentsEntry, - status: WorkspaceDescriptor["status"], - statusEnteredAt: Date | null, -): WorkspaceDescriptor { - const workspaceDirectory = resolveLegacyWorkspaceId(entry); - const checkout = entry.project.checkout; - const projectRootPath = - normalizeWorkspacePath(checkout.mainRepoRoot ?? checkout.worktreeRoot ?? checkout.cwd) ?? - workspaceDirectory; - return { - id: workspaceDirectory, - projectId: entry.project.projectKey, - projectDisplayName: entry.project.projectName, - projectCustomName: null, - projectRootPath, - workspaceDirectory, - projectKind: checkout.isGit ? "git" : "non_git", - workspaceKind: resolveLegacyWorkspaceKind(checkout), - name: resolveLegacyWorkspaceName(entry, workspaceDirectory), - title: null, - status, - statusEnteredAt, - archivingAt: null, - diffStat: null, - scripts: [], - gitRuntime: checkout.isGit - ? { - currentBranch: checkout.currentBranch, - remoteUrl: checkout.remoteUrl, - isPaseoOwnedWorktree: checkout.isPaseoOwnedWorktree, - isDirty: null, - aheadBehind: null, - aheadOfOrigin: null, - behindOfOrigin: null, - } - : null, - githubRuntime: null, - project: entry.project, - }; -} - -function resolveLegacyWorkspaceKind( - checkout: FetchAgentsEntry["project"]["checkout"], -): WorkspaceDescriptor["workspaceKind"] { - if (!checkout.isGit) { - return "directory"; - } - if (checkout.isPaseoOwnedWorktree) { - return "worktree"; - } - return "checkout"; -} - -function resolveLegacyWorkspaceId(entry: FetchAgentsEntry): string { - return ( - normalizeWorkspacePath(entry.project.checkout.cwd) ?? - normalizeWorkspacePath(entry.agent.cwd) ?? - entry.agent.cwd - ); -} - function resolveLegacyWorkspaceIdFromAgent( agent: Agent, workspaces: ReadonlyMap | undefined, @@ -294,30 +62,3 @@ function resolveLegacyWorkspaceIdFromAgent( return cwd; } - -function resolveLegacyWorkspaceName(entry: FetchAgentsEntry, workspaceDirectory: string): string { - const explicitName = entry.project.workspaceName?.trim(); - if (explicitName) { - return explicitName; - } - const branchName = entry.project.checkout.currentBranch?.trim(); - if (branchName && branchName !== "HEAD") { - return branchName; - } - return workspaceDirectoryName(workspaceDirectory); -} - -function workspaceDirectoryName(directory: string): string { - const trimmed = directory.trim().replace(/[/]+$/g, ""); - const separator = Math.max(trimmed.lastIndexOf("/"), trimmed.lastIndexOf("\\")); - return separator >= 0 ? trimmed.slice(separator + 1) : trimmed; -} - -function parseLegacyAgentTimestamp(entry: FetchAgentsEntry): Date | null { - const value = entry.agent.attentionTimestamp ?? entry.agent.updatedAt; - if (!value) { - return null; - } - const date = new Date(value); - return Number.isNaN(date.getTime()) ? null : date; -} diff --git a/packages/cli/src/commands/agent/attach.ts b/packages/cli/src/commands/agent/attach.ts index e73bb0a2f3..1118d4c905 100644 --- a/packages/cli/src/commands/agent/attach.ts +++ b/packages/cli/src/commands/agent/attach.ts @@ -155,6 +155,11 @@ export async function runAttachCommand( const unsubscribe = client.subscribeAgentTimeline(resolvedId, (message) => { if (message.type === "agent.timeline.replacement") { console.log("\n[Timeline replaced; earlier output is no longer current]"); + } else if (message.type === "agent.timeline.snapshot") { + console.log("\n[Reconnected; current recent history follows]"); + for (const entry of message.payload.page.entries) printTimelineItem(entry.item); + } else if (message.type === "agent.timeline.error") { + console.error(`Timeline observation stopped: ${message.payload.error}`); } else { printStreamEvent(message.payload.event); } diff --git a/packages/cli/src/commands/agent/logs.ts b/packages/cli/src/commands/agent/logs.ts index e29c9f6c69..32652d3e0e 100644 --- a/packages/cli/src/commands/agent/logs.ts +++ b/packages/cli/src/commands/agent/logs.ts @@ -208,6 +208,18 @@ async function runFollowMode( return; } + if (message.type === "agent.timeline.error") { + console.error(`Timeline observation stopped: ${message.payload.error}`); + return; + } + if (message.type === "agent.timeline.snapshot") { + console.log("\n[Reconnected; current recent history follows]"); + const items = message.payload.page.entries + .map((entry) => entry.item) + .filter((item) => !options.filter || matchesFilter(item, options.filter)); + console.log(formatAgentActivityTranscript(items, tailCount)); + return; + } if (message.payload.event.type === "timeline") { const item = message.payload.event.item; // Apply filter diff --git a/packages/client/examples/events-and-timeline.ts b/packages/client/examples/events-and-timeline.ts index 84855426de..a56a826677 100644 --- a/packages/client/examples/events-and-timeline.ts +++ b/packages/client/examples/events-and-timeline.ts @@ -10,13 +10,12 @@ export async function subscribeToEvents( url: string, agentId: string, workspaceId: string, -): Promise<() => void> { +): Promise<() => Promise> { const client = createClient(url); await client.connect(); - await client.workspaces.list({ - subscribe: { subscriptionId: `workspace-${workspaceId}` }, - }); + const agents = await client.agents.list({ subscribe: {} }); + const workspaces = await client.workspaces.list({ subscribe: {} }); const unsubscribeAgentUpdates = client.agents.subscribe((update) => { if (update.kind === "upsert" && update.agent.id === agentId) { @@ -31,14 +30,24 @@ export async function subscribeToEvents( }); const unsubscribeTimeline = client.agents.ref(agentId).timeline.subscribe((event) => { - void event.event; + if (event.event.type === "snapshot") { + console.log("Replace recent history", event.event.page.epoch, event.event.page.entries); + } else if (event.event.type === "error") { + console.error(event.event.error); + } }); - return () => { + await unsubscribeTimeline.ready; + + return async () => { unsubscribeTimeline(); unsubscribeWorkspaceUpdates(); unsubscribeAgentUpdates(); - void client.close(); + try { + await Promise.all([agents.subscription.release(), workspaces.subscription.release()]); + } finally { + await client.close(); + } }; } diff --git a/packages/client/src/connection.test.ts b/packages/client/src/connection.test.ts index 6807c4bf63..5a87b64e9c 100644 --- a/packages/client/src/connection.test.ts +++ b/packages/client/src/connection.test.ts @@ -2,11 +2,23 @@ import { expect, test } from "vitest"; import { CLIENT_CAPS } from "@getpaseo/protocol/client-capabilities"; import { DaemonClient, type DaemonTransport } from "./daemon-client"; -function connection(options: { acknowledgeSubscriptions?: boolean } = {}) { +function connection( + options: { + acknowledgeSubscriptions?: boolean; + ownedSubscriptions?: boolean; + acknowledgeTimelineReads?: boolean; + } = {}, +) { const sent: Array<{ type: string; capabilities?: Record; - message?: { type: string; requestId?: string; agentIds?: string[]; events?: string[] }; + message?: { + type: string; + requestId?: string; + agentIds?: string[]; + events?: string[]; + subscriptionId?: string; + }; }> = []; let receive = (_data: unknown) => {}; let open = () => {}; @@ -26,7 +38,11 @@ function connection(options: { acknowledgeSubscriptions?: boolean } = {}) { serverId: "test", hostname: null, version: null, - features: { selectiveAgentTimeline: true, explicitEventSubscriptions: true }, + features: { + ...(options.ownedSubscriptions === false ? {} : { ownedSubscriptions: true }), + selectiveAgentTimeline: true, + explicitEventSubscriptions: true, + }, }, }, }), @@ -41,7 +57,36 @@ function connection(options: { acknowledgeSubscriptions?: boolean } = {}) { type: "session", message: { type: frame.message.type.replace(".request", ".response"), - payload: { requestId: frame.message.requestId, agentIds: frame.message.agentIds }, + payload: { + requestId: frame.message.requestId, + agentIds: frame.message.agentIds, + subscriptionId: `server-${sent.length}`, + }, + }, + }), + ); + } + if ( + frame.message?.type === "fetch_agent_timeline_request" && + options.acknowledgeTimelineReads !== false + ) { + receive( + JSON.stringify({ + type: "session", + message: timelinePage(frame.message.requestId, "epoch", 0), + }), + ); + } + if (frame.message?.type === "subscription.release.request") { + receive( + JSON.stringify({ + type: "session", + message: { + type: "subscription.release.response", + payload: { + requestId: frame.message.requestId, + subscriptionId: frame.message.subscriptionId, + }, }, }), ); @@ -49,7 +94,7 @@ function connection(options: { acknowledgeSubscriptions?: boolean } = {}) { }, close() {}, onMessage(handler) { - receive = handler; + receive = (data) => handler(data, typeof data !== "string"); return () => {}; }, onOpen(handler) { @@ -96,35 +141,36 @@ test("a plain client advertises every protocol capability and no browser host", } }); -test("SDK timeline listeners own their union across unsubscribe and reconnect", async () => { +test("SDK timelines have independent lifetimes and fresh IDs on reconnect", async () => { const { createPaseoApi } = await import("./index"); const h = connection(); const api = createPaseoApi(h.client); try { - const ready = h.client.connect(); + const connecting = h.client.connect(); h.open(); - await ready; - const first = api.agents.ref("a").timeline.subscribe(() => {}); + await connecting; + const a = api.agents.ref("a").timeline.subscribe(() => {}); const same = api.agents.ref("a").timeline.subscribe(() => {}); - const second = api.agents.ref("b").timeline.subscribe(() => {}); - await Promise.resolve(); + const b = api.agents.ref("b").timeline.subscribe(() => {}); + await Promise.all([a.ready, same.ready, b.ready]); + expect(new Set([a.subscriptionId, same.subscriptionId, b.subscriptionId]).size).toBe(3); const memberships = () => h.sent - .filter((f) => f.message?.type === "agent.timeline.set_subscription.request") - .map((f) => f.message?.agentIds); - expect(memberships()).toEqual([["a"], ["a"], ["a", "b"]]); - first(); - expect(memberships()).toEqual([["a"], ["a"], ["a", "b"]]); - same(); - expect(memberships().at(-1)).toEqual(["b"]); + .filter((frame) => frame.message?.type === "agent.timeline.set_subscription.request") + .map((frame) => frame.message?.agentIds); + expect(memberships()).toEqual([["a"], ["a"], ["b"]]); + await a.release(); + await same.release(); + const previousId = b.subscriptionId; h.disconnect(); - const reconnect = h.client.connect(); + const reconnecting = h.client.connect(); h.open(); - await reconnect; - expect(memberships().at(-1)).toEqual(["b"]); - second(); - expect(memberships().at(-1)).toEqual([]); + await reconnecting; + await expect.poll(() => b.subscriptionId).not.toBe(previousId); + expect(memberships()).toEqual([["a"], ["a"], ["b"], ["b"]]); + await b.release(); } finally { + await api.dispose(); await h.client.close(); } }); @@ -140,7 +186,11 @@ test("SDK subscribers receive timeline replacement instead of silently losing hi const off = createPaseoApi(h.client) .agents.ref("agent") .timeline.subscribe((event) => received.push(event)); - h.receive({ type: "agent.timeline.replacement", payload: { agentId: "agent", epoch: "next" } }); + await off.ready; + h.receive({ + type: "agent.timeline.replacement", + payload: { agentId: "agent", epoch: "next", subscriptionId: off.subscriptionId }, + }); expect(received).toEqual([{ agentId: "agent", event: { type: "replacement", epoch: "next" } }]); off(); } finally { @@ -148,190 +198,110 @@ test("SDK subscribers receive timeline replacement instead of silently losing hi } }); -test("event listeners release demand and restore only remaining interests", async () => { +test("local listeners create no demand before or after reconnect", async () => { const h = connection(); try { - const ready = h.client.connect(); + const connecting = h.client.connect(); h.open(); - await ready; - const projects = h.client.on("project.update", () => {}); - const providers = h.client.on("providers_snapshot_update", () => {}); - const subscriptions = () => - h.sent - .filter((f) => f.message?.type === "session.events.set_subscription.request") - .map((f) => f.message?.events); - expect(subscriptions()).toEqual([ - ["project.update"], - ["project.update", "providers_snapshot_update"], - ]); - projects(); + await connecting; + const off = h.client.on("project.update", () => {}); h.disconnect(); - const reconnect = h.client.connect(); + const reconnecting = h.client.connect(); h.open(); - await reconnect; - expect(subscriptions().at(-1)).toEqual(["providers_snapshot_update"]); - providers(); - expect(subscriptions().at(-1)).toEqual([]); + await reconnecting; + expect(h.sent.filter((frame) => frame.type === "session")).toEqual([]); + off(); } finally { await h.client.close(); } }); -test("provider references fetch a body only for interested SDK listeners", async () => { +test("provider reference hydration preserves independent owners and drops released work", async () => { const h = connection(); - const received: unknown[] = []; - const payload = { - entries: [], - snapshotHash: "catalog", - fetchedAt: { test: "2026-09-08T00:00:00.000Z" }, - generatedAt: "2026-09-08T00:00:00.000Z", - }; + const updates: string[] = []; try { - const ready = h.client.connect(); + const connecting = h.client.connect(); h.open(); - await ready; - h.receive({ type: "providers_snapshot_update", payload }); - expect(h.sent).toHaveLength(1); - const off = h.client.on("providers_snapshot_update", (m) => received.push(m.payload.entries)); - h.receive({ type: "providers_snapshot_update", payload }); - h.receive({ type: "providers_snapshot_update", payload }); - const requests = h.sent.filter((f) => f.message?.type === "get_providers_snapshot_request"); - expect(requests).toHaveLength(1); - h.receive({ - type: "get_providers_snapshot_response", - payload: { - ...payload, - requestId: requests[0].message?.requestId, - compactSnapshot: { - entries: [ - { - provider: "test", - enabled: true, - status: "ready", - models: [{ id: "one", label: "One" }], - }, - ], - thinkingSets: [], + await connecting; + const a = h.client.observeEvents(["providers_snapshot_update"]); + const b = h.client.observeEvents(["providers_snapshot_update"]); + await Promise.all([a.ready, b.ready]); + for (const owner of [a, b]) + owner.subscribe({ + snapshot: () => {}, + update: (message) => { + if (message.type === "providers_snapshot_update") + updates.push(message.payload.subscriptionId!); }, - }, - }); - await expect - .poll(() => received) - .toEqual([ - [ - { - provider: "test", - enabled: true, - status: "ready", - fetchedAt: "2026-09-08T00:00:00.000Z", - models: [{ provider: "test", id: "one", label: "One" }], - }, - ], - ]); - off(); - h.receive({ type: "providers_snapshot_update", payload: { ...payload, snapshotHash: "next" } }); - expect(h.sent.filter((f) => f.message?.type === "get_providers_snapshot_request")).toHaveLength( - 1, + }); + const payload = { + entries: [], + snapshotHash: "catalog", + generatedAt: "2026-09-08T00:00:00.000Z", + }; + for (const owner of [a, b]) + h.receive({ + type: "providers_snapshot_update", + payload: { ...payload, subscriptionId: owner.subscriptionId }, + }); + const requests = h.sent.filter( + (frame) => frame.message?.type === "get_providers_snapshot_request", ); + expect(requests).toHaveLength(2); + await a.release(); + for (const request of requests) + h.receive({ + type: "get_providers_snapshot_response", + payload: { + ...payload, + requestId: request.message!.requestId, + compactSnapshot: { entries: [], thinkingSets: [] }, + }, + }); + await expect.poll(() => updates).toEqual([b.subscriptionId]); + await b.release(); } finally { await h.client.close(); } }); -test("SDK listeners preserve the app's explicit timeline membership", async () => { - const h = connection(); - try { - const ready = h.client.connect(); - h.open(); - await ready; - await h.client.setAgentTimelineSubscription(["visible"]); - const off = h.client.subscribeAgentTimeline("plugin", () => {}); - await h.client.setAgentTimelineSubscription(["next"]); - expect(h.sent.at(-1)?.message?.agentIds).toEqual(["next", "plugin"]); - off(); - expect(h.sent.at(-1)?.message?.agentIds).toEqual(["next"]); - } finally { - await h.client.close(); - } -}); - -test("timeline readiness waits for daemon acknowledgement, including shared listeners", async () => { +test("timeline readiness and updates belong to the acknowledged handle", async () => { const h = connection({ acknowledgeSubscriptions: false }); try { - const connect = h.client.connect(); + const connecting = h.client.connect(); h.open(); - await connect; - const first = h.client.subscribeAgentTimeline("agent", () => {}); - const second = h.client.subscribeAgentTimeline("agent", () => {}); - expect(first.ready).toBeInstanceOf(Promise); - let ready = false; - void second.ready.then(() => { - return (ready = true); - }); - await Promise.resolve(); - expect(ready).toBe(false); - const request = h.sent.at(-1)!.message!; - h.receive({ - type: "agent.timeline.set_subscription.response", - payload: { requestId: request.requestId, agentIds: ["agent"] }, - }); - await Promise.all([first.ready, second.ready]); - expect(ready).toBe(true); - h.disconnect(); - const third = h.client.subscribeAgentTimeline("agent", () => {}); - let reconnected = false; - void third.ready.then(() => { - return (reconnected = true); - }); - const reconnect = h.client.connect(); - h.open(); - await reconnect; - expect(reconnected).toBe(false); - h.receive({ - type: "agent.timeline.set_subscription.response", - payload: { requestId: h.sent.at(-1)!.message!.requestId, agentIds: ["agent"] }, - }); - await third.ready; - expect(reconnected).toBe(true); - first(); - second(); - third(); - } finally { - await h.client.close(); - } -}); - -test("an unresolved provider reference is fetched again after reconnect", async () => { - const h = connection(); - const received: unknown[] = []; - const payload = { entries: [], snapshotHash: "updated", generatedAt: "2026-09-08T00:00:00.000Z" }; - const requests = () => h.sent.filter((f) => f.message?.type === "get_providers_snapshot_request"); - try { - const connect = h.client.connect(); - h.open(); - await connect; - const off = h.client.on("providers_snapshot_update", (m) => - received.push(m.payload.snapshotHash), + await connecting; + const a = h.client.subscribeAgentTimeline("agent", () => {}); + const b = h.client.subscribeAgentTimeline("agent", () => {}); + const requests = h.sent.filter( + (frame) => frame.message?.type === "agent.timeline.set_subscription.request", ); - h.receive({ type: "providers_snapshot_update", payload }); - expect(requests()).toHaveLength(1); - h.disconnect(); - await Promise.resolve(); - await Promise.resolve(); - const reconnect = h.client.connect(); - h.open(); - await reconnect; - expect(requests()).toHaveLength(2); + let bReady = false; + void b.ready.then(() => { + bReady = true; + return undefined; + }); h.receive({ - type: "get_providers_snapshot_response", + type: "agent.timeline.set_subscription.response", payload: { - ...payload, - requestId: requests()[1].message?.requestId, - compactSnapshot: { entries: [], thinkingSets: [] }, + requestId: requests[0].message!.requestId, + agentIds: ["agent"], + subscriptionId: "a", }, }); - await expect.poll(() => received).toEqual(["updated"]); - off(); + await a.ready; + expect(bReady).toBe(false); + h.receive({ + type: "agent.timeline.set_subscription.response", + payload: { + requestId: requests[1].message!.requestId, + agentIds: ["agent"], + subscriptionId: "b", + }, + }); + await b.ready; + await a.release(); + await b.release(); } finally { await h.client.close(); } @@ -362,30 +332,306 @@ test("timeline readiness remains pending before connect and rejects when release } }); -test("unsubscribing while disconnected discards unresolved provider demand", async () => { +test("passive provider listeners never hydrate unowned references", async () => { const h = connection(); try { - const connect = h.client.connect(); + const connecting = h.client.connect(); h.open(); - await connect; + await connecting; const off = h.client.on("providers_snapshot_update", () => {}); h.receive({ type: "providers_snapshot_update", - payload: { - entries: [], - snapshotHash: "updated", - generatedAt: "2026-09-08T00:00:00.000Z", - }, + payload: { entries: [], snapshotHash: "updated", generatedAt: "2026-09-08T00:00:00.000Z" }, }); h.disconnect(); off(); - const reconnect = h.client.connect(); + const reconnecting = h.client.connect(); h.open(); - await reconnect; - expect(h.sent.filter((f) => f.message?.type === "get_providers_snapshot_request")).toHaveLength( + await reconnecting; + expect(h.sent.filter((frame) => frame.type === "session")).toEqual([]); + } finally { + await h.client.close(); + } +}); + +test("failed terminal bootstrap reports the domain error without reconnecting or retaining a route", async () => { + const h = connection(); + try { + const connecting = h.client.connect(); + h.open(); + await connecting; + const terminal = h.client.observeTerminal("missing-terminal", () => {}); + const request = h.sent.at(-1)!.message!; + h.receive({ + type: "subscribe_terminal_response", + payload: { + requestId: request.requestId, + terminalId: "missing-terminal", + error: "Terminal not found", + }, + }); + await expect(terminal.ready).rejects.toThrow("Terminal not found"); + await terminal.release(); + expect(terminal.subscriptionId).toBeNull(); + expect(h.client.isConnected).toBe(true); + expect( + h.sent.filter((frame) => frame.message?.type === "subscription.release.request"), + ).toEqual([]); + } finally { + await h.client.close(); + } +}); + +test("an old host produces an update-host error without sending a legacy subscription", async () => { + const h = connection({ ownedSubscriptions: false }); + try { + const connecting = h.client.connect(); + h.open(); + await connecting; + const observation = h.client.observeAgents({ filter: { labels: { role: "orchestrator" } } }); + await expect(observation.ready).rejects.toThrow( + "Update the host to use independent subscriptions.", + ); + await observation.release(); + expect(h.sent.filter((frame) => frame.type === "session")).toEqual([]); + expect(h.client.isConnected).toBe(true); + } finally { + await h.client.close(); + } +}); + +test("invalid observation input rejects without reconnecting the transport", async () => { + const h = connection(); + try { + const connecting = h.client.connect(); + h.open(); + await connecting; + const invalid = h.client.observeAgents({ page: { limit: -1 } }); + await expect(invalid.ready).rejects.toThrow(); + await invalid.release(); + expect(h.client.isConnected).toBe(true); + expect(h.sent.filter((frame) => frame.message?.type === "fetch_agents_request")).toHaveLength( + 0, + ); + } finally { + await h.client.close(); + } +}); + +test("a timed-out subscription rejects and cannot replay on reconnect", async () => { + const h = connection(); + try { + const connecting = h.client.connect(); + h.open(); + await connecting; + const observation = h.client.observeAgents({ timeout: 10 }); + await expect(observation.ready).rejects.toThrow(/timed out|timeout/i); + await observation.release(); + const reconnecting = h.client.connect(); + h.open(); + await reconnecting; + expect(h.sent.filter((frame) => frame.message?.type === "fetch_agents_request")).toHaveLength( 1, ); } finally { await h.client.close(); } }); + +function timelinePage(requestId: string | undefined, epoch: string, seq: number) { + return { + type: "fetch_agent_timeline_response", + payload: { + requestId, + agentId: "agent", + agent: null, + direction: "before", + projection: "projected", + epoch, + reset: false, + staleCursor: false, + gap: false, + window: { minSeq: 1, maxSeq: seq, nextSeq: seq + 1 }, + startCursor: { epoch, seq }, + endCursor: { epoch, seq }, + hasOlder: seq > 1, + hasNewer: false, + error: null, + entries: seq + ? [ + { + provider: "codex", + item: { type: "user_message", text: `persisted-${epoch}-${seq}` }, + timestamp: new Date(0).toISOString(), + seqStart: seq, + seqEnd: seq, + sourceSeqRanges: [{ startSeq: seq, endSeq: seq }], + collapsed: [], + }, + ] + : [], + }, + }; +} + +test("timeline recovery snapshots precede live updates for independent owners and survive epoch changes", async () => { + const { createPaseoApi } = await import("./index"); + const h = connection({ acknowledgeTimelineReads: false }); + const api = createPaseoApi(h.client); + const received: import("./index").PaseoAgentTimelineEvent[][] = [[], [], []]; + try { + const connected = h.client.connect(); + h.open(); + await connected; + const owners = received.map((events) => + api.agents.ref("agent").timeline.subscribe((event) => events.push(event)), + ); + await Promise.all(owners.map((owner) => owner.ready)); + const reads = () => + h.sent.filter((frame) => frame.message?.type === "fetch_agent_timeline_request"); + expect(reads()).toHaveLength(0); // Initial subscription remains live-only. + const oldIds = new Set(owners.map((owner) => owner.subscriptionId)); + h.disconnect(); + const reconnected = h.client.connect(); + h.open(); + await reconnected; + await expect.poll(() => reads().length).toBe(3); + expect(new Set(owners.map((owner) => owner.subscriptionId)).size).toBe(3); + expect(owners.every((owner) => !oldIds.has(owner.subscriptionId))).toBe(true); + await owners[2].release(); // The history reply can arrive after the owner is gone. + const live = (index: number, seq: number, epoch: string) => + h.receive({ + type: "agent_stream", + payload: { + agentId: "agent", + subscriptionId: owners[index].subscriptionId, + seq, + epoch, + timestamp: new Date(0).toISOString(), + event: { + type: "timeline", + provider: "codex", + item: { type: "user_message", text: `live-${seq}` }, + }, + }, + }); + live(0, 4, "old"); + live(0, 5, "old"); + h.receive(timelinePage(reads()[1].message?.requestId, "new", 4)); + h.receive(timelinePage(reads()[2].message?.requestId, "new", 4)); + await expect.poll(() => received[1].length).toBe(1); + expect(received[0]).toEqual([]); + live(1, 5, "new"); + h.receive(timelinePage(reads()[0].message?.requestId, "old", 4)); + await expect.poll(() => received[0].length).toBe(2); + for (const [index, epoch] of [ + [0, "old"], + [1, "new"], + ] as const) { + expect(received[index][0]).toMatchObject({ + event: { + type: "snapshot", + reason: "reconnect", + page: { + epoch, + hasOlder: true, + startCursor: { epoch, seq: 4 }, + entries: [{ item: { text: `persisted-${epoch}-4` } }], + }, + }, + }); + expect(received[index][1]).toMatchObject({ seq: 5 }); + } + expect(received[2]).toEqual([]); + await owners[0].release(); + live(1, 6, "new"); + expect(received[1]).toHaveLength(3); + } finally { + await api.dispose(); + await h.client.close(); + } +}); + +test("timeline recovery replaces an obsolete read after bounded overflow and epoch invalidation", async () => { + const h = connection({ acknowledgeTimelineReads: false }); + const messages: unknown[] = []; + try { + const connected = h.client.connect(); + h.open(); + await connected; + const owner = h.client.subscribeAgentTimeline("agent", (message) => messages.push(message)); + await owner.ready; + h.disconnect(); + const reconnected = h.client.connect(); + h.open(); + await reconnected; + const reads = () => + h.sent.filter((frame) => frame.message?.type === "fetch_agent_timeline_request"); + await expect.poll(() => reads().length).toBe(1); + for (let seq = 1; seq <= 130; seq++) + h.receive({ + type: "agent_stream", + payload: { + agentId: "agent", + subscriptionId: owner.subscriptionId, + seq, + epoch: "old", + timestamp: new Date(0).toISOString(), + event: { + type: "timeline", + provider: "codex", + item: { type: "assistant_message", text: "piece" }, + }, + }, + }); + h.receive(timelinePage(reads()[0].message?.requestId, "old", 1)); + await expect.poll(() => reads().length).toBe(2); + expect(messages).toEqual([]); + h.receive({ + type: "agent.timeline.replacement", + payload: { agentId: "agent", subscriptionId: owner.subscriptionId, epoch: "new" }, + }); + h.receive(timelinePage(reads()[1].message?.requestId, "old", 130)); + await expect.poll(() => reads().length).toBe(3); + expect(messages).toEqual([]); + h.receive(timelinePage(reads()[2].message?.requestId, "new", 2)); + await expect.poll(() => messages.length).toBe(2); + expect(messages).toMatchObject([ + { type: "agent.timeline.replacement", payload: { epoch: "new" } }, + { type: "agent.timeline.snapshot", payload: { page: { epoch: "new" } } }, + ]); + await owner.release(); + } finally { + await h.client.close(); + } +}); + +test("timeline recovery failure is observable and releases its owner", async () => { + const h = connection({ acknowledgeTimelineReads: false }); + const messages: unknown[] = []; + try { + const connected = h.client.connect(); + h.open(); + await connected; + const owner = h.client.subscribeAgentTimeline("agent", (message) => messages.push(message)); + await owner.ready; + h.disconnect(); + const reconnected = h.client.connect(); + h.open(); + await reconnected; + const reads = () => + h.sent.filter((frame) => frame.message?.type === "fetch_agent_timeline_request"); + await expect.poll(() => reads().length).toBe(1); + const page = timelinePage(reads()[0].message?.requestId, "epoch", 0); + h.receive({ ...page, payload: { ...page.payload, error: "Agent no longer available" } }); + await expect.poll(() => owner.subscriptionId).toBe(null); + expect(messages).toEqual([ + { + type: "agent.timeline.error", + payload: { agentId: "agent", error: "Agent no longer available" }, + }, + ]); + } finally { + await h.client.close(); + } +}); diff --git a/packages/client/src/connection/index.ts b/packages/client/src/connection/index.ts index fe8553588d..8d36083c99 100644 --- a/packages/client/src/connection/index.ts +++ b/packages/client/src/connection/index.ts @@ -1,9 +1,10 @@ -import type { SessionEventSubscription } from "@getpaseo/protocol/messages"; +export { OwnedSubscriptions, type OwnedSubscription, type SubscriptionObserver } from "./owned.js"; import { CLIENT_CAPS, type ClientCapability } from "@getpaseo/protocol/client-capabilities"; // Protocol support belongs to the installed client. Only browser hosting needs // a resource supplied by the caller. Keep this exhaustive as the protocol evolves. export const DEFAULT_CLIENT_CAPABILITIES = { + [CLIENT_CAPS.ownedSubscriptions]: true, [CLIENT_CAPS.allProviders]: true, [CLIENT_CAPS.selectiveAgentTimeline]: true, [CLIENT_CAPS.reasoningMergeEnum]: true, @@ -21,99 +22,8 @@ export const DEFAULT_CLIENT_CAPABILITIES = { } satisfies Record, true>; /** Calling releases demand; ready acknowledges the initial daemon membership. */ -export type TimelineSubscription = (() => void) & { readonly ready: Promise }; - -class TimelineInterest { - resolve!: () => void; - reject!: (error: unknown) => void; - readonly ready = new Promise((resolve, reject) => { - this.resolve = resolve; - this.reject = reject; - }); - - constructor() { - // Readiness is optional for fire-and-forget listeners. - void this.ready.catch(() => {}); - } -} - -/** Owns connection demand, independently of individual facades and React lifetimes. */ -export class ConnectionSubscriptions { - private viewed = new Set(); - private timelines = new Map>(); - private events: SessionEventSubscription[] = []; - - constructor( - private readonly send: { - timelines(agentIds: string[]): Promise | null; - events(events: SessionEventSubscription[]): Promise; - failed(error: unknown): void; - }, - ) {} - - private agentIds(): string[] { - return [...new Set([...this.viewed, ...this.timelines.keys()])].sort(); - } - - setViewed(agentIds: string[]): Promise { - this.viewed = new Set(agentIds); - return this.syncTimelines(); - } - - observeTimeline(agentId: string): TimelineSubscription { - const interests = this.timelines.get(agentId) ?? new Set(); - const interest = new TimelineInterest(); - interests.add(interest); - this.timelines.set(agentId, interests); - // Each caller gets acknowledgement for its own registration, even when an - // existing listener first subscribed on an earlier connection. - void this.syncTimelines().catch(this.send.failed); - let active = true; - return Object.assign( - () => { - if (!active) return; - active = false; - interest.reject(new Error("Timeline subscription released before it was ready")); - interests.delete(interest); - if (interests.size === 0) { - this.timelines.delete(agentId); - void this.syncTimelines().catch(this.send.failed); - } - }, - { ready: interest.ready }, - ); - } - - private async syncTimelines(): Promise { - const interests: TimelineInterest[] = []; - for (const listeners of this.timelines.values()) { - for (const interest of listeners) interests.push(interest); - } - try { - const sent = this.send.timelines(this.agentIds()); - if (!sent) return; - await sent; - for (const interest of interests) interest.resolve(); - } catch (error) { - for (const interest of interests) interest.reject(error); - throw error; - } - } - - setEvents(events: SessionEventSubscription[]): void { - if (JSON.stringify(this.events) === JSON.stringify(events)) return; - this.events = events; - void this.send.events(events).catch(this.send.failed); - } - - restore(): void { - if (this.agentIds().length) void this.syncTimelines().catch(this.send.failed); - if (this.events.length) void this.send.events(this.events).catch(this.send.failed); - } - - close(): void { - for (const interests of this.timelines.values()) { - for (const interest of interests) interest.reject(new Error("Daemon client closed")); - } - } -} +export type TimelineSubscription = (() => void) & { + readonly ready: Promise; + readonly subscriptionId: string | null; + release(): Promise; +}; diff --git a/packages/client/src/connection/owned.test.ts b/packages/client/src/connection/owned.test.ts new file mode 100644 index 0000000000..eca52f1749 --- /dev/null +++ b/packages/client/src/connection/owned.test.ts @@ -0,0 +1,129 @@ +import { expect, test } from "vitest"; +import { OwnedSubscriptions } from "./owned.js"; +import type { SessionOutboundMessage } from "@getpaseo/protocol/messages"; + +interface Snapshot { + subscriptionId: string; + requestId: string; +} + +function connection() { + const released: string[] = []; + const failures: unknown[] = []; + const requests: Array<(id: string) => void> = []; + const subscriptions = new OwnedSubscriptions({ + release: async (id) => { + released.push(id); + }, + failed: (error) => { + failures.push(error); + }, + }); + subscriptions.restore(); + return { + subscriptions, + released, + failures, + requests, + observe: (signal?: AbortSignal) => + subscriptions.observe( + (accept) => + new Promise((resolve) => { + requests.push((subscriptionId) => { + const snapshot = { subscriptionId, requestId: `request-${requests.length}` }; + accept(snapshot); + resolve(snapshot); + }); + }), + signal, + ), + }; +} + +function update(subscriptionId: string, seq: number): SessionOutboundMessage { + return { + type: "agent_update", + payload: { kind: "remove", agentId: `agent-${seq}`, subscriptionId, seq }, + }; +} + +test("a caller consuming the snapshot later receives its buffered updates in order", async () => { + const c = connection(); + const a = c.observe(); + const b = c.observe(); + c.requests[0]!("a"); + c.requests[1]!("b"); + c.subscriptions.receive(update("a", 1)); + c.subscriptions.receive(update("b", 2)); + await a.ready; + const seen: string[] = []; + a.subscribe({ + snapshot: (s) => seen.push(s.subscriptionId), + update: (m) => seen.push(JSON.stringify(m)), + }); + expect(seen).toEqual(["a", JSON.stringify(update("a", 1))]); + await a.release(); + c.subscriptions.receive(update("a", 3)); + expect(seen).toHaveLength(2); + expect(c.released).toEqual(["a"]); + expect(b.subscriptionId).toBe("b"); + await b.release(); +}); + +test("cancellation before the ID waits for bootstrap and releases exactly that ID", async () => { + const c = connection(); + const abort = new AbortController(); + const handle = c.observe(abort.signal); + abort.abort(); + const released = handle.release(); + expect(c.released).toEqual([]); + c.requests[0]!("cancelled"); + await released; + await expect(handle.ready).rejects.toThrow("released"); + expect(c.released).toEqual(["cancelled"]); +}); + +test("surviving handles use new remote IDs after reconnect and released handles stay gone", async () => { + const c = connection(); + const a = c.observe(); + const b = c.observe(); + c.requests[0]!("old-a"); + c.requests[1]!("old-b"); + await Promise.all([a.ready, b.ready]); + await b.release(); + const seen: string[] = []; + a.subscribe({ snapshot: (s) => seen.push(s.subscriptionId), update: () => seen.push("update") }); + c.subscriptions.disconnected(); + c.subscriptions.restore(); + expect(c.requests).toHaveLength(3); + c.requests[2]!("new-a"); + c.subscriptions.receive(update("old-a", 1)); + c.subscriptions.receive(update("new-a", 2)); + expect(seen).toEqual(["old-a", "new-a", "update"]); + expect(a.subscriptionId).toBe("new-a"); + await a.release(); +}); + +test("a late listener after bounded replay overflow gets a fresh snapshot", async () => { + const c = connection(); + const handle = c.observe(); + c.requests[0]!("old"); + await handle.ready; + const first: string[] = []; + handle.subscribe({ snapshot: (s) => first.push(s.subscriptionId), update: () => {} }); + for (let seq = 0; seq < 129; seq++) c.subscriptions.receive(update("old", seq)); + const late: string[] = []; + handle.subscribe({ + snapshot: (s) => late.push(s.subscriptionId), + update: () => late.push("update"), + }); + await expect.poll(() => c.requests.length).toBe(2); + expect(c.released).toEqual(["old"]); + expect(late).toEqual([]); + c.requests[1]!("fresh"); + c.subscriptions.receive(update("old", 130)); + c.subscriptions.receive(update("fresh", 131)); + expect(first).toEqual(["old", "fresh"]); + expect(late).toEqual(["fresh", "update"]); + await handle.release(); +}); diff --git a/packages/client/src/connection/owned.ts b/packages/client/src/connection/owned.ts new file mode 100644 index 0000000000..8aa3c94b17 --- /dev/null +++ b/packages/client/src/connection/owned.ts @@ -0,0 +1,235 @@ +import type { SessionOutboundMessage } from "@getpaseo/protocol/messages"; + +export type SubscriptionSnapshot = T & { subscriptionId: string }; + +export interface SubscriptionObserver { + snapshot(snapshot: T): void; + update(message: SessionOutboundMessage): void; + error?(error: unknown): void; +} + +/** A stable local lifetime. The host ID changes when the transport reconnects. */ +export interface OwnedSubscription { + readonly subscriptionId: string | null; + readonly ready: Promise>; + subscribe(observer: SubscriptionObserver>): () => void; + release(): Promise; +} + +interface Registration { + start(): void; + disconnected(): void; + receive(message: SessionOutboundMessage): void; + release(): Promise; +} + +export interface OwnedSubscriptionsHost { + release(subscriptionId: string): Promise; + failed(error: unknown): void; +} + +const MAX_BOOTSTRAP_UPDATES = 128; + +/** Transport routing and lifetime only; query semantics remain with each domain. */ +export class OwnedSubscriptions { + private readonly registrations = new Set(); + private readonly routes = new Map(); + private generation = 0; + private connected = false; + + constructor(private readonly host: OwnedSubscriptionsHost) {} + + observe( + request: (accept: (snapshot: T) => void) => Promise, + signal?: AbortSignal, + requestFailed?: () => void, + ): OwnedSubscription { + let id: string | null = null; + let snapshot: SubscriptionSnapshot | undefined; + let pending: Promise | null = null; + let teardown: Promise | null = null; + let disposed = false; + let overflow = false; + const buffered: SessionOutboundMessage[] = []; + const observers = new Set>>(); + let resolve!: (snapshot: SubscriptionSnapshot) => void; + let reject!: (error: unknown) => void; + const ready = new Promise>((accept, fail) => { + resolve = accept; + reject = fail; + }); + void ready.catch(() => {}); + + const deliver = ( + observer: SubscriptionObserver>, + message?: SessionOutboundMessage, + ) => { + try { + if (message) observer.update(message); + else if (snapshot) observer.snapshot(snapshot); + } catch (error) { + this.host.failed(error); + } + }; + const releaseId = async () => { + const released = id; + id = null; + if (!released) return; + this.routes.delete(released); + await this.host.release(released); + }; + const registration: Registration = { + start: () => { + if (disposed || pending || id || !this.connected) return; + const generation = this.generation; + pending = request((value) => { + if (generation !== this.generation) return; + assertSubscriptionSnapshot(value); + id = value.subscriptionId; + snapshot = value; + buffered.length = 0; + overflow = false; + if (disposed) return; + this.routes.set(id, registration); + for (const observer of observers) deliver(observer); + }) + .then(() => { + if (generation === this.generation && !disposed) { + if (!snapshot) throw new Error("The host omitted its subscription snapshot"); + resolve(snapshot); + } + return undefined; + }) + .catch((error) => { + if (generation !== this.generation) return; + reject(error); + for (const observer of observers) { + try { + observer.error?.(error); + } catch (failure) { + this.host.failed(failure); + } + } + void handle.release().catch(this.host.failed); + this.host.failed(error); + requestFailed?.(); + return undefined; + }) + .finally(() => { + pending = null; + if (generation !== this.generation && !disposed) registration.start(); + }); + }, + disconnected: () => { + id = null; + snapshot = undefined; + buffered.length = 0; + overflow = false; + }, + receive: (message) => { + if (disposed || ("payload" in message && Object.is(message.payload, snapshot))) return; + if (buffered.length < MAX_BOOTSTRAP_UPDATES && !overflow) buffered.push(message); + else { + buffered.length = 0; + overflow = true; + } + for (const observer of observers) deliver(observer, message); + }, + release: () => { + if (teardown) return teardown; + disposed = true; + signal?.removeEventListener("abort", abort); + observers.clear(); + buffered.length = 0; + if (id) this.routes.delete(id); + reject(new Error("Subscription released")); + teardown = (async () => { + await pending; + await releaseId(); + this.registrations.delete(registration); + })(); + return teardown; + }, + }; + const abort = () => { + void handle.release().catch(this.host.failed); + }; + const handle: OwnedSubscription = { + get subscriptionId() { + return id; + }, + ready, + release: registration.release, + subscribe: (observer) => { + if (disposed) throw new Error("Subscription released"); + observers.add(observer); + if (overflow) { + overflow = false; + snapshot = undefined; + void releaseId() + .then(() => { + registration.start(); + return undefined; + }) + .catch(this.host.failed); + } else { + deliver(observer); + for (const message of buffered) deliver(observer, message); + } + return () => { + observers.delete(observer); + }; + }, + }; + this.registrations.add(registration); + if (signal?.aborted) abort(); + else { + signal?.addEventListener("abort", abort, { once: true }); + registration.start(); + } + return handle; + } + + owns(message: SessionOutboundMessage): boolean { + const payload = "payload" in message ? message.payload : message; + return ( + "subscriptionId" in payload && + typeof payload.subscriptionId === "string" && + this.routes.has(payload.subscriptionId) + ); + } + + receive(message: SessionOutboundMessage): void { + const payload = "payload" in message ? message.payload : message; + if ("subscriptionId" in payload && typeof payload.subscriptionId === "string") + this.routes.get(payload.subscriptionId)?.receive(message); + } + + restore(): void { + this.connected = true; + for (const registration of this.registrations) registration.start(); + } + + disconnected(): void { + this.connected = false; + this.generation++; + this.routes.clear(); + for (const registration of this.registrations) registration.disconnected(); + } + + async close(): Promise { + await Promise.all([...this.registrations].map((registration) => registration.release())); + } +} + +function assertSubscriptionSnapshot(value: unknown): asserts value is { subscriptionId: string } { + if ( + value && + typeof value === "object" && + "subscriptionId" in value && + typeof value.subscriptionId === "string" && + value.subscriptionId + ) + return; + throw new Error("The host omitted its subscription ID"); +} diff --git a/packages/client/src/daemon-client-transport.test.ts b/packages/client/src/daemon-client-transport.test.ts index 378275c673..6f80717dd1 100644 --- a/packages/client/src/daemon-client-transport.test.ts +++ b/packages/client/src/daemon-client-transport.test.ts @@ -21,7 +21,7 @@ describe("daemon-client transport helpers", () => { createClientChannelMock.mockReset(); createClientChannelMock.mockRejectedValueOnce(new Error("handshake failed")); - let openHandler: (() => void) | null = null; + const connection: { open: (() => void) | null } = { open: null }; const close = vi.fn(); createEncryptedTransport( @@ -29,10 +29,10 @@ describe("daemon-client transport helpers", () => { send: vi.fn(), close, onOpen: (handler) => { - openHandler = handler; + connection.open = handler; return () => { - if (openHandler === handler) { - openHandler = null; + if (connection.open === handler) { + connection.open = null; } }; }, @@ -44,8 +44,8 @@ describe("daemon-client transport helpers", () => { { warn: vi.fn() }, ); - expect(openHandler).not.toBeNull(); - openHandler?.(); + expect(connection.open).not.toBeNull(); + connection.open?.(); await vi.waitFor(() => { expect(close).toHaveBeenCalledWith(4001, "E2EE handshake failed"); diff --git a/packages/client/src/daemon-client.test.ts b/packages/client/src/daemon-client.test.ts index d03fd7c754..72d78b4fe0 100644 --- a/packages/client/src/daemon-client.test.ts +++ b/packages/client/src/daemon-client.test.ts @@ -14,9 +14,6 @@ import { FileTransferOpcode, } from "@getpaseo/protocol/binary-frames/index"; import { - asUint8Array, - decodeTerminalResizePayload, - decodeTerminalStreamFrame, encodeTerminalSnapshotPayload, encodeTerminalStreamFrame, TerminalStreamOpcode, @@ -79,7 +76,7 @@ function createMockTransport() { }, close: () => {}, onMessage: (handler) => { - onMessage = handler; + onMessage = (data) => handler(data, typeof data !== "string"); return () => {}; }, onOpen: (handler) => { @@ -115,7 +112,7 @@ function createMockTransport() { serverId: `srv_test_${serverInfoOrdinal++}`, hostname: null, version: null, - ...(options?.features ? { features: options.features } : {}), + features: options?.features ?? { ownedSubscriptions: true }, }, }, }), @@ -255,7 +252,7 @@ test("does not infer browser automation capabilities from Electron runtime", asy const hello = z .object({ type: z.literal("hello"), - capabilities: z.record(z.unknown()), + capabilities: z.record(z.string(), z.unknown()), }) .parse(JSON.parse(assertStr(mock.sent[0]))); expect(hello.capabilities[CLIENT_CAPS.browserHost]).toBeUndefined(); @@ -285,7 +282,7 @@ test("advertises consumer-provided browser automation capabilities", async () => const hello = z .object({ type: z.literal("hello"), - capabilities: z.record(z.unknown()), + capabilities: z.record(z.string(), z.unknown()), }) .parse(JSON.parse(assertStr(mock.sent[0]))); expect(hello.capabilities[CLIENT_CAPS.browserHost]).toEqual({ @@ -331,57 +328,21 @@ test("Hub management requires daemon support before dispatching requests", async expect(mock.sent).toEqual([]); }); -test("sets the complete viewed timeline subscription only when the daemon supports it", async () => { - const supportedTransport = createMockTransport(); - const supportedClient = new DaemonClient({ - url: "ws://test", - clientId: "timeline_supported", - transportFactory: () => supportedTransport.transport, - reconnect: { enabled: false }, - }); - const legacyTransport = createMockTransport(); - const legacyClient = new DaemonClient({ +test("owned timeline observation requires the host feature", async () => { + const mock = createMockTransport(); + const client = new DaemonClient({ url: "ws://test", clientId: "timeline_legacy", - transportFactory: () => legacyTransport.transport, + transportFactory: () => mock.transport, reconnect: { enabled: false }, }); - clients.push(supportedClient, legacyClient); - - const supportedConnect = supportedClient.connect(); - supportedTransport.triggerOpen({ features: { selectiveAgentTimeline: true } }); - await supportedConnect; - const legacyConnect = legacyClient.connect(); - legacyTransport.triggerOpen(); - await legacyConnect; - - expect(supportedClient.getLastServerInfoMessage()?.features).toEqual({ - selectiveAgentTimeline: true, - }); - - const setPromise = supportedClient.setAgentTimelineSubscription(["agent-b", "agent-a"]); - await Promise.resolve(); - const request = parseSentFrame(supportedTransport.sent[0]); - supportedTransport.triggerMessage( - wrapSessionMessage({ - type: "agent.timeline.set_subscription.response", - payload: { - requestId: request.requestId, - agentIds: ["agent-a", "agent-b"], - }, - }), - ); - await setPromise; - await legacyClient.setAgentTimelineSubscription(["agent-a"]); - - expect({ request, legacyFrames: legacyTransport.sent }).toEqual({ - request: { - type: "agent.timeline.set_subscription.request", - requestId: expect.any(String), - agentIds: ["agent-a", "agent-b"], - }, - legacyFrames: [], - }); + clients.push(client); + const connecting = client.connect(); + mock.triggerOpen({ features: {} }); + await connecting; + const observation = client.observeTimeline(["agent"]); + await expect(observation.ready).rejects.toThrow("Update the host"); + expect(mock.sent).toEqual([]); }); test("normalizes legacy and dedicated agent attention notifications", async () => { @@ -468,7 +429,7 @@ class FakeDaemon { this.closeEvents.push({ code, reason }); }, onMessage: (handler) => { - this.onMessage = handler; + this.onMessage = (data) => handler(data, typeof data !== "string"); return () => {}; }, onOpen: (handler) => { @@ -760,6 +721,7 @@ test("advertises client capabilities in hello", async () => { timeline_replacement_invalidation: true, provider_snapshot_references: true, explicit_event_subscriptions: true, + owned_subscriptions: true, compact_provider_snapshots: true, custom_mode_icons: true, project_updates: true, @@ -797,7 +759,7 @@ test("allows callers to disable default client capabilities", async () => { const hello = z .object({ type: z.literal("hello"), - capabilities: z.record(z.unknown()), + capabilities: z.record(z.string(), z.unknown()), }) .parse(JSON.parse(assertStr(mock.sent[0]))); expect(hello.capabilities[CLIENT_CAPS.projectUpdates]).toBe(false); @@ -3642,16 +3604,14 @@ test("subscribes to checkout diff updates via RPC handshake", async () => { mock.triggerOpen(); await connectPromise; - const promise = client.subscribeCheckoutDiff( - "/tmp/project", - { mode: "uncommitted" }, - { subscriptionId: "checkout-sub-1" }, - ); + const subscription = client.observeCheckoutDiff("/tmp/project", { mode: "uncommitted" }); + const promise = subscription.ready; + await vi.waitFor(() => expect(mock.sent).toHaveLength(1)); expect(mock.sent).toHaveLength(1); const request = parseSentFrame(mock.sent[0]); expect(request.type).toBe("subscribe_checkout_diff_request"); - expect(request.subscriptionId).toBe("checkout-sub-1"); + expect(request.subscriptionId).toBeUndefined(); expect(request.cwd).toBe("/tmp/project"); expect(request.compare).toEqual({ mode: "uncommitted" }); @@ -3680,7 +3640,7 @@ test("subscribes to checkout diff updates via RPC handshake", async () => { }); }); -test("getCheckoutDiff uses one-shot subscription protocol", async () => { +test("getCheckoutDiff reads a snapshot without creating a subscription", async () => { const logger = createMockLogger(); const mock = createMockTransport(); @@ -3701,7 +3661,7 @@ test("getCheckoutDiff uses one-shot subscription protocol", async () => { expect(mock.sent).toHaveLength(1); const subscribeRequest = parseSentFrame(mock.sent[0]); - expect(subscribeRequest.type).toBe("subscribe_checkout_diff_request"); + expect(subscribeRequest.type).toBe("checkout.diff.get.request"); expect(subscribeRequest.cwd).toBe("/tmp/project"); expect(subscribeRequest.compare).toEqual({ mode: "base", @@ -3712,9 +3672,8 @@ test("getCheckoutDiff uses one-shot subscription protocol", async () => { JSON.stringify({ type: "session", message: { - type: "subscribe_checkout_diff_response", + type: "checkout.diff.get.response", payload: { - subscriptionId: subscribeRequest.subscriptionId, cwd: "/tmp/project", files: [], error: null, @@ -3731,10 +3690,7 @@ test("getCheckoutDiff uses one-shot subscription protocol", async () => { requestId: subscribeRequest.requestId, }); - expect(mock.sent).toHaveLength(2); - const unsubscribeRequest = parseSentFrame(mock.sent[1]); - expect(unsubscribeRequest.type).toBe("unsubscribe_checkout_diff_request"); - expect(unsubscribeRequest.subscriptionId).toBe(subscribeRequest.subscriptionId); + expect(mock.sent).toHaveLength(1); }); test("requests branch suggestions via RPC", async () => { @@ -4279,7 +4235,7 @@ test("renames a branch via RPC", async () => { }); expect(mock.sent).toHaveLength(1); - const request = JSON.parse(mock.sent[0]) as { + const request = JSON.parse(assertStr(mock.sent[0])) as { type: "session"; message: { type: "checkout.rename_branch.request"; @@ -4381,29 +4337,52 @@ test("resubscribes checkout diff streams after reconnect", async () => { }); clients.push(client); - const internal = client as unknown as { - checkoutDiffSubscriptions: Map< - string, - { cwd: string; compare: { mode: "uncommitted" | "base"; baseRef?: string } } - >; - }; - internal.checkoutDiffSubscriptions.set("checkout-sub-1", { - cwd: "/tmp/project", - compare: { mode: "base", baseRef: "main" }, - }); - const connectPromise = client.connect(); mock.triggerOpen(); await connectPromise; - expect(mock.sent).toHaveLength(1); + const observation = client.observeCheckoutDiff("/tmp/project", { mode: "base", baseRef: "main" }); + await vi.waitFor(() => expect(mock.sent).toHaveLength(1)); + const first = parseSentFrame(mock.sent[0]); + mock.triggerMessage( + wrapSessionMessage({ + type: "subscribe_checkout_diff_response", + payload: { + requestId: first.requestId, + subscriptionId: "server-first", + cwd: "/tmp/project", + files: [], + error: null, + }, + }), + ); + await observation.ready; + mock.triggerClose(); + const reconnect = client.connect(); + mock.triggerOpen(); + await reconnect; + await vi.waitFor(() => expect(mock.sent).toHaveLength(1)); const request = parseSentFrame(mock.sent[0]); - expect(request.type).toBe("subscribe_checkout_diff_request"); - expect(request.subscriptionId).toBe("checkout-sub-1"); - expect(request.cwd).toBe("/tmp/project"); - expect(request.compare).toEqual({ mode: "base", baseRef: "main" }); - expect(typeof request.requestId).toBe("string"); - expect(z.string().parse(request.requestId).length).toBeGreaterThan(0); + expect(request).toMatchObject({ + type: "subscribe_checkout_diff_request", + cwd: "/tmp/project", + compare: { mode: "base", baseRef: "main" }, + }); + expect(request.subscriptionId).toBeUndefined(); + expect(request.requestId).not.toBe(first.requestId); + mock.triggerMessage( + wrapSessionMessage({ + type: "subscribe_checkout_diff_response", + payload: { + requestId: request.requestId, + subscriptionId: "server-second", + cwd: "/tmp/project", + files: [], + error: null, + }, + }), + ); + await vi.waitFor(() => expect(observation.subscriptionId).toBe("server-second")); }); test("fetches agents via RPC with filters, sort, and pagination", async () => { @@ -4430,10 +4409,10 @@ test("fetches agents via RPC with filters, sort, and pagination", async () => { { key: "created_at", direction: "desc" }, ], page: { limit: 25, cursor: "cursor-1" }, - subscribe: { subscriptionId: "sub-1" }, + subscribe: {}, }); - expect(mock.sent).toHaveLength(1); + await vi.waitFor(() => expect(mock.sent).toHaveLength(1)); const request = parseSentFrame(mock.sent[0]); expect(request.type).toBe("fetch_agents_request"); expect(request.sort).toEqual([ @@ -4441,7 +4420,7 @@ test("fetches agents via RPC with filters, sort, and pagination", async () => { { key: "created_at", direction: "desc" }, ]); expect(request.page).toEqual({ limit: 25, cursor: "cursor-1" }); - expect(request.subscribe).toEqual({ subscriptionId: "sub-1" }); + expect(request.subscribe).toEqual({}); mock.triggerMessage( JSON.stringify({ @@ -4465,6 +4444,10 @@ test("fetches agents via RPC with filters, sort, and pagination", async () => { await expect(promise).resolves.toEqual({ requestId: request.requestId, subscriptionId: "sub-1", + subscription: expect.objectContaining({ + subscriptionId: "sub-1", + release: expect.any(Function), + }), entries: [], pageInfo: { nextCursor: null, @@ -5310,11 +5293,15 @@ test("emits output events for the active terminal stream", async () => { seen.push(new TextDecoder().decode(event.data)); }); - const subscribePromise = client.subscribeTerminal("term-1", "sub-1"); + const subscribePromise = client.subscribeTerminal("term-1", { requestId: "sub-1" }); + await vi.waitFor(() => + expect(parseSentFrame(mock.sent.at(-1)).type).toBe("subscribe_terminal_request"), + ); mock.triggerMessage( wrapSessionMessage({ type: "subscribe_terminal_response", payload: { + subscriptionId: "server-" + String(parseSentFrame(mock.sent.at(-1)).requestId), terminalId: "term-1", slot: 11, error: null, @@ -5362,11 +5349,15 @@ test("emits snapshot events for the subscribed terminal stream", async () => { snapshots.push(event.state); }); - const subscribePromise = client.subscribeTerminal("term-1", "sub-2"); + const subscribePromise = client.subscribeTerminal("term-1", { requestId: "sub-2" }); + await vi.waitFor(() => + expect(parseSentFrame(mock.sent.at(-1)).type).toBe("subscribe_terminal_request"), + ); mock.triggerMessage( wrapSessionMessage({ type: "subscribe_terminal_response", payload: { + subscriptionId: "server-" + String(parseSentFrame(mock.sent.at(-1)).requestId), terminalId: "term-1", slot: 12, error: null, @@ -5394,7 +5385,7 @@ test("emits snapshot events for the subscribed terminal stream", async () => { expect(snapshots).toEqual([state]); }); -test("sends input and resize frames for the subscribed terminal slot", async () => { +test("sends explicit terminal input and resize commands", async () => { const logger = createMockLogger(); const mock = createMockTransport(); @@ -5411,11 +5402,15 @@ test("sends input and resize frames for the subscribed terminal slot", async () mock.triggerOpen(); await connectPromise; - const subscribePromise = client.subscribeTerminal("term-1", "sub-3"); + const subscribePromise = client.subscribeTerminal("term-1", { requestId: "sub-3" }); + await vi.waitFor(() => + expect(parseSentFrame(mock.sent.at(-1)).type).toBe("subscribe_terminal_request"), + ); mock.triggerMessage( wrapSessionMessage({ type: "subscribe_terminal_response", payload: { + subscriptionId: "server-" + String(parseSentFrame(mock.sent.at(-1)).requestId), terminalId: "term-1", slot: 13, error: null, @@ -5437,19 +5432,18 @@ test("sends input and resize frames for the subscribed terminal slot", async () intent: "update", }); - const inputFrame = decodeTerminalStreamFrame(asUint8Array(mock.sent[0])!); - const resizeFrame = decodeTerminalStreamFrame(asUint8Array(mock.sent[1])!); - - expect(inputFrame?.opcode).toBe(TerminalStreamOpcode.Input); - expect(inputFrame?.slot).toBe(13); - expect(new TextDecoder().decode(inputFrame?.payload ?? new Uint8Array())).toBe("echo hello\r"); - expect(resizeFrame?.opcode).toBe(TerminalStreamOpcode.Resize); - expect(resizeFrame?.slot).toBe(13); - expect(decodeTerminalResizePayload(resizeFrame?.payload ?? new Uint8Array())).toEqual({ - rows: 24, - cols: 80, - intent: "update", - }); + expect(mock.sent.map(parseSentFrame)).toEqual([ + { + type: "terminal_input", + terminalId: "term-1", + message: { type: "input", data: "echo hello\r" }, + }, + { + type: "terminal_input", + terminalId: "term-1", + message: { type: "resize", rows: 24, cols: 80, intent: "update" }, + }, + ]); }); test("routes concurrent terminal stream frames by slot", async () => { @@ -5477,11 +5471,15 @@ test("routes concurrent terminal stream frames by slot", async () => { seen.push(`${event.terminalId}:${new TextDecoder().decode(event.data)}`); }); - const subscribeFirstPromise = client.subscribeTerminal("term-1", "sub-multi-1"); + const subscribeFirstPromise = client.subscribeTerminal("term-1", { requestId: "sub-multi-1" }); + await vi.waitFor(() => + expect(parseSentFrame(mock.sent.at(-1)).type).toBe("subscribe_terminal_request"), + ); mock.triggerMessage( wrapSessionMessage({ type: "subscribe_terminal_response", payload: { + subscriptionId: "server-" + String(parseSentFrame(mock.sent.at(-1)).requestId), terminalId: "term-1", slot: 21, error: null, @@ -5491,11 +5489,15 @@ test("routes concurrent terminal stream frames by slot", async () => { ); await subscribeFirstPromise; - const subscribeSecondPromise = client.subscribeTerminal("term-2", "sub-multi-2"); + const subscribeSecondPromise = client.subscribeTerminal("term-2", { requestId: "sub-multi-2" }); + await vi.waitFor(() => + expect(parseSentFrame(mock.sent.at(-1)).type).toBe("subscribe_terminal_request"), + ); mock.triggerMessage( wrapSessionMessage({ type: "subscribe_terminal_response", payload: { + subscriptionId: "server-" + String(parseSentFrame(mock.sent.at(-1)).requestId), terminalId: "term-2", slot: 22, error: null, @@ -5531,14 +5533,19 @@ test("routes concurrent terminal stream frames by slot", async () => { cols: 20, }); - const inputFrame = decodeTerminalStreamFrame(asUint8Array(mock.sent[0])!); - const resizeFrame = decodeTerminalStreamFrame(asUint8Array(mock.sent[1])!); - expect(seen).toEqual(["term-2:beta", "term-1:alpha"]); - expect(inputFrame?.opcode).toBe(TerminalStreamOpcode.Input); - expect(inputFrame?.slot).toBe(22); - expect(resizeFrame?.opcode).toBe(TerminalStreamOpcode.Resize); - expect(resizeFrame?.slot).toBe(21); + expect(mock.sent.map(parseSentFrame)).toEqual([ + { + type: "terminal_input", + terminalId: "term-2", + message: { type: "input", data: "echo beta\r" }, + }, + { + type: "terminal_input", + terminalId: "term-1", + message: { type: "resize", rows: 10, cols: 20 }, + }, + ]); }); test("ignores terminal stream frames after terminal_stream_exit", async () => { @@ -5566,11 +5573,15 @@ test("ignores terminal stream frames after terminal_stream_exit", async () => { seen.push(new TextDecoder().decode(event.data)); }); - const subscribePromise = client.subscribeTerminal("term-1", "sub-4"); + const subscribePromise = client.subscribeTerminal("term-1", { requestId: "sub-4" }); + await vi.waitFor(() => + expect(parseSentFrame(mock.sent.at(-1)).type).toBe("subscribe_terminal_request"), + ); mock.triggerMessage( wrapSessionMessage({ type: "subscribe_terminal_response", payload: { + subscriptionId: "server-" + String(parseSentFrame(mock.sent.at(-1)).requestId), terminalId: "term-1", slot: 14, error: null, @@ -5593,6 +5604,7 @@ test("ignores terminal stream frames after terminal_stream_exit", async () => { wrapSessionMessage({ type: "terminal_stream_exit", payload: { + subscriptionId: "server-sub-4", terminalId: "term-1", }, }), @@ -5607,6 +5619,21 @@ test("ignores terminal stream frames after terminal_stream_exit", async () => { ); expect(seen).toEqual(["before-exit"]); + await vi.waitFor(() => + expect(parseSentFrame(mock.sent.at(-1)).type).toBe("subscription.release.request"), + ); + const release = parseSentFrame(mock.sent.at(-1)); + mock.triggerMessage( + wrapSessionMessage({ + type: "subscription.release.response", + payload: { + requestId: release.requestId, + subscriptionId: "server-sub-4", + released: true, + }, + }), + ); + await Promise.resolve(); unsubscribe(); }); @@ -5952,24 +5979,41 @@ test("sends subscribe/unsubscribe terminals messages", async () => { mock.triggerOpen(); await connectPromise; - client.subscribeTerminals({ cwd: "/tmp/project" }); - client.unsubscribeTerminals({ cwd: "/tmp/project" }); - - expect(mock.sent).toHaveLength(2); - expect(JSON.parse(assertStr(mock.sent[0]))).toEqual({ - type: "session", - message: { - type: "subscribe_terminals_request", - cwd: "/tmp/project", - }, - }); - expect(JSON.parse(assertStr(mock.sent[1]))).toEqual({ - type: "session", - message: { - type: "unsubscribe_terminals_request", - cwd: "/tmp/project", - }, + const observation = client.observeTerminals({ cwd: "/tmp/project" }); + await vi.waitFor(() => expect(mock.sent).toHaveLength(1)); + const request = parseSentFrame(mock.sent[0]); + expect(request).toMatchObject({ type: "subscribe_terminals_request", cwd: "/tmp/project" }); + expect(request.subscriptionId).toBeUndefined(); + mock.triggerMessage( + wrapSessionMessage({ + type: "terminals_changed", + payload: { + requestId: request.requestId, + subscriptionId: "server-terminals", + cwd: "/tmp/project", + terminals: [], + }, + }), + ); + await observation.ready; + const release = observation.release(); + await vi.waitFor(() => expect(mock.sent).toHaveLength(2)); + const requestRelease = parseSentFrame(mock.sent[1]); + expect(requestRelease).toMatchObject({ + type: "subscription.release.request", + subscriptionId: "server-terminals", }); + mock.triggerMessage( + wrapSessionMessage({ + type: "subscription.release.response", + payload: { + requestId: requestRelease.requestId, + subscriptionId: "server-terminals", + released: true, + }, + }), + ); + await release; }); test("dispatches terminals_changed events to typed listeners", async () => { diff --git a/packages/client/src/daemon-client.ts b/packages/client/src/daemon-client.ts index e22534da35..9529b3179e 100644 --- a/packages/client/src/daemon-client.ts +++ b/packages/client/src/daemon-client.ts @@ -1,12 +1,14 @@ +import { subscribeTimeline, type TimelineMessage } from "./timeline-subscription/index.js"; import { ProviderSnapshotUpdates } from "./provider-snapshots/index.js"; -import type { SessionEventSubscription } from "@getpaseo/protocol/messages"; import { - ConnectionSubscriptions, + OwnedSubscriptions, + type OwnedSubscription, DEFAULT_CLIENT_CAPABILITIES, type TimelineSubscription, } from "./connection/index.js"; import type { z } from "zod"; -import { CLIENT_CAPS, type ClientCapability } from "@getpaseo/protocol/client-capabilities"; +import type { SessionEventSubscription } from "@getpaseo/protocol/messages"; +import type { ClientCapability } from "@getpaseo/protocol/client-capabilities"; import type { AgentAttentionNotificationPayload } from "@getpaseo/protocol/agent-attention-notification"; import { parsePluginSourceReference } from "@getpaseo/protocol/plugin-source-reference"; import { @@ -133,7 +135,6 @@ import type { MutableDaemonConfigPatch, } from "@getpaseo/protocol/messages"; import { isRelayClientWebSocketUrl } from "@getpaseo/protocol/daemon-endpoints"; -import { terminalSubscriptionKey } from "@getpaseo/protocol/terminal-subscription-key"; import { asUint8Array, decodeFileTransferFrame, @@ -652,6 +653,7 @@ type FetchAgentsPayload = Extract< >["payload"]; type FetchAgentsRequest = Extract; export type FetchAgentsOptions = Omit & { + signal?: AbortSignal; requestId?: string; timeout?: number; }; @@ -691,6 +693,7 @@ type FetchWorkspacesPayload = Extract< >["payload"]; type FetchWorkspacesRequest = Extract; export type FetchWorkspacesOptions = Omit & { + signal?: AbortSignal; requestId?: string; }; export type FetchWorkspacesEntry = FetchWorkspacesPayload["entries"][number]; @@ -871,13 +874,15 @@ type SetDaemonConfigResponse = Extract< >; type CorrelatedResponseMessage = | Extract + | Extract | GetDaemonConfigResponse | SetDaemonConfigResponse; type CorrelatedResponseType = CorrelatedResponseMessage["type"]; -type CorrelatedResponsePayload = Extract< - CorrelatedResponseMessage, - { type: TType } ->["payload"]; +type CorrelatedResponsePayloads = { + [Message in CorrelatedResponseMessage as Message["type"]]: Message["payload"]; +}; +type CorrelatedResponsePayload = + CorrelatedResponsePayloads[TType]; class DaemonRpcError extends Error { readonly requestId: string; @@ -1074,15 +1079,26 @@ interface PingProbe { export class DaemonClient { private readonly providerSnapshotUpdates = new ProviderSnapshotUpdates({ + active: (message) => this.owned.owns(message), fetch: (cwd) => this.requestProvidersSnapshot({ cwd }), emit: (message) => this.deliverSessionMessage(message), failed: (error) => this.logger.error({ err: error }, "Failed to resolve provider snapshot"), }); - private readonly subscriptions = new ConnectionSubscriptions({ - timelines: (agentIds) => (this.isConnected ? this.sendTimelineSubscription(agentIds) : null), - events: (events) => this.sendEventSubscription(events), - failed: (error) => - this.logger.error({ err: error }, "Failed to update connection subscriptions"), + private readonly owned = new OwnedSubscriptions({ + release: async (subscriptionId) => { + if (!this.isConnected) return; + try { + await this.sendCorrelatedSessionRequest({ + message: { type: "subscription.release.request", subscriptionId }, + responseType: "subscription.release.response", + }); + } catch (error) { + this.disposeTransport(1001, "Subscription release failed"); + this.scheduleReconnect({ reason: "Subscription release failed" }); + throw error; + } + }, + failed: (error) => this.logger.error({ err: error }, "Subscription failed"), }); private transport: DaemonTransport | null = null; private transportCleanup: Array<() => void> = []; @@ -1105,18 +1121,6 @@ export class DaemonClient { private connectReject: ((error: Error) => void) | null = null; private lastErrorValue: string | null = null; private connectionState: ConnectionState = { status: "idle" }; - private checkoutDiffSubscriptions = new Map< - string, - { - cwd: string; - compare: { mode: "uncommitted" | "base"; baseRef?: string; ignoreWhitespace?: boolean }; - } - >(); - private terminalDirectorySubscriptions = new Map(); - private fileSubscriptions = new Map< - string, - { cwd: string; path: string; onUpdate: (version: FileVersion) => void } - >(); private readonly terminalStreams = new TerminalStreamRouter(); private pendingBinaryFileReads = new Map(); private activeBinaryFileTransfers = new Map(); @@ -1346,7 +1350,9 @@ export class DaemonClient { reasonCode: "transport_error", }); }), - transport.onMessage((data) => this.handleTransportMessage(data)), + transport.onMessage((data) => { + if (this.transport === transport) this.handleTransportMessage(data); + }), ]; } catch (error) { this.resetConnectTimeout(); @@ -1384,7 +1390,6 @@ export class DaemonClient { return; } this.shouldReconnect = false; - this.subscriptions.close(); this.connectPromise = null; this.connectResolve = null; this.connectReject = null; @@ -1396,10 +1401,10 @@ export class DaemonClient { this.disposeTransport(1000, "Client closed"); this.providerSnapshotUpdates.clear(); this.clearWaiters(new Error("Daemon client closed")); + await this.owned.close(); this.rejectPendingSendQueue(new Error("Daemon client closed")); this.rejectPingProbe(new Error("Daemon client closed")); this.terminalStreams.clearSlots(); - this.fileSubscriptions.clear(); this.lastServerInfoMessage = null; if (this.runtimeMetricsInterval) { clearInterval(this.runtimeMetricsInterval); @@ -1471,10 +1476,8 @@ export class DaemonClient { subscribe(handler: DaemonEventHandler): () => void { this.eventListeners.add(handler); - this.updateEventSubscriptions(); return () => { this.eventListeners.delete(handler); - this.updateEventSubscriptions(); }; } @@ -1505,7 +1508,6 @@ export class DaemonClient { this.messageHandlers.set(type, new Set()); } this.messageHandlers.get(type)!.add(handler); - this.updateEventSubscriptions(); return () => { const handlers = this.messageHandlers.get(type); @@ -1516,7 +1518,6 @@ export class DaemonClient { if (handlers.size === 0) { this.messageHandlers.delete(type); } - this.updateEventSubscriptions(); }; } @@ -2082,7 +2083,122 @@ export class DaemonClient { // Agent RPCs (requestId-correlated) // ============================================================================ - async fetchAgents(options?: FetchAgentsOptions): Promise { + private requireOwnedSubscriptions(): void { + if (this.lastServerInfoMessage?.features?.ownedSubscriptions !== true) + throw new Error("Update the host to use independent subscriptions."); + } + + private observe( + responseType: T, + message: { type: SessionInboundMessage["type"] } & Record, + options?: { requestId?: string; timeout?: number; signal?: AbortSignal }, + ): OwnedSubscription> { + let resetSource = false; + return this.owned.observe>( + async (accept) => { + resetSource = false; + this.requireOwnedSubscriptions(); + const requestId = this.createRequestId(options?.requestId); + const request = SessionInboundMessageSchema.parse({ ...message, requestId }); + try { + return await this.sendCorrelatedRequest>({ + message: request, + responseType, + requestId, + timeout: options?.timeout, + options: { skipQueue: true }, + selectPayload: (payload): CorrelatedResponsePayload | null => { + if ("error" in payload && typeof payload.error === "string") + throw new DaemonRpcError({ + requestId: payload.requestId, + requestType: message.type, + error: payload.error, + }); + accept(payload); + return payload; + }, + }); + } catch (error) { + resetSource = !(error instanceof DaemonRpcError) && this.isConnected; + throw error; + } + }, + options?.signal, + () => { + // Reject and release this handle before closing a source with an unknown bootstrap outcome. + // Closing first would classify the failure as a reconnect and replay the failed request. + if (resetSource) { + this.disposeTransport(1001, "Subscription request failed"); + this.scheduleReconnect({ reason: "Subscription request failed" }); + } + }, + ); + } + + registerBrowserHost( + registration: Omit< + Extract, + "type" | "requestId" + >, + options?: { signal?: AbortSignal }, + ): OwnedSubscription> { + return this.observe( + "browser.host.register.response", + { type: "browser.host.register.request", ...registration }, + options, + ); + } + + observeEvents( + events: SessionEventSubscription[], + options?: { signal?: AbortSignal; notifications?: boolean }, + ): OwnedSubscription> { + return this.observe( + "session.events.set_subscription.response", + { + type: "session.events.set_subscription.request", + events, + notifications: options?.notifications, + }, + options, + ); + } + + observeAgents( + options: Omit = {}, + ): OwnedSubscription { + const { signal, requestId, timeout, ...query } = options; + return this.observe( + "fetch_agents_response", + { ...query, type: "fetch_agents_request", subscribe: {} }, + { signal, requestId, timeout }, + ); + } + + observeWorkspaces( + options: Omit = {}, + ): OwnedSubscription { + const { signal, requestId, ...query } = options; + return this.observe( + "fetch_workspaces_response", + { ...query, type: "fetch_workspaces_request", subscribe: {} }, + { signal, requestId }, + ); + } + + fetchAgents( + options: FetchAgentsOptions & { subscribe: {} }, + ): Promise }>; + fetchAgents(options?: FetchAgentsOptions): Promise; + async fetchAgents( + options?: FetchAgentsOptions, + ): Promise }> { + if (options?.subscribe) { + if (options.subscribe.subscriptionId !== undefined) + throw new Error("Subscription IDs are assigned by the host"); + const subscription = this.observeAgents(options); + return { ...(await subscription.ready), subscription }; + } const resolvedRequestId = this.createRequestId(options?.requestId); const message = SessionInboundMessageSchema.parse({ type: "fetch_agents_request", @@ -2166,7 +2282,21 @@ export class DaemonClient { }); } - async fetchWorkspaces(options?: FetchWorkspacesOptions): Promise { + fetchWorkspaces( + options: FetchWorkspacesOptions & { subscribe: {} }, + ): Promise }>; + fetchWorkspaces(options?: FetchWorkspacesOptions): Promise; + async fetchWorkspaces( + options?: FetchWorkspacesOptions, + ): Promise< + FetchWorkspacesPayload & { subscription?: OwnedSubscription } + > { + if (options?.subscribe) { + if (options.subscribe.subscriptionId !== undefined) + throw new Error("Subscription IDs are assigned by the host"); + const subscription = this.observeWorkspaces(options); + return { ...(await subscription.ready), subscription }; + } const resolvedRequestId = this.createRequestId(options?.requestId); const message = SessionInboundMessageSchema.parse({ type: "fetch_workspaces_request", @@ -2193,21 +2323,31 @@ export class DaemonClient { }); } - listWorkspaceLabels(options: { - subscriptionId: string; - sync?: { generation: string; afterSeq: number }; - requestId?: string; - }): Promise { + listWorkspaceLabels( + options: { + sync?: { generation: string; afterSeq: number }; + requestId?: string; + } = {}, + ): Promise { return this.sendNamespacedCorrelatedSessionRequest({ requestId: options.requestId, message: { type: "workspace.label.list.request", - subscribe: { subscriptionId: options.subscriptionId }, ...(options.sync ? { sync: options.sync } : {}), }, }); } + observeWorkspaceLabels(options?: { + signal?: AbortSignal; + }): OwnedSubscription { + return this.observe( + "workspace.label.list.response", + { type: "workspace.label.list.request", subscribe: {} }, + options, + ); + } + setWorkspaceLabel(options: { workspaceId: string; label: Extract< @@ -2495,53 +2635,6 @@ export class DaemonClient { return { agent: payload.agent, project: payload.project ?? null }; } - private resubscribeCheckoutDiffSubscriptions(): void { - if (this.checkoutDiffSubscriptions.size === 0) { - return; - } - for (const [subscriptionId, subscription] of this.checkoutDiffSubscriptions) { - const message = SessionInboundMessageSchema.parse({ - type: "subscribe_checkout_diff_request", - subscriptionId, - cwd: subscription.cwd, - compare: subscription.compare, - requestId: this.createRequestId(), - }); - this.sendSessionMessage(message); - } - } - - private resubscribeTerminalDirectorySubscriptions(): void { - if (this.terminalDirectorySubscriptions.size === 0) { - return; - } - for (const subscription of this.terminalDirectorySubscriptions.values()) { - this.sendSessionMessage({ - type: "subscribe_terminals_request", - cwd: subscription.cwd, - ...(subscription.workspaceId !== undefined - ? { workspaceId: subscription.workspaceId } - : {}), - }); - } - } - - private resubscribeFileSubscriptions(): void { - for (const [subscriptionId, subscription] of this.fileSubscriptions) { - void this.sendCorrelatedSessionRequest({ - message: { - type: "fs.file.subscribe.request", - cwd: subscription.cwd, - path: subscription.path, - subscriptionId, - }, - responseType: "fs.file.subscribe.response", - }) - .then((payload) => subscription.onUpdate(payload.initial)) - .catch(() => undefined); - } - } - // ============================================================================ // Agent Lifecycle // ============================================================================ @@ -3054,98 +3147,35 @@ export class DaemonClient { return payload; } - setAgentTimelineSubscription(agentIds: string[]): Promise { - return this.subscriptions.setViewed(agentIds); + observeTimeline( + agentIds: string[], + options?: { signal?: AbortSignal }, + ): OwnedSubscription> { + return this.observe( + "agent.timeline.set_subscription.response", + { type: "agent.timeline.set_subscription.request", agentIds }, + options, + ); } subscribeAgentTimeline( agentId: string, - handler: ( - message: Extract< - SessionOutboundMessage, - { type: "agent_stream" | "agent.timeline.replacement" } - >, - ) => void, + handler: (message: TimelineMessage) => void, ): TimelineSubscription { - const stream = this.on("agent_stream", (message) => { - if (message.payload.agentId === agentId) handler(message); - }); - const replacement = this.on("agent.timeline.replacement", (message) => { - if (message.payload.agentId === agentId) handler(message); - }); - const release = this.subscriptions.observeTimeline(agentId); - return Object.assign( - () => { - stream(); - replacement(); - release(); - }, - { ready: release.ready }, + return subscribeTimeline( + agentId, + this.observeTimeline([agentId]), + () => + this.fetchAgentTimeline(agentId, { + direction: "before", + limit: 100, + projection: "projected", + }), + handler, + (error) => this.logger.error({ err: error }, "Timeline observation failed"), ); } - private updateEventSubscriptions(): void { - const events: SessionEventSubscription[] = [ - "project.update", - "providers_snapshot_update", - "agent_attention_required", - "agent_permission_request", - "agent_permission_resolved", - ]; - if (this.eventListeners.size === 0 && !this.messageHandlers.has("providers_snapshot_update")) { - this.providerSnapshotUpdates.clear(); - } - this.subscriptions.setEvents( - events.filter( - (type) => this.eventListeners.size > 0 || (this.messageHandlers.get(type)?.size ?? 0) > 0, - ), - ); - } - - private async sendEventSubscription(events: SessionEventSubscription[]): Promise { - if (this.connectionState.status !== "connected") return; - // COMPAT(explicitEventSubscriptions): added in v0.8.0, remove legacy broadcast handling after 2027-03-08. - if ( - !this.lastServerInfoMessage?.features?.explicitEventSubscriptions || - this.config.capabilities?.[CLIENT_CAPS.explicitEventSubscriptions] === false - ) - return; - await this.sendCorrelatedSessionRequest({ - message: { type: "session.events.set_subscription.request", events }, - responseType: "session.events.set_subscription.response", - }); - } - - private async sendTimelineSubscription(agentIds: string[]): Promise { - if (this.connectionState.status !== "connected") return; - // COMPAT(selectiveAgentTimeline): added in v0.1.106. Old daemons keep their - // legacy global stream and do not understand this RPC. Remove after - // 2027-01-12 once the supported daemon floor is >= v0.1.106. - if (!this.lastServerInfoMessage?.features?.selectiveAgentTimeline) { - return; - } - - const requestId = this.createRequestId(); - const normalizedAgentIds = [...new Set(agentIds)].sort(); - const message = SessionInboundMessageSchema.parse({ - type: "agent.timeline.set_subscription.request", - agentIds: normalizedAgentIds, - requestId, - }); - - await this.sendRequest({ - requestId, - message, - options: { skipQueue: true }, - select: (response) => { - if (response.type !== "agent.timeline.set_subscription.response") { - return null; - } - return response.payload.requestId === requestId ? response.payload : null; - }, - }); - } - async buildAgentForkContext( agentId: string, options: AgentForkContextOptions = {}, @@ -3850,79 +3880,32 @@ export class DaemonClient { compare: { mode: "uncommitted" | "base"; baseRef?: string; ignoreWhitespace?: boolean }, requestId?: string, ): Promise { - const oneShotSubscriptionId = `oneshot-checkout-diff:${crypto.randomUUID()}`; - try { - const payload = await this.subscribeCheckoutDiff(cwd, compare, { - subscriptionId: oneShotSubscriptionId, - requestId, - }); - return { - cwd: payload.cwd, - files: payload.files, - error: payload.error, - diffTooLarge: payload.diffTooLarge, - requestId: payload.requestId, - }; - } finally { - try { - this.unsubscribeCheckoutDiff(oneShotSubscriptionId); - } catch { - // Ignore disconnect races during one-shot cleanup. - } - } + this.requireOwnedSubscriptions(); + return this.sendCorrelatedSessionRequest({ + message: { + type: "checkout.diff.get.request", + cwd, + compare: this.normalizeCheckoutDiffCompare(compare), + }, + responseType: "checkout.diff.get.response", + requestId, + }); } - async subscribeCheckoutDiff( + observeCheckoutDiff( cwd: string, compare: { mode: "uncommitted" | "base"; baseRef?: string; ignoreWhitespace?: boolean }, - options?: { subscriptionId?: string; requestId?: string }, - ): Promise { - const subscriptionId = options?.subscriptionId ?? crypto.randomUUID(); - const normalizedCompare = this.normalizeCheckoutDiffCompare(compare); - const previousSubscription = this.checkoutDiffSubscriptions.get(subscriptionId) ?? null; - this.checkoutDiffSubscriptions.set(subscriptionId, { - cwd, - compare: normalizedCompare, - }); - - const resolvedRequestId = this.createRequestId(options?.requestId); - const message = SessionInboundMessageSchema.parse({ - type: "subscribe_checkout_diff_request", - subscriptionId, - cwd, - compare: normalizedCompare, - requestId: resolvedRequestId, - }); - - try { - return await this.sendCorrelatedRequest({ - requestId: resolvedRequestId, - message, - responseType: "subscribe_checkout_diff_response", - options: { skipQueue: true }, - selectPayload: (payload) => { - if (payload.subscriptionId !== subscriptionId) { - return null; - } - return payload; - }, - }); - } catch (error) { - if (previousSubscription) { - this.checkoutDiffSubscriptions.set(subscriptionId, previousSubscription); - } else { - this.checkoutDiffSubscriptions.delete(subscriptionId); - } - throw error; - } - } - - unsubscribeCheckoutDiff(subscriptionId: string): void { - this.checkoutDiffSubscriptions.delete(subscriptionId); - this.sendSessionMessage({ - type: "unsubscribe_checkout_diff_request", - subscriptionId, - }); + options?: { requestId?: string; signal?: AbortSignal }, + ): OwnedSubscription { + return this.observe( + "subscribe_checkout_diff_response", + { + type: "subscribe_checkout_diff_request", + cwd, + compare: this.normalizeCheckoutDiffCompare(compare), + }, + options, + ); } async checkoutCommit( @@ -4526,36 +4509,35 @@ export class DaemonClient { } } + observeFile(input: { + cwd: string; + path: string; + signal?: AbortSignal; + }): OwnedSubscription> { + return this.observe( + "fs.file.subscribe.response", + { type: "fs.file.subscribe.request", cwd: input.cwd, path: input.path }, + { signal: input.signal }, + ); + } + async subscribeFile( - input: { cwd: string; path: string }, + input: { cwd: string; path: string; signal?: AbortSignal }, onUpdate: (version: FileVersion) => void, - ): Promise<{ initial: FileVersion; unsubscribe: () => void }> { - const subscriptionId = this.createRequestId(); - this.fileSubscriptions.set(subscriptionId, { ...input, onUpdate }); - try { - const payload = await this.sendCorrelatedSessionRequest({ - message: { - type: "fs.file.subscribe.request", - cwd: input.cwd, - path: input.path, - subscriptionId, - }, - responseType: "fs.file.subscribe.response", - }); - return { - initial: payload.initial, - unsubscribe: () => { - if (!this.fileSubscriptions.delete(subscriptionId)) return; - void this.sendCorrelatedSessionRequest({ - message: { type: "fs.file.unsubscribe.request", subscriptionId }, - responseType: "fs.file.unsubscribe.response", - }).catch(() => undefined); - }, - }; - } catch (error) { - this.fileSubscriptions.delete(subscriptionId); - throw error; - } + ): Promise<{ + initial: FileVersion; + subscription: OwnedSubscription>; + unsubscribe: () => Promise; + }> { + const subscription = this.observeFile(input); + subscription.subscribe({ + snapshot: (snapshot) => onUpdate(snapshot.initial), + update: (message) => { + if (message.type === "fs.file.update") onUpdate(message.payload.version); + }, + }); + const snapshot = await subscription.ready; + return { initial: snapshot.initial, subscription, unsubscribe: subscription.release }; } async writeFile(input: { @@ -5402,33 +5384,17 @@ export class DaemonClient { // Terminals // ============================================================================ - subscribeTerminals(input: { cwd: string; workspaceId?: string }): void { - this.terminalDirectorySubscriptions.set(terminalSubscriptionKey(input.cwd, input.workspaceId), { - cwd: input.cwd, - workspaceId: input.workspaceId, - }); - if (!this.transport || this.connectionState.status !== "connected") { - return; - } - this.sendSessionMessage({ - type: "subscribe_terminals_request", - cwd: input.cwd, - ...(input.workspaceId !== undefined ? { workspaceId: input.workspaceId } : {}), - }); - } - - unsubscribeTerminals(input: { cwd: string; workspaceId?: string }): void { - this.terminalDirectorySubscriptions.delete( - terminalSubscriptionKey(input.cwd, input.workspaceId), + observeTerminals(input: { + cwd: string; + workspaceId?: string; + signal?: AbortSignal; + }): OwnedSubscription> { + const { signal, ...query } = input; + return this.observe( + "terminals_changed", + { type: "subscribe_terminals_request", ...query }, + { signal }, ); - if (!this.transport || this.connectionState.status !== "connected") { - return; - } - this.sendSessionMessage({ - type: "unsubscribe_terminals_request", - cwd: input.cwd, - ...(input.workspaceId !== undefined ? { workspaceId: input.workspaceId } : {}), - }); } async listTerminals( @@ -5495,53 +5461,92 @@ export class DaemonClient { }); } - async subscribeTerminal( + /** + * Snapshot/restore and output arrive in receive. Subscribe to the returned handle + * for terminal_stream_exit: payload.error means observation failure, not PTY exit. + * Either outcome detaches this slot and releases the handle automatically. + */ + observeTerminal( terminalId: string, - optionsOrRequestId?: - | { restore?: SubscribeTerminalRequest["restore"]; requestId?: string } - | string, - ): Promise { - const restore = typeof optionsOrRequestId === "object" ? optionsOrRequestId.restore : undefined; - const requestId = - typeof optionsOrRequestId === "object" ? optionsOrRequestId.requestId : optionsOrRequestId; - const resolvedRequestId = this.createRequestId(requestId); - const message = SessionInboundMessageSchema.parse({ - type: "subscribe_terminal_request", - terminalId, - requestId: resolvedRequestId, - ...(restore ? { restore } : {}), + receive: (event: TerminalStreamEvent) => void, + options?: { + restore?: SubscribeTerminalRequest["restore"]; + signal?: AbortSignal; + requestId?: string; + }, + ): OwnedSubscription { + options?.signal?.throwIfAborted(); + const observation = this.observe( + "subscribe_terminal_response", + { + type: "subscribe_terminal_request", + terminalId, + ...(options?.restore ? { restore: options.restore } : {}), + }, + options, + ); + let detach = () => {}; + let released = false; + const release = () => { + released = true; + detach(); + options?.signal?.removeEventListener("abort", abort); + return observation.release(); + }; + const abort = () => { + void release().catch((error) => this.logger.error({ err: error }, "Terminal release failed")); + }; + options?.signal?.addEventListener("abort", abort, { once: true }); + observation.subscribe({ + snapshot: (payload) => { + detach(); + if (released || options?.signal?.aborted || payload.error !== null) return; + const subscriptionId = payload.subscriptionId; + detach = this.terminalStreams.attach(subscriptionId, terminalId, payload.slot, (event) => { + if (released || observation.subscriptionId !== subscriptionId) return; + try { + receive(event); + } catch (error) { + this.logger.error({ err: error }, "Terminal observer failed"); + } + }); + }, + update: (message) => { + if (message.type === "terminal_stream_exit") { + detach(); + queueMicrotask(abort); + } + }, + error: () => { + detach(); + }, }); - const payload = await this.sendCorrelatedRequest({ - requestId: resolvedRequestId, - message, - responseType: "subscribe_terminal_response", - options: { skipQueue: true }, - }); - if (payload.error === null) { - this.terminalStreams.setSlot(terminalId, payload.slot); - } - return payload; + return { + get subscriptionId() { + return observation.subscriptionId; + }, + ready: observation.ready, + subscribe: observation.subscribe, + release, + }; } - unsubscribeTerminal(terminalId: string): void { - this.terminalStreams.removeTerminal(terminalId); - this.sendSessionMessage({ - type: "unsubscribe_terminal_request", - terminalId, - }); + async subscribeTerminal( + terminalId: string, + options?: { + restore?: SubscribeTerminalRequest["restore"]; + signal?: AbortSignal; + requestId?: string; + }, + ): Promise< + SubscribeTerminalPayload & { subscription: OwnedSubscription } + > { + const subscription = this.observeTerminal(terminalId, () => {}, options); + return { ...(await subscription.ready), subscription }; } sendTerminalInput(terminalId: string, message: TerminalInput["message"]): void { - const frame = this.terminalStreams.encodeInput(terminalId, message); - if (frame) { - this.sendBinaryFrame(frame); - return; - } - this.sendSessionMessage({ - type: "terminal_input", - terminalId, - message, - }); + this.sendSessionMessage({ type: "terminal_input", terminalId, message }); } async killTerminal(terminalId: string, requestId?: string): Promise { @@ -5799,7 +5804,8 @@ export class DaemonClient { } private disposeTransport(code = 1001, reason = "Reconnecting"): void { - this.providerSnapshotUpdates.pause(); + this.owned.disconnected(); + this.providerSnapshotUpdates.clear(); this.stopLivenessHeartbeat(); this.cleanupTransport(); if (this.transport) { @@ -5845,10 +5851,11 @@ export class DaemonClient { rawData instanceof Blob && typeof rawData.arrayBuffer === "function" ) { + const transport = this.transport; void rawData .arrayBuffer() .then((buffer) => { - this.handleTransportMessage(buffer); + if (this.transport === transport) this.handleTransportMessage(buffer); return; }) .catch(() => { @@ -6111,7 +6118,7 @@ export class DaemonClient { this.lastErrorValue = reason.trim(); } - this.providerSnapshotUpdates.pause(); + this.providerSnapshotUpdates.clear(); // Clear all pending waiters and queued sends since the connection was lost // and responses from the previous connection will never arrive. @@ -6217,9 +6224,7 @@ export class DaemonClient { msg.payload.snapshotHash && !msg.payload.compactSnapshot ) { - if (this.messageHandlers.has(msg.type) || this.eventListeners.size > 0) { - this.providerSnapshotUpdates.receive(msg); - } + if (this.owned.owns(msg)) this.providerSnapshotUpdates.receive(msg); return; } this.deliverSessionMessage(msg); @@ -6240,27 +6245,13 @@ export class DaemonClient { this.reconnectAttempt = 0; this.updateConnectionState({ status: "connected" }, { event: "HELLO_SERVER_INFO" }); this.startLivenessHeartbeat(); - this.subscriptions.restore(); - this.providerSnapshotUpdates.resume(); - this.resubscribeCheckoutDiffSubscriptions(); - this.resubscribeTerminalDirectorySubscriptions(); - this.resubscribeFileSubscriptions(); + this.owned.restore(); this.flushPendingSendQueue(); this.resolveConnect(); } } } - if (consumerMessage.type === "terminal_stream_exit") { - this.terminalStreams.removeTerminal(consumerMessage.payload.terminalId); - } - - if (consumerMessage.type === "fs.file.update") { - this.fileSubscriptions - .get(consumerMessage.payload.subscriptionId) - ?.onUpdate(consumerMessage.payload.version); - } - if (this.rawMessageListeners.size > 0) { for (const handler of this.rawMessageListeners) { try { @@ -6290,17 +6281,21 @@ export class DaemonClient { } this.resolveWaiters(consumerMessage); + this.owned.receive(consumerMessage); } private resolveWaiters(msg: SessionOutboundMessage): void { for (const waiter of Array.from(this.waiters)) { - const result = waiter.predicate(msg); - if (result !== null) { + try { + const result = waiter.predicate(msg); + if (result === null) continue; this.waiters.delete(waiter); - if (waiter.timeoutHandle) { - clearTimeout(waiter.timeoutHandle); - } + if (waiter.timeoutHandle) clearTimeout(waiter.timeoutHandle); waiter.resolve(result); + } catch (error) { + this.waiters.delete(waiter); + if (waiter.timeoutHandle) clearTimeout(waiter.timeoutHandle); + waiter.reject(error instanceof Error ? error : new Error(String(error))); } } } diff --git a/packages/client/src/index.test.ts b/packages/client/src/index.test.ts index 7cf75b5806..508eba4c61 100644 --- a/packages/client/src/index.test.ts +++ b/packages/client/src/index.test.ts @@ -1,3 +1,5 @@ +import { execFile } from "node:child_process"; +import { promisify } from "node:util"; import { afterEach, expect, test, vi } from "vitest"; import { createPaseoApi, createPaseoClient } from "./index.js"; import { DaemonClient } from "./daemon-client.js"; @@ -24,6 +26,17 @@ class FakeWebSocket { send(data: string | ArrayBuffer | Uint8Array): void { this.sent.push(data); + const frame = JSON.parse(String(data)); + if (frame.message?.type === "subscription.release.request") + this.message( + sessionMessage({ + type: "subscription.release.response", + payload: { + requestId: frame.message.requestId, + subscriptionId: frame.message.subscriptionId, + }, + }), + ); } close(): void { @@ -86,6 +99,7 @@ async function connectClient( features: Record = { providerUsageList: true, providersSnapshotCwd: true, + ownedSubscriptions: true, }, ): Promise<{ client: PaseoClient; ws: FakeWebSocket }> { vi.stubGlobal("WebSocket", FakeWebSocket); @@ -121,6 +135,27 @@ async function connectClient( return { client, ws }; } +function acknowledgeObservation(ws: FakeWebSocket, subscriptionId: string): void { + const request = parseSentSessionMessage(ws.sent.at(-1)); + ws.message( + sessionMessage({ + type: request.type.replace(/([._])request$/, "$1response"), + payload: { + requestId: request.requestId, + subscriptionId, + entries: [], + pageInfo: { nextCursor: null, prevCursor: null, hasMore: false }, + }, + }), + ); +} + +async function observeAgents(client: PaseoClient, ws: FakeWebSocket): Promise { + const ready = client.agents.list({ subscribe: {} }); + acknowledgeObservation(ws, "agents-sdk"); + return (await ready).subscriptionId!; +} + function createWorkspace(input: Partial = {}): PaseoWorkspace { return { id: "workspace_sdk", @@ -231,6 +266,8 @@ test("createPaseoApi borrows daemon capabilities without exposing connection own expect(Object.keys(paseo).sort()).toEqual([ "agents", "config", + "dispose", + "observeEvents", "projects", "providers", "terminals", @@ -340,6 +377,9 @@ test("project actions list registered projects through the existing RPC", async test("project actions subscribe to existing project updates", async () => { const { client, ws } = await connectClient(); + const observation = client.observeEvents(["project.update"]); + acknowledgeObservation(ws, "projects-sdk"); + await observation.ready; const updates: string[] = []; const unsubscribe = client.projects.subscribe((update) => { updates.push(update.kind === "upsert" ? update.project.projectDisplayName : update.projectId); @@ -349,6 +389,7 @@ test("project actions subscribe to existing project updates", async () => { sessionMessage({ type: "project.update", payload: { + subscriptionId: "projects-sdk", kind: "upsert", project: { projectId: "project_sdk", @@ -370,6 +411,7 @@ test("project actions subscribe to existing project updates", async () => { sessionMessage({ type: "project.update", payload: { + subscriptionId: "projects-sdk", kind: "remove", projectId: "project_removed", generation: "daemon-generation", @@ -385,6 +427,7 @@ test("project actions subscribe to existing project updates", async () => { sessionMessage({ type: "project.update", payload: { + subscriptionId: "projects-sdk", kind: "remove", projectId: "project_after_unsubscribe", }, @@ -522,6 +565,9 @@ test("workspace handles keep identity and refresh snapshots through existing dri await expect(refetchPromise).resolves.toEqual(refreshedWorkspace); expect(workspace.current()).toEqual(refreshedWorkspace); + const directoryReady = client.workspaces.list({ subscribe: {} }); + acknowledgeObservation(ws, "workspaces-sdk"); + await directoryReady; const updates: string[] = []; const unsubscribe = workspace.subscribe((update) => { if (update.kind === "upsert") { @@ -532,10 +578,7 @@ test("workspace handles keep identity and refresh snapshots through existing dri ws.message( sessionMessage({ type: "workspace_update", - payload: { - kind: "upsert", - workspace: pushedWorkspace, - }, + payload: { subscriptionId: "workspaces-sdk", kind: "upsert", workspace: pushedWorkspace }, }), ); expect(updates).toEqual(["sdk pushed"]); @@ -567,6 +610,7 @@ test("workspace handles keep identity and refresh snapshots through existing dri sessionMessage({ type: "workspace_update", payload: { + subscriptionId: "workspaces-sdk", kind: "upsert", workspace: createWorkspace({ name: "sdk after unsubscribe" }), }, @@ -681,6 +725,7 @@ test("agent handles delegate create, send, timeline refetch, archive, and local expect(agent.id).toBe("agent_sdk"); expect(agent.current()).toEqual(createdAgent); + await observeAgents(client, ws); const updatedAgents: string[] = []; const unsubscribe = agent.subscribe((update) => { if (update.kind === "upsert") { @@ -691,11 +736,7 @@ test("agent handles delegate create, send, timeline refetch, archive, and local ws.message( sessionMessage({ type: "agent_update", - payload: { - kind: "upsert", - agent: updatedAgent, - project: null, - }, + payload: { subscriptionId: "agents-sdk", kind: "upsert", agent: updatedAgent, project: null }, }), ); expect(updatedAgents).toEqual(["Updated"]); @@ -959,11 +1000,13 @@ test("agent handles expose the observed snapshot through readonly properties", a runtimeInfo: { provider: "codex", sessionId: "session-sdk", model: "gpt-5.4" }, archivedAt: "2026-05-16T02:00:00.000Z", }); + await observeAgents(client, ws); const unsubscribe = agent.subscribe(() => {}); ws.message( sessionMessage({ type: "agent_update", payload: { + subscriptionId: "agents-sdk", kind: "upsert", agent: observedAgent, project: null, @@ -1138,6 +1181,8 @@ test("provider actions delegate to existing provider RPCs and local snapshot upd }); const readyPromise = client.providers.waitForReady({ cwd: "/repo/sdk", timeoutMs: 5_000 }); + acknowledgeObservation(ws, "provider-wait"); + await new Promise((resolve) => setTimeout(resolve, 0)); const readyRequest = parseSentSessionMessage(ws.sent.at(-1)); expect(readyRequest).toMatchObject({ type: "get_providers_snapshot_request", @@ -1158,6 +1203,7 @@ test("provider actions delegate to existing provider RPCs and local snapshot upd sessionMessage({ type: "providers_snapshot_update", payload: { + subscriptionId: "provider-wait", cwd: "/repo/other", entries: [{ provider: "codex", status: "ready", enabled: true }], generatedAt: "2026-05-16T00:00:30.000Z", @@ -1168,6 +1214,7 @@ test("provider actions delegate to existing provider RPCs and local snapshot upd sessionMessage({ type: "providers_snapshot_update", payload: { + subscriptionId: "provider-wait", cwd: "/repo/sdk", entries: [{ provider: "codex", status: "ready", enabled: true }], generatedAt: "2026-05-16T00:00:01.000Z", @@ -1184,11 +1231,14 @@ test("provider actions delegate to existing provider RPCs and local snapshot upd cwd: "/repo/./sdk", timeoutMs: 5_000, }); + acknowledgeObservation(ws, "provider-canonical-wait"); + await new Promise((resolve) => setTimeout(resolve, 0)); const canonicalReadyRequest = parseSentSessionMessage(ws.sent.at(-1)); ws.message( sessionMessage({ type: "providers_snapshot_update", payload: { + subscriptionId: "provider-canonical-wait", cwd: "/repo/sdk", entries: [{ provider: "codex", status: "ready", enabled: true }], generatedAt: "2026-05-16T00:00:02.000Z", @@ -1317,6 +1367,9 @@ test("provider actions delegate to existing provider RPCs and local snapshot upd ], }); + const observation = client.observeEvents(["providers_snapshot_update"]); + acknowledgeObservation(ws, "provider-updates"); + await observation.ready; const snapshotUpdates: string[] = []; const snapshotModelDefaults: Array = []; const unsubscribe = client.providers.subscribe((update) => { @@ -1327,6 +1380,7 @@ test("provider actions delegate to existing provider RPCs and local snapshot upd sessionMessage({ type: "providers_snapshot_update", payload: { + subscriptionId: "provider-updates", cwd: "/repo/sdk", entries: [ { @@ -1354,12 +1408,12 @@ test("provider actions delegate to existing provider RPCs and local snapshot upd await client.close(); }); -test("waitForReady requires canonical provider snapshot identity from the host", async () => { +test("waitForReady requires owned subscriptions from the host", async () => { const { client, ws } = await connectClient({}); const sentBeforeWait = ws.sent.length; await expect(client.providers.waitForReady({ cwd: "/repo/./sdk" })).rejects.toThrow( - "Update the host to wait for provider discovery.", + "Update the host to use independent subscriptions.", ); expect(ws.sent).toHaveLength(sentBeforeWait); @@ -1546,3 +1600,62 @@ test("agent config requires provider/model syntax", async () => { await client.close(); }); + +test("canceled timeline handles and captured state are collectible while their API stays alive", async () => { + const source = ` + import assert from "node:assert/strict"; + import { createPaseoApi } from ${JSON.stringify(new URL("./index.ts", import.meta.url).href)}; + import { DaemonClient } from ${JSON.stringify(new URL("./daemon-client.ts", import.meta.url).href)}; + const drivers = [], apis = []; + let errors = 0; + async function batch(mode) { + const driver = new DaemonClient({ url: "ws://127.0.0.1:1/ws", clientId: mode, reconnect: { enabled: false } }); + const scope = new AbortController(); + const api = createPaseoApi(driver, { signal: scope.signal }); + drivers.push(driver); apis.push(api); + const refs = [], ready = []; + for (let i = 0; i < 100; i++) { + const state = { bytes: new Uint8Array(64 * 1024) }; + const handle = api.agents.ref("unconnected-agent").timeline.subscribe(update => { + if (update.event.type === "error") errors++; + return state.bytes[0]; + }); + refs.push({ handle: new WeakRef(handle), state: new WeakRef(state) }); + ready.push(handle.ready.catch(() => {})); + if (mode === "callable") handle(); + if (mode === "release") await handle.release(); + } + if (mode === "scope-abort") scope.abort(); + if (mode === "client-close") await driver.close(); + if (mode !== "active") await Promise.all(ready); + return refs; + } + const groups = {}; + for (const mode of ["callable", "release", "scope-abort", "client-close", "active"]) groups[mode] = await batch(mode); + async function collect() { + for (let i = 0; i < 12; i++) { await new Promise(r => setImmediate(r)); global.gc(); } + } + const count = refs => ({ handles: refs.filter(r => r.handle.deref()).length, states: refs.filter(r => r.state.deref()).length }); + await collect(); + const live = Object.fromEntries(Object.entries(groups).map(([mode, refs]) => [mode, count(refs)])); + console.log(JSON.stringify({ phase: "API alive", live, errors })); + for (const api of apis) await api.dispose(); + await collect(); + const disposed = Object.fromEntries(Object.entries(groups).map(([mode, refs]) => [mode, count(refs)])); + console.log(JSON.stringify({ phase: "API disposed", disposed })); + for (const driver of drivers) await driver.close(); + for (const mode of ["callable", "release", "scope-abort", "client-close"]) assert.deepEqual(live[mode], { handles: 0, states: 0 }, mode); + assert.deepEqual(live.active, { handles: 100, states: 100 }, "active ownership is retained"); + assert.equal(errors, 100, "client shutdown reports failure; explicit cancellation does not"); + for (const result of Object.values(disposed)) assert.deepEqual(result, { handles: 0, states: 0 }); + `; + const env = Object.fromEntries( + Object.entries(process.env).filter(([key]) => !/^(PASEO_|EXPO_|E2E_|AGENT_BROWSER_)/.test(key)), + ); + const result = await promisify(execFile)( + process.execPath, + ["--expose-gc", "--import", "tsx", "--input-type=module", "-e", source], + { env, timeout: 15000 }, + ); + expect(result.stdout).toContain('"phase":"API alive"'); +}, 20000); diff --git a/packages/client/src/index.ts b/packages/client/src/index.ts index 7068cdc0bf..fb67a4228a 100644 --- a/packages/client/src/index.ts +++ b/packages/client/src/index.ts @@ -1,3 +1,5 @@ +import type { OwnedSubscription } from "./connection/index.js"; +export type { OwnedSubscription, SubscriptionObserver } from "./connection/index.js"; import type { DaemonClientConfig } from "./daemon-client.js"; import type { AgentPermissionResponse } from "@getpaseo/protocol/agent-types"; import type { @@ -114,6 +116,7 @@ export type PaseoProjectUpdate = Extract< export type PaseoProjectUpdateHandler = (update: PaseoProjectUpdate) => void; export interface PaseoAgentListResult { + subscription?: OwnedSubscription; requestId: string; subscriptionId?: string | null; entries: FetchAgentsEntry[]; @@ -127,6 +130,7 @@ export type PaseoWorkspaceListOptions = Omit< }; export interface PaseoWorkspaceListResult { + subscription?: OwnedSubscription; requestId: string; subscriptionId?: string | null; entries: PaseoWorkspace[]; @@ -185,6 +189,12 @@ export interface PaseoProjectActions { } export interface PaseoWorkspaceActions { + list(options: PaseoWorkspaceListOptions & { subscribe: {} }): Promise< + PaseoWorkspaceListResult & { + subscriptionId: string; + subscription: OwnedSubscription; + } + >; list(options?: PaseoWorkspaceListOptions): Promise; ref(workspace: string | PaseoWorkspace): PaseoWorkspaceHandle; open( @@ -289,7 +299,13 @@ export type PaseoAgentTimelineEvent = | { agentId: string; event: { type: "replacement"; epoch: string }; - }; + } + | { + agentId: string; + subscriptionId: string; + event: { type: "snapshot"; reason: "reconnect"; page: FetchAgentTimelinePayload }; + } + | { agentId: string; event: { type: "error"; error: string } }; export type PaseoAgentTimelineSubscription = ReturnType; @@ -302,8 +318,10 @@ export interface PaseoAgentTimelineHandle { */ refetch(options?: PaseoAgentTimelineRefetchOptions): Promise; /** - * Subscribe to this agent and restore demand after reconnect. A replacement - * event invalidates previously fetched history; refetch the page you need. + * Initially delivers live events only. Reconnect delivers a projected snapshot + * of the latest 100 entries before subsequent updates. Replace your recent view + * with this page; use its cursors to load older history. A replacement event + * invalidates the previous epoch. Recovery errors release this observation. * Await the returned unsubscribe function's `ready` promise before starting * work that must be observed. It rejects if establishment fails. */ @@ -354,6 +372,12 @@ export interface PaseoAgentHandle { } export interface PaseoAgentActions { + list(options: PaseoAgentListOptions & { subscribe: {} }): Promise< + PaseoAgentListResult & { + subscriptionId: string; + subscription: OwnedSubscription; + } + >; list(options?: PaseoAgentListOptions): Promise; ref(agent: string | PaseoAgent): PaseoAgentHandle; create(options: PaseoAgentCreateOptions): Promise; @@ -450,6 +474,8 @@ export interface PaseoConfigActions { } export interface PaseoApi { + dispose(): Promise; + observeEvents: DaemonClient["observeEvents"]; readonly terminals: PaseoTerminalActions; readonly workspaces: PaseoWorkspaceActions; readonly projects: PaseoProjectActions; @@ -471,17 +497,62 @@ export function createPaseoClient(config: PaseoClientConfig): PaseoClient { clientId: config.clientId ?? createGeneratedClientId(), clientType: "cli", }); + const api = createPaseoApi(daemonClient); return { - ...createPaseoApi(daemonClient), + ...api, connect: () => daemonClient.connect(), - close: () => daemonClient.close(), + close: async () => { + try { + await api.dispose(); + } finally { + await daemonClient.close(); + } + }, ensureConnected: () => daemonClient.ensureConnected(), getConnectionState: () => daemonClient.getConnectionState(), }; } -export function createPaseoApi(daemonClient: DaemonClient): PaseoApi { - const createAgentHandle = createAgentHandleFactory(daemonClient); +export function createPaseoApi( + daemonClient: DaemonClient, + scopeOptions?: { signal?: AbortSignal }, +): PaseoApi { + const handles = new Set<{ release(): Promise }>(); + const agentListeners = new Set(); + const workspaceListeners = new Set(); + const projectListeners = new Set(); + const providerListeners = new Set<(update: PaseoProviderSnapshotUpdate) => void>(); + const lifetime = new AbortController(); + const own = }>(create: () => T): T => { + if (lifetime.signal.aborted) throw new Error("Paseo API is disposed"); + const handle = create(); + handles.add(handle); + const release = handle.release.bind(handle); + handle.release = async () => { + await release(); + handles.delete(handle); + }; + return handle; + }; + const listenAgents = (handler: PaseoAgentUpdateHandler) => { + if (lifetime.signal.aborted) throw new Error("Paseo API is disposed"); + agentListeners.add(handler); + return () => { + agentListeners.delete(handler); + }; + }; + const listenWorkspaces = (handler: PaseoWorkspaceUpdateHandler) => { + if (lifetime.signal.aborted) throw new Error("Paseo API is disposed"); + workspaceListeners.add(handler); + return () => { + workspaceListeners.delete(handler); + }; + }; + const createAgentHandle = createAgentHandleFactory( + daemonClient, + listenAgents, + (agentId, handler) => own(() => daemonClient.subscribeAgentTimeline(agentId, handler)), + ); const createAgent = async ( options: PaseoAgentCreateOptions, placement?: { workspaceId: string; cwd: string }, @@ -513,19 +584,117 @@ export function createPaseoApi(daemonClient: DaemonClient): PaseoApi { } return workspace.workspaceDirectory; }); - const createWorkspaceHandle = createWorkspaceHandleFactory(daemonClient, createAgent, terminals); + const createWorkspaceHandle = createWorkspaceHandleFactory( + daemonClient, + createAgent, + terminals, + listenWorkspaces, + ); + + let disposal: Promise | null = null; + const dispose = (): Promise => { + if (disposal) return disposal; + lifetime.abort(); + scopeOptions?.signal?.removeEventListener("abort", abort); + agentListeners.clear(); + workspaceListeners.clear(); + projectListeners.clear(); + providerListeners.clear(); + disposal = Promise.allSettled([...handles].map((handle) => handle.release())).then( + (results) => { + handles.clear(); + const failures = results.flatMap((result) => + result.status === "rejected" ? [result.reason] : [], + ); + if (failures.length) + throw new AggregateError(failures, "Failed to release API subscriptions"); + return undefined; + }, + ); + return disposal; + }; + const abort = () => { + void dispose().catch((error) => console.error("API subscription cleanup failed", error)); + }; + if (scopeOptions?.signal?.aborted) abort(); + else scopeOptions?.signal?.addEventListener("abort", abort, { once: true }); + + const observeEvents: DaemonClient["observeEvents"] = (events, options) => { + const subscription = own(() => daemonClient.observeEvents(events, options)); + subscription.subscribe({ + snapshot: () => {}, + update: (message) => { + if (message.type === "project.update") + for (const listener of projectListeners) listener(message.payload); + if (message.type === "providers_snapshot_update") + for (const listener of providerListeners) listener(message.payload); + }, + }); + return subscription; + }; + + function listWorkspaces(options: PaseoWorkspaceListOptions & { subscribe: {} }): Promise< + PaseoWorkspaceListResult & { + subscriptionId: string; + subscription: OwnedSubscription; + } + >; + function listWorkspaces(options?: PaseoWorkspaceListOptions): Promise; + async function listWorkspaces( + options?: PaseoWorkspaceListOptions, + ): Promise { + if (!options?.subscribe) return daemonClient.fetchWorkspaces(options); + if (options.subscribe.subscriptionId !== undefined) + throw new Error("Subscription IDs are assigned by the host"); + const subscription = own(() => daemonClient.observeWorkspaces(options)); + subscription.subscribe({ + snapshot: () => {}, + update: (message) => { + if (message.type === "workspace_update") + for (const listener of workspaceListeners) listener(message.payload); + }, + }); + return { ...(await subscription.ready), subscription }; + } + + function listAgents(options: PaseoAgentListOptions & { subscribe: {} }): Promise< + PaseoAgentListResult & { + subscriptionId: string; + subscription: OwnedSubscription; + } + >; + function listAgents(options?: PaseoAgentListOptions): Promise; + async function listAgents(options?: PaseoAgentListOptions): Promise { + if (!options?.subscribe) return daemonClient.fetchAgents(options); + if (options.subscribe.subscriptionId !== undefined) + throw new Error("Subscription IDs are assigned by the host"); + const subscription = own(() => daemonClient.observeAgents(options)); + subscription.subscribe({ + snapshot: () => {}, + update: (message) => { + if (message.type === "agent_update") + for (const listener of agentListeners) listener(message.payload); + }, + }); + return { ...(await subscription.ready), subscription }; + } return { + dispose, + observeEvents, terminals, projects: { list: (options) => daemonClient.listProjects(options), - subscribe: (handler) => - daemonClient.on("project.update", (message) => { - handler(message.payload); - }), + subscribe: (handler) => { + if (lifetime.signal.aborted) throw new Error("Paseo API is disposed"); + projectListeners.add(handler); + return () => { + projectListeners.delete(handler); + }; + }, }, workspaces: { - list: (options) => daemonClient.fetchWorkspaces(options), + list: listWorkspaces, ref: (workspace) => createWorkspaceHandle(workspace), open: (input, requestId) => openWorkspace(daemonClient, createWorkspaceHandle, input, requestId), @@ -538,19 +707,13 @@ export function createPaseoApi(daemonClient: DaemonClient): PaseoApi { }, archive: (workspace, requestId) => daemonClient.archiveWorkspace(resolveWorkspaceId(workspace), requestId), - subscribe: (handler) => - daemonClient.on("workspace_update", (message) => { - handler(message.payload); - }), + subscribe: listenWorkspaces, }, agents: { - list: (options) => daemonClient.fetchAgents(options), + list: listAgents, ref: (agent) => createAgentHandle(agent), create: (options) => createAgent(options), - subscribe: (handler) => - daemonClient.on("agent_update", (message) => { - handler(message.payload); - }), + subscribe: listenAgents, }, providers: { listModels: (provider, options) => daemonClient.listProviderModels(provider, options), @@ -561,14 +724,23 @@ export function createPaseoApi(daemonClient: DaemonClient): PaseoApi { }, listAvailable: (options) => daemonClient.listAvailableProviders(options), snapshot: (options) => daemonClient.getProvidersSnapshot(options), - waitForReady: (options) => waitForProvidersReady(daemonClient, options), + waitForReady: (options) => + waitForProvidersReady( + daemonClient, + observeEvents(["providers_snapshot_update"]), + lifetime.signal, + options, + ), refresh: (options) => daemonClient.refreshProvidersSnapshot(options), diagnostic: (provider, options) => daemonClient.getProviderDiagnostic(provider, options), listUsage: (options) => listProviderUsage(daemonClient, options), - subscribe: (handler) => - daemonClient.on("providers_snapshot_update", (message) => { - handler(message.payload); - }), + subscribe: (handler) => { + if (lifetime.signal.aborted) throw new Error("Paseo API is disposed"); + providerListeners.add(handler); + return () => { + providerListeners.delete(handler); + }; + }, }, config: { get: (requestId) => daemonClient.getDaemonConfig(requestId), @@ -588,6 +760,7 @@ function createWorkspaceHandleFactory( daemonClient: DaemonClient, createAgent: CreateAgent, terminals: PaseoTerminalActions, + listen: (handler: PaseoWorkspaceUpdateHandler) => () => void, ): WorkspaceHandleFactory { return (workspace) => { const id = typeof workspace === "string" ? workspace : workspace.id; @@ -654,14 +827,12 @@ function createWorkspaceHandleFactory( return result; }, subscribe: (handler) => - daemonClient.on("workspace_update", (message) => { - const update = message.payload; + listen((update) => { if (update.kind === "upsert" && update.workspace.id === id) { current = update.workspace; handler(update); } if (update.kind === "remove" && update.id === id) { - current = null; handler(update); } }), @@ -669,7 +840,11 @@ function createWorkspaceHandleFactory( }; } -function createAgentHandleFactory(daemonClient: DaemonClient): AgentHandleFactory { +function createAgentHandleFactory( + daemonClient: DaemonClient, + listen: (handler: PaseoAgentUpdateHandler) => () => void, + subscribeTimeline: DaemonClient["subscribeAgentTimeline"], +): AgentHandleFactory { return (agent) => { const id = typeof agent === "string" ? agent : agent.id; let current = typeof agent === "string" ? null : agent; @@ -686,15 +861,27 @@ function createAgentHandleFactory(daemonClient: DaemonClient): AgentHandleFactor return result; }, subscribe: (handler) => - daemonClient.subscribeAgentTimeline(id, (message) => { - handler( - message.type === "agent_stream" - ? message.payload - : { - agentId: message.payload.agentId, - event: { type: "replacement", epoch: message.payload.epoch }, - }, - ); + subscribeTimeline(id, (message) => { + switch (message.type) { + case "agent_stream": + return handler(message.payload); + case "agent.timeline.snapshot": + return handler({ + agentId: id, + subscriptionId: message.payload.subscriptionId, + event: { type: "snapshot", reason: "reconnect", page: message.payload.page }, + }); + case "agent.timeline.error": + return handler({ + agentId: id, + event: { type: "error", error: message.payload.error }, + }); + case "agent.timeline.replacement": + return handler({ + agentId: id, + event: { type: "replacement", epoch: message.payload.epoch }, + }); + } }), }, get workspaceId() { @@ -779,14 +966,12 @@ function createAgentHandleFactory(daemonClient: DaemonClient): AgentHandleFactor await daemonClient.detachAgent(id); }, subscribe: (handler) => - daemonClient.on("agent_update", (message) => { - const update = message.payload; + listen((update) => { if (update.kind === "upsert" && update.agent.id === id) { current = update.agent; handler(update); } if (update.kind === "remove" && update.agentId === id) { - current = null; handler(update); } }), @@ -840,86 +1025,96 @@ function listProviderUsage( return daemonClient.listProviderUsage(options); } -function waitForProvidersReady( +async function waitForProvidersReady( daemonClient: DaemonClient, + observation: ReturnType, + signal: AbortSignal, options: PaseoProviderWaitOptions = {}, ): Promise { - // COMPAT(providersSnapshotCwd): added in v0.3.2, remove gate after 2027-02-10. - if (daemonClient.getLastServerInfoMessage()?.features?.providersSnapshotCwd !== true) { - return Promise.reject(new Error("Update the host to wait for provider discovery.")); - } - const { timeoutMs = 60_000, ...snapshotOptions } = options; - return new Promise((resolve, reject) => { - let settled = false; - let requestId: string | null = null; - let snapshotCwd: string | undefined; - const pendingUpdates = new Map(); - let latestEntries: PaseoProviderSnapshotResult["entries"] = []; + try { + await observation.ready; + signal.throwIfAborted(); + return await new Promise((resolve, reject) => { + let settled = false; + let requestId: string | null = null; + let snapshotCwd: string | undefined; + const pendingUpdates = new Map(); + let latestEntries: PaseoProviderSnapshotResult["entries"] = []; - const cleanup = () => { - clearTimeout(timeout); - unsubscribe(); - }; - const finish = (snapshot: PaseoProviderSnapshotResult) => { - if (settled) return; - settled = true; - cleanup(); - resolve(snapshot); - }; - const fail = (error: unknown) => { - if (settled) return; - settled = true; - cleanup(); - reject(error instanceof Error ? error : new Error(String(error))); - }; - const updateMatches = (update: PaseoProviderSnapshotUpdate) => update.cwd === snapshotCwd; + const cleanup = () => { + clearTimeout(timeout); + unsubscribe(); + signal.removeEventListener("abort", abort); + }; + const finish = (snapshot: PaseoProviderSnapshotResult) => { + if (settled) return; + settled = true; + cleanup(); + resolve(snapshot); + }; + const fail = (error: unknown) => { + if (settled) return; + settled = true; + cleanup(); + reject(error instanceof Error ? error : new Error(String(error))); + }; + const updateMatches = (update: PaseoProviderSnapshotUpdate) => update.cwd === snapshotCwd; - const unsubscribe = daemonClient.on("providers_snapshot_update", (message) => { - const update = message.payload; - if (!requestId) { - pendingUpdates.set(update.cwd, update); - return; - } - if (!updateMatches(update)) return; - latestEntries = update.entries; - if (update.entries.some((entry) => entry.status === "loading")) return; - finish({ ...update, requestId }); + const unsubscribe = observation.subscribe({ + snapshot: () => {}, + update: (message) => { + if (message.type !== "providers_snapshot_update") return; + const update = message.payload; + if (!requestId) { + pendingUpdates.set(update.cwd, update); + return; + } + if (!updateMatches(update)) return; + latestEntries = update.entries; + if (update.entries.some((entry) => entry.status === "loading")) return; + finish({ ...update, requestId }); + }, + }); + const abort = () => fail(new Error("Paseo API is disposed")); + signal.addEventListener("abort", abort, { once: true }); + + const timeout = setTimeout(() => { + const loading = latestEntries + .filter((entry) => entry.status === "loading") + .map((entry) => entry.provider) + .join(", "); + fail( + new Error( + loading + ? `Timed out waiting for providers: ${loading}` + : "Timed out waiting for provider discovery", + ), + ); + }, timeoutMs); + + void daemonClient + .getProvidersSnapshot(snapshotOptions) + .then((snapshot) => { + requestId = snapshot.requestId; + snapshotCwd = snapshot.cwd; + latestEntries = snapshot.entries; + if (!snapshot.entries.some((entry) => entry.status === "loading")) { + finish(snapshot); + return; + } + const pendingUpdate = pendingUpdates.get(snapshotCwd); + if (pendingUpdate && !pendingUpdate.entries.some((entry) => entry.status === "loading")) { + finish({ ...pendingUpdate, requestId }); + } + return undefined; + }) + .catch(fail); }); - - const timeout = setTimeout(() => { - const loading = latestEntries - .filter((entry) => entry.status === "loading") - .map((entry) => entry.provider) - .join(", "); - fail( - new Error( - loading - ? `Timed out waiting for providers: ${loading}` - : "Timed out waiting for provider discovery", - ), - ); - }, timeoutMs); - - void daemonClient - .getProvidersSnapshot(snapshotOptions) - .then((snapshot) => { - requestId = snapshot.requestId; - snapshotCwd = snapshot.cwd; - latestEntries = snapshot.entries; - if (!snapshot.entries.some((entry) => entry.status === "loading")) { - finish(snapshot); - return; - } - const pendingUpdate = pendingUpdates.get(snapshotCwd); - if (pendingUpdate && !pendingUpdate.entries.some((entry) => entry.status === "loading")) { - finish({ ...pendingUpdate, requestId }); - } - return undefined; - }) - .catch(fail); - }); + } finally { + await observation.release(); + } } function createGeneratedClientId(): string { diff --git a/packages/client/src/provider-snapshots/index.ts b/packages/client/src/provider-snapshots/index.ts index 7450a61639..b73a88e944 100644 --- a/packages/client/src/provider-snapshots/index.ts +++ b/packages/client/src/provider-snapshots/index.ts @@ -13,11 +13,11 @@ interface PendingSnapshot { /** Resolve announcements only while observed, coalescing changes during a fetch. */ export class ProviderSnapshotUpdates { - private pending = new Map(); - private paused = false; + private pending = new Map(); constructor( private readonly host: { + active(update: Update): boolean; fetch(cwd: string | undefined): Promise; emit(update: Update): void; failed(error: unknown): void; @@ -28,35 +28,25 @@ export class ProviderSnapshotUpdates { this.pending.clear(); } - pause(): void { - this.paused = true; - // Invalidate old responses while retaining the announcements they would resolve. - for (const [cwd, pending] of this.pending) { - this.pending.set(cwd, { latest: pending.latest, running: false }); - } - } - - resume(): void { - this.paused = false; - for (const pending of this.pending.values()) this.receive(pending.latest); - } - receive(update: Update): void { - const cwd = update.payload.cwd; - const pending = this.pending.get(cwd) ?? { latest: update, running: false }; + if (!this.host.active(update)) return; + const key = JSON.stringify([update.payload.subscriptionId, update.payload.cwd]); + const pending = this.pending.get(key) ?? { latest: update, running: false }; pending.latest = update; - this.pending.set(cwd, pending); - if (this.paused || pending.running) return; + this.pending.set(key, pending); + if (pending.running) return; pending.running = true; - void this.resolve(cwd, pending); + void this.resolve(key, pending); } - private async resolve(cwd: string | undefined, pending: PendingSnapshot): Promise { + private async resolve(key: string, pending: PendingSnapshot): Promise { try { - while (this.pending.get(cwd) === pending) { + while (this.pending.get(key) === pending) { const announced = pending.latest; - const snapshot = await this.host.fetch(cwd); - if (this.pending.get(cwd) !== pending) return; + if (!this.host.active(announced)) return; + const snapshot = await this.host.fetch(announced.payload.cwd); + if (!this.host.active(announced)) return; + if (this.pending.get(key) !== pending) return; // A change arriving while the body was in flight needs the current body. // If that response already covers it, no second fetch is necessary. if ( @@ -64,14 +54,17 @@ export class ProviderSnapshotUpdates { pending.latest.payload.snapshotHash !== snapshot.snapshotHash ) continue; - this.pending.delete(cwd); - this.host.emit({ type: "providers_snapshot_update", payload: snapshot }); + this.pending.delete(key); + this.host.emit({ + type: "providers_snapshot_update", + payload: { ...snapshot, subscriptionId: announced.payload.subscriptionId }, + }); return; } } catch (error) { - if (this.pending.get(cwd) === pending) this.host.failed(error); + if (this.pending.get(key) === pending) this.host.failed(error); } finally { - if (this.pending.get(cwd) === pending) this.pending.delete(cwd); + if (this.pending.get(key) === pending) this.pending.delete(key); } } } diff --git a/packages/client/src/terminal-stream-router.test.ts b/packages/client/src/terminal-stream-router.test.ts index f4a6c294c7..88b79efc29 100644 --- a/packages/client/src/terminal-stream-router.test.ts +++ b/packages/client/src/terminal-stream-router.test.ts @@ -9,8 +9,7 @@ describe("terminal-stream-router", () => { const events: TerminalStreamEvent[] = []; const payload = new TextEncoder().encode("restored screen"); - router.setSlot("term-1", 7); - router.onEvent((event) => events.push(event)); + router.attach("subscription-1", "term-1", 7, (event) => events.push(event)); router.handleFrame({ opcode: TerminalStreamOpcode.Restore, slot: 7, @@ -20,9 +19,28 @@ describe("terminal-stream-router", () => { expect(events).toEqual([ { terminalId: "term-1", + subscriptionId: "subscription-1", type: "restore", data: payload, }, ]); }); }); + +test("two registrations for one terminal retain independent slots and release", () => { + const router = new TerminalStreamRouter(); + const a: TerminalStreamEvent[] = []; + const b: TerminalStreamEvent[] = []; + const releaseA = router.attach("subscription-a", "same-terminal", 1, (event) => a.push(event)); + router.attach("subscription-b", "same-terminal", 2, (event) => b.push(event)); + const payload = new TextEncoder().encode("output"); + router.handleFrame({ opcode: TerminalStreamOpcode.Output, slot: 1, payload }); + router.handleFrame({ opcode: TerminalStreamOpcode.Output, slot: 2, payload }); + expect(a).toEqual([expect.objectContaining({ subscriptionId: "subscription-a" })]); + expect(b).toEqual([expect.objectContaining({ subscriptionId: "subscription-b" })]); + releaseA(); + router.handleFrame({ opcode: TerminalStreamOpcode.Output, slot: 1, payload }); + router.handleFrame({ opcode: TerminalStreamOpcode.Output, slot: 2, payload }); + expect(a).toHaveLength(1); + expect(b).toHaveLength(2); +}); diff --git a/packages/client/src/terminal-stream-router.ts b/packages/client/src/terminal-stream-router.ts index 5760af28eb..fa4d5e7613 100644 --- a/packages/client/src/terminal-stream-router.ts +++ b/packages/client/src/terminal-stream-router.ts @@ -1,22 +1,28 @@ import { decodeTerminalSnapshotPayload, - encodeTerminalResizePayload, - encodeTerminalStreamFrame, TerminalStreamOpcode, type TerminalStreamFrame, } from "@getpaseo/protocol/binary-frames/index"; -import type { TerminalInput, TerminalState } from "@getpaseo/protocol/messages"; +import type { TerminalState } from "@getpaseo/protocol/messages"; -export type TerminalStreamEvent = - | { terminalId: string; type: "output"; data: Uint8Array } - | { terminalId: string; type: "snapshot"; state: TerminalState } - | { terminalId: string; type: "restore"; data: Uint8Array }; +export type TerminalStreamEvent = { terminalId: string; subscriptionId: string } & ( + | { type: "output"; data: Uint8Array } + | { type: "snapshot"; state: TerminalState } + | { type: "restore"; data: Uint8Array } +); export class TerminalStreamRouter { - private readonly terminalSlots = new Map(); - private readonly slotTerminals = new Map(); + private readonly slots = new Map< + number, + { + subscriptionId: string; + terminalId: string; + receive: (event: TerminalStreamEvent) => void; + } + >(); private readonly listeners = new Set<(event: TerminalStreamEvent) => void>(); + /** Passive wire diagnostics. App state consumes the registration's callback. */ onEvent(handler: (event: TerminalStreamEvent) => void): () => void { this.listeners.add(handler); return () => { @@ -24,109 +30,46 @@ export class TerminalStreamRouter { }; } - setSlot(terminalId: string, slot: number): void { - const existingTerminalId = this.slotTerminals.get(slot); - if (existingTerminalId && existingTerminalId !== terminalId) { - this.terminalSlots.delete(existingTerminalId); - } - - const existingSlot = this.terminalSlots.get(terminalId); - if (typeof existingSlot === "number" && existingSlot !== slot) { - this.slotTerminals.delete(existingSlot); - } - - this.terminalSlots.set(terminalId, slot); - this.slotTerminals.set(slot, terminalId); - } - - removeTerminal(terminalId: string): void { - const slot = this.terminalSlots.get(terminalId); - if (typeof slot !== "number") { - return; - } - this.terminalSlots.delete(terminalId); - if (this.slotTerminals.get(slot) === terminalId) { - this.slotTerminals.delete(slot); - } + attach( + subscriptionId: string, + terminalId: string, + slot: number, + receive: (event: TerminalStreamEvent) => void, + ): () => void { + const registration = { subscriptionId, terminalId, receive }; + this.slots.set(slot, registration); + return () => { + if (this.slots.get(slot) === registration) this.slots.delete(slot); + }; } clearSlots(): void { - this.terminalSlots.clear(); - this.slotTerminals.clear(); - } - - encodeInput(terminalId: string, message: TerminalInput["message"]): Uint8Array | null { - const slot = this.terminalSlots.get(terminalId); - if (typeof slot !== "number") { - return null; - } - - if (message.type === "input") { - return encodeTerminalStreamFrame({ - opcode: TerminalStreamOpcode.Input, - slot, - payload: message.data, - }); - } - - if (message.type === "resize") { - return encodeTerminalStreamFrame({ - opcode: TerminalStreamOpcode.Resize, - slot, - payload: encodeTerminalResizePayload({ - rows: message.rows, - cols: message.cols, - ...(message.intent ? { intent: message.intent } : {}), - }), - }); - } - - return null; + this.slots.clear(); } handleFrame(frame: TerminalStreamFrame): void { - const terminalId = this.slotTerminals.get(frame.slot); - if (!terminalId) { - return; - } - - if (frame.opcode === TerminalStreamOpcode.Output) { - this.emit({ - terminalId, - type: "output", - data: frame.payload, - }); - return; - } - - if (frame.opcode === TerminalStreamOpcode.Restore) { - this.emit({ - terminalId, - type: "restore", - data: frame.payload, - }); - return; - } - - if (frame.opcode === TerminalStreamOpcode.Snapshot) { + const registration = this.slots.get(frame.slot); + if (!registration) return; + const identity = { + subscriptionId: registration.subscriptionId, + terminalId: registration.terminalId, + }; + let event: TerminalStreamEvent; + if (frame.opcode === TerminalStreamOpcode.Output) + event = { ...identity, type: "output", data: frame.payload }; + else if (frame.opcode === TerminalStreamOpcode.Restore) + event = { ...identity, type: "restore", data: frame.payload }; + else if (frame.opcode === TerminalStreamOpcode.Snapshot) { const state = decodeTerminalSnapshotPayload(frame.payload); - if (!state) { - return; - } - this.emit({ - terminalId, - type: "snapshot", - state, - }); - } - } - - private emit(event: TerminalStreamEvent): void { + if (!state) return; + event = { ...identity, type: "snapshot", state }; + } else return; + registration.receive(event); for (const listener of this.listeners) { try { listener(event); } catch { - // no-op + /* Passive diagnostics cannot interrupt delivery. */ } } } diff --git a/packages/client/src/terminals/index.test.ts b/packages/client/src/terminals/index.test.ts index 23cadbe6a9..2e9a51a93f 100644 --- a/packages/client/src/terminals/index.test.ts +++ b/packages/client/src/terminals/index.test.ts @@ -11,6 +11,8 @@ test("an older host rejects terminal operations before sending requests", async getLastServerInfoMessage: () => ({ status: "server_info", serverId: "older-host", + hostname: null, + version: null, features: {}, }), createTerminal: unexpectedRequest, diff --git a/packages/client/src/timeline-subscription/index.ts b/packages/client/src/timeline-subscription/index.ts new file mode 100644 index 0000000000..6671b26707 --- /dev/null +++ b/packages/client/src/timeline-subscription/index.ts @@ -0,0 +1,134 @@ +import type { SessionOutboundMessage } from "@getpaseo/protocol/messages"; +import type { FetchAgentTimelinePayload } from "../daemon-client.js"; +import type { OwnedSubscription, TimelineSubscription } from "../connection/index.js"; + +type TimelineUpdate = Extract< + SessionOutboundMessage, + { type: "agent_stream" | "agent.timeline.replacement" } +>; + +/** Local SDK recovery messages; these are not additional wire RPCs. */ +export type TimelineMessage = + | TimelineUpdate + | { + type: "agent.timeline.snapshot"; + payload: { agentId: string; subscriptionId: string; page: FetchAgentTimelinePayload }; + } + | { type: "agent.timeline.error"; payload: { agentId: string; error: string } }; + +const MAX_RECOVERY_UPDATES = 128; + +/** A live-only initial observation, with a fresh bounded history view after each outage. */ +export function subscribeTimeline( + agentId: string, + observation: OwnedSubscription<{ agentIds: string[] }>, + readSnapshot: () => Promise, + handler: (message: TimelineMessage) => void, + reportError: (error: unknown) => void, +): TimelineSubscription { + let established = false; + let released = false; + interface Recovery { + id: string; + revision: number; + updates: TimelineUpdate[]; + replacement?: Extract; + } + let recovery: Recovery | null = null; + const current = (state: Recovery) => + !released && recovery === state && observation.subscriptionId === state.id; + const notify = (message: TimelineMessage) => { + if (released) return; + try { + handler(message); + } catch (error) { + reportError(error); + } + }; + const release = () => { + released = true; + recovery = null; + return observation.release(); + }; + const stop = () => { + // Use the public release entry point so the owning API also forgets this handle. + void subscription.release().catch(reportError); + }; + const fail = (error: unknown) => { + notify({ + type: "agent.timeline.error", + payload: { agentId, error: error instanceof Error ? error.message : String(error) }, + }); + stop(); + }; + const recover = async (state: Recovery) => { + while (current(state)) { + const revision = state.revision; + let page: FetchAgentTimelinePayload; + try { + page = await readSnapshot(); + } catch (error) { + if (current(state)) fail(error); + return; + } + if (!current(state)) return; + // Overflow or replacement makes this in-flight read obsolete. Read a fresh + // bounded page instead of retaining an unbounded backlog of transient events. + if (revision !== state.revision) continue; + if (state.replacement) notify(state.replacement); + if (!current(state)) return; + notify({ + type: "agent.timeline.snapshot", + payload: { agentId, subscriptionId: state.id, page }, + }); + if (!current(state)) return; + recovery = null; + for (const message of state.updates) { + if (released || observation.subscriptionId !== state.id) break; + if (message.type === "agent_stream" && typeof message.payload.seq === "number") { + if ( + message.payload.epoch !== page.epoch || + message.payload.seq <= (page.window.maxSeq ?? 0) + ) + continue; + } + notify(message); + } + } + }; + observation.subscribe({ + snapshot: ({ subscriptionId }) => { + if (!established) { + established = true; + return; + } + const state: Recovery = { id: subscriptionId, revision: 0, updates: [] }; + recovery = state; + void recover(state); + }, + update: (message) => { + if (message.type !== "agent_stream" && message.type !== "agent.timeline.replacement") return; + if (!recovery) return notify(message); + if (message.type === "agent.timeline.replacement") { + recovery.replacement = message; + recovery.revision++; + recovery.updates.length = 0; + } else { + if (recovery.updates.length === MAX_RECOVERY_UPDATES) { + recovery.revision++; + recovery.updates.length = 0; + } + recovery.updates.push(message); + } + }, + error: fail, + }); + const subscription: TimelineSubscription = Object.assign(stop, { + ready: observation.ready.then(() => undefined), + release, + subscriptionId: null, + }); + Object.defineProperty(subscription, "subscriptionId", { get: () => observation.subscriptionId }); + void subscription.ready.catch(fail); + return subscription; +} diff --git a/packages/protocol/src/browser-automation/rpc-schemas.ts b/packages/protocol/src/browser-automation/rpc-schemas.ts index 55785dd69d..d2c5870e71 100644 --- a/packages/protocol/src/browser-automation/rpc-schemas.ts +++ b/packages/protocol/src/browser-automation/rpc-schemas.ts @@ -498,6 +498,7 @@ export const BrowserAutomationDialogEventSchema = z.object({ export const BrowserAutomationExecuteRequestSchema = z .object({ type: z.literal("browser.automation.execute.request"), + subscriptionId: z.string().optional(), requestId: z.string().min(1), agentId: z.string().min(1).optional(), cwd: z.string().min(1).optional(), diff --git a/packages/protocol/src/client-capabilities.ts b/packages/protocol/src/client-capabilities.ts index f60bf40a34..d7f5da1b7c 100644 --- a/packages/protocol/src/client-capabilities.ts +++ b/packages/protocol/src/client-capabilities.ts @@ -1,4 +1,6 @@ export const CLIENT_CAPS = { + // COMPAT(ownedSubscriptions): added in v0.8.0, remove legacy ownership after 2027-03-09. + ownedSubscriptions: "owned_subscriptions", // Clients outside the Paseo app version series can explicitly accept provider IDs. // COMPAT(explicitEventSubscriptions): added in v0.8.0, remove legacy broadcasts after 2027-03-08. explicitEventSubscriptions: "explicit_event_subscriptions", diff --git a/packages/protocol/src/messages.ts b/packages/protocol/src/messages.ts index ed36eeb1ad..b8d0e56cc2 100644 --- a/packages/protocol/src/messages.ts +++ b/packages/protocol/src/messages.ts @@ -56,6 +56,7 @@ import { LoopStopResponseSchema, } from "./loop/rpc-schemas.js"; import { + BROWSER_AUTOMATION_COMMAND_NAMES, BrowserAutomationExecuteRequestSchema, BrowserAutomationExecuteResponseSchema, } from "./browser-automation/rpc-schemas.js"; @@ -1036,7 +1037,7 @@ const WorkspaceLabelSyncCursorSchema = z.object({ export const WorkspaceLabelListRequestSchema = z.object({ type: z.literal("workspace.label.list.request"), requestId: z.string(), - subscribe: z.object({ subscriptionId: z.string() }), + subscribe: z.object({ subscriptionId: z.string().optional() }).optional(), sync: WorkspaceLabelSyncCursorSchema.optional(), }); export const WorkspaceLabelAssignmentSetRequestSchema = z.object({ @@ -2116,7 +2117,14 @@ export const CheckoutStatusRequestSchema = z.object({ export const SubscribeCheckoutDiffRequestSchema = z.object({ type: z.literal("subscribe_checkout_diff_request"), - subscriptionId: z.string(), + subscriptionId: z.string().optional(), + cwd: z.string(), + compare: CheckoutDiffCompareSchema, + requestId: z.string(), +}); + +export const CheckoutDiffGetRequestSchema = z.object({ + type: z.literal("checkout.diff.get.request"), cwd: z.string(), compare: CheckoutDiffCompareSchema, requestId: z.string(), @@ -2692,7 +2700,7 @@ export const FileSubscribeRequestSchema = z.object({ type: z.literal("fs.file.subscribe.request"), cwd: z.string(), path: z.string(), - subscriptionId: z.string(), + subscriptionId: z.string().optional(), requestId: z.string(), }); @@ -2847,6 +2855,7 @@ export const ListTerminalsRequestSchema = z.object({ export const SubscribeTerminalsRequestSchema = z.object({ type: z.literal("subscribe_terminals_request"), + requestId: z.string().optional(), cwd: z.string(), workspaceId: z.string().optional(), }); @@ -3053,19 +3062,55 @@ export const SessionEventSubscriptionSchema = z.enum([ "agent_attention_required", "agent_permission_request", "agent_permission_resolved", + "checkout_status_update", + "script_status_update", + "workspace_setup_progress", + "agent.provider_subagents.update", + "terminal_attention_required", + "status.server_info", + "status.daemon_config_changed", + "status.plugin_catalog_changed", + "status.plugin_settings_changed", + "activity_log", + "hub.execution.agent.update", + "hub.execution.agent.stream", ]); export type SessionEventSubscription = z.infer; export const SessionEventsSetSubscriptionRequestSchema = z.object({ type: z.literal("session.events.set_subscription.request"), requestId: z.string(), events: z.array(SessionEventSubscriptionSchema), + notifications: z.boolean().optional(), }); export const SessionEventsSetSubscriptionResponseSchema = z.object({ type: z.literal("session.events.set_subscription.response"), - payload: z.object({ requestId: z.string() }), + payload: z.object({ requestId: z.string(), subscriptionId: z.string().optional() }), +}); + +export const BrowserHostRegisterRequestSchema = z.object({ + type: z.literal("browser.host.register.request"), + requestId: z.string(), + hostKind: z.string().min(1), + supportedCommands: z.array(z.enum(BROWSER_AUTOMATION_COMMAND_NAMES)).min(1), +}); +export const BrowserHostRegisterResponseSchema = z.object({ + type: z.literal("browser.host.register.response"), + payload: z.object({ requestId: z.string(), subscriptionId: z.string() }), +}); + +export const SubscriptionReleaseRequestSchema = z.object({ + type: z.literal("subscription.release.request"), + requestId: z.string(), + subscriptionId: z.string(), +}); +export const SubscriptionReleaseResponseSchema = z.object({ + type: z.literal("subscription.release.response"), + payload: z.object({ requestId: z.string(), subscriptionId: z.string() }), }); export const SessionInboundMessageSchema = z.discriminatedUnion("type", [ + BrowserHostRegisterRequestSchema, + SubscriptionReleaseRequestSchema, SessionEventsSetSubscriptionRequestSchema, HubExecutionAgentCreateRequestSchema, HubExecutionAgentValidateRequestSchema, @@ -3165,6 +3210,7 @@ export const SessionInboundMessageSchema = z.discriminatedUnion("type", [ AgentRewindRequestMessageSchema, AgentPermissionResponseMessageSchema, CheckoutStatusRequestSchema, + CheckoutDiffGetRequestSchema, SubscribeCheckoutDiffRequestSchema, UnsubscribeCheckoutDiffRequestSchema, CheckoutCommitRequestSchema, @@ -3272,6 +3318,7 @@ export type SessionInboundMessage = z.infer; // ============================================================================ export const ActivityLogPayloadSchema = z.object({ + subscriptionId: z.string().optional(), id: z.string(), timestamp: z.coerce.date(), type: z.enum(["transcript", "assistant", "tool_call", "tool_result", "error", "system"]), @@ -3566,6 +3613,7 @@ export const ServerInfoStatusPayloadSchema = z // COMPAT(selectiveAgentTimeline): added in v0.1.106, remove after 2027-01-12. selectiveAgentTimeline: z.boolean().optional(), explicitEventSubscriptions: z.boolean().optional(), + ownedSubscriptions: z.boolean().optional(), // COMPAT(canonicalSubmittedPrompts): added in v0.2.6, remove gate after 2027-01-30. canonicalSubmittedPrompts: z.boolean().optional(), // COMPAT(agentTurnIdentity): accept peers that observed pre-release v0.2.6 through 2027-01-31. @@ -3934,6 +3982,7 @@ export const AgentUpdateMessageSchema = z.object({ type: z.literal("agent_update"), payload: z.discriminatedUnion("kind", [ z.object({ + subscriptionId: z.string().optional(), kind: z.literal("upsert"), agent: AgentSnapshotPayloadSchema, project: ProjectPlacementPayloadSchema.nullable().optional(), @@ -3941,6 +3990,7 @@ export const AgentUpdateMessageSchema = z.object({ seq: z.number().int().positive().optional(), }), z.object({ + subscriptionId: z.string().optional(), kind: z.literal("remove"), agentId: z.string(), generation: z.string().optional(), @@ -3952,6 +4002,7 @@ export const AgentUpdateMessageSchema = z.object({ export const AgentStreamMessageSchema = z.object({ type: z.literal("agent_stream"), payload: z.object({ + subscriptionId: z.string().optional(), agentId: z.string(), event: AgentStreamEventPayloadSchema, timestamp: z.string(), @@ -4098,12 +4149,14 @@ export const WorkspaceUpdateMessageSchema = z.object({ type: z.literal("workspace_update"), payload: z.discriminatedUnion("kind", [ z.object({ + subscriptionId: z.string().optional(), kind: z.literal("upsert"), workspace: WorkspaceDescriptorPayloadSchema, generation: z.string().optional(), seq: z.number().int().positive().optional(), }), z.object({ + subscriptionId: z.string().optional(), kind: z.literal("remove"), id: z.string(), // COMPAT(workspaceProjects): added in v0.1.97, drop the optional gate when floor >= v0.1.97. @@ -4132,6 +4185,7 @@ export const WorkspaceLabelListResponseSchema = z.object({ type: z.literal("workspace.label.list.response"), payload: z.object({ requestId: z.string(), + subscriptionId: z.string().optional(), labels: z.array(WorkspaceLabelDefinitionSchema), sync: WorkspaceLabelSyncMetadataSchema, }), @@ -4140,6 +4194,7 @@ export const WorkspaceLabelUpdateSchema = z.object({ type: z.literal("workspace.label.update"), payload: z.discriminatedUnion("kind", [ z.object({ + subscriptionId: z.string().optional(), kind: z.literal("upsert"), label: WorkspaceLabelDefinitionSchema, previousName: z.string().optional(), @@ -4147,6 +4202,7 @@ export const WorkspaceLabelUpdateSchema = z.object({ seq: z.number().int().positive(), }), z.object({ + subscriptionId: z.string().optional(), kind: z.literal("remove"), name: z.string(), generation: z.string(), @@ -4189,12 +4245,14 @@ export const ProjectUpdateMessageSchema = z.object({ type: z.literal("project.update"), payload: z.discriminatedUnion("kind", [ z.object({ + subscriptionId: z.string().optional(), kind: z.literal("upsert"), project: WorkspaceProjectDescriptorPayloadSchema, generation: z.string().optional(), seq: z.number().int().positive().optional(), }), z.object({ + subscriptionId: z.string().optional(), kind: z.literal("remove"), projectId: z.string(), generation: z.string().optional(), @@ -4215,6 +4273,7 @@ export const ProjectListResponseMessageSchema = z.object({ export const ScriptStatusUpdateMessageSchema = z.object({ type: z.literal("script_status_update"), payload: z.object({ + subscriptionId: z.string().optional(), workspaceId: z.string(), scripts: z.array(WorkspaceScriptPayloadSchema), }), @@ -4223,6 +4282,7 @@ export const ScriptStatusUpdateMessageSchema = z.object({ export const WorkspaceSetupProgressMessageSchema = z.object({ type: z.literal("workspace_setup_progress"), payload: z.object({ + subscriptionId: z.string().optional(), workspaceId: z.string(), status: z.enum(["running", "completed", "failed", "blocked"]), detail: WorktreeSetupDetailPayloadSchema, @@ -4484,6 +4544,7 @@ export const FetchAgentTimelineResponseMessageSchema = z.object({ export const AgentTimelineReplacementMessageSchema = z.object({ type: z.literal("agent.timeline.replacement"), payload: z.object({ + subscriptionId: z.string().optional(), agentId: z.string(), epoch: z.string(), }), @@ -4572,10 +4633,12 @@ export const ProviderSubagentUpdateMessageSchema = z.object({ type: z.literal("agent.provider_subagents.update"), payload: z.discriminatedUnion("kind", [ z.object({ + subscriptionId: z.string().optional(), kind: z.literal("upsert"), subagent: ProviderSubagentDescriptorPayloadSchema, }), z.object({ + subscriptionId: z.string().optional(), kind: z.literal("timeline"), parentAgentId: z.string(), subagentId: z.string(), @@ -4586,6 +4649,7 @@ export const ProviderSubagentUpdateMessageSchema = z.object({ epoch: z.string(), }), z.object({ + subscriptionId: z.string().optional(), kind: z.literal("remove"), parentAgentId: z.string(), subagentId: z.string(), @@ -4596,6 +4660,7 @@ export const ProviderSubagentUpdateMessageSchema = z.object({ export const SetAgentTimelineSubscriptionResponseMessageSchema = z.object({ type: z.literal("agent.timeline.set_subscription.response"), payload: z.object({ + subscriptionId: z.string().optional(), agentIds: z.array(z.string()), requestId: z.string(), }), @@ -4604,6 +4669,7 @@ export const SetAgentTimelineSubscriptionResponseMessageSchema = z.object({ export const AgentAttentionRequiredMessageSchema = z.object({ type: z.literal("agent_attention_required"), payload: z.object({ + subscriptionId: z.string().optional(), agentId: z.string(), reason: z.enum(["finished", "error", "permission"]), timestamp: z.string(), @@ -4887,6 +4953,7 @@ export const WriteProjectConfigResponseMessageSchema = z.object({ export const AgentPermissionRequestMessageSchema = z.object({ type: z.literal("agent_permission_request"), payload: z.object({ + subscriptionId: z.string().optional(), agentId: z.string(), request: AgentPermissionRequestPayloadSchema, }), @@ -4895,6 +4962,7 @@ export const AgentPermissionRequestMessageSchema = z.object({ export const AgentPermissionResolvedMessageSchema = z.object({ type: z.literal("agent_permission_resolved"), payload: z.object({ + subscriptionId: z.string().optional(), agentId: z.string(), requestId: z.string(), resolution: AgentPermissionResponseSchema, @@ -5135,6 +5203,7 @@ const CheckoutPrStatusPayloadSchema = z.object({ }); const CheckoutStatusUpdateMetadataSchema = z.object({ + subscriptionId: z.string().optional(), prStatus: CheckoutPrStatusPayloadSchema.optional(), }); @@ -5165,6 +5234,13 @@ export const SubscribeCheckoutDiffResponseSchema = z.object({ }), }); +export const CheckoutDiffGetResponseSchema = z.object({ + type: z.literal("checkout.diff.get.response"), + payload: CheckoutDiffSubscriptionPayloadSchema.omit({ subscriptionId: true }).extend({ + requestId: z.string(), + }), +}); + export const CheckoutDiffUpdateSchema = z.object({ type: z.literal("checkout_diff_update"), payload: CheckoutDiffSubscriptionPayloadSchema, @@ -5929,6 +6005,7 @@ export const GetProvidersSnapshotResponseMessageSchema = z.object({ export const ProvidersSnapshotUpdateMessageSchema = z.object({ type: z.literal("providers_snapshot_update"), payload: z.object({ + subscriptionId: z.string().optional(), cwd: z.string().optional(), entries: z.array(ProviderSnapshotEntrySchema), compactSnapshot: CompactProviderSnapshotSchema.optional(), @@ -6094,6 +6171,9 @@ export const ListTerminalsResponseSchema = z.object({ export const TerminalsChangedSchema = z.object({ type: z.literal("terminals_changed"), payload: z.object({ + subscriptionId: z.string().optional(), + requestId: z.string().optional(), + workspaceId: z.string().optional(), cwd: z.string(), terminals: z.array(TerminalInfoSchema.omit({ cwd: true })), }), @@ -6123,6 +6203,7 @@ export const SubscribeTerminalResponseSchema = z.object({ z.object({ terminalId: z.string(), slot: z.number().int().min(0).max(255), + subscriptionId: z.string().optional(), error: z.null(), requestId: z.string(), }), @@ -6156,13 +6237,17 @@ export const CaptureTerminalResponseSchema = z.object({ export const TerminalStreamExitSchema = z.object({ type: z.literal("terminal_stream_exit"), payload: z.object({ + subscriptionId: z.string().optional(), terminalId: z.string(), + // Observation failure; the underlying terminal process may still be running. + error: z.string().optional(), }), }); export const TerminalAttentionRequiredSchema = z.object({ type: z.literal("terminal_attention_required"), payload: z.object({ + subscriptionId: z.string().optional(), serverId: z.string().optional(), terminalId: z.string(), cwd: z.string(), @@ -6241,6 +6326,7 @@ export const HubExecutionControlResponseSchema = z.object({ export const HubExecutionAgentUpdateSchema = z.object({ type: z.literal("hub.execution.agent.update"), payload: z.object({ + subscriptionId: z.string().optional(), executionId: z.string(), agentId: z.string(), agent: AgentSnapshotPayloadSchema, @@ -6250,6 +6336,7 @@ export const HubExecutionAgentUpdateSchema = z.object({ export const HubExecutionAgentStreamSchema = z.object({ type: z.literal("hub.execution.agent.stream"), payload: z.object({ + subscriptionId: z.string().optional(), executionId: z.string(), agentId: z.string(), event: AgentStreamEventPayloadSchema, @@ -6459,6 +6546,8 @@ export const AgentSkillsImportLegacySelectionResponseSchema = z.object({ }); export const SessionOutboundMessageSchema = z.discriminatedUnion("type", [ + BrowserHostRegisterResponseSchema, + SubscriptionReleaseResponseSchema, SessionEventsSetSubscriptionResponseSchema, HubExecutionAgentCreateResponseSchema, HubExecutionAgentValidateResponseSchema, @@ -6584,6 +6673,7 @@ export const SessionOutboundMessageSchema = z.discriminatedUnion("type", [ CloseItemsResponseSchema, CheckoutStatusResponseSchema, CheckoutStatusUpdateSchema, + CheckoutDiffGetResponseSchema, SubscribeCheckoutDiffResponseSchema, CheckoutDiffUpdateSchema, CheckoutCommitResponseSchema, diff --git a/packages/server/scripts/benchmark-startup-git-real-home.ts b/packages/server/scripts/benchmark-startup-git-real-home.ts index ba1d1129e4..4dde4e190d 100644 --- a/packages/server/scripts/benchmark-startup-git-real-home.ts +++ b/packages/server/scripts/benchmark-startup-git-real-home.ts @@ -140,14 +140,15 @@ async function main(): Promise { await client.connect(); const seen = new Set(); client.on("checkout_status_update", (message) => seen.add(message.payload.cwd)); + await client.observeEvents(["checkout_status_update"]).ready; const workspaces = await client.fetchWorkspaces({ - subscribe: { subscriptionId: `startup-git-${scenario}` }, + subscribe: {}, sort: [{ key: "activity_at", direction: "desc" }], page: { limit: 200 }, }); await client.fetchAgents({ scope: "active", - subscribe: { subscriptionId: `startup-agents-${scenario}` }, + subscribe: {}, page: { limit: 200 }, }); diff --git a/packages/server/scripts/test-mcp-inject.ts b/packages/server/scripts/test-mcp-inject.ts index be5d5f6db4..e9ec1deaa7 100644 --- a/packages/server/scripts/test-mcp-inject.ts +++ b/packages/server/scripts/test-mcp-inject.ts @@ -151,7 +151,7 @@ async function main(): Promise { await client.connect(); await client.fetchAgents({ - subscribe: { subscriptionId: "mcp-inject-real-claude" }, + subscribe: {}, }); const results: ProviderRunResult[] = []; diff --git a/packages/server/scripts/voice-main-daemon-e2e.ts b/packages/server/scripts/voice-main-daemon-e2e.ts index 27ca9d247a..1e59d052e3 100644 --- a/packages/server/scripts/voice-main-daemon-e2e.ts +++ b/packages/server/scripts/voice-main-daemon-e2e.ts @@ -47,7 +47,7 @@ async function runVoiceRoundTrip(params: { const client = new DaemonClient({ url: `${params.daemonUrl}/ws` }); await client.connect(); await client.fetchAgents({ - subscribe: { subscriptionId: `voice-e2e-${randomUUID()}` }, + subscribe: {}, }); const mode = await client.setVoiceMode(true, params.voiceAgentId); @@ -77,7 +77,7 @@ async function runVoiceRoundTrip(params: { }); }); - const offStream = client.on("agent_stream", (msg) => { + const offStream = client.subscribeAgentTimeline(activeVoiceAgentId, (msg) => { if (msg.type !== "agent_stream") return; if (msg.payload.agentId !== activeVoiceAgentId) return; if (msg.payload.event.type !== "timeline") return; @@ -88,6 +88,8 @@ async function runVoiceRoundTrip(params: { } }); + await offStream.ready; + const chunkBytes = 4800; for (let offset = 0; offset < pcm.length; offset += chunkBytes) { const chunk = pcm.subarray(offset, Math.min(pcm.length, offset + chunkBytes)); diff --git a/packages/server/scripts/voice-roundtrip-debug.ts b/packages/server/scripts/voice-roundtrip-debug.ts index 108b3cb035..17ca7b6b19 100644 --- a/packages/server/scripts/voice-roundtrip-debug.ts +++ b/packages/server/scripts/voice-roundtrip-debug.ts @@ -49,7 +49,7 @@ async function main(): Promise { try { await client.connect(); - await client.fetchAgents({ subscribe: { subscriptionId: "voice-debug" } }); + await client.fetchAgents({ subscribe: {} }); const voiceCwd = mkdtempSync(path.join(tmpdir(), "voice-roundtrip-debug-")); const voiceAgent = await client.createAgent({ @@ -88,7 +88,7 @@ async function main(): Promise { } }); - const offStream = client.on("agent_stream", (msg) => { + const offStream = client.subscribeAgentTimeline(voiceAgentId, (msg) => { if (msg.type !== "agent_stream") return; if (msg.payload.event.type !== "timeline") return; const item = msg.payload.event.item; @@ -100,6 +100,8 @@ async function main(): Promise { }); }); + await offStream.ready; + let audioChunkCount = 0; const firstAudio = new Promise((resolve) => { const offAudio = client.on("audio_output", (msg) => { diff --git a/packages/server/src/server/agent/agent-prompt.ts b/packages/server/src/server/agent/agent-prompt.ts index f46822d924..8e222304e9 100644 --- a/packages/server/src/server/agent/agent-prompt.ts +++ b/packages/server/src/server/agent/agent-prompt.ts @@ -269,6 +269,7 @@ const AGENT_RUN_START_TIMEOUT_MS = 60_000; export async function waitForAgentRunStartWithTimeout( agentManager: AgentManager, agentId: string, + signal?: AbortSignal, ): Promise { const provider = agentManager.getAgent(agentId)?.provider ?? "provider"; const startAbort = new AbortController(); @@ -283,7 +284,9 @@ export async function waitForAgentRunStartWithTimeout( ); try { - await agentManager.waitForAgentRunStart(agentId, { signal: startAbort.signal }); + await agentManager.waitForAgentRunStart(agentId, { + signal: signal ? AbortSignal.any([startAbort.signal, signal]) : startAbort.signal, + }); } finally { clearTimeout(startTimeout); } diff --git a/packages/server/src/server/authorization/operation-permissions.ts b/packages/server/src/server/authorization/operation-permissions.ts index ec6c4db2dd..6a6a1e78ec 100644 --- a/packages/server/src/server/authorization/operation-permissions.ts +++ b/packages/server/src/server/authorization/operation-permissions.ts @@ -20,6 +20,8 @@ const INBOUND_PERMISSION = { "agent.skills.uninstall.request": "daemon.manage", "agent.timeline.list_prompts.request": "workspace.read", "agent.timeline.append.request": "workspace.write", + "browser.host.register.request": ["workspace.write"], + "subscription.release.request": null, "session.events.set_subscription.request": ["workspace.read", "daemon.read", "hub.execute"], "agent.timeline.set_subscription.request": ["workspace.read", "hub.execute"], agent_permission_response: "workspace.write", @@ -172,6 +174,7 @@ const INBOUND_PERMISSION = { stash_list_request: "workspace.read", stash_pop_request: "workspace.write", stash_save_request: "workspace.write", + "checkout.diff.get.request": "workspace.read", subscribe_checkout_diff_request: "workspace.read", subscribe_terminal_request: "workspace.read", subscribe_terminals_request: "workspace.read", @@ -222,6 +225,8 @@ const OUTBOUND_PERMISSION = { "agent.timeline.list_prompts.response": "workspace.read", "agent.timeline.append.response": "workspace.write", "agent.timeline.replacement": ["workspace.read", "hub.execute"], + "browser.host.register.response": ["workspace.write"], + "subscription.release.response": null, "session.events.set_subscription.response": ["workspace.read", "daemon.read", "hub.execute"], "agent.timeline.set_subscription.response": ["workspace.read", "hub.execute"], agent_archived: ["workspace.read", "hub.execute"], @@ -385,6 +390,7 @@ const OUTBOUND_PERMISSION = { stash_pop_response: "workspace.read", stash_save_response: "workspace.read", status: "daemon.read", + "checkout.diff.get.response": "workspace.read", subscribe_checkout_diff_response: "workspace.read", subscribe_terminal_response: "workspace.read", "terminal.rename.response": "workspace.write", diff --git a/packages/server/src/server/browser-tools/broker.ts b/packages/server/src/server/browser-tools/broker.ts index dc1eca7f2d..b59094de20 100644 --- a/packages/server/src/server/browser-tools/broker.ts +++ b/packages/server/src/server/browser-tools/broker.ts @@ -158,7 +158,10 @@ export class BrowserToolsBroker { }); } - public receiveResponse(response: BrowserAutomationExecuteResponse): boolean { + public receiveResponse( + response: BrowserAutomationExecuteResponse, + clientIds?: readonly string[], + ): boolean { const parsed = BrowserAutomationExecuteResponseSchema.safeParse(response); if (!parsed.success) { const requestId = getBrowserAutomationResponseRequestId(response); @@ -167,7 +170,7 @@ export class BrowserToolsBroker { } const pending = this.pending.get(requestId); - if (!pending) { + if (!pending || (clientIds && !clientIds.includes(pending.clientId))) { return false; } @@ -184,7 +187,7 @@ export class BrowserToolsBroker { } const pending = this.pending.get(parsed.data.payload.requestId); - if (!pending) { + if (!pending || (clientIds && !clientIds.includes(pending.clientId))) { return false; } diff --git a/packages/server/src/server/checkout-diff-manager.ts b/packages/server/src/server/checkout-diff-manager.ts index 9bf0439aff..628bb32a0a 100644 --- a/packages/server/src/server/checkout-diff-manager.ts +++ b/packages/server/src/server/checkout-diff-manager.ts @@ -73,6 +73,12 @@ export class CheckoutDiffManager { this.workspaceGitService = options.workspaceGitService; } + async read( + params: Omit, + ): Promise { + return this.computeCheckoutDiffSnapshot(params.cwd, this.normalizeCompare(params.compare)); + } + async subscribe( params: CheckoutDiffSubscriptionRequest, listener: (snapshot: CheckoutDiffSnapshotPayload) => void, diff --git a/packages/server/src/server/cli-run-workspace-precedence.e2e.test.ts b/packages/server/src/server/cli-run-workspace-precedence.e2e.test.ts index e0524c8948..0247e7af97 100644 --- a/packages/server/src/server/cli-run-workspace-precedence.e2e.test.ts +++ b/packages/server/src/server/cli-run-workspace-precedence.e2e.test.ts @@ -40,7 +40,7 @@ test("daemon resolves human and managed CLI workspace ownership", async () => { try { await client.connect(); - await client.fetchAgents({ subscribe: { subscriptionId: "test" } }); + await client.fetchAgents({ subscribe: {} }); // A bare run mints a fresh local workspace for the cwd, then the agent is // stamped with that workspace's id. diff --git a/packages/server/src/server/daemon-client.e2e.test.ts b/packages/server/src/server/daemon-client.e2e.test.ts index 934125af11..f429cb672c 100644 --- a/packages/server/src/server/daemon-client.e2e.test.ts +++ b/packages/server/src/server/daemon-client.e2e.test.ts @@ -121,7 +121,7 @@ test("createAgent without an initial prompt returns an idle snapshot", async () try { await client.connect(); - await client.fetchAgents({ subscribe: { subscriptionId: "create-no-prompt" } }); + await client.fetchAgents({ subscribe: {} }); const agent = await client.createAgent({ provider: "codex", @@ -185,7 +185,7 @@ test("createAgent with background initialPrompt returns a running snapshot befor try { await client.connect(); - await client.fetchAgents({ subscribe: { subscriptionId: "create-background-prompt" } }); + await client.fetchAgents({ subscribe: {} }); const agent = await client.createAgent({ provider: "codex", @@ -415,7 +415,7 @@ test("createAgent fails when the initial turn cannot start", async () => { try { await client.connect(); - await client.fetchAgents({ subscribe: { subscriptionId: "create-start-failure" } }); + await client.fetchAgents({ subscribe: {} }); await expect( client.createAgent({ @@ -1325,7 +1325,7 @@ test("creates agent and exercises lifecycle", async () => { const cwd = tmpCwd(); await ctx.client.fetchAgents({ - subscribe: { subscriptionId: "daemon-client-lifecycle" }, + subscribe: {}, }); const agentUpdatePromise = waitForSignal(15000, (resolve) => { diff --git a/packages/server/src/server/daemon-e2e/agent-archive.native.real.e2e.test.ts b/packages/server/src/server/daemon-e2e/agent-archive.native.real.e2e.test.ts index 17dce841cf..c3e58f8b94 100644 --- a/packages/server/src/server/daemon-e2e/agent-archive.native.real.e2e.test.ts +++ b/packages/server/src/server/daemon-e2e/agent-archive.native.real.e2e.test.ts @@ -41,7 +41,7 @@ async function expectContextAfterWorkspaceRestore(legacyNativeArchive: boolean): }); client = new DaemonClient({ url: `ws://127.0.0.1:${daemon.port}/ws`, appVersion: "0.8.0" }); await client.connect(); - await client.fetchAgents({ subscribe: { subscriptionId: "archive-qa" } }); + await client.fetchAgents({ subscribe: {} }); const agent = await client.createAgent({ config: { provider: "codex", diff --git a/packages/server/src/server/daemon-e2e/agent-operations.e2e.test.ts b/packages/server/src/server/daemon-e2e/agent-operations.e2e.test.ts index c5618b9f09..e8bb548688 100644 --- a/packages/server/src/server/daemon-e2e/agent-operations.e2e.test.ts +++ b/packages/server/src/server/daemon-e2e/agent-operations.e2e.test.ts @@ -130,7 +130,7 @@ describe("cancelAgent", () => { const cwd = tmpCwd(); await ctx.client.fetchAgents({ - subscribe: { subscriptionId: "agent-operations-cancel" }, + subscribe: {}, }); // Create Codex agent @@ -203,7 +203,7 @@ describe("setAgentMode", () => { const cwd = tmpCwd(); await ctx.client.fetchAgents({ - subscribe: { subscriptionId: "agent-operations-mode" }, + subscribe: {}, }); // Create a Codex agent with default mode ("auto") diff --git a/packages/server/src/server/daemon-e2e/agent-refresh-rehydrates-timeline.e2e.test.ts b/packages/server/src/server/daemon-e2e/agent-refresh-rehydrates-timeline.e2e.test.ts index 44e774d7a3..301df35e4b 100644 --- a/packages/server/src/server/daemon-e2e/agent-refresh-rehydrates-timeline.e2e.test.ts +++ b/packages/server/src/server/daemon-e2e/agent-refresh-rehydrates-timeline.e2e.test.ts @@ -113,7 +113,7 @@ describe("daemon E2E - refresh rehydrates timeline from on-disk session", () => client = new DaemonClient({ url: `ws://127.0.0.1:${daemon.port}/ws` }); await client.connect(); await client.fetchAgents({ - subscribe: { subscriptionId: "refresh-rehydrate-test" }, + subscribe: {}, }); const imported = await client.importAgent({ provider: "claude", sessionId, cwd }); diff --git a/packages/server/src/server/daemon-e2e/agent-reload.native.real.e2e.test.ts b/packages/server/src/server/daemon-e2e/agent-reload.native.real.e2e.test.ts index ac9d2f83cb..cbbacde969 100644 --- a/packages/server/src/server/daemon-e2e/agent-reload.native.real.e2e.test.ts +++ b/packages/server/src/server/daemon-e2e/agent-reload.native.real.e2e.test.ts @@ -61,7 +61,7 @@ async function withNativeConversation( }); client = new DaemonClient({ url: `ws://127.0.0.1:${daemon.port}/ws`, appVersion: "0.7.2" }); await client.connect(); - await client.fetchAgents({ subscribe: { subscriptionId: "native-reload-qa" } }); + await client.fetchAgents({ subscribe: {} }); const agent = await client.createAgent({ provider, cwd, ...config }); process.stdout.write(`${provider}: model=${agent.model ?? "default"}\n`); const manager = daemon.daemon.agentManager; diff --git a/packages/server/src/server/daemon-e2e/auto-archive-unrelated-github-merge.real.e2e.test.ts b/packages/server/src/server/daemon-e2e/auto-archive-unrelated-github-merge.real.e2e.test.ts index b3a3d4854f..67d39b3134 100644 --- a/packages/server/src/server/daemon-e2e/auto-archive-unrelated-github-merge.real.e2e.test.ts +++ b/packages/server/src/server/daemon-e2e/auto-archive-unrelated-github-merge.real.e2e.test.ts @@ -126,7 +126,7 @@ githubTest( throw new Error("Failed to register both worktrees"); } await context.client.fetchWorkspaces({ - subscribe: { subscriptionId: "real-unrelated-merge" }, + subscribe: {}, }); writeFileSync(path.join(merged.worktreePath, "feature.txt"), "merged\n"); diff --git a/packages/server/src/server/daemon-e2e/checkout-diff-subscription.e2e.test.ts b/packages/server/src/server/daemon-e2e/checkout-diff-subscription.e2e.test.ts index fac7d01a3f..3af53a9133 100644 --- a/packages/server/src/server/daemon-e2e/checkout-diff-subscription.e2e.test.ts +++ b/packages/server/src/server/daemon-e2e/checkout-diff-subscription.e2e.test.ts @@ -77,12 +77,9 @@ test("pushes file-level checkout diff updates with deterministic path order", as initGitRepo(cwd); commitFile(cwd, "base.txt", "base\n"); - const subscriptionId = "checkout-diff-e2e-subscription"; - const initial = await ctx.client.subscribeCheckoutDiff( - cwd, - { mode: "uncommitted" }, - { subscriptionId }, - ); + const subscription = ctx.client.observeCheckoutDiff(cwd, { mode: "uncommitted" }); + const initial = await subscription.ready; + const subscriptionId = initial.subscriptionId; expect(initial.error).toBeNull(); expect(initial.files).toEqual([]); @@ -98,7 +95,7 @@ test("pushes file-level checkout diff updates with deterministic path order", as expect(update.error).toBeNull(); expect(update.files.map((file) => file.path)).toEqual(["alpha.txt", "zeta.txt"]); - ctx.client.unsubscribeCheckoutDiff(subscriptionId); + await subscription.release(); } finally { rmSync(cwd, { recursive: true, force: true }); } @@ -114,12 +111,9 @@ test("pushes updates when subscribed from a subdirectory and files change outsid const nestedDir = path.join(cwd, "nested", "dir"); mkdirSync(nestedDir, { recursive: true }); - const subscriptionId = "checkout-diff-subdir-e2e-subscription"; - const initial = await ctx.client.subscribeCheckoutDiff( - nestedDir, - { mode: "uncommitted" }, - { subscriptionId }, - ); + const subscription = ctx.client.observeCheckoutDiff(nestedDir, { mode: "uncommitted" }); + const initial = await subscription.ready; + const subscriptionId = initial.subscriptionId; expect(initial.error).toBeNull(); expect(initial.files).toEqual([]); @@ -133,7 +127,7 @@ test("pushes updates when subscribed from a subdirectory and files change outsid expect(update.error).toBeNull(); expect(update.files.some((file) => file.path === "outside-subdir.txt")).toBe(true); - ctx.client.unsubscribeCheckoutDiff(subscriptionId); + await subscription.release(); } finally { rmSync(cwd, { recursive: true, force: true }); } @@ -152,11 +146,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, @@ -167,6 +158,7 @@ test("keeps the socket usable after rejecting an oversized structured diff", asy const status = await ctx.client.getCheckoutStatus(cwd); expect(status).toMatchObject({ cwd, isGit: true }); + await subscription.release(); } finally { rmSync(cwd, { recursive: true, force: true }); } diff --git a/packages/server/src/server/daemon-e2e/claude-autonomous-wake-simple.real.e2e.test.ts b/packages/server/src/server/daemon-e2e/claude-autonomous-wake-simple.real.e2e.test.ts index 77bd164732..0631cfd867 100644 --- a/packages/server/src/server/daemon-e2e/claude-autonomous-wake-simple.real.e2e.test.ts +++ b/packages/server/src/server/daemon-e2e/claude-autonomous-wake-simple.real.e2e.test.ts @@ -45,7 +45,7 @@ describe("daemon E2E (real claude) - autonomous wake simple", () => { try { await client.connect(); await client.fetchAgents({ - subscribe: { subscriptionId: "claude-autonomous-simple-real" }, + subscribe: {}, }); const agent = await client.createAgent({ diff --git a/packages/server/src/server/daemon-e2e/claude-autonomous-wake.real.e2e.test.ts b/packages/server/src/server/daemon-e2e/claude-autonomous-wake.real.e2e.test.ts index fe482d00a5..5899a91bc8 100644 --- a/packages/server/src/server/daemon-e2e/claude-autonomous-wake.real.e2e.test.ts +++ b/packages/server/src/server/daemon-e2e/claude-autonomous-wake.real.e2e.test.ts @@ -451,7 +451,7 @@ describe("daemon E2E (real claude) - autonomous wake from background task", () = try { await client.connect(); await client.fetchAgents({ - subscribe: { subscriptionId: "claude-autonomous-abc-a" }, + subscribe: {}, }); const agent = await client.createAgent({ @@ -524,7 +524,7 @@ describe("daemon E2E (real claude) - autonomous wake from background task", () = try { await client.connect(); await client.fetchAgents({ - subscribe: { subscriptionId: "claude-autonomous-abc-b" }, + subscribe: {}, }); const agent = await client.createAgent({ @@ -597,7 +597,7 @@ describe("daemon E2E (real claude) - autonomous wake from background task", () = try { await client.connect(); await client.fetchAgents({ - subscribe: { subscriptionId: "claude-autonomous-abc-c" }, + subscribe: {}, }); const agent = await client.createAgent({ @@ -663,7 +663,7 @@ describe("daemon E2E (real claude) - autonomous wake from background task", () = try { await client.connect(); await client.fetchAgents({ - subscribe: { subscriptionId: "claude-autonomous-wake-real" }, + subscribe: {}, }); const agent = await client.createAgent({ @@ -721,7 +721,7 @@ describe("daemon E2E (real claude) - autonomous wake from background task", () = try { await client.connect(); await client.fetchAgents({ - subscribe: { subscriptionId: "claude-autonomous-followup-real" }, + subscribe: {}, }); const agent = await client.createAgent({ @@ -767,7 +767,7 @@ describe("daemon E2E (real claude) - autonomous wake from background task", () = try { await client.connect(); await client.fetchAgents({ - subscribe: { subscriptionId: "claude-hang-repro-real" }, + subscribe: {}, }); const agent = await client.createAgent({ @@ -869,7 +869,7 @@ describe("daemon E2E (real claude) - autonomous wake from background task", () = try { await client.connect(); await client.fetchAgents({ - subscribe: { subscriptionId: "claude-background-repro-real" }, + subscribe: {}, }); const agent = await client.createAgent({ @@ -941,7 +941,7 @@ describe("daemon E2E (real claude) - autonomous wake from background task", () = try { await client.connect(); await client.fetchAgents({ - subscribe: { subscriptionId: "claude-autonomous-race-stress-real" }, + subscribe: {}, }); const agent = await client.createAgent({ @@ -1027,7 +1027,7 @@ describe("daemon E2E (real claude) - autonomous wake from background task", () = try { await client.connect(); await client.fetchAgents({ - subscribe: { subscriptionId: "claude-transcript-parity-race" }, + subscribe: {}, }); const agent = await client.createAgent({ diff --git a/packages/server/src/server/daemon-e2e/claude-live-usage.e2e.test.ts b/packages/server/src/server/daemon-e2e/claude-live-usage.e2e.test.ts index 4d4ceb3a57..39b3d7d176 100644 --- a/packages/server/src/server/daemon-e2e/claude-live-usage.e2e.test.ts +++ b/packages/server/src/server/daemon-e2e/claude-live-usage.e2e.test.ts @@ -150,7 +150,7 @@ describe("daemon E2E (claude live usage)", () => { try { await client.connect(); - await client.fetchAgents({ subscribe: { subscriptionId: "claude-live-usage" } }); + await client.fetchAgents({ subscribe: {} }); const agent = await client.createAgent({ provider: "claude", diff --git a/packages/server/src/server/daemon-e2e/claude-rewind.real.e2e.test.ts b/packages/server/src/server/daemon-e2e/claude-rewind.real.e2e.test.ts index c362529e23..23ab49e67d 100644 --- a/packages/server/src/server/daemon-e2e/claude-rewind.real.e2e.test.ts +++ b/packages/server/src/server/daemon-e2e/claude-rewind.real.e2e.test.ts @@ -234,7 +234,7 @@ describe("daemon E2E (real claude) - rewind", () => { }); await client.connect(); - await client.fetchAgents({ subscribe: { subscriptionId: "claude-rewind-real" } }); + await client.fetchAgents({ subscribe: {} }); harness = { client, daemon }; }, 30_000); diff --git a/packages/server/src/server/daemon-e2e/claude-thinking-memory.real.e2e.test.ts b/packages/server/src/server/daemon-e2e/claude-thinking-memory.real.e2e.test.ts index 937f3d0c6a..c1640a7d2d 100644 --- a/packages/server/src/server/daemon-e2e/claude-thinking-memory.real.e2e.test.ts +++ b/packages/server/src/server/daemon-e2e/claude-thinking-memory.real.e2e.test.ts @@ -72,7 +72,7 @@ describe("daemon E2E (real claude) - thinking effort memory", () => { try { await client.connect(); await client.fetchAgents({ - subscribe: { subscriptionId: "claude-thinking-memory-real" }, + subscribe: {}, }); const modelList = await client.listProviderModels("claude", { cwd }); diff --git a/packages/server/src/server/daemon-e2e/codex-auto-review.local.e2e.test.ts b/packages/server/src/server/daemon-e2e/codex-auto-review.local.e2e.test.ts index c0e9bc6154..8237ce9ef7 100644 --- a/packages/server/src/server/daemon-e2e/codex-auto-review.local.e2e.test.ts +++ b/packages/server/src/server/daemon-e2e/codex-auto-review.local.e2e.test.ts @@ -288,7 +288,7 @@ async function runScenario(modeId: "auto" | "auto-review") { try { writeMockCodexConfig(codexHome, mockServer.url); await client.connect(); - await client.fetchAgents({ subscribe: { subscriptionId: `codex-auto-review-${modeId}` } }); + await client.fetchAgents({ subscribe: {} }); const agent = await client.createAgent({ provider: "codex", diff --git a/packages/server/src/server/daemon-e2e/codex-goal-mid-turn.real.e2e.test.ts b/packages/server/src/server/daemon-e2e/codex-goal-mid-turn.real.e2e.test.ts index 9e4722ea26..6f43929f2d 100644 --- a/packages/server/src/server/daemon-e2e/codex-goal-mid-turn.real.e2e.test.ts +++ b/packages/server/src/server/daemon-e2e/codex-goal-mid-turn.real.e2e.test.ts @@ -116,7 +116,7 @@ describe("daemon E2E (real codex) - /goal command", () => { try { await client.connect(); - await client.fetchAgents({ subscribe: { subscriptionId: "primary" } }); + await client.fetchAgents({ subscribe: {} }); const agent = await client.createAgent({ cwd, @@ -170,7 +170,7 @@ describe("daemon E2E (real codex) - /goal command", () => { try { await client.connect(); - await client.fetchAgents({ subscribe: { subscriptionId: "primary" } }); + await client.fetchAgents({ subscribe: {} }); const agent = await client.createAgent({ cwd, @@ -247,7 +247,7 @@ describe("daemon E2E (real codex) - /goal command", () => { try { await client.connect(); - await client.fetchAgents({ subscribe: { subscriptionId: "primary" } }); + await client.fetchAgents({ subscribe: {} }); const agent = await client.createAgent({ cwd, diff --git a/packages/server/src/server/daemon-e2e/codex-rewind.real.e2e.test.ts b/packages/server/src/server/daemon-e2e/codex-rewind.real.e2e.test.ts index e61346a6a2..c55322de0e 100644 --- a/packages/server/src/server/daemon-e2e/codex-rewind.real.e2e.test.ts +++ b/packages/server/src/server/daemon-e2e/codex-rewind.real.e2e.test.ts @@ -164,7 +164,7 @@ describe("daemon E2E (real codex) - rewind", () => { }); await client.connect(); - await client.fetchAgents({ subscribe: { subscriptionId: "codex-rewind-real" } }); + await client.fetchAgents({ subscribe: {} }); harness = { client, daemon }; }, 30_000); diff --git a/packages/server/src/server/daemon-e2e/empty-project-persists.e2e.test.ts b/packages/server/src/server/daemon-e2e/empty-project-persists.e2e.test.ts index 1c5fed39ed..6b2e0bef4b 100644 --- a/packages/server/src/server/daemon-e2e/empty-project-persists.e2e.test.ts +++ b/packages/server/src/server/daemon-e2e/empty-project-persists.e2e.test.ts @@ -108,7 +108,7 @@ test("archiving the last workspace leaves the project parent with no workspaces" const client = new DaemonClient({ url: `ws://127.0.0.1:${daemon.port}/ws` }); cleanupClients.add(client); await client.connect(); - await client.fetchAgents({ subscribe: { subscriptionId: "empty-project-agents" } }); + await client.fetchAgents({ subscribe: {} }); const created = await client.createWorkspace({ source: { kind: "directory", path: repoRoot } }); expect(created.error).toBeNull(); diff --git a/packages/server/src/server/daemon-e2e/live-preferences.e2e.test.ts b/packages/server/src/server/daemon-e2e/live-preferences.e2e.test.ts index 25c74c62a0..9eb1ff929a 100644 --- a/packages/server/src/server/daemon-e2e/live-preferences.e2e.test.ts +++ b/packages/server/src/server/daemon-e2e/live-preferences.e2e.test.ts @@ -96,7 +96,7 @@ beforeEach(async () => { unsubscribe = ctx.client.subscribeRawMessages((message) => { messages.push(message); }); - await ctx.client.fetchAgents({ subscribe: { subscriptionId: "live-preferences" } }); + await ctx.client.fetchAgents({ subscribe: {} }); }); afterEach(async () => { diff --git a/packages/server/src/server/daemon-e2e/mode-switch-propagation.e2e.test.ts b/packages/server/src/server/daemon-e2e/mode-switch-propagation.e2e.test.ts index d1acba0e55..81d60901a7 100644 --- a/packages/server/src/server/daemon-e2e/mode-switch-propagation.e2e.test.ts +++ b/packages/server/src/server/daemon-e2e/mode-switch-propagation.e2e.test.ts @@ -115,7 +115,7 @@ describe("mode-switch update propagation", () => { await client2.connect(); const bootstrapResult = await client2.fetchAgents({ - subscribe: { subscriptionId: "bootstrap-test" }, + subscribe: {}, }); const found = bootstrapResult.entries.find((e) => e.agent.id === agent.id); diff --git a/packages/server/src/server/daemon-e2e/model-resolution-on-init.real.e2e.test.ts b/packages/server/src/server/daemon-e2e/model-resolution-on-init.real.e2e.test.ts index 71e9f9ed73..52bee9f79f 100644 --- a/packages/server/src/server/daemon-e2e/model-resolution-on-init.real.e2e.test.ts +++ b/packages/server/src/server/daemon-e2e/model-resolution-on-init.real.e2e.test.ts @@ -41,7 +41,7 @@ describe("daemon E2E (real claude) - model resolution on init", () => { try { await client.connect(); await client.fetchAgents({ - subscribe: { subscriptionId: "model-init-test" }, + subscribe: {}, }); const modelsResult = await client.listProviderModels("claude", { cwd }); diff --git a/packages/server/src/server/daemon-e2e/model-runtime-reconcile-claude.real.e2e.test.ts b/packages/server/src/server/daemon-e2e/model-runtime-reconcile-claude.real.e2e.test.ts index 83fe12e1e3..fba40a681c 100644 --- a/packages/server/src/server/daemon-e2e/model-runtime-reconcile-claude.real.e2e.test.ts +++ b/packages/server/src/server/daemon-e2e/model-runtime-reconcile-claude.real.e2e.test.ts @@ -37,7 +37,7 @@ describe("daemon E2E (real claude) - runtime model reconciliation", () => { try { await client.connect(); await client.fetchAgents({ - subscribe: { subscriptionId: "claude-runtime-model-reconcile" }, + subscribe: {}, }); const modelsResult = await client.listProviderModels("claude", { cwd }); diff --git a/packages/server/src/server/daemon-e2e/omp.real.e2e.test.ts b/packages/server/src/server/daemon-e2e/omp.real.e2e.test.ts index a0634e2bd7..f336f325bf 100644 --- a/packages/server/src/server/daemon-e2e/omp.real.e2e.test.ts +++ b/packages/server/src/server/daemon-e2e/omp.real.e2e.test.ts @@ -1,5 +1,4 @@ import { execFile } from "node:child_process"; -import { randomUUID } from "node:crypto"; import { mkdtempSync, rmSync } from "node:fs"; import { tmpdir } from "node:os"; import path from "node:path"; @@ -69,7 +68,7 @@ async function createHarness(): Promise { }); await client.connect(); await client.fetchAgents({ - subscribe: { subscriptionId: `omp-real-${randomUUID()}` }, + subscribe: {}, }); return { daemon, client, cwd, paseoHomeRoot, staticDir }; } diff --git a/packages/server/src/server/daemon-e2e/open-project-missing-directory.e2e.test.ts b/packages/server/src/server/daemon-e2e/open-project-missing-directory.e2e.test.ts index ebd93a4ff5..8989f9ebac 100644 --- a/packages/server/src/server/daemon-e2e/open-project-missing-directory.e2e.test.ts +++ b/packages/server/src/server/daemon-e2e/open-project-missing-directory.e2e.test.ts @@ -48,7 +48,7 @@ test("openProject on a nonexistent directory does not broadcast an upsert that i const client = new DaemonClient({ url: `ws://127.0.0.1:${daemon.port}/ws` }); cleanupClients.add(client); await client.connect(); - await client.fetchAgents({ subscribe: { subscriptionId: "missing-dir-agents" } }); + await client.fetchAgents({ subscribe: {} }); const workspaceEvents: Array<{ kind: "upsert" | "remove"; workspaceId: string }> = []; client.subscribe((event: DaemonEvent) => { @@ -57,7 +57,7 @@ test("openProject on a nonexistent directory does not broadcast an upsert that i } workspaceEvents.push({ kind: event.payload.kind, workspaceId: event.workspaceId }); }); - await client.fetchWorkspaces({ subscribe: { subscriptionId: "missing-dir-workspaces" } }); + await client.fetchWorkspaces({ subscribe: {} }); const tempParent = await mkdtemp(path.join(os.tmpdir(), "paseo-open-project-")); cleanupPaths.add(tempParent); @@ -77,8 +77,8 @@ test("openProject expands tilde before creating the workspace", async () => { const client = new DaemonClient({ url: `ws://127.0.0.1:${daemon.port}/ws` }); cleanupClients.add(client); await client.connect(); - await client.fetchAgents({ subscribe: { subscriptionId: "tilde-project-agents" } }); - await client.fetchWorkspaces({ subscribe: { subscriptionId: "tilde-project-workspaces" } }); + await client.fetchAgents({ subscribe: {} }); + await client.fetchWorkspaces({ subscribe: {} }); const home = process.env.HOME || os.homedir(); const workspacePath = await mkdtemp(path.join(home, ".paseo-open-project-")); diff --git a/packages/server/src/server/daemon-e2e/open-project-worktree-reclassification.e2e.test.ts b/packages/server/src/server/daemon-e2e/open-project-worktree-reclassification.e2e.test.ts index ab49ecf9d3..e463d2936a 100644 --- a/packages/server/src/server/daemon-e2e/open-project-worktree-reclassification.e2e.test.ts +++ b/packages/server/src/server/daemon-e2e/open-project-worktree-reclassification.e2e.test.ts @@ -106,7 +106,7 @@ test("openProject preserves a worktree's exact-root project without rehoming it" const client = new DaemonClient({ url: `ws://127.0.0.1:${daemon.port}/ws` }); cleanupClients.add(client); await client.connect(); - await client.fetchAgents({ subscribe: { subscriptionId: "worktree-reclassification" } }); + await client.fetchAgents({ subscribe: {} }); const response = await client.openProject(worktreeRoot); const persistedProjects = await readRegistry(projectsPath); diff --git a/packages/server/src/server/daemon-e2e/opencode-custom-agents.real.e2e.test.ts b/packages/server/src/server/daemon-e2e/opencode-custom-agents.real.e2e.test.ts index 048a8dd0c6..93e227ecdd 100644 --- a/packages/server/src/server/daemon-e2e/opencode-custom-agents.real.e2e.test.ts +++ b/packages/server/src/server/daemon-e2e/opencode-custom-agents.real.e2e.test.ts @@ -23,7 +23,7 @@ async function createHarness(): Promise<{ }); const client = new DaemonClient({ url: `ws://127.0.0.1:${daemon.port}/ws` }); await client.connect(); - await client.fetchAgents({ subscribe: { subscriptionId: "opencode-custom-agents" } }); + await client.fetchAgents({ subscribe: {} }); return { client, daemon }; } diff --git a/packages/server/src/server/daemon-e2e/opencode-draft-features.real.e2e.test.ts b/packages/server/src/server/daemon-e2e/opencode-draft-features.real.e2e.test.ts index d66ca028eb..a06880d612 100644 --- a/packages/server/src/server/daemon-e2e/opencode-draft-features.real.e2e.test.ts +++ b/packages/server/src/server/daemon-e2e/opencode-draft-features.real.e2e.test.ts @@ -1,4 +1,3 @@ -import { randomUUID } from "node:crypto"; import { mkdtempSync, realpathSync, rmSync } from "node:fs"; import { tmpdir } from "node:os"; import path from "node:path"; @@ -30,7 +29,7 @@ async function withConnectedOpenCodeDaemon( try { await client.connect(); await client.fetchAgents({ - subscribe: { subscriptionId: `opencode-draft-features-${randomUUID()}` }, + subscribe: {}, }); await run({ client }); } finally { diff --git a/packages/server/src/server/daemon-e2e/opencode-import-persistence.real.e2e.test.ts b/packages/server/src/server/daemon-e2e/opencode-import-persistence.real.e2e.test.ts index 3d1aec0a96..d518dac227 100644 --- a/packages/server/src/server/daemon-e2e/opencode-import-persistence.real.e2e.test.ts +++ b/packages/server/src/server/daemon-e2e/opencode-import-persistence.real.e2e.test.ts @@ -36,7 +36,7 @@ async function withConnectedOpenCodeDaemon( try { await client.connect(); await client.fetchAgents({ - subscribe: { subscriptionId: `opencode-import-persistence-${randomUUID()}` }, + subscribe: {}, }); await run({ client }); } finally { diff --git a/packages/server/src/server/daemon-e2e/opencode-initial-prompt-wait.real.e2e.test.ts b/packages/server/src/server/daemon-e2e/opencode-initial-prompt-wait.real.e2e.test.ts index 9fa2f13cf6..e03c66bca0 100644 --- a/packages/server/src/server/daemon-e2e/opencode-initial-prompt-wait.real.e2e.test.ts +++ b/packages/server/src/server/daemon-e2e/opencode-initial-prompt-wait.real.e2e.test.ts @@ -29,7 +29,7 @@ async function createHarness(): Promise<{ }); const client = new DaemonClient({ url: `ws://127.0.0.1:${daemon.port}/ws` }); await client.connect(); - await client.fetchAgents({ subscribe: { subscriptionId: "opencode-init-prompt" } }); + await client.fetchAgents({ subscribe: {} }); return { client, daemon }; } diff --git a/packages/server/src/server/daemon-e2e/opencode-invalid-mode.real.e2e.test.ts b/packages/server/src/server/daemon-e2e/opencode-invalid-mode.real.e2e.test.ts index 8235937f64..0291c5e338 100644 --- a/packages/server/src/server/daemon-e2e/opencode-invalid-mode.real.e2e.test.ts +++ b/packages/server/src/server/daemon-e2e/opencode-invalid-mode.real.e2e.test.ts @@ -23,7 +23,7 @@ async function createHarness(): Promise<{ }); const client = new DaemonClient({ url: `ws://127.0.0.1:${daemon.port}/ws` }); await client.connect(); - await client.fetchAgents({ subscribe: { subscriptionId: "opencode-invalid-mode" } }); + await client.fetchAgents({ subscribe: {} }); return { client, daemon }; } diff --git a/packages/server/src/server/daemon-e2e/opencode-invalid-model.real.e2e.test.ts b/packages/server/src/server/daemon-e2e/opencode-invalid-model.real.e2e.test.ts index 455d099088..8cdc409c7f 100644 --- a/packages/server/src/server/daemon-e2e/opencode-invalid-model.real.e2e.test.ts +++ b/packages/server/src/server/daemon-e2e/opencode-invalid-model.real.e2e.test.ts @@ -23,7 +23,7 @@ async function createHarness(): Promise<{ }); const client = new DaemonClient({ url: `ws://127.0.0.1:${daemon.port}/ws` }); await client.connect(); - await client.fetchAgents({ subscribe: { subscriptionId: "opencode-invalid-model" } }); + await client.fetchAgents({ subscribe: {} }); return { client, daemon }; } diff --git a/packages/server/src/server/daemon-e2e/opencode-omo-real-runtime.ts b/packages/server/src/server/daemon-e2e/opencode-omo-real-runtime.ts index 923431f9bf..70296864ed 100644 --- a/packages/server/src/server/daemon-e2e/opencode-omo-real-runtime.ts +++ b/packages/server/src/server/daemon-e2e/opencode-omo-real-runtime.ts @@ -187,7 +187,7 @@ export async function createOpenCodeOmoRealRuntime(): Promise { }); await client.connect(); - await client.fetchAgents({ subscribe: { subscriptionId: "opencode-rewind-real" } }); + await client.fetchAgents({ subscribe: {} }); harness = { client, daemon }; }, 30_000); diff --git a/packages/server/src/server/daemon-e2e/opencode-send-interrupt.real.e2e.test.ts b/packages/server/src/server/daemon-e2e/opencode-send-interrupt.real.e2e.test.ts index bdf5012f67..6fafc0f40c 100644 --- a/packages/server/src/server/daemon-e2e/opencode-send-interrupt.real.e2e.test.ts +++ b/packages/server/src/server/daemon-e2e/opencode-send-interrupt.real.e2e.test.ts @@ -256,7 +256,7 @@ async function createHarness(): Promise<{ }); const client = new DaemonClient({ url: `ws://127.0.0.1:${daemon.port}/ws` }); await client.connect(); - await client.fetchAgents({ subscribe: { subscriptionId: "opencode-send-interrupt-real" } }); + await client.fetchAgents({ subscribe: {} }); return { client, daemon }; } diff --git a/packages/server/src/server/daemon-e2e/opencode-slow-plugin-readiness.real.e2e.test.ts b/packages/server/src/server/daemon-e2e/opencode-slow-plugin-readiness.real.e2e.test.ts index 3645b018f1..ebb603e0ee 100644 --- a/packages/server/src/server/daemon-e2e/opencode-slow-plugin-readiness.real.e2e.test.ts +++ b/packages/server/src/server/daemon-e2e/opencode-slow-plugin-readiness.real.e2e.test.ts @@ -66,7 +66,7 @@ describe("daemon E2E (real opencode) - slow plugin readiness", () => { }); const client = new DaemonClient({ url: `ws://127.0.0.1:${daemon.port}/ws` }); await client.connect(); - await client.fetchAgents({ subscribe: { subscriptionId: "opencode-slow-plugin" } }); + await client.fetchAgents({ subscribe: {} }); try { const agent = await client.createAgent({ diff --git a/packages/server/src/server/daemon-e2e/permissions-codex.e2e.test.ts b/packages/server/src/server/daemon-e2e/permissions-codex.e2e.test.ts index a3a84a17ea..16ba0da1f5 100644 --- a/packages/server/src/server/daemon-e2e/permissions-codex.e2e.test.ts +++ b/packages/server/src/server/daemon-e2e/permissions-codex.e2e.test.ts @@ -42,8 +42,9 @@ describe("permission flow: Codex", () => { thinkingOptionId: CODEX_TEST_THINKING_OPTION_ID, cwd, title: "Codex Permission Test", - modeId: "read-only", + modeId: "always-ask", }); + await ctx.client.subscribeAgentTimeline(agent.id, () => {}).ready; expect(agent.id).toBeTruthy(); expect(agent.status).toBe("idle"); @@ -81,17 +82,18 @@ describe("permission flow: Codex", () => { // Verify permission_resolved event was received const queue = messages; - const hasPermissionResolved = queue.some((m) => { - if (m.type === "agent_stream" && m.payload.agentId === agent.id) { - return ( - m.payload.event.type === "permission_resolved" && - m.payload.event.requestId === permission.id && - m.payload.event.resolution.behavior === "allow" - ); - } - return false; - }); - expect(hasPermissionResolved).toBe(true); + const hasPermissionResolved = () => + queue.some((m) => { + if (m.type === "agent_stream" && m.payload.agentId === agent.id) { + return ( + m.payload.event.type === "permission_resolved" && + m.payload.event.requestId === permission.id && + m.payload.event.resolution.behavior === "allow" + ); + } + return false; + }); + await expect.poll(hasPermissionResolved).toBe(true); rmSync(cwd, { recursive: true, force: true }); }, 30_000); @@ -107,8 +109,9 @@ describe("permission flow: Codex", () => { thinkingOptionId: CODEX_TEST_THINKING_OPTION_ID, cwd, title: "Codex Permission Deny Test", - modeId: "read-only", + modeId: "always-ask", }); + await ctx.client.subscribeAgentTimeline(agent.id, () => {}).ready; expect(agent.id).toBeTruthy(); @@ -145,17 +148,18 @@ describe("permission flow: Codex", () => { // Verify permission_resolved event was received with deny const queue = messages; - const hasPermissionDenied = queue.some((m) => { - if (m.type === "agent_stream" && m.payload.agentId === agent.id) { - return ( - m.payload.event.type === "permission_resolved" && - m.payload.event.requestId === permission.id && - m.payload.event.resolution.behavior === "deny" - ); - } - return false; - }); - expect(hasPermissionDenied).toBe(true); + const hasPermissionDenied = () => + queue.some((m) => { + if (m.type === "agent_stream" && m.payload.agentId === agent.id) { + return ( + m.payload.event.type === "permission_resolved" && + m.payload.event.requestId === permission.id && + m.payload.event.resolution.behavior === "deny" + ); + } + return false; + }); + await expect.poll(hasPermissionDenied).toBe(true); rmSync(cwd, { recursive: true, force: true }); }, 30_000); @@ -172,6 +176,7 @@ describe("permission flow: Codex", () => { title: "Codex Interrupt Test", modeId: "full-access", }); + await ctx.client.subscribeAgentTimeline(agent.id, () => {}).ready; expect(agent.id).toBeTruthy(); expect(agent.currentModeId).toBe("full-access"); diff --git a/packages/server/src/server/daemon-e2e/persistence.e2e.test.ts b/packages/server/src/server/daemon-e2e/persistence.e2e.test.ts index 9e9e185895..78161f842a 100644 --- a/packages/server/src/server/daemon-e2e/persistence.e2e.test.ts +++ b/packages/server/src/server/daemon-e2e/persistence.e2e.test.ts @@ -61,6 +61,7 @@ describe("daemon E2E - persistence", () => { const resumed = await ctx.client.resumeAgent(handle); expect(resumed.provider).toBe("codex"); expect(resumed.cwd).toBe(cwd); + await ctx.client.subscribeAgentTimeline(resumed.id, () => {}).ready; messages.length = 0; await ctx.client.sendMessage(resumed.id, "What did I ask you to say earlier?"); diff --git a/packages/server/src/server/daemon-e2e/pi-rewind.real.e2e.test.ts b/packages/server/src/server/daemon-e2e/pi-rewind.real.e2e.test.ts index e013328a12..51efc705cf 100644 --- a/packages/server/src/server/daemon-e2e/pi-rewind.real.e2e.test.ts +++ b/packages/server/src/server/daemon-e2e/pi-rewind.real.e2e.test.ts @@ -122,7 +122,7 @@ describe("daemon E2E (real pi) - rewind", () => { }); await client.connect(); - await client.fetchAgents({ subscribe: { subscriptionId: "pi-rewind-real" } }); + await client.fetchAgents({ subscribe: {} }); harness = { client, daemon }; }, 30_000); diff --git a/packages/server/src/server/daemon-e2e/pi.real.e2e.test.ts b/packages/server/src/server/daemon-e2e/pi.real.e2e.test.ts index 5a0510c1ea..984aa2fdcc 100644 --- a/packages/server/src/server/daemon-e2e/pi.real.e2e.test.ts +++ b/packages/server/src/server/daemon-e2e/pi.real.e2e.test.ts @@ -1,7 +1,6 @@ import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import path from "node:path"; -import { randomUUID } from "node:crypto"; import { beforeAll, beforeEach, expect, test } from "vitest"; import pino from "pino"; @@ -118,7 +117,7 @@ async function withConnectedPiDaemon( try { await client.connect(); await client.fetchAgents({ - subscribe: { subscriptionId: `pi-real-${randomUUID()}` }, + subscribe: {}, }); await run({ client, daemon }); } finally { diff --git a/packages/server/src/server/daemon-e2e/project-becomes-git.e2e.test.ts b/packages/server/src/server/daemon-e2e/project-becomes-git.e2e.test.ts index e7843f78ae..a4db7ce7bf 100644 --- a/packages/server/src/server/daemon-e2e/project-becomes-git.e2e.test.ts +++ b/packages/server/src/server/daemon-e2e/project-becomes-git.e2e.test.ts @@ -62,7 +62,7 @@ test("an empty project becomes Git without changing its identity or creating a w const client = new DaemonClient({ url: `ws://127.0.0.1:${daemon.port}/ws` }); cleanupClients.add(client); await client.connect(); - await client.fetchAgents({ subscribe: { subscriptionId: "project-becomes-git" } }); + await client.fetchAgents({ subscribe: {} }); const added = await client.addProject(projectRoot); diff --git a/packages/server/src/server/daemon-e2e/rewind-user-message-dedupe-claude.real.e2e.test.ts b/packages/server/src/server/daemon-e2e/rewind-user-message-dedupe-claude.real.e2e.test.ts index aaea430b7f..bafb61e2b4 100644 --- a/packages/server/src/server/daemon-e2e/rewind-user-message-dedupe-claude.real.e2e.test.ts +++ b/packages/server/src/server/daemon-e2e/rewind-user-message-dedupe-claude.real.e2e.test.ts @@ -41,7 +41,7 @@ describe("daemon E2E (real claude) - rewind user message dedupe", () => { try { await client.connect(); await client.fetchAgents({ - subscribe: { subscriptionId: "rewind-user-message-dedupe" }, + subscribe: {}, }); const agent = await client.createAgent({ diff --git a/packages/server/src/server/daemon-e2e/send-during-tool-call-claude.real.e2e.test.ts b/packages/server/src/server/daemon-e2e/send-during-tool-call-claude.real.e2e.test.ts index 6638c425a0..2471ff8c5a 100644 --- a/packages/server/src/server/daemon-e2e/send-during-tool-call-claude.real.e2e.test.ts +++ b/packages/server/src/server/daemon-e2e/send-during-tool-call-claude.real.e2e.test.ts @@ -345,7 +345,7 @@ describe("daemon E2E (real claude) - send message during tool call", () => { await within( "subscribe Claude steering test client", 15_000, - client.fetchAgents({ subscribe: { subscriptionId: "steer" } }), + client.fetchAgents({ subscribe: {} }), ); const agent = await within( "create Claude steering test agent", @@ -518,7 +518,7 @@ describe("daemon E2E (real claude) - send message during tool call", () => { await within( "subscribe queued-steer Stop client", 15_000, - client.fetchAgents({ subscribe: { subscriptionId: "queued-steer-stop" } }), + client.fetchAgents({ subscribe: {} }), ); const agent = await within( "create queued-steer Stop agent", @@ -587,7 +587,7 @@ describe("daemon E2E (real claude) - send message during tool call", () => { try { await client.connect(); - await client.fetchAgents({ subscribe: { subscriptionId: "primary" } }); + await client.fetchAgents({ subscribe: {} }); const agent = await client.createAgent({ cwd, diff --git a/packages/server/src/server/daemon-e2e/send-during-tool-call-codex.real.e2e.test.ts b/packages/server/src/server/daemon-e2e/send-during-tool-call-codex.real.e2e.test.ts index 73ccc01988..39cc2554cc 100644 --- a/packages/server/src/server/daemon-e2e/send-during-tool-call-codex.real.e2e.test.ts +++ b/packages/server/src/server/daemon-e2e/send-during-tool-call-codex.real.e2e.test.ts @@ -254,11 +254,7 @@ describe("daemon E2E (real codex) - send message during tool call", () => { const { client, cwd } = resources; if (!client) throw new Error("Codex steering test client was not created"); await within("connect steering test client", 15_000, client.connect()); - await within( - "subscribe steering test client", - 15_000, - client.fetchAgents({ subscribe: { subscriptionId: "steer" } }), - ); + await within("subscribe steering test client", 15_000, client.fetchAgents({ subscribe: {} })); const agent = await within( "create Codex steering test agent", 30_000, @@ -399,7 +395,7 @@ describe("daemon E2E (real codex) - send message during tool call", () => { try { await client.connect(); - await client.fetchAgents({ subscribe: { subscriptionId: "primary" } }); + await client.fetchAgents({ subscribe: {} }); const agent = await client.createAgent({ cwd, @@ -478,7 +474,7 @@ describe("daemon E2E (real codex) - send message during tool call", () => { try { await client.connect(); - await client.fetchAgents({ subscribe: { subscriptionId: "primary" } }); + await client.fetchAgents({ subscribe: {} }); const agent = await client.createAgent({ cwd, diff --git a/packages/server/src/server/daemon-e2e/send-while-running-stuck-claude.real.e2e.test.ts b/packages/server/src/server/daemon-e2e/send-while-running-stuck-claude.real.e2e.test.ts index f9f6eb6aff..310b7b5cd4 100644 --- a/packages/server/src/server/daemon-e2e/send-while-running-stuck-claude.real.e2e.test.ts +++ b/packages/server/src/server/daemon-e2e/send-while-running-stuck-claude.real.e2e.test.ts @@ -44,8 +44,8 @@ describe("daemon E2E (real claude) - send while running recovery", () => { try { await primary.connect(); await secondary.connect(); - await primary.fetchAgents({ subscribe: { subscriptionId: "primary" } }); - await secondary.fetchAgents({ subscribe: { subscriptionId: "secondary" } }); + await primary.fetchAgents({ subscribe: {} }); + await secondary.fetchAgents({ subscribe: {} }); const agent = await primary.createAgent({ cwd, @@ -104,7 +104,7 @@ describe("daemon E2E (real claude) - send while running recovery", () => { }); const initial = await reconnected.fetchAgents({ - subscribe: { subscriptionId: "reconnected" }, + subscribe: {}, }); const hydratedSnapshot = initial.entries.find( (candidate) => candidate.agent.id === agent.id, diff --git a/packages/server/src/server/daemon-e2e/send-while-running-stuck.real.e2e.test.ts b/packages/server/src/server/daemon-e2e/send-while-running-stuck.real.e2e.test.ts index aa75a77ee2..4f179998ab 100644 --- a/packages/server/src/server/daemon-e2e/send-while-running-stuck.real.e2e.test.ts +++ b/packages/server/src/server/daemon-e2e/send-while-running-stuck.real.e2e.test.ts @@ -44,8 +44,8 @@ describe("daemon E2E (real codex) - send while running recovery", () => { try { await primary.connect(); await secondary.connect(); - await primary.fetchAgents({ subscribe: { subscriptionId: "primary" } }); - await secondary.fetchAgents({ subscribe: { subscriptionId: "secondary" } }); + await primary.fetchAgents({ subscribe: {} }); + await secondary.fetchAgents({ subscribe: {} }); const agent = await primary.createAgent({ cwd, @@ -101,7 +101,7 @@ describe("daemon E2E (real codex) - send while running recovery", () => { }); const initial = await reconnected.fetchAgents({ - subscribe: { subscriptionId: "reconnected" }, + subscribe: {}, }); const hydratedSnapshot = initial.entries.find( (candidate) => candidate.agent.id === agent.id, diff --git a/packages/server/src/server/daemon-e2e/streaming.e2e.test.ts b/packages/server/src/server/daemon-e2e/streaming.e2e.test.ts index df1a522018..3ef308701d 100644 --- a/packages/server/src/server/daemon-e2e/streaming.e2e.test.ts +++ b/packages/server/src/server/daemon-e2e/streaming.e2e.test.ts @@ -51,6 +51,7 @@ describe("daemon E2E - streaming", () => { title: "Streaming concat test", modeId: "bypassPermissions", }); + await ctx.client.subscribeAgentTimeline(agent.id, () => {}).ready; messages.length = 0; await ctx.client.sendMessage( @@ -78,6 +79,7 @@ describe("daemon E2E - streaming", () => { title: "Overlap stream test", modeId: "full-access", }); + await ctx.client.subscribeAgentTimeline(agent.id, () => {}).ready; messages.length = 0; await ctx.client.sendMessage(agent.id, "Run: sleep 30"); diff --git a/packages/server/src/server/daemon-e2e/terminal-byte-headless-parity.e2e.test.ts b/packages/server/src/server/daemon-e2e/terminal-byte-headless-parity.e2e.test.ts index f21f5f9b36..2b789788ad 100644 --- a/packages/server/src/server/daemon-e2e/terminal-byte-headless-parity.e2e.test.ts +++ b/packages/server/src/server/daemon-e2e/terminal-byte-headless-parity.e2e.test.ts @@ -8,7 +8,7 @@ import { expect, test } from "vitest"; import type { TerminalCell, TerminalState } from "@getpaseo/protocol/messages"; import { renderTerminalSnapshotToAnsi } from "@getpaseo/protocol/terminal-snapshot"; -import type { TerminalStreamEvent } from "@getpaseo/client/internal/terminal-stream-router"; +import type { TerminalStreamEvent } from "@getpaseo/client/internal/daemon-client"; import { DaemonClient } from "../test-utils/daemon-client.js"; import { createTestPaseoDaemon } from "../test-utils/paseo-daemon.js"; @@ -48,7 +48,7 @@ test("byte-stream headless terminal matches daemon state after high-output attac }); const initialSnapshot = waitForTerminalStreamEvent(client, terminal.id, "snapshot"); - await subscribeTerminal(client, terminal.id); + const liveSubscription = await subscribeTerminal(client, terminal.id); await initialSnapshot; client.sendTerminalInput(terminal.id, { type: "resize", ...BYTE_TEST_SIZE }); liveHeadless.resize(BYTE_TEST_SIZE); @@ -70,7 +70,7 @@ test("byte-stream headless terminal matches daemon state after high-output attac expect(liveHeadless.cursor()).toEqual(daemonSnapshot.cursor); expect(liveHeadless.visibleLines()).toEqual(capturedViewport.lines); - client.unsubscribeTerminal(terminal.id); + await liveSubscription.release(); stopLive(); const restoreHeadless = new ClientHeadlessTerminal(BYTE_TEST_SIZE); @@ -179,11 +179,12 @@ async function subscribeTerminal( client: DaemonClient, terminalId: string, options?: Parameters[1], -): Promise { +) { const response = await client.subscribeTerminal(terminalId, options); if (response.error) { throw new Error(response.error); } + return response.subscription; } async function readTerminalSnapshot(input: { diff --git a/packages/server/src/server/daemon-e2e/terminal.e2e.test.ts b/packages/server/src/server/daemon-e2e/terminal.e2e.test.ts index 6d22b4695e..633bd3bc1c 100644 --- a/packages/server/src/server/daemon-e2e/terminal.e2e.test.ts +++ b/packages/server/src/server/daemon-e2e/terminal.e2e.test.ts @@ -1048,6 +1048,9 @@ test("one client can stream two terminals concurrently", async () => { expect(firstSubscribe.error).toBeNull(); expect(secondSubscribe.error).toBeNull(); + if (firstSubscribe.error !== null || secondSubscribe.error !== null) { + throw new Error("Expected both terminal subscriptions to succeed"); + } expect(firstSubscribe.slot).not.toBe(secondSubscribe.slot); const firstOutput = waitForTerminalOutput(ctx.client, firstTerminalId, (text) => @@ -1077,8 +1080,8 @@ test("disconnect and reconnect both receive the current snapshot", async () => { const created = await createTerminalInWorkspace(ctx.client, { cwd }); const terminalId = created.terminal!.id; - await ctx.client.subscribeTerminal(terminalId); - ctx.client.unsubscribeTerminal(terminalId); + const observation = await ctx.client.subscribeTerminal(terminalId); + await observation.subscription.release(); ctx.client.sendTerminalInput(terminalId, { type: "input", diff --git a/packages/server/src/server/daemon-e2e/timeline-reconnect-contract.e2e.test.ts b/packages/server/src/server/daemon-e2e/timeline-reconnect-contract.e2e.test.ts index 8a794b54b5..510fb9db5e 100644 --- a/packages/server/src/server/daemon-e2e/timeline-reconnect-contract.e2e.test.ts +++ b/packages/server/src/server/daemon-e2e/timeline-reconnect-contract.e2e.test.ts @@ -110,7 +110,7 @@ test("reconnect catches up committed rows without replaying a provisional seed", try { await reconnectClient.fetchAgents({ - subscribe: { subscriptionId: "timeline-reconnect-a" }, + subscribe: {}, }); expect( @@ -197,7 +197,7 @@ test("reconnect with no new committed rows resumes from future live provisional try { await reconnectClient.fetchAgents({ - subscribe: { subscriptionId: "timeline-reconnect-b" }, + subscribe: {}, }); expect( diff --git a/packages/server/src/server/daemon-e2e/ui-action-stress.real.e2e.test.ts b/packages/server/src/server/daemon-e2e/ui-action-stress.real.e2e.test.ts index 925415a96e..7643138e0e 100644 --- a/packages/server/src/server/daemon-e2e/ui-action-stress.real.e2e.test.ts +++ b/packages/server/src/server/daemon-e2e/ui-action-stress.real.e2e.test.ts @@ -466,7 +466,7 @@ describe.each(realProviders)("daemon E2E (real %s) - UI action stress", (provide try { await client.connect(); await client.fetchAgents({ - subscribe: { subscriptionId: `ui-stress-normal-${provider}` }, + subscribe: {}, }); const agent = await client.createAgent({ cwd, @@ -504,7 +504,7 @@ describe.each(realProviders)("daemon E2E (real %s) - UI action stress", (provide try { await client.connect(); await client.fetchAgents({ - subscribe: { subscriptionId: `ui-stress-overlap-${provider}` }, + subscribe: {}, }); for (const scenario of scenarios) { diff --git a/packages/server/src/server/daemon-e2e/user-message-contract.real.e2e.test.ts b/packages/server/src/server/daemon-e2e/user-message-contract.real.e2e.test.ts index 41b40fed57..9e2d96f699 100644 --- a/packages/server/src/server/daemon-e2e/user-message-contract.real.e2e.test.ts +++ b/packages/server/src/server/daemon-e2e/user-message-contract.real.e2e.test.ts @@ -103,7 +103,7 @@ describe.each(CONTRACT_CASES)("daemon E2E (real $provider) - user_message contra appVersion: "0.1.80", }); await client.connect(); - await client.fetchAgents({ subscribe: { subscriptionId: `${entry.provider}-user-contract` } }); + await client.fetchAgents({ subscribe: {} }); }, 30_000); afterEach(async () => { diff --git a/packages/server/src/server/daemon-e2e/wait-for-idle.e2e.test.ts b/packages/server/src/server/daemon-e2e/wait-for-idle.e2e.test.ts index e2220a4109..ad2a62c804 100644 --- a/packages/server/src/server/daemon-e2e/wait-for-idle.e2e.test.ts +++ b/packages/server/src/server/daemon-e2e/wait-for-idle.e2e.test.ts @@ -130,7 +130,7 @@ test("waitForFinish resolves first idle edge even if a new run starts immediatel const secondary = new DaemonClient({ url: `ws://127.0.0.1:${ctx.daemon.port}/ws` }); await secondary.connect(); - await secondary.fetchAgents({ subscribe: { subscriptionId: "wait-for-idle-secondary" } }); + await secondary.fetchAgents({ subscribe: {} }); const agent = await ctx.client.createAgent({ provider: "codex", @@ -193,7 +193,7 @@ test("waitForFinish stays blocked when sendMessage transactionally replaces a ru try { await secondary.connect(); - await secondary.fetchAgents({ subscribe: { subscriptionId: "secondary" } }); + await secondary.fetchAgents({ subscribe: {} }); await ctx.client.sendMessage(agent.id, "Run: sleep 5"); await ctx.client.waitForAgentUpsert( diff --git a/packages/server/src/server/dictation/dictation-stream-manager.test.ts b/packages/server/src/server/dictation/dictation-stream-manager.test.ts index 6d389a17e8..975d6677b8 100644 --- a/packages/server/src/server/dictation/dictation-stream-manager.test.ts +++ b/packages/server/src/server/dictation/dictation-stream-manager.test.ts @@ -534,3 +534,54 @@ describe("DictationStreamManager (provider-agnostic provider)", () => { } }); }); + +it("cancellation during STT bootstrap closes the producer and never acknowledges a late connection", async () => { + let connected!: () => void; + const gate = new Promise((resolve) => { + connected = resolve; + }); + class ConnectingSession extends FakeRealtimeSession { + override async connect(): Promise { + await gate; + } + } + const session = new ConnectingSession(); + const messages: Array<{ type: string }> = []; + const manager = new DictationStreamManager({ + logger: pino({ level: "silent" }), + emit: (message) => messages.push(message), + sessionId: "cancel-bootstrap", + stt: new FakeSttProvider(session), + }); + const starting = manager.handleStart("dictation", "audio/pcm;rate=24000;bits=16"); + manager.handleCancel("dictation"); + expect(session.closed).toBe(true); + connected(); + await starting; + expect(messages).toEqual([]); + manager.cleanupAll(); +}); + +it("closes every dictation stream when one provider cleanup fails", async () => { + class FailingCloseSession extends FakeRealtimeSession { + override close(): void { + super.close(); + throw new Error("provider cleanup failed"); + } + } + const first = new FailingCloseSession(); + const second = new FakeRealtimeSession(); + const sessions = [first, second]; + const manager = new DictationStreamManager({ + logger: pino({ level: "silent" }), + sessionId: "cleanup-failure", + emit: () => {}, + stt: { id: "controlled", createSession: () => sessions.shift()! }, + }); + await manager.handleStart("first", "audio/pcm;rate=24000;bits=16"); + await manager.handleStart("second", "audio/pcm;rate=24000;bits=16"); + expect(() => manager.cleanupAll()).toThrow(); + expect(first.closed).toBe(true); + expect(second.closed).toBe(true); + expect(manager.hasDemand).toBe(false); +}); diff --git a/packages/server/src/server/dictation/dictation-stream-manager.ts b/packages/server/src/server/dictation/dictation-stream-manager.ts index e911db3d6e..b1b061501f 100644 --- a/packages/server/src/server/dictation/dictation-stream-manager.ts +++ b/packages/server/src/server/dictation/dictation-stream-manager.ts @@ -134,6 +134,7 @@ export class DictationStreamManager { private readonly language: string; private readonly finalTimeoutMs: number; private readonly autoCommitSeconds: number; + private readonly onIdle: (() => void) | undefined; private readonly streams = new Map(); constructor(params: { @@ -144,7 +145,9 @@ export class DictationStreamManager { language?: string; finalTimeoutMs?: number; autoCommitSeconds?: number; + onIdle?: () => void; }) { + this.onIdle = params.onIdle; this.logger = params.logger.child({ component: "dictation-stream-manager" }); this.emit = params.emit; this.sessionId = params.sessionId; @@ -157,10 +160,20 @@ export class DictationStreamManager { DEFAULT_DICTATION_AUTO_COMMIT_SECONDS; } + get hasDemand(): boolean { + return this.streams.size > 0; + } + public cleanupAll(): void { + const failures: unknown[] = []; for (const dictationId of this.streams.keys()) { - this.cleanupDictationStream(dictationId); + try { + this.cleanupDictationStream(dictationId); + } catch (error) { + failures.push(error); + } } + if (failures.length) throw new AggregateError(failures, "Dictation cleanup failed"); } public async handleStart(dictationId: string, format: string): Promise { @@ -189,79 +202,6 @@ export class DictationStreamManager { return; } - stt.on("committed", ({ segmentId }) => { - const state = this.streams.get(dictationId); - if (!state) { - return; - } - if (state.inFlightCommitCount > 0) { - state.inFlightCommitCount -= 1; - } - state.committedSegmentIds.push(segmentId); - - if (state.finishRequested && state.awaitingFinalCommit) { - state.awaitingFinalCommit = false; - } - - this.maybeFinalizeDictationStream(dictationId); - }); - - stt.on("transcript", ({ segmentId, transcript, isFinal }) => { - const state = this.streams.get(dictationId); - if (!state) { - return; - } - state.transcriptsBySegmentId.set(segmentId, transcript); - if (isFinal) { - state.finalTranscriptSegmentIds.add(segmentId); - } - - if (state.finishRequested && state.awaitingFinalCommit && isFinal) { - state.awaitingFinalCommit = false; - } - - const orderedIds = state.committedSegmentIds.includes(segmentId) - ? state.committedSegmentIds - : [...state.committedSegmentIds, segmentId]; - const partialText = orderedIds - .map((id) => state.transcriptsBySegmentId.get(id) ?? "") - .join(" ") - .trim(); - this.emitDictationPartial(dictationId, partialText); - - this.maybeSealDictationStreamFinish(dictationId); - this.maybeFinalizeDictationStream(dictationId); - }); - - stt.on("error", (err) => { - const message = err instanceof Error ? err.message : String(err); - const state = this.streams.get(dictationId); - if (state && state.finishRequested && isBufferTooSmallError(message)) { - if (state.inFlightCommitCount > 0) { - state.inFlightCommitCount -= 1; - } - if (state.awaitingFinalCommit) { - state.awaitingFinalCommit = false; - } - this.maybeFinalizeDictationStream(dictationId); - return; - } - void this.failAndCleanupDictationStream(dictationId, message, true); - }); - - try { - await stt.connect(); - } catch (error) { - const message = error instanceof Error ? error.message : String(error); - this.failDictationStream(dictationId, message, true); - try { - stt.close(); - } catch { - // no-op - } - return; - } - const inputRate = parsePcmRateFromFormat(format, 16000) ?? 16000; if (!Number.isFinite(inputRate) || inputRate <= 0) { this.failDictationStream( @@ -322,6 +262,78 @@ export class DictationStreamManager { finalTimeout: null, }); + stt.on("committed", ({ segmentId }) => { + const state = this.streams.get(dictationId); + if (state?.stt !== stt) { + return; + } + if (state.inFlightCommitCount > 0) { + state.inFlightCommitCount -= 1; + } + state.committedSegmentIds.push(segmentId); + + if (state.finishRequested && state.awaitingFinalCommit) { + state.awaitingFinalCommit = false; + } + + this.maybeFinalizeDictationStream(dictationId); + }); + + stt.on("transcript", ({ segmentId, transcript, isFinal }) => { + const state = this.streams.get(dictationId); + if (state?.stt !== stt) { + return; + } + state.transcriptsBySegmentId.set(segmentId, transcript); + if (isFinal) { + state.finalTranscriptSegmentIds.add(segmentId); + } + + if (state.finishRequested && state.awaitingFinalCommit && isFinal) { + state.awaitingFinalCommit = false; + } + + const orderedIds = state.committedSegmentIds.includes(segmentId) + ? state.committedSegmentIds + : [...state.committedSegmentIds, segmentId]; + const partialText = orderedIds + .map((id) => state.transcriptsBySegmentId.get(id) ?? "") + .join(" ") + .trim(); + this.emitDictationPartial(dictationId, partialText); + + this.maybeSealDictationStreamFinish(dictationId); + this.maybeFinalizeDictationStream(dictationId); + }); + + stt.on("error", (err) => { + const message = err instanceof Error ? err.message : String(err); + const state = this.streams.get(dictationId); + if (state?.stt !== stt) return; + if (state.finishRequested && isBufferTooSmallError(message)) { + if (state.inFlightCommitCount > 0) { + state.inFlightCommitCount -= 1; + } + if (state.awaitingFinalCommit) { + state.awaitingFinalCommit = false; + } + this.maybeFinalizeDictationStream(dictationId); + return; + } + void this.failAndCleanupDictationStream(dictationId, message, true); + }); + + try { + await stt.connect(); + } catch (error) { + if (this.streams.get(dictationId)?.stt !== stt) return; + const message = error instanceof Error ? error.message : String(error); + this.failDictationStream(dictationId, message, true); + this.cleanupDictationStream(dictationId); + return; + } + if (this.streams.get(dictationId)?.stt !== stt) return; + this.emitDictationAck(dictationId, -1); } @@ -528,7 +540,9 @@ export class DictationStreamManager { error: string, retryable: boolean, ): Promise { + const state = this.streams.get(dictationId); const debugRecordingPath = await this.maybePersistDictationStreamAudio(dictationId); + if (!state || this.streams.get(dictationId) !== state) return; this.emit({ type: "dictation_stream_error", payload: { @@ -561,12 +575,12 @@ export class DictationStreamManager { if (state.finalTimeout) { clearTimeout(state.finalTimeout); } + this.streams.delete(dictationId); try { state.stt.close(); - } catch { - // no-op + } finally { + if (this.streams.size === 0) this.onIdle?.(); } - this.streams.delete(dictationId); } private estimateFinalizationTimeout(state: DictationStreamState): { @@ -731,6 +745,7 @@ export class DictationStreamManager { if (orderedSegmentIds.length === 0) { void (async () => { const debugRecordingPath = await this.maybePersistDictationStreamAudio(dictationId); + if (this.streams.get(dictationId) !== state) return; this.emit({ type: "dictation_stream_final", payload: { @@ -770,6 +785,7 @@ export class DictationStreamManager { void (async () => { const debugRecordingPath = await this.maybePersistDictationStreamAudio(dictationId); + if (this.streams.get(dictationId) !== state) return; this.emit({ type: "dictation_stream_final", payload: { diff --git a/packages/server/src/server/file-upload/index.test.ts b/packages/server/src/server/file-upload/index.test.ts index b338d7dd1c..7eb97a07c3 100644 --- a/packages/server/src/server/file-upload/index.test.ts +++ b/packages/server/src/server/file-upload/index.test.ts @@ -1,6 +1,6 @@ -import { existsSync, mkdtempSync, readFileSync, realpathSync, rmSync } from "node:fs"; +import { existsSync, mkdtempSync, readFileSync, readdirSync, realpathSync, rmSync } from "node:fs"; import { tmpdir } from "node:os"; -import { join } from "node:path"; +import { dirname, join } from "node:path"; import { afterEach, describe, expect, it, vi } from "vitest"; import { @@ -37,14 +37,14 @@ describe("file uploads", () => { await expect(uploads.receiveFrame(uploadChunk("req-upload", "hello"))).resolves.toBeNull(); await expect(uploads.receiveFrame(uploadChunk("req-upload", " world"))).resolves.toBeNull(); - const path = join(paseoHome, "uploads", "upload_req-upload", "notes.txt"); + const path = uploadedPath(paseoHome, "notes.txt"); await expect(uploads.receiveFrame(uploadEnds("req-upload"))).resolves.toEqual({ type: "file.upload.response", payload: { requestId: "req-upload", file: { type: "uploaded_file", - id: "upload_req-upload", + id: expect.any(String), fileName: "notes.txt", mimeType: "text/plain", size: 11, @@ -70,8 +70,8 @@ describe("file uploads", () => { }); await expect(uploads.receiveFrame(uploadBegins("req-overflow"))).resolves.toBeNull(); - const uploadDir = join(paseoHome, "uploads", "upload_req-overflow"); - const path = join(uploadDir, "notes.txt"); + const path = uploadedPath(paseoHome, "notes.txt"); + const uploadDir = dirname(path); await expect(uploads.receiveFrame(uploadChunk("req-overflow", "hello!"))).resolves.toEqual({ type: "file.upload.response", payload: { @@ -106,9 +106,7 @@ describe("file uploads", () => { expect(results.slice(0, 3)).toEqual([null, null, null]); expect(results[3]?.payload.error).toBeNull(); - expect(readFileSync(join(paseoHome, "uploads", "upload_req-queued", "notes.txt"), "utf8")).toBe( - "hello world", - ); + expect(readFileSync(uploadedPath(paseoHome, "notes.txt"), "utf8")).toBe("hello world"); }); it("replaces duplicate upload starts without letting the old stale timeout evict the replacement", async () => { @@ -139,16 +137,16 @@ describe("file uploads", () => { }); await vi.advanceTimersByTimeAsync(30); - const path = join(paseoHome, "uploads", "upload_req-duplicate_2", "new.txt"); await expect(uploads.receiveFrame(uploadBegins("req-duplicate"))).resolves.toBeNull(); await expect(uploads.receiveFrame(uploadChunk("req-duplicate", "new"))).resolves.toBeNull(); + const path = uploadedPath(paseoHome, "new.txt"); await expect(uploads.receiveFrame(uploadEnds("req-duplicate"))).resolves.toEqual({ type: "file.upload.response", payload: { requestId: "req-duplicate", file: { type: "uploaded_file", - id: "upload_req-duplicate_2", + id: expect.any(String), fileName: "new.txt", mimeType: "text/plain", size: 3, @@ -184,14 +182,14 @@ describe("file uploads", () => { uploads.receiveFrame(uploadChunk("req-slow-active", " world")), ).resolves.toBeNull(); - const path = join(paseoHome, "uploads", "upload_req-slow-active", "notes.txt"); + const path = uploadedPath(paseoHome, "notes.txt"); await expect(uploads.receiveFrame(uploadEnds("req-slow-active"))).resolves.toEqual({ type: "file.upload.response", payload: { requestId: "req-slow-active", file: { type: "uploaded_file", - id: "upload_req-slow-active", + id: expect.any(String), fileName: "notes.txt", mimeType: "text/plain", size: 11, @@ -252,3 +250,12 @@ function decodeUploadFrame(bytes: Uint8Array): FileTransferFrame { } return frame; } + +function uploadedPath(paseoHome: string, fileName: string): string { + const root = join(paseoHome, "uploads"); + const file = readdirSync(root) + .map((id) => join(root, id, fileName)) + .find((candidate) => existsSync(candidate)); + if (!file) throw new Error(`Upload file ${fileName} is missing`); + return file; +} diff --git a/packages/server/src/server/file-upload/index.ts b/packages/server/src/server/file-upload/index.ts index 929e8e8a52..8b93e31f2f 100644 --- a/packages/server/src/server/file-upload/index.ts +++ b/packages/server/src/server/file-upload/index.ts @@ -1,3 +1,4 @@ +import { randomUUID } from "node:crypto"; import { appendFile, mkdir, rm, writeFile } from "node:fs/promises"; import { basename, join } from "node:path"; @@ -13,7 +14,9 @@ interface FileUploadStoreOptions { interface PendingUpload { requestId: string; id: string; - attempt: number; + source: object; + completed: boolean; + finished(response: FileUploadResponse | null): void; fileName: string; mimeType: string; size: number; @@ -22,6 +25,7 @@ interface PendingUpload { started: boolean; staleTimeout: ReturnType; queue: Promise; + cleanup?: Promise; } export class FileUploadStore { @@ -29,7 +33,8 @@ export class FileUploadStore { private readonly paseoHome: string; private readonly staleUploadTimeoutMs: number; - private readonly pending = new Map(); + private readonly defaultSource = {}; + private readonly pending = new Map>(); constructor(options: FileUploadStoreOptions) { this.paseoHome = options.paseoHome; @@ -37,41 +42,55 @@ export class FileUploadStore { options.staleUploadTimeoutMs ?? FileUploadStore.defaultStaleUploadTimeoutMs; } - beginUpload(request: FileUploadRequest): void { - const existingUpload = this.pending.get(request.requestId); - if (existingUpload) { - this.clearPendingUpload(existingUpload); - void existingUpload.queue.then(() => this.removeUploadDirectory(existingUpload)); - } - + beginUpload( + request: FileUploadRequest, + source: object = this.defaultSource, + finished: (response: FileUploadResponse | null) => void = () => {}, + ): () => Promise { + const existingUpload = this.pending.get(source)?.get(request.requestId); + if (existingUpload) void this.cancel(existingUpload).catch(() => {}); const fileName = sanitizeFileName(request.fileName); - const attempt = existingUpload ? existingUpload.attempt + 1 : 1; - const id = buildUploadId(request.requestId, attempt); + const id = `upload_${randomUUID()}`; const uploadDir = join(this.paseoHome, "uploads", id); const upload: PendingUpload = { requestId: request.requestId, id, - attempt, + source, + completed: false, + finished, fileName, mimeType: request.mimeType, size: request.size, path: join(uploadDir, fileName), receivedBytes: 0, started: false, - staleTimeout: this.createStaleUploadTimeout(request.requestId), + staleTimeout: this.createStaleUploadTimeout(source, request.requestId), queue: Promise.resolve(), }; - this.pending.set(request.requestId, upload); + const uploads = this.pending.get(source) ?? new Map(); + uploads.set(request.requestId, upload); + this.pending.set(source, uploads); + return () => this.cancel(upload); } - async receiveFrame(frame: FileTransferFrame): Promise { - const upload = this.pending.get(frame.requestId); + async receiveFrame( + frame: FileTransferFrame, + source: object = this.defaultSource, + ): Promise { + const upload = this.pending.get(source)?.get(frame.requestId); if (!upload) { return null; } this.refreshStaleUploadTimeout(upload); const operation = upload.queue.then(() => this.applyFrame(upload, frame)); + void operation.then( + (response) => { + if (response) upload.finished(response); + return undefined; + }, + () => upload.finished(null), + ); upload.queue = operation.then( () => undefined, () => undefined, @@ -83,7 +102,7 @@ export class FileUploadStore { upload: PendingUpload, frame: FileTransferFrame, ): Promise { - if (this.pending.get(upload.requestId) !== upload) { + if (this.pending.get(upload.source)?.get(upload.requestId) !== upload) { return null; } @@ -132,12 +151,17 @@ export class FileUploadStore { `Upload size mismatch: expected ${upload.size}, received ${upload.receivedBytes}.`, ); } + upload.completed = true; return buildUploadResponse(upload, null); } - private createStaleUploadTimeout(requestId: string): ReturnType { + private createStaleUploadTimeout( + source: object, + requestId: string, + ): ReturnType { const timeout = setTimeout(() => { - this.expireStaleUpload(requestId); + const upload = this.pending.get(source)?.get(requestId); + if (upload) void this.cancel(upload).catch(() => {}); }, this.staleUploadTimeoutMs); timeout.unref?.(); return timeout; @@ -145,30 +169,25 @@ export class FileUploadStore { private refreshStaleUploadTimeout(upload: PendingUpload): void { clearTimeout(upload.staleTimeout); - upload.staleTimeout = this.createStaleUploadTimeout(upload.requestId); + upload.staleTimeout = this.createStaleUploadTimeout(upload.source, upload.requestId); } - private expireStaleUpload(requestId: string): void { - const upload = this.pending.get(requestId); - if (!upload) { - return; - } + private cancel(upload: PendingUpload): Promise { + if (upload.cleanup) return upload.cleanup; this.clearPendingUpload(upload); - const cleanup = upload.queue.then( - () => this.removeUploadDirectory(upload), - () => this.removeUploadDirectory(upload), - ); - upload.queue = cleanup.then( - () => undefined, - () => undefined, - ); + upload.cleanup = upload.queue.then(async () => { + if (!upload.completed) await this.removeUploadDirectory(upload); + return undefined; + }); + upload.finished(null); + return upload.cleanup; } private clearPendingUpload(upload: PendingUpload): void { clearTimeout(upload.staleTimeout); - if (this.pending.get(upload.requestId) === upload) { - this.pending.delete(upload.requestId); - } + const uploads = this.pending.get(upload.source); + if (uploads?.get(upload.requestId) === upload) uploads.delete(upload.requestId); + if (uploads?.size === 0) this.pending.delete(upload.source); } private async removeFailedUpload(upload: PendingUpload): Promise { @@ -177,9 +196,7 @@ export class FileUploadStore { } private async removeUploadDirectory(upload: PendingUpload): Promise { - await rm(join(this.paseoHome, "uploads", upload.id), { recursive: true, force: true }).catch( - () => undefined, - ); + await rm(join(this.paseoHome, "uploads", upload.id), { recursive: true, force: true }); } } @@ -203,15 +220,6 @@ function buildUploadResponse(upload: PendingUpload, error: string | null): FileU }; } -function sanitizeUploadId(value: string): string { - return value.replace(/[^a-zA-Z0-9._-]/g, "_") || "file"; -} - -function buildUploadId(requestId: string, attempt: number): string { - const baseId = `upload_${sanitizeUploadId(requestId)}`; - return attempt === 1 ? baseId : `${baseId}_${attempt}`; -} - function sanitizeFileName(value: string): string { const name = basename(value) .replace(/[^a-zA-Z0-9._ -]/g, "_") diff --git a/packages/server/src/server/hub/execution-controller.test.ts b/packages/server/src/server/hub/execution-controller.test.ts index 4f2836d1ce..95c6555035 100644 --- a/packages/server/src/server/hub/execution-controller.test.ts +++ b/packages/server/src/server/hub/execution-controller.test.ts @@ -34,6 +34,7 @@ function deferred(): Deferred { } class ControlledHubExecutionAgents implements HubExecutionAgents { + observerCount = 0; private readonly createObserved = deferred(); private readonly createGate = deferred(); @@ -45,7 +46,10 @@ class ControlledHubExecutionAgents implements HubExecutionAgents { async control(): Promise {} subscribe(_listener: (event: OwnedAgentEvent) => void): () => void { - return () => undefined; + this.observerCount++; + return () => { + this.observerCount--; + }; } async invalidateAuthority(): Promise {} @@ -224,3 +228,28 @@ describe("HubExecutionController", () => { ]); }); }); + +test("Hub observation is acquired and released explicitly without closing its RPC controller", async () => { + const agents = new ControlledHubExecutionAgents(); + const messages: SessionOutboundMessage[] = []; + const controller = new HubExecutionController({ + agents, + validateAgentConfiguration: async () => [], + send: (message) => messages.push(message), + }); + expect(agents.observerCount).toBe(0); + controller.setObserving(true); + expect(agents.observerCount).toBe(1); + controller.setObserving(true); + expect(agents.observerCount).toBe(1); + controller.setObserving(false); + expect(agents.observerCount).toBe(0); + await controller.validateAgent({ + type: "hub.execution.agent.validate.request", + requestId: "plain-validation", + provider: "codex", + }); + expect(messages).toHaveLength(1); + expect(agents.observerCount).toBe(0); + await controller.cleanup(); +}); diff --git a/packages/server/src/server/hub/execution-controller.ts b/packages/server/src/server/hub/execution-controller.ts index 025b05a48b..4199433d3d 100644 --- a/packages/server/src/server/hub/execution-controller.ts +++ b/packages/server/src/server/hub/execution-controller.ts @@ -26,7 +26,7 @@ export class HubExecutionController { private readonly agents: HubExecutionAgents; private readonly send: (message: SessionOutboundMessage) => void; private readonly validateAgentConfiguration: HubExecutionControllerOptions["validateAgentConfiguration"]; - private readonly unsubscribe: () => void; + private unsubscribe: (() => void) | null = null; private readonly pendingCreates = new Set>(); private readonly pendingControls = new Set>(); private readonly pendingValidations = new Set>(); @@ -37,7 +37,19 @@ export class HubExecutionController { this.agents = options.agents; this.validateAgentConfiguration = options.validateAgentConfiguration; this.send = options.send; - this.unsubscribe = this.agents.subscribe((event) => this.sendOwnedEvent(event)); + } + + get isObserving(): boolean { + return this.unsubscribe !== null; + } + + setObserving(observing: boolean): void { + if (observing && !this.closed) + this.unsubscribe ??= this.agents.subscribe((event) => this.sendOwnedEvent(event)); + else { + this.unsubscribe?.(); + this.unsubscribe = null; + } } cleanup(): Promise { @@ -47,7 +59,7 @@ export class HubExecutionController { private async cleanupOnce(): Promise { this.closed = true; - this.unsubscribe(); + this.setObserving(false); await Promise.allSettled([ ...this.pendingCreates, ...this.pendingControls, diff --git a/packages/server/src/server/hub/test-utils/relationship-harness.ts b/packages/server/src/server/hub/test-utils/relationship-harness.ts index 3113b543ad..31fa1617f1 100644 --- a/packages/server/src/server/hub/test-utils/relationship-harness.ts +++ b/packages/server/src/server/hub/test-utils/relationship-harness.ts @@ -1582,7 +1582,7 @@ export class HubRelationshipHarness { appVersion: "0.1.106", }); await client.connect(); - await client.fetchAgents({ subscribe: { subscriptionId: "hub-relationship-trusted" } }); + await client.observeAgents().ready; return client; } diff --git a/packages/server/src/server/owned-subscriptions-client.e2e.test.ts b/packages/server/src/server/owned-subscriptions-client.e2e.test.ts new file mode 100644 index 0000000000..1502f39047 --- /dev/null +++ b/packages/server/src/server/owned-subscriptions-client.e2e.test.ts @@ -0,0 +1,167 @@ +import { WebSocket } from "ws"; +import { + DaemonClient as PublicDaemonClient, + type WebSocketLike, +} from "@getpaseo/client/internal/daemon-client"; +import { expect, test } from "vitest"; +import { createPaseoApi } from "@getpaseo/client"; +import { DaemonClient } from "./test-utils/daemon-client.js"; +import { createTestPaseoDaemon } from "./test-utils/paseo-daemon.js"; + +test("two SDK facades own combined agent lists and disposal preserves the other facade", async () => { + const daemon = await createTestPaseoDaemon({ mcpEnabled: false }); + const client = new DaemonClient({ + url: `ws://127.0.0.1:${daemon.port}/ws`, + appVersion: "0.8.0", + capabilities: { owned_subscriptions: true }, + }); + const admin = new DaemonClient({ url: `ws://127.0.0.1:${daemon.port}/ws`, appVersion: "0.8.0" }); + const app = createPaseoApi(client); + const plugin = createPaseoApi(client); + try { + await admin.connect(); + await client.connect(); + const agent = await admin.createAgent({ + provider: "codex", + cwd: daemon.staticDir, + title: "Before", + }); + const appUpdates: unknown[] = []; + const pluginUpdates: unknown[] = []; + app.agents.subscribe((update) => appUpdates.push(update)); + plugin.agents.subscribe((update) => pluginUpdates.push(update)); + const broad = await app.agents.list({ scope: "active", subscribe: {} }); + const filtered = await plugin.agents.list({ + scope: "active", + filter: { labels: { role: "orchestrator" } }, + page: { limit: 100 }, + subscribe: {}, + }); + expect(broad.subscriptionId).toEqual(expect.any(String)); + expect(filtered.subscriptionId).toEqual(expect.any(String)); + expect(broad.subscriptionId).not.toBe(filtered.subscriptionId); + expect(filtered.entries).toEqual([]); + await admin.updateAgent(agent.id, { name: "After" }); + await expect + .poll(() => appUpdates) + .toEqual( + expect.arrayContaining([ + expect.objectContaining({ + kind: "upsert", + subscriptionId: broad.subscriptionId, + agent: expect.objectContaining({ id: agent.id, title: "After" }), + }), + ]), + ); + expect(appUpdates).not.toEqual( + expect.arrayContaining([expect.objectContaining({ kind: "remove" })]), + ); + expect(pluginUpdates).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + kind: "remove", + subscriptionId: filtered.subscriptionId, + agentId: agent.id, + }), + ]), + ); + await plugin.dispose(); + const before = pluginUpdates.length; + await admin.updateAgent(agent.id, { name: "Still observed" }); + await expect + .poll(() => appUpdates) + .toEqual( + expect.arrayContaining([ + expect.objectContaining({ agent: expect.objectContaining({ title: "Still observed" }) }), + ]), + ); + expect(pluginUpdates).toHaveLength(before); + } finally { + await client.close(); + await admin.close(); + await daemon.close(); + } +}); + +test("public SDK scope cancellation during bootstrap releases the returned ID", async () => { + const daemon = await createTestPaseoDaemon({ mcpEnabled: false }); + const client = new DaemonClient({ url: `ws://127.0.0.1:${daemon.port}/ws` }); + const lifetime = new AbortController(); + const api = createPaseoApi(client, { signal: lifetime.signal }); + try { + await client.connect(); + const listing = api.agents.list({ subscribe: {} }); + lifetime.abort(); + await expect(listing).rejects.toThrow("released"); + await api.dispose(); + const { diagnostic } = await client.collectDiagnostics(); + expect(diagnostic).toContain("Source registrations: 0"); + expect(diagnostic).toContain("Producer listeners: 0"); + await expect(api.agents.list({ subscribe: {} })).rejects.toThrow("disposed"); + } finally { + await client.close(); + await daemon.close(); + } +}); + +test("a surviving public SDK timeline recovers persistent rows written during its socket outage", async () => { + const daemon = await createTestPaseoDaemon({ mcpEnabled: false }); + const sockets: WebSocket[] = []; + const makeClient = (observer: boolean) => + new PublicDaemonClient({ + url: `ws://127.0.0.1:${daemon.port}/ws`, + clientId: observer ? "r5-observer" : "r5-actor", + appVersion: "0.8.0", + reconnect: { enabled: false }, + webSocketFactory: (url) => { + const socket = new WebSocket(url); + if (observer) { + sockets.push(socket); + } + return socket as unknown as WebSocketLike; + }, + }); + const observer = makeClient(true); + const actor = makeClient(false); + const api = createPaseoApi(observer); + const received: unknown[] = []; + try { + await actor.connect(); + await observer.connect(); + const agent = await actor.createAgent({ + provider: "codex", + cwd: daemon.staticDir, + title: "Reconnect review", + }); + const handle = api.agents.ref(agent.id); + const subscription = handle.timeline.subscribe((event) => received.push(event)); + await subscription.ready; + await actor.sendMessage(agent.id, "before-outage-r5"); + await actor.waitForFinish(agent.id); + await expect.poll(() => JSON.stringify(received)).toContain("before-outage-r5"); + const oldId = subscription.subscriptionId; + sockets[0]!.terminate(); + await expect.poll(() => observer.getConnectionState().status).not.toBe("connected"); + await actor.sendMessage(agent.id, "during-outage-r5"); + await actor.waitForFinish(agent.id); + await observer.connect(); + await expect + .poll(() => subscription.subscriptionId) + .toSatisfy((id: unknown) => typeof id === "string" && id !== oldId); + await actor.sendMessage(agent.id, "after-outage-r5"); + await actor.waitForFinish(agent.id); + await expect.poll(() => JSON.stringify(received)).toContain("after-outage-r5"); + const history = await handle.timeline.refetch({ + direction: "before", + limit: 100, + projection: "projected", + }); + expect(JSON.stringify(history)).toContain("during-outage-r5"); + expect(JSON.stringify(received)).toContain("during-outage-r5"); + } finally { + await api.dispose(); + await observer.close(); + await actor.close(); + await daemon.close(); + } +}); diff --git a/packages/server/src/server/owned-subscriptions.e2e.test.ts b/packages/server/src/server/owned-subscriptions.e2e.test.ts new file mode 100644 index 0000000000..0ae518fae8 --- /dev/null +++ b/packages/server/src/server/owned-subscriptions.e2e.test.ts @@ -0,0 +1,1674 @@ +import { MockLoadTestAgentClient } from "./agent/providers/mock-load-test-agent.js"; +import { execFileSync } from "node:child_process"; +import { mkdir, writeFile } from "node:fs/promises"; +import path from "node:path"; +import { expect, test } from "vitest"; +import type { z } from "zod"; +import { WebSocket, WebSocketServer } from "ws"; +import { createServer } from "node:http"; +import { + decodeTerminalStreamFrame, + encodeFileTransferFrame, + FileTransferOpcode, + TerminalStreamOpcode, +} from "@getpaseo/protocol/binary-frames/index"; +import { DaemonClient } from "./test-utils/daemon-client.js"; +import { createTestPaseoDaemon } from "./test-utils/paseo-daemon.js"; +import { + SessionInboundMessageSchema, + type SessionInboundMessage, + type SessionOutboundMessage, + type WSOutboundMessage, +} from "./messages.js"; + +class SubscriptionPeer { + readonly frames: WSOutboundMessage[] = []; + readonly binaryFrames: Uint8Array[] = []; + + private constructor(private readonly socket: WebSocket) { + socket.on("message", (data, binary) => { + if (!binary) this.frames.push(JSON.parse(data.toString())); + else this.binaryFrames.push(new Uint8Array(data as Buffer)); + }); + } + + static async connect( + port: number, + clientId: string, + capabilities: Record = {}, + ): Promise { + const socket = new WebSocket(`ws://127.0.0.1:${port}/ws`); + const peer = new SubscriptionPeer(socket); + await new Promise((resolve, reject) => { + socket.once("open", resolve); + socket.once("error", reject); + }); + socket.send( + JSON.stringify({ + type: "hello", + clientType: "browser", + clientId, + protocolVersion: 1, + appVersion: "0.8.0", + capabilities: { owned_subscriptions: true, ...capabilities }, + }), + ); + await expect + .poll(() => + peer.frames.some( + (frame) => + frame.type === "session" && + frame.message.type === "status" && + frame.message.payload.status === "server_info", + ), + ) + .toBe(true); + peer.frames.length = 0; + return peer; + } + + async request(message: z.input): Promise { + this.socket.send(JSON.stringify({ type: "session", message })); + await expect + .poll(() => + this.frames.some( + (frame) => + frame.type === "session" && + "payload" in frame.message && + "requestId" in frame.message.payload && + "requestId" in message && + frame.message.payload.requestId === message.requestId, + ), + ) + .toBe(true); + } + + send(message: z.input): void { + this.socket.send( + JSON.stringify({ type: "session", message: SessionInboundMessageSchema.parse(message) }), + ); + } + + upload(requestId: string, text: string, fileName: string): void { + this.socket.send( + encodeFileTransferFrame({ + opcode: FileTransferOpcode.FileBegin, + requestId, + metadata: { + mime: "text/plain", + size: new TextEncoder().encode(text).byteLength, + encoding: "binary", + modifiedAt: "2026-09-09T00:00:00Z", + fileName, + }, + }), + ); + this.socket.send( + encodeFileTransferFrame({ + opcode: FileTransferOpcode.FileChunk, + requestId, + payload: new TextEncoder().encode(text), + }), + ); + this.socket.send(encodeFileTransferFrame({ opcode: FileTransferOpcode.FileEnd, requestId })); + } + + close(): void { + this.socket.terminate(); + } +} + +test("a pure list reply belongs only to its source socket in a shared logical session", async () => { + const daemon = await createTestPaseoDaemon({ mcpEnabled: false }); + const peers: SubscriptionPeer[] = []; + try { + const idle = await SubscriptionPeer.connect(daemon.port, "shared-owned-session"); + peers.push(idle); + const reader = await SubscriptionPeer.connect(daemon.port, "shared-owned-session"); + peers.push(reader); + await reader.request({ type: "fetch_agents_request", requestId: "reader-snapshot" }); + await idle.request({ type: "ping", requestId: "idle-barrier", clientSentAt: 1 }); + expect( + idle.frames.filter( + (frame) => frame.type === "session" && frame.message.type === "fetch_agents_response", + ), + ).toEqual([]); + } finally { + for (const peer of peers) peer.close(); + await daemon.close(); + } +}); + +test("combined filtered agent lists create independent server-owned observations", async () => { + const daemon = await createTestPaseoDaemon({ mcpEnabled: false }); + const admin = new DaemonClient({ url: `ws://127.0.0.1:${daemon.port}/ws`, appVersion: "0.8.0" }); + let peer: SubscriptionPeer | undefined; + try { + await admin.connect(); + const agent = await admin.createAgent({ + provider: "codex", + cwd: daemon.staticDir, + title: "Before", + }); + peer = await SubscriptionPeer.connect(daemon.port, "independent-agent-views"); + await peer.request({ type: "fetch_agents_request", requestId: "all", subscribe: {} }); + await peer.request({ + type: "fetch_agents_request", + requestId: "filtered", + filter: { labels: { role: "orchestrator" } }, + subscribe: {}, + }); + const snapshots = peer.frames.flatMap((frame) => + frame.type === "session" && frame.message.type === "fetch_agents_response" + ? [frame.message.payload] + : [], + ); + expect(snapshots).toHaveLength(2); + const [all, filtered] = snapshots; + expect(typeof all.subscriptionId).toBe("string"); + expect(typeof filtered.subscriptionId).toBe("string"); + expect(all.subscriptionId).not.toBe(filtered.subscriptionId); + await admin.updateAgent(agent.id, { name: "After" }); + await expect + .poll( + () => + peer!.frames.flatMap((frame) => + frame.type === "session" && frame.message.type === "agent_update" + ? [frame.message.payload] + : [], + ), + { timeout: 5000 }, + ) + .toEqual( + expect.arrayContaining([ + expect.objectContaining({ + subscriptionId: all.subscriptionId, + kind: "upsert", + agent: expect.objectContaining({ id: agent.id, title: "After" }), + }), + expect.objectContaining({ + subscriptionId: filtered.subscriptionId, + kind: "remove", + agentId: agent.id, + }), + ]), + ); + await peer.request({ + type: "subscription.release.request", + requestId: "release-filtered", + subscriptionId: filtered.subscriptionId!, + }); + peer.frames.length = 0; + await admin.updateAgent(agent.id, { name: "Still observed" }); + await expect + .poll( + () => + peer!.frames.flatMap((frame) => + frame.type === "session" && frame.message.type === "agent_update" + ? [frame.message.payload] + : [], + ), + { timeout: 5000 }, + ) + .toEqual([ + expect.objectContaining({ + subscriptionId: all.subscriptionId, + kind: "upsert", + agent: expect.objectContaining({ id: agent.id, title: "Still observed" }), + }), + ]); + } finally { + peer?.close(); + await admin.close(); + await daemon.close(); + } +}); + +test("workspace filters and identical queries coexist until their own release", async () => { + const daemon = await createTestPaseoDaemon({ mcpEnabled: false }); + const admin = new DaemonClient({ url: `ws://127.0.0.1:${daemon.port}/ws`, appVersion: "0.8.0" }); + let peer: SubscriptionPeer | undefined; + try { + await admin.connect(); + const aDir = path.join(daemon.staticDir, "a"); + const bDir = path.join(daemon.staticDir, "b"); + await mkdir(aDir); + await mkdir(bDir); + const a = (await admin.createWorkspace({ source: { kind: "directory", path: aDir } })) + .workspace!; + const b = (await admin.createWorkspace({ source: { kind: "directory", path: bDir } })) + .workspace!; + peer = await SubscriptionPeer.connect(daemon.port, "independent-workspace-views"); + await peer.request({ + type: "fetch_workspaces_request", + requestId: "a", + filter: { projectId: a.projectId }, + subscribe: {}, + }); + await peer.request({ + type: "fetch_workspaces_request", + requestId: "b", + filter: { projectId: b.projectId }, + subscribe: {}, + }); + await peer.request({ + type: "fetch_workspaces_request", + requestId: "b-copy", + filter: { projectId: b.projectId }, + subscribe: {}, + }); + const snapshots = peer.frames.flatMap((frame) => + frame.type === "session" && frame.message.type === "fetch_workspaces_response" + ? [frame.message.payload] + : [], + ); + expect(snapshots).toHaveLength(3); + const ids = snapshots.map((snapshot) => snapshot.subscriptionId!); + expect(new Set(ids).size).toBe(3); + await peer.request({ + type: "subscription.release.request", + requestId: "release-b", + subscriptionId: ids[1], + }); + await admin.setWorkspaceTitle(a.id, "A updated"); + await admin.setWorkspaceTitle(b.id, "B updated"); + await expect + .poll( + () => + peer!.frames.flatMap((frame) => + frame.type === "session" && frame.message.type === "workspace_update" + ? [frame.message.payload] + : [], + ), + { timeout: 5000 }, + ) + .toEqual( + expect.arrayContaining([ + expect.objectContaining({ + subscriptionId: ids[0], + kind: "upsert", + workspace: expect.objectContaining({ id: a.id, name: "A updated" }), + }), + expect.objectContaining({ + subscriptionId: ids[2], + kind: "upsert", + workspace: expect.objectContaining({ id: b.id, name: "B updated" }), + }), + ]), + ); + expect( + peer.frames.filter( + (frame) => + frame.type === "session" && + frame.message.type === "workspace_update" && + "subscriptionId" in frame.message.payload && + frame.message.payload.subscriptionId === ids[1], + ), + ).toEqual([]); + } finally { + peer?.close(); + await admin.close(); + await daemon.close(); + } +}); + +test("label lists are pure and label observers have independent lifetimes", async () => { + const daemon = await createTestPaseoDaemon({ mcpEnabled: false }); + const admin = new DaemonClient({ url: `ws://127.0.0.1:${daemon.port}/ws`, appVersion: "0.8.0" }); + let peer: SubscriptionPeer | undefined; + try { + await admin.connect(); + const workspace = ( + await admin.createWorkspace({ source: { kind: "directory", path: daemon.staticDir } }) + ).workspace!; + peer = await SubscriptionPeer.connect(daemon.port, "label-views"); + await peer.request({ type: "workspace.label.list.request", requestId: "plain" }); + await peer.request({ type: "workspace.label.list.request", requestId: "first", subscribe: {} }); + await peer.request({ + type: "workspace.label.list.request", + requestId: "second", + subscribe: {}, + }); + const snapshots = peer.frames.flatMap((frame) => + frame.type === "session" && frame.message.type === "workspace.label.list.response" + ? [frame.message.payload] + : [], + ); + expect(snapshots).toHaveLength(3); + expect(snapshots[0]).not.toHaveProperty("subscriptionId"); + const first = snapshots[1] as (typeof snapshots)[number] & { subscriptionId: string }; + const second = snapshots[2] as (typeof snapshots)[number] & { subscriptionId: string }; + expect(typeof first.subscriptionId).toBe("string"); + expect(first.subscriptionId).not.toBe(second.subscriptionId); + await peer.request({ + type: "subscription.release.request", + requestId: "release", + subscriptionId: first.subscriptionId, + }); + peer.frames.length = 0; + await peer.request({ + type: "workspace.label.assignment.set.request", + requestId: "assign", + workspaceId: workspace.id, + label: { name: "QA", color: "blue" }, + assigned: true, + }); + await expect + .poll(() => + peer!.frames.flatMap((frame) => + frame.type === "session" && frame.message.type === "workspace.label.update" + ? [frame.message.payload] + : [], + ), + ) + .toEqual([ + expect.objectContaining({ + kind: "upsert", + subscriptionId: second.subscriptionId, + label: { name: "QA", color: "blue" }, + }), + ]); + } finally { + peer?.close(); + await admin.close(); + await daemon.close(); + } +}); + +test("identical file observations get server IDs and release independently", async () => { + const daemon = await createTestPaseoDaemon({ mcpEnabled: false }); + let peer: SubscriptionPeer | undefined; + try { + await writeFile(path.join(daemon.staticDir, "observed.txt"), "before"); + peer = await SubscriptionPeer.connect(daemon.port, "file-observers"); + await peer.request({ + type: "fs.file.subscribe.request", + requestId: "file-a", + cwd: daemon.staticDir, + path: "observed.txt", + }); + await peer.request({ + type: "fs.file.subscribe.request", + requestId: "file-b", + cwd: daemon.staticDir, + path: "observed.txt", + }); + const snapshots = peer.frames.flatMap((frame) => + frame.type === "session" && frame.message.type === "fs.file.subscribe.response" + ? [frame.message.payload] + : [], + ); + expect(snapshots).toHaveLength(2); + expect(snapshots[0].subscriptionId).not.toBe(snapshots[1].subscriptionId); + await peer.request({ + type: "subscription.release.request", + requestId: "release-file-a", + subscriptionId: snapshots[0].subscriptionId, + }); + peer.frames.length = 0; + await writeFile(path.join(daemon.staticDir, "observed.txt"), "after"); + await expect + .poll( + () => + peer!.frames.flatMap((frame) => + frame.type === "session" && frame.message.type === "fs.file.update" + ? [frame.message.payload] + : [], + ), + { timeout: 5000 }, + ) + .toEqual([ + expect.objectContaining({ + subscriptionId: snapshots[1].subscriptionId, + version: expect.objectContaining({ status: "ready" }), + }), + ]); + } finally { + peer?.close(); + await daemon.close(); + } +}); + +test("checkout diff reads are snapshots and identical observers release independently", async () => { + const daemon = await createTestPaseoDaemon({ mcpEnabled: false }); + let peer: SubscriptionPeer | undefined; + try { + const cwd = daemon.staticDir; + execFileSync("git", ["init", "-q", "--initial-branch=main", cwd]); + await writeFile(path.join(cwd, "tracked.txt"), "before\n"); + execFileSync("git", ["-C", cwd, "add", "tracked.txt"]); + execFileSync("git", [ + "-C", + cwd, + "-c", + "user.name=QA", + "-c", + "user.email=qa@example.invalid", + "commit", + "-qm", + "Initial", + ]); + peer = await SubscriptionPeer.connect(daemon.port, "diff-observers"); + await peer.request({ + type: "checkout.diff.get.request", + requestId: "read", + cwd, + compare: { mode: "uncommitted" }, + }); + await peer.request({ + type: "subscribe_checkout_diff_request", + requestId: "diff-a", + cwd, + compare: { mode: "uncommitted" }, + }); + await peer.request({ + type: "subscribe_checkout_diff_request", + requestId: "diff-b", + cwd, + compare: { mode: "uncommitted" }, + }); + const snapshots = peer.frames.flatMap((frame) => + frame.type === "session" && frame.message.type === "subscribe_checkout_diff_response" + ? [frame.message.payload] + : [], + ); + expect(snapshots).toHaveLength(2); + expect(snapshots[0].subscriptionId).not.toBe(snapshots[1].subscriptionId); + await peer.request({ + type: "subscription.release.request", + requestId: "release-diff-a", + subscriptionId: snapshots[0].subscriptionId, + }); + peer.frames.length = 0; + await writeFile(path.join(cwd, "tracked.txt"), "after\n"); + await expect + .poll( + () => + peer!.frames.flatMap((frame) => + frame.type === "session" && + frame.message.type === "checkout_diff_update" && + frame.message.payload.files.length > 0 + ? [frame.message.payload] + : [], + ), + { timeout: 10000 }, + ) + .toEqual([ + expect.objectContaining({ + subscriptionId: snapshots[1].subscriptionId, + error: null, + files: [expect.objectContaining({ path: "tracked.txt" })], + }), + ]); + } finally { + peer?.close(); + await daemon.close(); + } +}); + +test("event-category observations use independent IDs without enabling other categories", async () => { + const daemon = await createTestPaseoDaemon({ mcpEnabled: false }); + const admin = new DaemonClient({ url: `ws://127.0.0.1:${daemon.port}/ws`, appVersion: "0.8.0" }); + let peer: SubscriptionPeer | undefined; + try { + await admin.connect(); + peer = await SubscriptionPeer.connect(daemon.port, "event-observers"); + await peer.request({ + type: "session.events.set_subscription.request", + requestId: "events-a", + events: ["project.update"], + }); + await peer.request({ + type: "session.events.set_subscription.request", + requestId: "events-b", + events: ["project.update"], + }); + const snapshots = peer.frames.flatMap((frame) => + frame.type === "session" && frame.message.type === "session.events.set_subscription.response" + ? [frame.message.payload] + : [], + ); + expect(snapshots).toHaveLength(2); + const ids = snapshots.map( + (snapshot) => (snapshot as typeof snapshot & { subscriptionId: string }).subscriptionId, + ); + expect(ids.every((id) => typeof id === "string")).toBe(true); + expect(ids[0]).not.toBe(ids[1]); + await peer.request({ + type: "subscription.release.request", + requestId: "release-events-a", + subscriptionId: ids[0], + }); + peer.frames.length = 0; + await admin.createWorkspace({ source: { kind: "directory", path: daemon.staticDir } }); + await expect + .poll( + () => + peer!.frames.flatMap((frame) => + frame.type === "session" && frame.message.type === "project.update" + ? [frame.message.payload] + : [], + ), + { timeout: 5000 }, + ) + .toEqual( + expect.arrayContaining([ + expect.objectContaining({ subscriptionId: ids[1], kind: "upsert" }), + ]), + ); + expect( + peer.frames.filter( + (frame) => frame.type === "session" && frame.message.type !== "project.update", + ), + ).toEqual([]); + } finally { + peer?.close(); + await admin.close(); + await daemon.close(); + } +}); + +test("timeline observers for the same agent survive an independent release", async () => { + const daemon = await createTestPaseoDaemon({ mcpEnabled: false }); + const admin = new DaemonClient({ url: `ws://127.0.0.1:${daemon.port}/ws`, appVersion: "0.8.0" }); + let peer: SubscriptionPeer | undefined; + try { + await admin.connect(); + const agent = await admin.createAgent({ + provider: "codex", + cwd: daemon.staticDir, + title: "Timeline", + }); + peer = await SubscriptionPeer.connect(daemon.port, "timeline-observers"); + await peer.request({ + type: "agent.timeline.set_subscription.request", + requestId: "timeline-a", + agentIds: [agent.id], + }); + await peer.request({ + type: "agent.timeline.set_subscription.request", + requestId: "timeline-b", + agentIds: [agent.id], + }); + const snapshots = peer.frames.flatMap((frame) => + frame.type === "session" && frame.message.type === "agent.timeline.set_subscription.response" + ? [frame.message.payload] + : [], + ); + expect(snapshots).toHaveLength(2); + const ids = snapshots.map( + (snapshot) => (snapshot as typeof snapshot & { subscriptionId: string }).subscriptionId, + ); + expect(ids.every((id) => typeof id === "string")).toBe(true); + expect(ids[0]).not.toBe(ids[1]); + await peer.request({ + type: "subscription.release.request", + requestId: "release-timeline-a", + subscriptionId: ids[0], + }); + peer.frames.length = 0; + await admin.sendMessage(agent.id, "An observed turn"); + await expect + .poll( + () => + peer!.frames.filter( + (frame) => frame.type === "session" && frame.message.type === "agent_stream", + ).length, + { timeout: 5000 }, + ) + .toBeGreaterThan(0); + const updates = peer.frames.flatMap((frame) => + frame.type === "session" && frame.message.type === "agent_stream" + ? [frame.message.payload] + : [], + ); + expect( + updates.every( + (update) => + "subscriptionId" in update && + update.subscriptionId === ids[1] && + update.agentId === agent.id, + ), + ).toBe(true); + } finally { + peer?.close(); + await admin.close(); + await daemon.close(); + } +}); + +test("same-workspace terminal directory observers do not share a teardown slot", async () => { + const daemon = await createTestPaseoDaemon({ mcpEnabled: false }); + const admin = new DaemonClient({ url: `ws://127.0.0.1:${daemon.port}/ws`, appVersion: "0.8.0" }); + let peer: SubscriptionPeer | undefined; + try { + await admin.connect(); + const workspace = ( + await admin.createWorkspace({ source: { kind: "directory", path: daemon.staticDir } }) + ).workspace!; + peer = await SubscriptionPeer.connect(daemon.port, "terminal-directory-observers"); + await peer.request({ + type: "subscribe_terminals_request", + requestId: "terminals-a", + cwd: daemon.staticDir, + workspaceId: workspace.id, + }); + await peer.request({ + type: "subscribe_terminals_request", + requestId: "terminals-b", + cwd: daemon.staticDir, + workspaceId: workspace.id, + }); + const snapshots = peer.frames.flatMap((frame) => + frame.type === "session" && frame.message.type === "terminals_changed" + ? [frame.message.payload] + : [], + ); + expect(snapshots).toHaveLength(2); + const ids = snapshots.map( + (snapshot) => (snapshot as typeof snapshot & { subscriptionId: string }).subscriptionId, + ); + expect(ids.every((id) => typeof id === "string")).toBe(true); + expect(ids[0]).not.toBe(ids[1]); + await peer.request({ + type: "subscription.release.request", + requestId: "release-terminals-a", + subscriptionId: ids[0], + }); + peer.frames.length = 0; + const result = await admin.createTerminal(daemon.staticDir, "Observed terminal", undefined, { + workspaceId: workspace.id, + command: "/bin/cat", + }); + expect(result.error).toBe(null); + await expect + .poll( + () => + peer!.frames.flatMap((frame) => + frame.type === "session" && frame.message.type === "terminals_changed" + ? [frame.message.payload] + : [], + ), + { timeout: 5000 }, + ) + .toEqual( + expect.arrayContaining([ + expect.objectContaining({ + subscriptionId: ids[1], + workspaceId: workspace.id, + terminals: [expect.objectContaining({ id: result.terminal!.id })], + }), + ]), + ); + const updates = peer.frames.flatMap((frame) => + frame.type === "session" && frame.message.type === "terminals_changed" + ? [frame.message.payload] + : [], + ); + expect( + updates.every((update) => "subscriptionId" in update && update.subscriptionId === ids[1]), + ).toBe(true); + await admin.killTerminal(result.terminal!.id); + } finally { + peer?.close(); + await admin.close(); + await daemon.close(); + } +}); + +test("terminal output has separate server IDs and binary slots, including in a shared session", async () => { + const daemon = await createTestPaseoDaemon({ mcpEnabled: false }); + const admin = new DaemonClient({ url: `ws://127.0.0.1:${daemon.port}/ws`, appVersion: "0.8.0" }); + const peers: SubscriptionPeer[] = []; + try { + await admin.connect(); + const workspace = ( + await admin.createWorkspace({ source: { kind: "directory", path: daemon.staticDir } }) + ).workspace!; + const result = await admin.createTerminal(daemon.staticDir, "Output ownership", undefined, { + workspaceId: workspace.id, + command: "/bin/cat", + }); + expect(result.error).toBe(null); + const idle = await SubscriptionPeer.connect(daemon.port, "terminal-output-session"); + peers.push(idle); + const peer = await SubscriptionPeer.connect(daemon.port, "terminal-output-session"); + peers.push(peer); + for (const requestId of ["output-a", "output-b"]) { + await peer.request({ + type: "subscribe_terminal_request", + requestId, + terminalId: result.terminal!.id, + }); + } + const replies = peer.frames.flatMap((frame) => + frame.type === "session" && + frame.message.type === "subscribe_terminal_response" && + frame.message.payload.error === null + ? [frame.message.payload] + : [], + ); + expect(replies).toHaveLength(2); + const ids = replies.map( + (reply) => (reply as typeof reply & { subscriptionId: string }).subscriptionId, + ); + expect(ids.every((id) => typeof id === "string")).toBe(true); + expect(ids[0]).not.toBe(ids[1]); + expect(replies[0].slot).not.toBe(replies[1].slot); + await expect + .poll(() => + peer.binaryFrames + .map(decodeTerminalStreamFrame) + .filter((frame) => frame?.opcode === TerminalStreamOpcode.Snapshot) + .map((frame) => frame!.slot), + ) + .toEqual(expect.arrayContaining(replies.map((reply) => reply.slot))); + expect(idle.binaryFrames).toEqual([]); + await peer.request({ + type: "subscription.release.request", + requestId: "release-output-a", + subscriptionId: ids[0], + }); + peer.binaryFrames.length = 0; + admin.sendTerminalInput(result.terminal!.id, { type: "input", data: "remaining-observer\n" }); + await expect + .poll( + () => + peer.binaryFrames + .map(decodeTerminalStreamFrame) + .filter((frame) => frame?.opcode === TerminalStreamOpcode.Output) + .map((frame) => Buffer.from(frame!.payload).toString()) + .join(""), + { timeout: 5000 }, + ) + .toContain("remaining-observer"); + expect( + peer.binaryFrames + .map(decodeTerminalStreamFrame) + .every((frame) => frame?.slot === replies[1].slot), + ).toBe(true); + expect(idle.binaryFrames).toEqual([]); + await admin.killTerminal(result.terminal!.id); + } finally { + for (const peer of peers) peer.close(); + await admin.close(); + await daemon.close(); + } +}); + +test("a modern connection and pure reads retain no client observation producers", async () => { + const daemon = await createTestPaseoDaemon({ mcpEnabled: false }); + let peer: SubscriptionPeer | undefined; + try { + peer = await SubscriptionPeer.connect(daemon.port, "no-demand-producers"); + await peer.request({ type: "fetch_agents_request", requestId: "plain-agents" }); + await peer.request({ type: "fetch_workspaces_request", requestId: "plain-workspaces" }); + await peer.request({ type: "workspace.label.list.request", requestId: "plain-labels" }); + await peer.request({ type: "list_terminals_request", requestId: "plain-terminals" }); + await peer.request({ type: "diagnostics.request", requestId: "producer-diagnostics" }); + const diagnostic = peer.frames.flatMap((frame) => + frame.type === "session" && frame.message.type === "diagnostics.response" + ? [frame.message.payload.diagnostic] + : [], + )[0]; + expect(diagnostic).toContain("Session observations"); + expect(diagnostic).toContain("Registrations: 0"); + expect(diagnostic).toContain("Producer listeners: 0"); + expect(diagnostic).toContain("Git observations: 0"); + } finally { + peer?.close(); + await daemon.close(); + } +}); + +test("config changes require their own event demand and cannot escape to an idle socket", async () => { + const daemon = await createTestPaseoDaemon({ mcpEnabled: false }); + const admin = new DaemonClient({ url: `ws://127.0.0.1:${daemon.port}/ws`, appVersion: "0.8.0" }); + const peers: SubscriptionPeer[] = []; + try { + await admin.connect(); + const idle = await SubscriptionPeer.connect(daemon.port, "config-session"); + peers.push(idle); + const observer = await SubscriptionPeer.connect(daemon.port, "config-session"); + peers.push(observer); + await observer.request({ + type: "session.events.set_subscription.request", + requestId: "config-feed", + events: ["status.daemon_config_changed"], + }); + await admin.patchDaemonConfig({ autoArchiveAfterMerge: true }); + await expect + .poll(() => + observer.frames.some( + (frame) => + frame.type === "session" && + frame.message.type === "status" && + frame.message.payload.status === "daemon_config_changed", + ), + ) + .toBe(true); + expect(idle.frames).toEqual([]); + const changes = observer.frames.flatMap((frame) => + frame.type === "session" && frame.message.type === "status" ? [frame.message.payload] : [], + ); + expect( + changes.every( + (change) => + change.status === "daemon_config_changed" && typeof change.subscriptionId === "string", + ), + ).toBe(true); + } finally { + for (const peer of peers) peer.close(); + await admin.close(); + await daemon.close(); + } +}); + +test("status-shaped operation replies use actual source request provenance", async () => { + const daemon = await createTestPaseoDaemon({ mcpEnabled: false }); + const peers: SubscriptionPeer[] = []; + try { + const idle = await SubscriptionPeer.connect(daemon.port, "status-reply-session"); + peers.push(idle); + const actor = await SubscriptionPeer.connect(daemon.port, "status-reply-session"); + peers.push(actor); + await actor.request({ + type: "create_agent_request", + requestId: "created-status", + config: { provider: "codex", cwd: daemon.staticDir }, + }); + expect(actor.frames).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + type: "session", + message: expect.objectContaining({ + type: "status", + payload: expect.objectContaining({ + status: "agent_created", + requestId: "created-status", + }), + }), + }), + ]), + ); + expect(idle.frames).toEqual([]); + await actor.request({ type: "schedule/list", requestId: "schedule-list" }); + expect(idle.frames).toEqual([]); + } finally { + for (const peer of peers) peer.close(); + await daemon.close(); + } +}); + +test("browser capability advertisement is passive and explicit hosts own command delivery", async () => { + const daemon = await createTestPaseoDaemon({ mcpEnabled: false }); + const peers: SubscriptionPeer[] = []; + try { + const capability = { hostKind: "QA browser", supportedCommands: ["list_tabs"] }; + const idle = await SubscriptionPeer.connect(daemon.port, "browser-host-session", { + browser_host: capability, + }); + peers.push(idle); + expect(daemon.daemon.browserToolsBroker.getRegisteredClientCount()).toBe(0); + const host = await SubscriptionPeer.connect(daemon.port, "browser-host-session", { + browser_host: capability, + }); + peers.push(host); + await host.request({ + type: "browser.host.register.request", + requestId: "host-register", + hostKind: capability.hostKind, + supportedCommands: ["list_tabs"], + }); + expect(daemon.daemon.browserToolsBroker.getRegisteredClientCount()).toBe(1); + const pending = daemon.daemon.browserToolsBroker.execute({ + command: { command: "list_tabs", args: {} }, + }); + await expect + .poll(() => + host.frames.some( + (frame) => + frame.type === "session" && frame.message.type === "browser.automation.execute.request", + ), + ) + .toBe(true); + expect(idle.frames).toEqual([]); + const registration = host.frames.flatMap((frame) => + frame.type === "session" && frame.message.type === "browser.host.register.response" + ? [frame.message.payload] + : [], + )[0]; + await host.request({ + type: "subscription.release.request", + requestId: "release-host", + subscriptionId: registration.subscriptionId, + }); + expect(daemon.daemon.browserToolsBroker.getRegisteredClientCount()).toBe(0); + expect(daemon.daemon.browserToolsBroker.getPendingRequestCount()).toBe(0); + await expect(pending).resolves.toMatchObject({ ok: false, error: { code: "browser_no_host" } }); + } finally { + for (const peer of peers) peer.close(); + await daemon.close(); + } +}); + +test("adding public SDK listeners does not create server observation demand", async () => { + const daemon = await createTestPaseoDaemon({ mcpEnabled: false }); + const client = new DaemonClient({ + url: `ws://127.0.0.1:${daemon.port}/ws`, + appVersion: "0.8.0", + capabilities: { owned_subscriptions: true }, + }); + try { + await client.connect(); + const off = client.on("project.update", () => {}); + const all = client.subscribe(() => {}); + await client.ping(); + const { diagnostic } = await client.collectDiagnostics(); + expect(diagnostic).toContain("Registrations: 0"); + expect(diagnostic).toContain("Producer listeners: 0"); + off(); + all(); + } finally { + await client.close(); + await daemon.close(); + } +}); + +test("upload operations with identical request IDs belong to their source sockets", async () => { + const daemon = await createTestPaseoDaemon({ mcpEnabled: false }); + const peers: SubscriptionPeer[] = []; + try { + const a = await SubscriptionPeer.connect(daemon.port, "upload-shared-session"); + peers.push(a); + const b = await SubscriptionPeer.connect(daemon.port, "upload-shared-session"); + peers.push(b); + const idle = await SubscriptionPeer.connect(daemon.port, "upload-shared-session"); + peers.push(idle); + a.send({ + type: "file.upload.request", + requestId: "same-upload", + fileName: "a.txt", + mimeType: "text/plain", + size: 5, + modifiedAt: "2026-09-09T00:00:00Z", + }); + b.send({ + type: "file.upload.request", + requestId: "same-upload", + fileName: "b.txt", + mimeType: "text/plain", + size: 5, + modifiedAt: "2026-09-09T00:00:00Z", + }); + await a.request({ type: "ping", requestId: "a-upload-begun", clientSentAt: 1 }); + await b.request({ type: "ping", requestId: "b-upload-begun", clientSentAt: 1 }); + a.upload("same-upload", "alpha", "a.txt"); + b.upload("same-upload", "bravo", "b.txt"); + const uploads = (peer: SubscriptionPeer) => + peer.frames.flatMap((frame) => + frame.type === "session" && frame.message.type === "file.upload.response" + ? [frame.message.payload] + : [], + ); + await expect + .poll(() => uploads(a)) + .toEqual([ + expect.objectContaining({ + error: null, + file: expect.objectContaining({ fileName: "a.txt" }), + }), + ]); + await expect + .poll(() => uploads(b)) + .toEqual([ + expect.objectContaining({ + error: null, + file: expect.objectContaining({ fileName: "b.txt" }), + }), + ]); + expect(uploads(a)[0]?.file?.id).not.toBe(uploads(b)[0]?.file?.id); + expect(uploads(idle)).toEqual([]); + } finally { + peers.forEach((peer) => peer.close()); + await daemon.close(); + } +}); + +test("notification ownership is separate from data demand and another socket's focus", async () => { + const daemon = await createTestPaseoDaemon({ mcpEnabled: false }); + const admin = new DaemonClient({ url: `ws://127.0.0.1:${daemon.port}/ws`, appVersion: "0.8.0" }); + const peers: SubscriptionPeer[] = []; + try { + await admin.connect(); + const workspace = ( + await admin.createWorkspace({ source: { kind: "directory", path: daemon.staticDir } }) + ).workspace; + if (!workspace) throw new Error("Workspace creation failed"); + const agent = await admin.createAgent({ + provider: "codex", + cwd: daemon.staticDir, + workspaceId: workspace.id, + title: "Attention", + }); + const observer = await SubscriptionPeer.connect(daemon.port, "attention-sources"); + const passive = await SubscriptionPeer.connect(daemon.port, "attention-sources"); + peers.push(observer, passive); + for (const requestId of ["data-a", "data-b", "notifications"]) { + await observer.request({ + type: "session.events.set_subscription.request", + requestId, + events: ["agent_attention_required"], + notifications: requestId === "notifications", + }); + } + const snapshots = observer.frames.flatMap((frame) => + frame.type === "session" && frame.message.type === "session.events.set_subscription.response" + ? [frame.message.payload] + : [], + ); + const notificationId = snapshots.find( + (snapshot) => snapshot.requestId === "notifications", + )!.subscriptionId; + observer.send({ + type: "client_heartbeat", + deviceType: "web", + focusedAgentId: null, + lastActivityAt: new Date().toISOString(), + appVisible: true, + }); + passive.send({ + type: "client_heartbeat", + deviceType: "web", + focusedAgentId: agent.id, + lastActivityAt: new Date().toISOString(), + appVisible: true, + }); + await passive.request({ type: "ping", requestId: "focused-barrier", clientSentAt: 1 }); + observer.frames.length = 0; + passive.frames.length = 0; + await admin.sendMessage(agent.id, "Complete a turn"); + const updates = () => + observer.frames.flatMap((frame) => + frame.type === "session" && frame.message.type === "agent_attention_required" + ? [frame.message.payload] + : [], + ); + await expect.poll(() => updates().length, { timeout: 10000 }).toBe(3); + expect( + updates() + .filter((update) => update.shouldNotify) + .map((update) => update.subscriptionId), + ).toEqual([notificationId]); + expect(new Set(updates().map((update) => update.subscriptionId))).toEqual( + new Set(snapshots.map((snapshot) => snapshot.subscriptionId)), + ); + expect( + passive.frames.filter( + (frame) => frame.type === "session" && frame.message.type === "agent_attention_required", + ), + ).toEqual([]); + } finally { + for (const peer of peers) peer.close(); + await admin.close(); + await daemon.close(); + } +}); + +test("protocol rejection is a source-only control reply on modern connections", async () => { + const daemon = await createTestPaseoDaemon({ mcpEnabled: false }); + const peers: SubscriptionPeer[] = []; + try { + const sender = await SubscriptionPeer.connect(daemon.port, "invalid-message-sources"); + const idle = await SubscriptionPeer.connect(daemon.port, "invalid-message-sources"); + peers.push(sender, idle); + await sender.request({ + type: "fetch_agents_request", + requestId: "invalid-query", + scope: 42, + } as unknown as SessionInboundMessage); + const replies = sender.frames.flatMap((frame) => + frame.type === "session" && frame.message.type === "rpc_error" ? [frame.message.payload] : [], + ); + expect(replies).toEqual([ + expect.objectContaining({ requestId: "invalid-query", requestType: "fetch_agents_request" }), + ]); + expect(idle.frames).toEqual([]); + } finally { + for (const peer of peers) peer.close(); + await daemon.close(); + } +}); + +test("dictation failure belongs only to its requesting source and releases speech demand", async () => { + const daemon = await createTestPaseoDaemon({ mcpEnabled: false }); + const peers: SubscriptionPeer[] = []; + try { + const requester = await SubscriptionPeer.connect(daemon.port, "speech-sources"); + const idle = await SubscriptionPeer.connect(daemon.port, "speech-sources"); + peers.push(requester, idle); + requester.send({ + type: "dictation_stream_start", + dictationId: "same-dictation", + format: "audio/pcm;rate=16000;bits=16", + }); + await expect + .poll(() => + requester.frames.some( + (frame) => frame.type === "session" && frame.message.type === "dictation_stream_error", + ), + ) + .toBe(true); + expect(idle.frames).toEqual([]); + await requester.request({ type: "diagnostics.request", requestId: "speech-producers" }); + const report = requester.frames.flatMap((frame) => + frame.type === "session" && frame.message.type === "diagnostics.response" + ? [frame.message.payload.diagnostic] + : [], + )[0]; + expect(report).toContain("Registrations: 0"); + expect(report).toContain("Producer listeners: 0"); + } finally { + for (const peer of peers) peer.close(); + await daemon.close(); + } +}); + +test("a legacy socket cannot borrow a modern sibling's directory observation", async () => { + const daemon = await createTestPaseoDaemon({ mcpEnabled: false }); + const admin = new DaemonClient({ url: `ws://127.0.0.1:${daemon.port}/ws`, appVersion: "0.8.0" }); + const peers: SubscriptionPeer[] = []; + try { + await admin.connect(); + const workspace = ( + await admin.createWorkspace({ source: { kind: "directory", path: daemon.staticDir } }) + ).workspace!; + if (!workspace) throw new Error("Workspace creation failed"); + const agent = await admin.createAgent({ + provider: "codex", + cwd: daemon.staticDir, + workspaceId: workspace.id, + }); + const modern = await SubscriptionPeer.connect(daemon.port, "mixed-notifications"); + const legacy = await SubscriptionPeer.connect(daemon.port, "mixed-notifications", { + owned_subscriptions: false, + selective_agent_timeline: true, + explicit_event_subscriptions: true, + }); + peers.push(modern, legacy); + await modern.request({ + type: "fetch_agents_request", + requestId: "modern-directory", + subscribe: {}, + }); + await modern.request({ + type: "session.events.set_subscription.request", + requestId: "modern-attention", + events: ["agent_attention_required"], + }); + await admin.sendMessage(agent.id, "Finish a turn"); + await expect + .poll(() => + modern.frames.some( + (frame) => frame.type === "session" && frame.message.type === "agent_attention_required", + ), + ) + .toBe(true); + expect( + legacy.frames.filter( + (frame) => + frame.type === "session" && + (frame.message.type === "agent_attention_required" || + (frame.message.type === "agent_stream" && + frame.message.payload.event.type === "attention_required")), + ), + ).toEqual([]); + } finally { + for (const peer of peers) peer.close(); + await admin.close(); + await daemon.close(); + } +}); + +test("archive and delete replies retain their historical names and reach only the requester", async () => { + const daemon = await createTestPaseoDaemon({ mcpEnabled: false }); + const admin = new DaemonClient({ url: `ws://127.0.0.1:${daemon.port}/ws`, appVersion: "0.8.0" }); + const peers: SubscriptionPeer[] = []; + try { + await admin.connect(); + const agent = await admin.createAgent({ provider: "codex", cwd: daemon.staticDir }); + const requester = await SubscriptionPeer.connect(daemon.port, "mutation-replies"); + const sibling = await SubscriptionPeer.connect(daemon.port, "mutation-replies"); + peers.push(requester, sibling); + await requester.request({ + type: "archive_agent_request", + requestId: "archive", + agentId: agent.id, + }); + await requester.request({ + type: "delete_agent_request", + requestId: "delete", + agentId: agent.id, + }); + expect( + requester.frames.flatMap((frame) => (frame.type === "session" ? [frame.message.type] : [])), + ).toEqual(["agent_archived", "agent_deleted"]); + expect(sibling.frames).toEqual([]); + } finally { + for (const peer of peers) peer.close(); + await admin.close(); + await daemon.close(); + } +}); + +test("Hub bootstrap sends its server ID before the producer's synchronous snapshot", async () => { + const daemon = await createTestPaseoDaemon({ mcpEnabled: false }); + const admin = new DaemonClient({ url: `ws://127.0.0.1:${daemon.port}/ws` }); + const frames: WSOutboundMessage[] = []; + const hub = createServer(async (request, response) => { + let body = ""; + for await (const chunk of request) body += chunk; + response.setHeader("content-type", "application/json"); + if (request.url !== "/api/daemons/enroll") { + response.end("{}"); + return; + } + const enrollment = JSON.parse(body); + const address = hub.address(); + if (!address || typeof address === "string") throw new Error("Missing local Hub address"); + response.end( + JSON.stringify({ + daemonId: enrollment.daemonId, + permissions: enrollment.permissions, + webSocketUrl: `ws://127.0.0.1:${address.port}/socket`, + }), + ); + }); + const wss = new WebSocketServer({ server: hub }); + wss.on("headers", (headers) => headers.push("x-paseo-session-protocol: 1")); + const connected = new Promise((resolve) => + wss.once("connection", (socket) => { + socket.on("message", (data) => frames.push(JSON.parse(data.toString()))); + resolve(socket); + }), + ); + let socket: WebSocket | undefined; + try { + await new Promise((resolve) => hub.listen(0, "127.0.0.1", resolve)); + const address = hub.address(); + if (!address || typeof address === "string") throw new Error("Missing local Hub address"); + await admin.connect(); + await admin.connectHub(`http://127.0.0.1:${address.port}`, "local-fixture-token", [ + "hub.execute", + ]); + socket = await connected; + socket.send( + JSON.stringify({ + type: "hello", + clientId: "local-hub", + clientType: "hub", + protocolVersion: 1, + capabilities: { owned_subscriptions: true }, + }), + ); + await expect.poll(() => frames.length).toBeGreaterThan(0); + socket.send( + JSON.stringify({ + type: "session", + message: { + type: "hub.execution.agent.create.request", + requestId: "create", + executionId: "bootstrap", + provider: "codex", + cwd: daemon.staticDir, + prompt: "hello", + }, + }), + ); + await expect + .poll(() => + frames.some( + (frame) => + frame.type === "session" && + frame.message.type === "hub.execution.agent.create.response" && + frame.message.payload.success, + ), + ) + .toBe(true); + frames.length = 0; + socket.send( + JSON.stringify({ + type: "session", + message: { + type: "session.events.set_subscription.request", + requestId: "observe", + events: ["hub.execution.agent.update"], + }, + }), + ); + await expect + .poll(() => + frames.some( + (frame) => + frame.type === "session" && frame.message.type === "hub.execution.agent.update", + ), + ) + .toBe(true); + expect(frames[0]).toMatchObject({ + type: "session", + message: { + type: "session.events.set_subscription.response", + payload: { requestId: "observe", subscriptionId: expect.any(String) }, + }, + }); + } finally { + await admin.disconnectHub(true).catch(() => {}); + socket?.terminate(); + await admin.close(); + await daemon.close(); + await new Promise((resolve) => wss.close(() => resolve())); + await new Promise((resolve) => hub.close(() => resolve())); + } +}); + +test("provider child changes require explicit event ownership and leave an idle peer quiet", async () => { + const daemon = await createTestPaseoDaemon({ mcpEnabled: false }); + const admin = new DaemonClient({ url: `ws://127.0.0.1:${daemon.port}/ws` }); + let idle: SubscriptionPeer | undefined; + let observer: SubscriptionPeer | undefined; + try { + await admin.connect(); + idle = await SubscriptionPeer.connect(daemon.port, "idle-provider-child"); + observer = await SubscriptionPeer.connect(daemon.port, "provider-child-observer"); + await observer.request({ + type: "session.events.set_subscription.request", + requestId: "children", + events: ["agent.provider_subagents.update"], + }); + const response = observer.frames.find( + (frame) => + frame.type === "session" && + frame.message.type === "session.events.set_subscription.response", + ); + expect(response).toBeDefined(); + const agent = await admin.createAgent({ provider: "codex", cwd: daemon.staticDir }); + await admin.sendMessage(agent.id, "Emit a provider child"); + await expect + .poll(() => + observer!.frames.some( + (frame) => + frame.type === "session" && + frame.message.type === "agent.provider_subagents.update" && + frame.message.payload.kind === "upsert" && + frame.message.payload.subagent.id === "fixture-child", + ), + ) + .toBe(true); + expect(idle.frames).toEqual([]); + expect(idle.binaryFrames).toEqual([]); + } finally { + idle?.close(); + observer?.close(); + await admin.close(); + await daemon.close(); + } +}); + +test("request outcomes for import and attention reach only their requesting socket", async () => { + const daemon = await createTestPaseoDaemon({ + mcpEnabled: false, + isDev: true, + agentClients: { mock: new MockLoadTestAgentClient() }, + }); + const requester = await SubscriptionPeer.connect(daemon.port, "reply-shared"); + const sibling = await SubscriptionPeer.connect(daemon.port, "reply-shared"); + const admin = new DaemonClient({ url: `ws://127.0.0.1:${daemon.port}/ws` }); + try { + await admin.connect(); + await requester.request({ + type: "import_agent_request", + requestId: "missing-import", + provider: "codex", + sessionId: "does-not-exist", + }); + expect(requester.frames).toContainEqual( + expect.objectContaining({ + message: expect.objectContaining({ + type: "status", + payload: expect.objectContaining({ + requestId: "missing-import", + status: "agent_create_failed", + }), + }), + }), + ); + await requester.request({ + type: "import_agent_request", + requestId: "successful-import", + provider: "mock", + sessionId: "saved-provider-session", + cwd: daemon.staticDir, + }); + expect(requester.frames).toContainEqual( + expect.objectContaining({ + message: expect.objectContaining({ + type: "status", + payload: expect.objectContaining({ + requestId: "successful-import", + status: "agent_resumed", + }), + }), + }), + ); + const agent = await admin.createAgent({ + provider: "codex", + cwd: daemon.staticDir, + title: "Attention", + }); + await requester.request({ + type: "clear_agent_attention", + requestId: "clear-attention", + agentId: agent.id, + }); + expect(requester.frames).toContainEqual( + expect.objectContaining({ + message: expect.objectContaining({ type: "clear_agent_attention_response" }), + }), + ); + expect(sibling.frames).toEqual([]); + expect(sibling.binaryFrames).toEqual([]); + } finally { + requester.close(); + sibling.close(); + await admin.close(); + await daemon.close(); + } +}); + +test("SDK import failure settles without waiting for the request timeout", async () => { + const daemon = await createTestPaseoDaemon({ mcpEnabled: false }); + const client = new DaemonClient({ url: `ws://127.0.0.1:${daemon.port}/ws` }); + try { + await client.connect(); + await expect( + client.importAgent({ provider: "codex", sessionId: "does-not-exist" }), + ).rejects.toThrow(); + } finally { + await client.close(); + await daemon.close(); + } +}, 5000); + +test("permission outcomes and domain observations keep independent source ownership", async () => { + const daemon = await createTestPaseoDaemon({ mcpEnabled: false }); + const clients = [ + "permission-shared", + "permission-shared", + "permission-control", + "permission-shared", + ].map((clientId) => new DaemonClient({ url: `ws://127.0.0.1:${daemon.port}/ws`, clientId })); + const legacy = new DaemonClient({ + url: `ws://127.0.0.1:${daemon.port}/ws`, + clientId: "permission-shared", + capabilities: { owned_subscriptions: false, explicit_event_subscriptions: false }, + }); + clients.push(legacy); + const actor = clients[0]; + const observed: SessionOutboundMessage[][] = clients.map(() => []); + const raw: SessionOutboundMessage[][] = clients.map(() => []); + try { + for (const [index, client] of clients.entries()) { + await client.connect(); + client.subscribeRawMessages((message) => raw[index].push(message)); + } + const owners = clients.slice(0, 3).map((client, index) => { + const owner = client.observeEvents(["agent_permission_request", "agent_permission_resolved"]); + owner.subscribe({ snapshot: () => {}, update: (message) => observed[index].push(message) }); + return owner; + }); + await Promise.all(owners.map((owner) => owner.ready)); + const agent = await actor.createAgent({ + provider: "codex", + cwd: daemon.staticDir, + modeId: "always-ask", + }); + for (let round = 0; round < 3; round += 1) { + if (round === 1) await owners[1].release(); + observed.forEach((messages) => { + messages.length = 0; + }); + raw.forEach((messages) => { + messages.length = 0; + }); + await actor.sendMessage( + agent.id, + 'Request approval to run the command `printf "ok" > permission.txt`. If denied, stop.', + ); + const state = await actor.waitForFinish(agent.id, 5000); + const permission = state.final?.pendingPermissions[0]; + expect(permission).toBeDefined(); + if (!permission) throw new Error("Expected a pending permission"); + await (round === 2 ? legacy : actor).respondToPermission(agent.id, permission.id, { + behavior: "deny", + message: "Owned observation regression", + }); + const resolutions = (messages: SessionOutboundMessage[]) => + messages.filter( + ( + message, + ): message is Extract => + message.type === "agent_permission_resolved" && + message.payload.requestId === permission.id, + ); + await expect.poll(() => resolutions(observed[2]).length).toBe(1); + await actor.waitForFinish(agent.id, 5000); + await expect.poll(() => resolutions(observed[0]).length).toBe(1); + for (const index of [0, 1, 2]) { + const expected = index === 1 && round >= 1 ? 0 : 1; + expect(resolutions(observed[index])).toHaveLength(expected); + for (const message of resolutions(observed[index])) { + expect(message.payload).toMatchObject({ + subscriptionId: owners[index].subscriptionId, + resolution: { behavior: "deny" }, + }); + } + const outcomes = resolutions(raw[index]).filter( + (message) => !message.payload.subscriptionId, + ); + expect(outcomes).toHaveLength(index === 0 && round < 2 ? 1 : 0); + } + expect(resolutions(raw[4])).toHaveLength(1); + expect(raw[3]).toEqual([]); + } + } finally { + await Promise.all(clients.map((client) => client.close())); + await daemon.close(); + } +}); + +test("mark unread replies remain source-owned while directory observers receive attention", async () => { + const daemon = await createTestPaseoDaemon({ mcpEnabled: false }); + const admin = new DaemonClient({ url: `ws://127.0.0.1:${daemon.port}/ws` }); + const peers: SubscriptionPeer[] = []; + try { + await admin.connect(); + const agent = await admin.createAgent({ + provider: "codex", + cwd: daemon.staticDir, + title: "Finished", + }); + const actor = await SubscriptionPeer.connect(daemon.port, "mark-unread-shared"); + peers.push(actor); + const sibling = await SubscriptionPeer.connect(daemon.port, "mark-unread-shared"); + peers.push(sibling); + const quiet = await SubscriptionPeer.connect(daemon.port, "mark-unread-shared"); + peers.push(quiet); + await sibling.request({ type: "fetch_agents_request", requestId: "directory", subscribe: {} }); + const snapshot = sibling.frames.flatMap((frame) => + frame.type === "session" && frame.message.type === "fetch_agents_response" + ? [frame.message.payload] + : [], + )[0]; + const workspaceId = snapshot.entries.find((entry) => entry.agent.id === agent.id)!.agent + .workspaceId!; + await actor.request({ + type: "workspace.clear_attention.request", + workspaceId, + requestId: "clear", + }); + actor.frames.length = sibling.frames.length = quiet.frames.length = 0; + await actor.request({ type: "workspace.mark_unread.request", workspaceId, requestId: "mark" }); + expect(actor.frames).toContainEqual( + expect.objectContaining({ + message: { + type: "workspace.mark_unread.response", + payload: { + workspaceId, + requestId: "mark", + markedAgentId: agent.id, + success: true, + error: null, + }, + }, + }), + ); + await expect + .poll(() => + sibling.frames.some( + (frame) => + frame.type === "session" && + frame.message.type === "agent_update" && + frame.message.payload.kind === "upsert" && + frame.message.payload.agent.id === agent.id && + frame.message.payload.agent.requiresAttention && + frame.message.payload.subscriptionId === snapshot.subscriptionId, + ), + ) + .toBe(true); + expect( + sibling.frames.some( + (frame) => + frame.type === "session" && frame.message.type === "workspace.mark_unread.response", + ), + ).toBe(false); + expect(quiet.frames).toEqual([]); + expect(quiet.binaryFrames).toEqual([]); + await actor.request({ + type: "workspace.mark_unread.request", + workspaceId, + requestId: "already-unread", + }); + expect(actor.frames).toContainEqual( + expect.objectContaining({ + message: expect.objectContaining({ + type: "workspace.mark_unread.response", + payload: expect.objectContaining({ requestId: "already-unread", success: false }), + }), + }), + ); + } finally { + for (const peer of peers) peer.close(); + await admin.close(); + await daemon.close(); + } +}); diff --git a/packages/server/src/server/plugins/agent-configuration.e2e.test.ts b/packages/server/src/server/plugins/agent-configuration.e2e.test.ts index adb0c6d2da..3228306297 100644 --- a/packages/server/src/server/plugins/agent-configuration.e2e.test.ts +++ b/packages/server/src/server/plugins/agent-configuration.e2e.test.ts @@ -17,7 +17,7 @@ test("the configuration example adds MCP servers and overrides Codex options whi const client = new DaemonClient({ url: `ws://127.0.0.1:${daemon.port}/ws`, appVersion: "0.8.0" }); try { await client.connect(); - await client.fetchAgents({ subscribe: { subscriptionId: "configuration-example" } }); + await client.fetchAgents({ subscribe: {} }); await client.patchDaemonConfig({ pluginsEnabled: true }); await client.installDirectoryPlugin( fileURLToPath(new URL("../../../../../plugin-examples/agent-configuration", import.meta.url)), diff --git a/packages/server/src/server/plugins/lifecycle-archive.e2e.test.ts b/packages/server/src/server/plugins/lifecycle-archive.e2e.test.ts index 06ced0f347..8457c94711 100644 --- a/packages/server/src/server/plugins/lifecycle-archive.e2e.test.ts +++ b/packages/server/src/server/plugins/lifecycle-archive.e2e.test.ts @@ -12,7 +12,7 @@ test("workspace archive publishes agent archive hooks for both live and closed a const client = new DaemonClient({ url: `ws://127.0.0.1:${daemon.port}/ws`, appVersion: "0.8.0" }); try { await client.connect(); - await client.fetchAgents({ subscribe: { subscriptionId: "archive-hooks" } }); + await client.fetchAgents({ subscribe: {} }); await client.patchDaemonConfig({ pluginsEnabled: true }); await client.installDirectoryPlugin( fileURLToPath(new URL("../../../../../plugin-examples/lifecycle-logger", import.meta.url)), diff --git a/packages/server/src/server/plugins/lifecycle.e2e.test.ts b/packages/server/src/server/plugins/lifecycle.e2e.test.ts index f21d1b86e5..48af2cd80c 100644 --- a/packages/server/src/server/plugins/lifecycle.e2e.test.ts +++ b/packages/server/src/server/plugins/lifecycle.e2e.test.ts @@ -32,7 +32,7 @@ export default function contribute(server) { `, ); await client.connect(); - await client.fetchAgents({ subscribe: { subscriptionId: "lifecycle" } }); + await client.fetchAgents({ subscribe: {} }); await client.patchDaemonConfig({ pluginsEnabled: true }); await client.installDirectoryPlugin(directory); const created = await client.createWorkspace({ @@ -100,7 +100,7 @@ export default function contribute(server) { `, ); await client.connect(); - await client.fetchAgents({ subscribe: { subscriptionId: "turn-hooks" } }); + await client.fetchAgents({ subscribe: {} }); await client.patchDaemonConfig({ pluginsEnabled: true }); await client.installDirectoryPlugin(directory); const agent = await client.createAgent({ @@ -183,7 +183,7 @@ export default function contribute(server) { `, ); await client.connect(); - await client.fetchAgents({ subscribe: { subscriptionId: "agent-hooks" } }); + await client.fetchAgents({ subscribe: {} }); await client.patchDaemonConfig({ pluginsEnabled: true }); await client.installDirectoryPlugin(directory); const agent = await client.createAgent({ diff --git a/packages/server/src/server/plugins/lifecycle.real.e2e.test.ts b/packages/server/src/server/plugins/lifecycle.real.e2e.test.ts index 7dad32752e..bd71f0ce7e 100644 --- a/packages/server/src/server/plugins/lifecycle.real.e2e.test.ts +++ b/packages/server/src/server/plugins/lifecycle.real.e2e.test.ts @@ -130,7 +130,7 @@ test.skipIf(process.platform !== "linux")( }; try { await client.connect(); - await client.fetchAgents({ subscribe: { subscriptionId: "real-hooks" } }); + await client.fetchAgents({ subscribe: {} }); await client.patchDaemonConfig({ pluginsEnabled: true }); await client.installDirectoryPlugin(path.join(repoRoot, "plugin-examples/lifecycle-logger")); await client.installDirectoryPlugin(path.join(repoRoot, "plugin-examples/lifecycle-actions")); diff --git a/packages/server/src/server/plugins/plugin-process.ts b/packages/server/src/server/plugins/plugin-process.ts index d08bd6f669..ee8a598a64 100644 --- a/packages/server/src/server/plugins/plugin-process.ts +++ b/packages/server/src/server/plugins/plugin-process.ts @@ -282,6 +282,9 @@ async function initialize(message: Extract { if (stopping) return; stopping = true; + const releaseApi = paseo + ?.dispose() + .catch((error) => console.error("Plugin API cleanup failed", error)); hooks.close(); for (const pending of pendingProviderConnections.values()) pending.tombstoned = true; const currentCleanup = cleanup; @@ -292,6 +295,7 @@ async function shutdown(): Promise { console.error("Plugin cleanup failed", error); } await Promise.all([...providerConnections.keys()].map(closeProviderConnection)); + await releaseApi; await daemonClient?.close().catch(() => undefined); await sendAndWait({ type: "paseo_close" }); daemonClient = null; @@ -321,6 +325,9 @@ process.on("message", (rawMessage: unknown) => { if (message.type === "initialize") { void initialize(message).catch(async (error) => { send({ type: "fatal", error: describeError(error) }); + await paseo + ?.dispose() + .catch((failure) => console.error("Plugin API cleanup failed", failure)); await daemonClient?.close().catch(() => undefined); }); return; diff --git a/packages/server/src/server/plugins/settings.e2e.test.ts b/packages/server/src/server/plugins/settings.e2e.test.ts index 2823cd79ab..5828e84beb 100644 --- a/packages/server/src/server/plugins/settings.e2e.test.ts +++ b/packages/server/src/server/plugins/settings.e2e.test.ts @@ -33,6 +33,7 @@ export default function(server) { server.registerSettings(defineSettings({ id: " ); await first.connect(); await second.connect(); + await second.observeEvents(["status.plugin_settings_changed"]).ready; second.on("status", (message) => { if (message.payload.status === "plugin_settings_changed") changed.push(z.string().parse(message.payload.settingsId)); diff --git a/packages/server/src/server/schedule-run-lifecycle.e2e.test.ts b/packages/server/src/server/schedule-run-lifecycle.e2e.test.ts index 250d8ec583..a27de8825c 100644 --- a/packages/server/src/server/schedule-run-lifecycle.e2e.test.ts +++ b/packages/server/src/server/schedule-run-lifecycle.e2e.test.ts @@ -222,7 +222,7 @@ test("archiveOnFinish=false local scheduled run emits upserts and remains active }, runOnCreate: false, }); - await ctx.client.fetchWorkspaces({ subscribe: { subscriptionId: "schedule-local-workspaces" } }); + await ctx.client.fetchWorkspaces({ subscribe: {} }); const events = collectLifecycleUpdates(); const ran = await runScheduleOnce(schedule.id); @@ -256,7 +256,7 @@ test("archiveOnFinish=true scheduled run emits a workspace remove", async () => runOnCreate: false, }); await ctx.client.fetchWorkspaces({ - subscribe: { subscriptionId: "schedule-archive-workspaces" }, + subscribe: {}, }); const events = collectLifecycleUpdates(); @@ -293,7 +293,7 @@ test("worktree isolation creates a run worktree and archiveOnFinish removes it", runOnCreate: false, }); await ctx.client.fetchWorkspaces({ - subscribe: { subscriptionId: "schedule-worktree-workspaces" }, + subscribe: {}, }); const events = collectLifecycleUpdates(); diff --git a/packages/server/src/server/selective-timeline-delivery.e2e.test.ts b/packages/server/src/server/selective-timeline-delivery.e2e.test.ts index a06b7a32c8..f47b30ce85 100644 --- a/packages/server/src/server/selective-timeline-delivery.e2e.test.ts +++ b/packages/server/src/server/selective-timeline-delivery.e2e.test.ts @@ -6,7 +6,8 @@ import { createPersistedWorkspaceRecord } from "./workspace-registry.js"; import { afterEach, beforeEach, expect, test } from "vitest"; import { CLIENT_CAPS } from "@getpaseo/protocol/client-capabilities"; import type { SessionOutboundMessage } from "@getpaseo/protocol/messages"; -import { DaemonClient } from "./test-utils/daemon-client.js"; +import { DaemonClient, type WebSocketLike } from "@getpaseo/client/internal/daemon-client"; +import { WebSocket } from "ws"; import { createTestPaseoDaemon, type TestPaseoDaemon } from "./test-utils/paseo-daemon.js"; import { MockLoadTestAgentClient, @@ -25,7 +26,10 @@ class ConnectedClient { private readonly waiters: MessageWaiter[] = []; private readonly unsubscribe: () => void; - constructor(readonly client: DaemonClient) { + constructor( + readonly client: DaemonClient, + private readonly socket: WebSocket, + ) { this.unsubscribe = client.subscribeRawMessages((message) => { this.messages.push(message); for (let waiterIndex = this.waiters.length - 1; waiterIndex >= 0; waiterIndex -= 1) { @@ -38,6 +42,46 @@ class ConnectedClient { }); } + // These cases exercise the pre-owned-subscriptions selective timeline contract. + async setLegacyTimelineMembership(agentIds: string[]): Promise { + const requestId = crypto.randomUUID(); + this.socket.send( + JSON.stringify({ + type: "session", + message: { + type: "agent.timeline.set_subscription.request", + requestId, + agentIds, + }, + }), + ); + await this.next( + (message) => + message.type === "agent.timeline.set_subscription.response" && + message.payload.requestId === requestId, + "legacy timeline acknowledgement", + ); + } + + async observeLegacyDirectory(subscriptionId: string): Promise { + const requestId = crypto.randomUUID(); + this.socket.send( + JSON.stringify({ + type: "session", + message: { + type: "fetch_agents_request", + requestId, + subscribe: { subscriptionId }, + }, + }), + ); + await this.next( + (message) => + message.type === "fetch_agents_response" && message.payload.requestId === requestId, + "legacy directory acknowledgement", + ); + } + clear(): void { this.messages.length = 0; } @@ -141,24 +185,28 @@ async function connect(input: { pluginTimelineItems?: boolean; workspaceSetupBlocked?: boolean; }): Promise { + let socket!: WebSocket; const client = new DaemonClient({ url: `ws://127.0.0.1:${daemon.port}/ws`, clientId: input.clientId, + webSocketFactory: (url) => { + socket = new WebSocket(url); + return socket as unknown as WebSocketLike; + }, capabilities: { + [CLIENT_CAPS.ownedSubscriptions]: false, [CLIENT_CAPS.selectiveAgentTimeline]: input.selective, [CLIENT_CAPS.pluginTimelineItems]: input.pluginTimelineItems ?? false, [CLIENT_CAPS.workspaceSetupBlocked]: input.workspaceSetupBlocked ?? false, ...(input.timelineNotifications === undefined ? {} : { [CLIENT_CAPS.timelineNotifications]: input.timelineNotifications }), - ...(input.timelineReplacementInvalidation - ? { [CLIENT_CAPS.timelineReplacementInvalidation]: true } - : {}), + [CLIENT_CAPS.timelineReplacementInvalidation]: input.timelineReplacementInvalidation ?? false, }, reconnect: { enabled: false }, }); await client.connect(); - const connected = new ConnectedClient(client); + const connected = new ConnectedClient(client, socket); clients.push(connected); return connected; } @@ -358,9 +406,9 @@ test("rewind routes replacement completion by source capability and subscription }); await Promise.all([ - initiating.client.setAgentTimelineSubscription([agent.id]), - passive.client.setAgentTimelineSubscription([agent.id]), - unrelated.client.setAgentTimelineSubscription([]), + initiating.setLegacyTimelineMembership([agent.id]), + passive.setLegacyTimelineMembership([agent.id]), + unrelated.setLegacyTimelineMembership([]), ]); await initiating.client.sendMessage(agent.id, "Rewind this synthetic prompt"); await initiating.client.cancelAgent(agent.id); @@ -409,7 +457,7 @@ test("subscription acknowledgements stay on the requesting socket of a retained legacy.clear(); capable.clear(); - await capable.client.setAgentTimelineSubscription(["agent-a"]); + await capable.setLegacyTimelineMembership(["agent-a"]); await capable.barrier("targeted-subscription-ack"); expect( @@ -444,7 +492,7 @@ test("real WebSocket sessions enforce selective delivery, retained resets, downg await capable.barrier("before-membership"); expect(capable.hasTimeline(agentC.id)).toBe(false); - await capable.client.setAgentTimelineSubscription([agentA.id, agentB.id]); + await capable.setLegacyTimelineMembership([agentA.id, agentB.id]); legacy.clear(); capable.clear(); await daemon.daemon.agentManager.emitLiveTimelineItem(agentA.id, { @@ -467,7 +515,7 @@ test("real WebSocket sessions enforce selective delivery, retained resets, downg await capable.barrier("unviewed-c"); expect(capable.hasTimeline(agentC.id)).toBe(false); - await capable.client.setAgentTimelineSubscription([agentB.id]); + await capable.setLegacyTimelineMembership([agentB.id]); legacy.clear(); capable.clear(); await daemon.daemon.agentManager.emitLiveTimelineItem(agentA.id, { @@ -500,8 +548,8 @@ test("real WebSocket sessions enforce selective delivery, retained resets, downg expect(capable.hasTimeline(agentB.id)).toBe(false); await Promise.all([ - legacy.client.fetchAgents({ subscribe: { subscriptionId: "legacy-directory" } }), - capable.client.fetchAgents({ subscribe: { subscriptionId: "capable-directory" } }), + legacy.observeLegacyDirectory("legacy-directory"), + capable.observeLegacyDirectory("capable-directory"), ]); capable.client.sendHeartbeat({ deviceType: "mobile", diff --git a/packages/server/src/server/session.test.ts b/packages/server/src/server/session.test.ts index 04406b21f0..a32f5265c9 100644 --- a/packages/server/src/server/session.test.ts +++ b/packages/server/src/server/session.test.ts @@ -372,7 +372,10 @@ function createSessionForTest(options: SessionForTestOptions = {}): Session { onMessageToSource: (source: object, message: SessionOutboundMessage) => options.targetedMessages?.push({ source, message }), } - : {}), + : { + onMessageToSource: (_source: object, message: SessionOutboundMessage) => + messages.push(message), + }), onBinaryMessage: createBinaryMessageHandler(options.binaryMessages), logger, downloadTokenStore: options.downloadTokenStore ?? asDownloadTokenStore(), @@ -517,6 +520,12 @@ test("routes plugin requests and releases its owned catalog subscription on clea }; const session = createSessionForTest({ messages, pluginRuntime }); + await session.handleMessage({ + type: "session.events.set_subscription.request", + requestId: "catalog", + events: ["status.plugin_catalog_changed"], + }); + messages.length = 0; await session.handleMessage({ type: "plugin.list.request", requestId: "list" }); await session.handleMessage({ type: "plugin.logs.get.request", @@ -1308,43 +1317,56 @@ describe("workspace file access (behavior preservation)", () => { const messages: SessionOutboundMessage[] = []; const session = createSessionForTest({ messages, paseoHome }); - await session.handleMessage({ - type: "file.upload.request", - fileName: "notes.txt", - mimeType: "text/plain", - size: 11, - modifiedAt: "2026-05-02T00:00:00.000Z", - requestId: "req-upload", - }); - await session.handleBinaryFrame({ - kind: "file_transfer", - frame: uploadFrame({ - opcode: FileTransferOpcode.FileBegin, + const source = {}; + await session.handleMessage( + { + type: "file.upload.request", + fileName: "notes.txt", + mimeType: "text/plain", + size: 11, + modifiedAt: "2026-05-02T00:00:00.000Z", requestId: "req-upload", - metadata: { - mime: "text/plain", - size: 11, - encoding: "binary", - modifiedAt: "2026-05-02T00:00:00.000Z", - fileName: "notes.txt", - }, - }), - }); - await session.handleBinaryFrame({ - kind: "file_transfer", - frame: uploadFrame({ - opcode: FileTransferOpcode.FileChunk, - requestId: "req-upload", - payload: new TextEncoder().encode("hello world"), - }), - }); - await session.handleBinaryFrame({ - kind: "file_transfer", - frame: uploadFrame({ - opcode: FileTransferOpcode.FileEnd, - requestId: "req-upload", - }), - }); + }, + source, + ); + await session.handleBinaryFrame( + { + kind: "file_transfer", + frame: uploadFrame({ + opcode: FileTransferOpcode.FileBegin, + requestId: "req-upload", + metadata: { + mime: "text/plain", + size: 11, + encoding: "binary", + modifiedAt: "2026-05-02T00:00:00.000Z", + fileName: "notes.txt", + }, + }), + }, + source, + ); + await session.handleBinaryFrame( + { + kind: "file_transfer", + frame: uploadFrame({ + opcode: FileTransferOpcode.FileChunk, + requestId: "req-upload", + payload: new TextEncoder().encode("hello world"), + }), + }, + source, + ); + await session.handleBinaryFrame( + { + kind: "file_transfer", + frame: uploadFrame({ + opcode: FileTransferOpcode.FileEnd, + requestId: "req-upload", + }), + }, + source, + ); const response = messages.find((message) => message.type === "file.upload.response"); if (response?.type !== "file.upload.response") { @@ -5200,6 +5222,7 @@ test("acknowledges a timeline subscription only to its socket source", async () payload: { agentIds: ["agent-a"], requestId: "timeline-subscription-targeted", + subscriptionId: expect.any(String), }, }, }, @@ -5725,34 +5748,50 @@ test("provider snapshots preserve versionless visibility while capabilities upda { provider: "plugin-provider", status: "ready", enabled: true }, ]); const session = createSessionForTest({ messages, providerSnapshotManager: manager }); + const source = {}; + session.updateClientCapabilities(null, source); const read = async () => { messages.length = 0; - await session.handleMessage({ - type: "get_providers_snapshot_request", - requestId: "visibility", - }); + await session.handleMessage( + { + type: "get_providers_snapshot_request", + requestId: "visibility", + }, + source, + ); return findByType(messages, "get_providers_snapshot_response")!.payload; }; const versionless = await read(); expect(versionless.entries.map((entry) => entry.provider)).toEqual(["codex"]); expect(versionless.entries[0]!.modes![0]!.icon).toBe("ShieldCheck"); - session.updateClientCapabilities({ - [CLIENT_CAPS.customModeIcons]: true, - [CLIENT_CAPS.providerSnapshotReferences]: true, - }); + session.updateClientCapabilities( + { + [CLIENT_CAPS.customModeIcons]: true, + [CLIENT_CAPS.providerSnapshotReferences]: true, + }, + source, + ); const iconsOnly = await read(); expect(iconsOnly.entries.map((entry) => entry.provider)).toEqual(["codex"]); expect(iconsOnly.entries[0]!.modes![0]!.icon).toBe("Sparkles"); expect(iconsOnly.snapshotHash).toBeUndefined(); - session.updateAppVersion("0.1.45"); + session.updateClientCapabilities( + { [CLIENT_CAPS.customModeIcons]: true, [CLIENT_CAPS.providerSnapshotReferences]: true }, + source, + "0.1.45", + ); expect((await read()).entries.map((entry) => entry.provider)).toEqual([ "codex", "plugin-provider", ]); - session.updateClientCapabilities({ - [CLIENT_CAPS.compactProviderSnapshots]: true, - [CLIENT_CAPS.providerSnapshotReferences]: true, - }); + session.updateClientCapabilities( + { + [CLIENT_CAPS.compactProviderSnapshots]: true, + [CLIENT_CAPS.providerSnapshotReferences]: true, + }, + source, + "0.1.45", + ); const references = await read(); expect(references.entries).toEqual([]); expect(references.compactSnapshot!.entries.map((entry) => entry.provider)).toEqual([ diff --git a/packages/server/src/server/session.ts b/packages/server/src/server/session.ts index c1e149ee5e..20f4143edf 100644 --- a/packages/server/src/server/session.ts +++ b/packages/server/src/server/session.ts @@ -1,6 +1,9 @@ +import type { BrowserToolsBroker } from "./browser-tools/broker.js"; +import { BrowserAutomationHostCapabilitySchema } from "@getpaseo/protocol/browser-automation/capabilities"; import type { SessionEventSubscription } from "@getpaseo/protocol/messages"; import type { AgentRequests } from "./agent/requests/index.js"; import equal from "fast-deep-equal"; +import { SessionDelivery, type OwnedSubscription } from "./session/owned-subscriptions/index.js"; import { v4 as uuidv4 } from "uuid"; import { lstat, mkdir, mkdtemp, rename, rm, stat } from "node:fs/promises"; import { basename, resolve, sep } from "path"; @@ -164,7 +167,7 @@ import { removeProjectCustomIcon, setProjectCustomIcon, } from "../utils/project-custom-icon.js"; -import { VoiceSession } from "./session/voice/voice-session.js"; +import { VoiceSessions } from "./session/voice/index.js"; import { CheckoutSession } from "./session/checkout/checkout-session.js"; import { createWorkspaceGitObserverService, @@ -290,17 +293,6 @@ function errorToFriendlyMessage(error: unknown): string { return "Unknown error"; } -function resolveSubscriptionId( - subscribe: unknown, - requestedSubscriptionId: string | undefined, -): string | null { - if (!subscribe) return null; - if (requestedSubscriptionId && requestedSubscriptionId.length > 0) { - return requestedSubscriptionId; - } - return uuidv4(); -} - function isAppVersionAtLeast(appVersion: string | null, minVersion: string): boolean { if (!appVersion) return false; // Strip prerelease suffix: "0.1.45-beta.4" -> "0.1.45" @@ -413,6 +405,7 @@ type WorkspaceUpdatePayload = Extract< >["payload"]; interface WorkspaceUpdatesSubscriptionState { subscriptionId: string; + owner: OwnedSubscription; syncEnabled?: boolean; filter?: WorkspaceUpdatesFilter; isBootstrapping: boolean; @@ -446,6 +439,7 @@ const nodeSessionFileSystem: SessionFileSystem = { type AgentMcpTransportFactory = () => Promise; export interface SessionOptions { + browserToolsBroker?: BrowserToolsBroker | null; clientId: string; permissions: readonly DaemonPermission[]; appVersion?: string | null; @@ -454,7 +448,7 @@ export interface SessionOptions { onMessageToSource?: (source: object, msg: SessionOutboundMessage) => void; onBinaryMessage?: (frame: Uint8Array) => void; onBinaryMessageToSource?: (source: object, frame: Uint8Array) => Promise; - getTransportBufferedAmount?: () => number | null; + getTransportBufferedAmount?: (source?: object) => number | null; onLifecycleIntent?: (intent: SessionLifecycleIntent) => void; onWorkspaceRecovered?: (workspace: PersistedWorkspaceRecord) => Promise; logger: pino.Logger; @@ -616,7 +610,6 @@ interface ArchivedRecordSnapshot { } interface WorkspaceUpdateOptions { - dedupeGitState?: boolean; removedProjectId?: string; optimisticStatus?: WorkspaceDescriptorPayload["status"]; } @@ -654,7 +647,41 @@ function workspaceLabelErrorCode(error: unknown): string { return "workspace_label_failed"; } +interface ClientActivity { + deviceType: "web" | "mobile"; + focusedAgentId: string | null; + focusedTerminalId: string | null; + lastActivityAt: Date; + appVisible: boolean; + appVisibilityChangedAt: Date; +} + export class Session { + readonly delivery = new SessionDelivery( + (source, message) => { + if (!this.authorization.allowsOutbound(message)) return; + if (this.onMessageToSource) this.onMessageToSource(source, message); + else this.onMessage(message); + }, + (source, frame) => { + if (this.onBinaryMessageToSource) { + void this.onBinaryMessageToSource(source, frame).catch((error) => + this.sessionLogger.warn({ err: error }, "Failed to emit binary frame"), + ); + } else this.emitBinary(frame); + }, + (source, message) => this.workspaceSetupMessageForClient(message, source), + (request, message) => + this.sessionLogger.warn( + { + requestType: request.type, + messageType: message.type, + status: message.type === "status" ? message.payload.status : undefined, + }, + "Unrecognized reply contract; application delivery denied", + ), + ); + private readonly browserToolsBroker: SessionOptions["browserToolsBroker"]; private readonly clientId: string; private readonly authorization: SessionAuthorization; private appVersion: string | null; @@ -668,7 +695,7 @@ export class Session { private readonly onBinaryMessageToSource: | ((source: object, frame: Uint8Array) => Promise) | null; - private readonly getTransportBufferedAmount: () => number | null; + private readonly getTransportBufferedAmount: (source?: object) => number | null; private readonly onLifecycleIntent: ((intent: SessionLifecycleIntent) => void) | null; private readonly onWorkspaceRecovered: | ((workspace: PersistedWorkspaceRecord) => Promise) @@ -703,32 +730,31 @@ export class Session { private registryMutationQueue: Promise = Promise.resolve(); private projectUpdateQueue: Promise = Promise.resolve(); private isCleanedUp = false; - private viewedTimelineAgentIds = new Set(); - private readonly viewedTimelineAgentIdsBySource = new Map>(); - private readonly clientCapabilitiesBySource = new Map>(); - private readonly defaultTimelineSubscriptionSource = {}; + private readonly timelineSubscriptions = new Map< + string, + { owner: OwnedSubscription; agentIds: Set } + >(); + private readonly clientSources = new Map< + object, + { + capabilities: ReadonlySet; + appVersion: string | null; + activity: ClientActivity | null; + pushToken: string | null; + } + >(); private unsubscribeTerminalWorkspaceContributionEvents: (() => void) | null = null; private readonly agentUpdates: AgentUpdatesService; - private workspaceUpdatesSubscription: WorkspaceUpdatesSubscriptionState | null = null; + private readonly workspaceUpdatesSubscriptions = new Map< + string, + WorkspaceUpdatesSubscriptionState + >(); private readonly workspaceLabelService: WorkspaceLabelService | null; - private workspaceLabelSubscription: { - owner: object; - id: string; - unsubscribe: () => void; - } | null = null; - private projectSyncEnabled = false; - private readonly defaultEventSubscriptionSource = {}; - private readonly eventSubscriptions = new Map>(); + private readonly eventSubscriptions = new Map< + string, + { owner: OwnedSubscription; events: Set; notifications: boolean } + >(); private readonly workspaceUpdateTails = new Map>(); - private clientActivity: { - deviceType: "web" | "mobile"; - focusedAgentId: string | null; - focusedTerminalId: string | null; - lastActivityAt: Date; - appVisible: boolean; - appVisibilityChangedAt: Date; - } | null = null; - private registeredPushToken: string | null = null; private readonly terminalManager: TerminalManager | null; private readonly providerSnapshotManager: ProviderSnapshotManager; private readonly serviceProxy: ServiceProxySubsystem | null; @@ -744,7 +770,7 @@ export class Session { private readonly workspaceSetupRuntime: WorkspaceSetupRuntime; private readonly workspaceGitObserver: WorkspaceGitObserverService; private readonly workspaceDirectory: WorkspaceDirectory; - private readonly voiceSession: VoiceSession; + private readonly voiceSessions: VoiceSessions; private readonly checkoutSession: CheckoutSession; private readonly scheduleSession: ScheduleSession; private readonly providerCatalogSession: ProviderCatalogSession; @@ -814,6 +840,7 @@ export class Session { daemonRuntimeConfig, getWebSocketRuntimeMetrics, } = options; + this.browserToolsBroker = options.browserToolsBroker; this.clientId = clientId; this.authorization = new SessionAuthorization(permissions); this.appVersion = appVersion ?? null; @@ -833,7 +860,6 @@ export class Session { this.worktreesRoot = worktreesRoot; this.pluginRuntime = pluginRuntime; this.orchestrationSkills = orchestrationSkills; - this.unsubscribePluginChanges = this.subscribeToPluginChanges(pluginRuntime); this.sessionLogger = logger.child({ module: "session", clientId: this.clientId, @@ -909,11 +935,7 @@ export class Session { }); this.workspaceGitObserver = createWorkspaceGitObserverService({ workspaceGitService: this.workspaceGitService, - describeWorkspaceRecordWithGitData: (workspace) => - this.describeWorkspaceRecordWithGitData(workspace), emitWorkspaceUpdateForCwd: (cwd) => this.emitWorkspaceUpdateForCwd(cwd), - emitWorkspaceUpdateForWorkspaceId: (workspaceId) => - this.emitWorkspaceUpdateForWorkspaceId(workspaceId), emitStatusUpdate: (cwd, snapshot) => this.checkoutSession.emitStatusUpdate(cwd, snapshot), onBranchChanged, logger: this.sessionLogger, @@ -929,7 +951,26 @@ export class Session { isProviderVisibleToClient: (provider) => this.isProviderVisibleToClient(provider), supportsCustomModeIcons: () => this.supports(CLIENT_CAPS.customModeIcons), supportsCompactProviderSnapshots: () => this.supports(CLIENT_CAPS.compactProviderSnapshots), - wantsSnapshotChanges: () => this.wantsEvent("providers_snapshot_update"), + publishSnapshot: (project) => { + const delivered = new Set(); + for (const subscription of this.eventSubscriptions.values()) { + if (!subscription.events.has("providers_snapshot_update")) continue; + const message = this.delivery.forSource(subscription.owner.source, project); + if (message) subscription.owner.emit(message); + delivered.add(subscription.owner.source); + } + // COMPAT(ownedSubscriptions): added in v0.8.0, remove implicit provider delivery after 2027-03-09. + for (const source of this.clientSources.keys()) { + if ( + delivered.has(source) || + this.delivery.isModern(source) || + !this.wantsEvent("providers_snapshot_update", source) + ) + continue; + const message = this.delivery.forSource(source, project); + if (message) this.onMessageToSource?.(source, message); + } + }, supportsProviderSnapshotReferences: () => this.supports(CLIENT_CAPS.providerSnapshotReferences), listProviderAvailability: () => this.agentManager.listProviderAvailability(), @@ -979,6 +1020,22 @@ export class Session { daemonVersion, daemonRuntimeConfig, getWebSocketRuntimeMetrics, + getObservationMetrics: () => ({ + Registrations: this.delivery.registrationCount, + "Source registrations": this.delivery.sourceRegistrationCount(), + "Producer listeners": [ + this.unsubscribeAgentEvents, + this.unsubscribeProjectMutations, + this.unsubscribeWorkspaceMutations, + this.unsubscribePluginChanges, + this.unsubscribeTerminalWorkspaceContributionEvents, + this.providerCatalogSession.isObserving, + this.hubExecutionController?.isObserving, + ].filter(Boolean).length, + "Git observations": this.workspaceGitObserver.getMetrics().subscriptionCount, + "Terminal directories": this.terminalController.getMetrics().directorySubscriptionCount, + "Terminal output": this.terminalController.getMetrics().streamSubscriptionCount, + }), listProviderAvailability: () => this.agentManager.listProviderAvailability(), listAgents: () => this.agentManager.listAgents(), listProjects: () => this.projectRegistry.list(), @@ -1000,14 +1057,13 @@ export class Session { this.terminalController = new TerminalSessionController({ terminalManager, emit: (msg) => this.emit(msg), - emitBinary: (frame) => this.emitBinary(frame), hasBinaryChannel: () => this.onBinaryMessage !== null, isPathWithinRoot: (rootPath, candidatePath) => this.isPathWithinRoot(rootPath, candidatePath), sessionLogger: this.sessionLogger, listTerminalWorkspaceRefs: () => this.listActiveWorkspaceRefs(), - clientSupportsWrapReflow: () => - this.clientCapabilities.has(CLIENT_CAPS.terminalReflowableSnapshot), - getClientBufferedAmount: () => this.getTransportBufferedAmount(), + clientSupportsWrapReflow: (source) => + this.supportsForSource(CLIENT_CAPS.terminalReflowableSnapshot, source), + getClientBufferedAmount: (source) => this.getTransportBufferedAmount(source), }); this.agentUpdates = createAgentUpdatesService({ emit: (message) => this.emit(message), @@ -1074,11 +1130,11 @@ export class Session { logger: this.sessionLogger, emit: (message) => this.emit(message), spawnWorkspaceScript, + wantsStatusUpdates: () => this.wantsEvent("script_status_update"), assertAutomationAllowed: (workspaceId) => assertWorkspaceAutomationAllowedForWorkspace(this.workspaceRegistry, workspaceId), globalServicePorts: loadPersistedConfig(this.paseoHome).worktrees?.servicePorts, }); - this.subscribeToOptionalManagers(); this.workspaceDirectory = new WorkspaceDirectory({ logger: this.sessionLogger, projectRegistry: this.projectRegistry, @@ -1090,43 +1146,44 @@ export class Session { buildWorkspaceDescriptor: (input) => this.buildWorkspaceDescriptor(input), }); - this.voiceSession = new VoiceSession({ - host: { - emit: (msg) => this.emit(msg), - loadAgent: (agentId) => - ensureAgentLoaded(agentId, { - agentManager: this.agentManager, - agentStorage: this.agentStorage, - logger: this.sessionLogger, - }), - reloadAgentSession: (agentId, overrides) => - this.agentManager.reloadAgentSession(agentId, overrides), - sendSpokenInput: async (agentId, text) => { - await this.handleSendAgentMessage( - agentId, - text, - undefined, - undefined, - undefined, - undefined, - { spokenInput: true }, - ); + this.voiceSessions = new VoiceSessions( + { + host: { + emit: (msg) => this.emit(msg), + loadAgent: (agentId) => + ensureAgentLoaded(agentId, { + agentManager: this.agentManager, + agentStorage: this.agentStorage, + logger: this.sessionLogger, + }), + reloadAgentSession: (agentId, overrides) => + this.agentManager.reloadAgentSession(agentId, overrides), + sendSpokenInput: async (agentId, text) => { + await this.handleSendAgentMessage( + agentId, + text, + undefined, + undefined, + undefined, + undefined, + { spokenInput: true }, + ); + }, + interruptAgentIfRunning: (agentId) => this.interruptAgentIfRunning(agentId), + hasActiveAgentRun: (agentId) => this.hasActiveAgentRun(agentId), }, - interruptAgentIfRunning: (agentId) => this.interruptAgentIfRunning(agentId), - hasActiveAgentRun: (agentId) => this.hasActiveAgentRun(agentId), + logger: this.sessionLogger, + sessionId: this.sessionId, + sttLanguage, + tts, + stt, + voice, + voiceBridge, + dictation, }, - logger: this.sessionLogger, - sessionId: this.sessionId, - sttLanguage, - tts, - stt, - voice, - voiceBridge, - dictation, - }); - - this.subscribeToAgentEvents(); - this.subscribeToRegistryMutations(); + this.delivery, + () => this.refreshObservationProducers(), + ); this.sessionLogger.trace({}, "agent.session.lifecycle.created"); } @@ -1137,49 +1194,60 @@ export class Session { } } - updateClientCapabilities(capabilities: Record | null, source?: object): void { + updateClientCapabilities( + capabilities: Record | null, + source?: object, + appVersion = this.appVersion, + ): void { this.clientCapabilities = parseClientCapabilities(capabilities); if (source) { - this.eventSubscriptions.delete(source); - this.clientCapabilitiesBySource.set(source, this.clientCapabilities); - } - if (!source && !this.supports(CLIENT_CAPS.selectiveAgentTimeline)) { - this.viewedTimelineAgentIdsBySource.clear(); - this.viewedTimelineAgentIds.clear(); + this.delivery.attach(source, capabilities?.[CLIENT_CAPS.ownedSubscriptions] === true); + this.clientSources.set(source, { + capabilities: this.clientCapabilities, + appVersion, + activity: this.clientSources.get(source)?.activity ?? null, + pushToken: this.clientSources.get(source)?.pushToken ?? null, + }); + this.refreshObservationProducers(); + // COMPAT(ownedSubscriptions): added in v0.8.0, remove capability-driven legacy host registration after 2027-03-09. + if ( + !this.delivery.isModern(source) && + this.authorization.allowsPermission("workspace.write") + ) { + const host = BrowserAutomationHostCapabilitySchema.safeParse(capabilities?.browser_host); + if (host.success && this.browserToolsBroker) { + const request = { + type: "browser.host.register.request" as const, + requestId: uuidv4(), + hostKind: host.data.hostKind, + supportedCommands: host.data.supportedCommands, + }; + void this.delivery + .request(source, request, () => this.registerBrowserHost(request, false)) + .catch((error) => + this.sessionLogger.warn({ err: error }, "Failed to register legacy browser host"), + ); + } + } } } clearAgentTimelineSubscription(source: object): void { - this.clientCapabilitiesBySource.delete(source); - this.eventSubscriptions.delete(source); - if (this.viewedTimelineAgentIdsBySource.delete(source)) { - this.rebuildViewedTimelineAgentIds(); - } + void this.delivery + .detach(source) + .catch((err) => this.sessionLogger.error({ err }, "Failed to release source subscriptions")); + this.clientSources.delete(source); + this.refreshObservationProducers(); } - private replaceAgentTimelineSubscription(source: object | undefined, agentIds: string[]): void { - const subscriptionSource = source ?? this.defaultTimelineSubscriptionSource; - if (agentIds.length === 0) this.viewedTimelineAgentIdsBySource.delete(subscriptionSource); - else this.viewedTimelineAgentIdsBySource.set(subscriptionSource, new Set(agentIds)); - this.rebuildViewedTimelineAgentIds(); - } - - private rebuildViewedTimelineAgentIds(): void { - const viewedAgentIds = new Set(); - for (const agentIds of this.viewedTimelineAgentIdsBySource.values()) { - for (const agentId of agentIds) viewedAgentIds.add(agentId); - } - this.viewedTimelineAgentIds = viewedAgentIds; - } - - private usesSelectiveTimelineDelivery(): boolean { - if (this.clientCapabilitiesBySource.size === 0) { - return this.supports(CLIENT_CAPS.selectiveAgentTimeline); - } - for (const capabilities of this.clientCapabilitiesBySource.values()) { - if (!capabilities.has(CLIENT_CAPS.selectiveAgentTimeline)) return false; - } - return true; + private subscribeAgentTimelines(agentIds: string[]): OwnedSubscription { + const owner = this.delivery.begin("timelines", undefined, (id) => { + this.timelineSubscriptions.delete(id); + this.refreshObservationProducers(); + }); + this.timelineSubscriptions.set(owner.id, { owner, agentIds: new Set(agentIds) }); + this.refreshObservationProducers(); + return owner; } // COMPAT(timelineItemCapabilities): plugin items added in v0.8.0, notifications in v0.7.2. @@ -1196,77 +1264,78 @@ export class Session { event: Extract, serializedEvent: Extract["payload"]["event"], ): void { - if (this.clientCapabilitiesBySource.size === 0 || !this.onMessageToSource) { - if (serializedEvent.type === "timeline" && !this.supportsTimelineItem(serializedEvent.item)) - return; - if (this.usesSelectiveTimelineDelivery() && serializedEvent.type === "attention_required") { - this.emit({ - type: "agent_attention_required", - payload: { - agentId: event.agentId, - reason: serializedEvent.reason, - timestamp: serializedEvent.timestamp, - shouldNotify: serializedEvent.shouldNotify, - ...(serializedEvent.notification ? { notification: serializedEvent.notification } : {}), - }, - }); - } else if ( - !this.usesSelectiveTimelineDelivery() || - this.viewedTimelineAgentIds.has(event.agentId) - ) { - this.emit({ - type: "agent_stream", - payload: this.buildAgentStreamPayload(event, serializedEvent), - }); - } - return; + const attention = serializedEvent.type === "attention_required"; + if (attention) { + this.emit({ + type: "agent_attention_required", + payload: { + agentId: event.agentId, + reason: serializedEvent.reason, + timestamp: serializedEvent.timestamp, + shouldNotify: serializedEvent.shouldNotify, + ...(serializedEvent.notification ? { notification: serializedEvent.notification } : {}), + }, + }); } - - for (const [source, capabilities] of this.clientCapabilitiesBySource) { + const message: SessionOutboundMessage = { + type: "agent_stream", + payload: this.buildAgentStreamPayload(event, serializedEvent), + }; + for (const subscription of this.timelineSubscriptions.values()) { + const source = subscription.owner.source; + if (!subscription.agentIds.has(event.agentId)) continue; + if ( + attention && + (this.delivery.isModern(source) || + this.supportsForSource(CLIENT_CAPS.selectiveAgentTimeline, source)) + ) + continue; if ( serializedEvent.type === "timeline" && !this.supportsTimelineItem(serializedEvent.item, source) ) continue; - const supportsSelectiveDelivery = capabilities.has(CLIENT_CAPS.selectiveAgentTimeline); - if (supportsSelectiveDelivery && serializedEvent.type === "attention_required") { - if (!this.wantsEvent("agent_attention_required", source)) continue; - this.onMessageToSource(source, { - type: "agent_attention_required", - payload: { - agentId: event.agentId, - reason: serializedEvent.reason, - timestamp: serializedEvent.timestamp, - shouldNotify: serializedEvent.shouldNotify, - ...(serializedEvent.notification ? { notification: serializedEvent.notification } : {}), - }, - }); - continue; - } - if ( - supportsSelectiveDelivery && - !this.viewedTimelineAgentIdsBySource.get(source)?.has(event.agentId) - ) { - continue; - } - this.onMessageToSource(source, { - type: "agent_stream", - payload: this.buildAgentStreamPayload(event, serializedEvent), - }); + subscription.owner.emit(message); } + // COMPAT(ownedSubscriptions): added in v0.8.0, remove implicit timeline delivery after 2027-03-09. + for (const [source, { capabilities }] of this.clientSources) { + if (this.delivery.isModern(source) || capabilities.has(CLIENT_CAPS.selectiveAgentTimeline)) + continue; + if ( + serializedEvent.type === "timeline" && + !this.supportsTimelineItem(serializedEvent.item, source) + ) + continue; + const alreadyDelivered = [...this.timelineSubscriptions.values()].some( + (subscription) => + subscription.owner.source === source && subscription.agentIds.has(event.agentId), + ); + if (!alreadyDelivered) this.onMessageToSource?.(source, message); + } + if ( + this.clientSources.size === 0 && + !this.supports(CLIENT_CAPS.selectiveAgentTimeline) && + this.timelineSubscriptions.size === 0 + ) + this.emit(message); } supports(capability: ClientCapability): boolean { - return this.clientCapabilities.has(capability); + const source = this.delivery.currentSource; + return source + ? this.supportsForSource(capability, source) + : this.clientCapabilities.has(capability); } supportsForSource(capability: ClientCapability, source: object): boolean { return ( - this.clientCapabilitiesBySource.get(source)?.has(capability) ?? this.supports(capability) + this.clientSources.get(source)?.capabilities.has(capability) ?? + this.clientCapabilities.has(capability) ); } emitProjectUpdate(update: ProjectUpdate): Promise { + if (!this.wantsEvent("project.update")) return Promise.resolve(); const queued = this.projectUpdateQueue .catch(() => undefined) .then(() => this.publishProjectUpdate(update)); @@ -1280,15 +1349,23 @@ export class Session { update.kind === "upsert" ? { kind: "upsert" as const, project: await this.buildProjectDescriptor(update.project) } : update; + if (!this.wantsEvent("project.update")) return; const message: SessionOutboundMessage = { type: "project.update", - payload: this.directorySync.sequenceProjectUpdate(projectedPayload, this.projectSyncEnabled), + payload: this.directorySync.sequenceProjectUpdate(projectedPayload, true), }; this.emit(message); } async syncWorkspaceGitObserverForWorkspace(workspace: PersistedWorkspaceRecord): Promise { - await this.workspaceGitObserver.syncObserverForWorkspace(workspace); + if (this.workspaceUpdatesSubscriptions.size === 0 && !this.wantsEvent("checkout_status_update")) + return; + const descriptor = await this.describeWorkspaceRecord(workspace); + const demanded = [...this.workspaceUpdatesSubscriptions.values()].some((subscription) => + this.matchesWorkspaceFilter({ workspace: descriptor, filter: subscription.filter }), + ); + if (demanded || this.wantsEvent("checkout_status_update")) + this.workspaceGitObserver.syncObservers([descriptor]); } async emitWorkspaceUpdateForWorkspaceId(workspaceId: string): Promise { @@ -1299,7 +1376,7 @@ export class Session { workspace: WorkspaceDescriptorPayload, optimisticStatus?: WorkspaceDescriptorPayload["status"], ): Promise { - if (this.workspaceUpdatesSubscription) { + if (this.workspaceUpdatesSubscriptions.size > 0) { await this.emitWorkspaceUpdatesForWorkspaceIds( [workspace.id], optimisticStatus ? { optimisticStatus } : undefined, @@ -1335,25 +1412,29 @@ export class Session { async syncWorkspaceGitObserversForExternalWorkspaceIds( workspaceIds: Iterable, ): Promise { + if (!this.delivery.hasDemand("workspaces") && !this.wantsEvent("checkout_status_update")) + return; await Promise.all( Array.from(new Set(workspaceIds)).map(async (workspaceId) => { const workspace = await this.workspaceRegistry.get(workspaceId); if (workspace && !workspace.archivedAt) { - await this.workspaceGitObserver.syncObserverForWorkspace(workspace); + await this.syncWorkspaceGitObserverForWorkspace(workspace); } }), ); } async warmWorkspaceGitDataForWorkspace(workspace: PersistedWorkspaceRecord): Promise { - await this.workspaceGitObserver.warmGitData(workspace); + await this.syncWorkspaceGitObserverForWorkspace(workspace); + await this.emitWorkspaceUpdateForWorkspaceId(workspace.workspaceId); } async refreshRecoveredWorkspaceForExternalMutation( workspace: PersistedWorkspaceRecord, ): Promise { try { - await this.workspaceGitObserver.warmGitData(workspace); + await this.syncWorkspaceGitObserverForWorkspace(workspace); + await this.emitWorkspaceUpdateForWorkspaceId(workspace.workspaceId); } catch (error) { this.sessionLogger.warn( { err: error, workspaceId: workspace.workspaceId }, @@ -1373,15 +1454,25 @@ export class Session { /** * Get the client's current activity state */ - public getClientActivity(): { - deviceType: "web" | "mobile"; - focusedAgentId: string | null; - focusedTerminalId: string | null; - lastActivityAt: Date; - appVisible: boolean; - appVisibilityChangedAt: Date; - } | null { - return this.clientActivity; + private currentClientMetadata() { + const source = this.delivery.currentSource; + if (!source) throw new Error("Client control has no source"); + let metadata = this.clientSources.get(source); + if (!metadata) { + // COMPAT(ownedSubscriptions): added in v0.8.0, remove socketless legacy session adapter after 2027-03-09. + metadata = { + capabilities: this.clientCapabilities, + appVersion: this.appVersion, + activity: null, + pushToken: null, + }; + this.clientSources.set(source, metadata); + } + return metadata; + } + + public getClientActivity(source = this.delivery.currentSource): ClientActivity | null { + return source ? (this.clientSources.get(source)?.activity ?? null) : null; } private getFocusedAgentSelectionForCwd(cwd: string): @@ -1391,7 +1482,7 @@ export class Session { thinkingOptionId?: string | null; } | undefined { - const focusedAgentId = this.clientActivity?.focusedAgentId; + const focusedAgentId = this.getClientActivity()?.focusedAgentId; if (!focusedAgentId) { return undefined; } @@ -1509,33 +1600,75 @@ export class Session { /** * Subscribe to AgentManager events and forward them to the client */ - private subscribeToOptionalManagers(): void { - this.terminalController.start(); - if (this.terminalManager) { - this.unsubscribeTerminalWorkspaceContributionEvents = - this.terminalManager.subscribeTerminalWorkspaceContributionChanged((event) => { - void this.emitWorkspaceUpdateForTerminalContribution(event).catch((error) => { - this.sessionLogger.warn( - { err: error, terminalId: event.terminalId }, - "Failed to emit workspace update after terminal contribution changed", - ); - }); - }); + private refreshObservationProducers(): void { + const legacy = this.delivery.hasLegacySources(); + const workspaces = + legacy || this.delivery.hasDemand("workspaces") || this.wantsEvent("checkout_status_update"); + const agents = + workspaces || + this.delivery.hasDemand("agents") || + this.delivery.hasDemand("timelines") || + this.voiceSessions.hasDemand || + this.wantsEvent("agent_attention_required") || + this.wantsEvent("agent_permission_request") || + this.wantsEvent("agent_permission_resolved") || + this.wantsEvent("agent.provider_subagents.update"); + if (agents && !this.unsubscribeAgentEvents) this.subscribeToAgentEvents(); + if (!agents) { + this.unsubscribeAgentEvents?.(); + this.unsubscribeAgentEvents = null; } - this.providerCatalogSession.start(); + this.refreshWorkspaceProducers(workspaces); + this.refreshCatalogProducers(legacy); } - private subscribeToRegistryMutations(): void { - this.unsubscribeProjectMutations?.(); - this.unsubscribeProjectMutations = - this.projectRegistry.subscribeToMutations?.((mutation) => - this.enqueueRegistryMutation(() => this.handleProjectMutation(mutation)), - ) ?? null; - this.unsubscribeWorkspaceMutations?.(); - this.unsubscribeWorkspaceMutations = - this.workspaceRegistry.subscribeToMutations?.((mutation) => - this.enqueueRegistryMutation(() => this.handleWorkspaceMutation(mutation)), - ) ?? null; + private refreshWorkspaceProducers(observing: boolean): void { + if (observing) { + this.unsubscribeProjectMutations ??= + this.projectRegistry.subscribeToMutations?.((mutation) => + this.enqueueRegistryMutation(() => this.handleProjectMutation(mutation)), + ) ?? null; + this.unsubscribeWorkspaceMutations ??= + this.workspaceRegistry.subscribeToMutations?.((mutation) => + this.enqueueRegistryMutation(() => this.handleWorkspaceMutation(mutation)), + ) ?? null; + this.unsubscribeTerminalWorkspaceContributionEvents ??= + this.terminalManager?.subscribeTerminalWorkspaceContributionChanged((event) => { + void this.emitWorkspaceUpdateForTerminalContribution(event).catch((error) => + this.sessionLogger.warn( + { err: error, terminalId: event.terminalId }, + "Failed to emit workspace terminal contribution", + ), + ); + }) ?? null; + } else { + this.unsubscribeProjectMutations?.(); + this.unsubscribeProjectMutations = null; + this.unsubscribeWorkspaceMutations?.(); + this.unsubscribeWorkspaceMutations = null; + this.unsubscribeTerminalWorkspaceContributionEvents?.(); + this.unsubscribeTerminalWorkspaceContributionEvents = null; + } + } + + private refreshCatalogProducers(legacy: boolean): void { + this.hubExecutionController?.setObserving( + legacy || + this.wantsEvent("hub.execution.agent.update") || + this.wantsEvent("hub.execution.agent.stream"), + ); + if (this.wantsEvent("providers_snapshot_update")) this.providerCatalogSession.start(); + else this.providerCatalogSession.dispose(); + if ( + legacy || + this.wantsEvent("status.plugin_catalog_changed") || + this.wantsEvent("status.plugin_settings_changed") + ) + this.unsubscribePluginChanges ??= this.subscribeToPluginChanges(this.pluginRuntime); + else { + this.unsubscribePluginChanges?.(); + this.unsubscribePluginChanges = null; + } } private enqueueRegistryMutation(handleMutation: () => Promise): Promise { @@ -1574,10 +1707,11 @@ export class Session { } private async syncWorkspaceMutationObserver(mutation: WorkspaceMutation): Promise { - const subscription = this.workspaceUpdatesSubscription; - if (!mutation.workspace || !subscription) { + if ( + !mutation.workspace || + (this.workspaceUpdatesSubscriptions.size === 0 && !this.wantsEvent("checkout_status_update")) + ) return; - } const descriptorsByWorkspaceId = await this.buildWorkspaceDescriptorMap({ workspaceIds: [mutation.workspaceId], includeGitData: false, @@ -1585,7 +1719,10 @@ export class Session { const descriptor = descriptorsByWorkspaceId.get(mutation.workspaceId); if ( !descriptor || - !this.matchesWorkspaceFilter({ workspace: descriptor, filter: subscription.filter }) + (!this.wantsEvent("checkout_status_update") && + ![...this.workspaceUpdatesSubscriptions.values()].some((subscription) => + this.matchesWorkspaceFilter({ workspace: descriptor, filter: subscription.filter }), + )) ) { this.workspaceGitObserver.removeForWorkspaceId(mutation.workspaceId); return; @@ -1595,16 +1732,24 @@ export class Session { this.workspaceGitObserver.removeForWorkspaceId(mutation.workspaceId); return; } - await this.workspaceGitObserver.syncObserverForWorkspace(currentWorkspace); + await this.syncWorkspaceGitObserverForWorkspace(currentWorkspace); if (this.isCleanedUp) { this.workspaceGitObserver.removeForWorkspaceId(mutation.workspaceId); } } private async handleProjectMutation(mutation: ProjectMutation): Promise { + for (const subscription of this.workspaceUpdatesSubscriptions.values()) { + await this.handleProjectMutationForSubscription(mutation, subscription); + } + } + + private async handleProjectMutationForSubscription( + mutation: ProjectMutation, + subscription: WorkspaceUpdatesSubscriptionState, + ): Promise { try { - const subscription = this.workspaceUpdatesSubscription; - if (this.isCleanedUp || !subscription) { + if (this.isCleanedUp) { return; } const projectWorkspaceIds = (await this.workspaceRegistry.list()) @@ -1633,7 +1778,7 @@ export class Session { for (const workspaceId of projectWorkspaceIds) { this.workspaceGitObserver.removeForWorkspaceId(workspaceId); } - await this.emitWorkspaceUpdatesForWorkspaceIds(updateIds, { + await this.enqueueWorkspaceUpdates(new Set(updateIds), subscription, { removedProjectId: mutation.projectId, }); return; @@ -1644,7 +1789,7 @@ export class Session { this.workspaceGitObserver.removeForWorkspaceId(workspaceId); } } - await this.emitWorkspaceUpdatesForWorkspaceIds(projectWorkspaceIds); + await this.enqueueWorkspaceUpdates(new Set(projectWorkspaceIds), subscription, undefined); } catch (error) { this.sessionLogger.warn( { err: error, projectId: mutation.projectId, mutationKind: mutation.kind }, @@ -1687,7 +1832,18 @@ export class Session { }; } - if (this.clientCapabilitiesBySource.size === 0 || !this.onMessageToSource) { + const delivered = new Set(); + for (const subscription of this.eventSubscriptions.values()) { + if (!subscription.events.has("agent.provider_subagents.update")) continue; + if ( + update.type === "timeline" && + !this.supportsTimelineItem(update.row.item, subscription.owner.source) + ) + continue; + subscription.owner.emit(message); + delivered.add(subscription.owner.source); + } + if (this.clientSources.size === 0 || !this.onMessageToSource) { if ( this.supports(CLIENT_CAPS.providerSubagents) && (update.type !== "timeline" || this.supportsTimelineItem(update.row.item)) @@ -1696,8 +1852,13 @@ export class Session { } return; } - for (const [source, capabilities] of this.clientCapabilitiesBySource) { - if (!capabilities.has(CLIENT_CAPS.providerSubagents)) continue; + for (const [source, { capabilities }] of this.clientSources) { + if ( + delivered.has(source) || + this.delivery.isModern(source) || + !capabilities.has(CLIENT_CAPS.providerSubagents) + ) + continue; if (update.type === "timeline" && !this.supportsTimelineItem(update.row.item, source)) continue; this.onMessageToSource(source, message); @@ -1738,7 +1899,7 @@ export class Session { } if ( - this.voiceSession.isActiveForAgent(event.agentId) && + this.voiceSessions.isActiveForAgent(event.agentId) && event.event.type === "permission_requested" && isVoicePermissionAllowed(event.event.request) ) { @@ -1786,7 +1947,8 @@ export class Session { }, }); } else if (event.event.type === "permission_resolved") { - this.emit({ + // A provider event is not the outcome of whichever request is currently running. + this.emitSubscribedEvent({ type: "agent_permission_resolved", payload: { agentId: event.agentId, @@ -1852,7 +2014,14 @@ export class Session { } private isProviderVisibleToClient(provider: string): boolean { - if (this.supports(CLIENT_CAPS.allProviders) || clientSupportsAllProviders(this.appVersion)) { + if ( + this.supports(CLIENT_CAPS.allProviders) || + clientSupportsAllProviders( + this.delivery.currentSource + ? (this.clientSources.get(this.delivery.currentSource)?.appVersion ?? null) + : this.appVersion, + ) + ) { return true; } return LEGACY_PROVIDER_IDS.has(provider); @@ -1898,6 +2067,10 @@ export class Session { * Main entry point for processing session messages */ public async handleMessage(msg: SessionInboundMessage, source?: object): Promise { + return this.delivery.request(source, msg, () => this.handleRequest(msg, source)); + } + + private async handleRequest(msg: SessionInboundMessage, source?: object): Promise { this.inflightRequests++; if (this.inflightRequests > this.peakInflightRequests) { this.peakInflightRequests = this.inflightRequests; @@ -1966,6 +2139,13 @@ export class Session { public setPermissions(permissions: readonly DaemonPermission[]): void { this.authorization.replacePermissions(permissions); + if (!this.authorization.allowsPermission("workspace.write")) { + void this.delivery + .releaseFamily("browser-host") + .catch((error) => + this.sessionLogger.error({ err: error }, "Failed to release revoked browser hosting"), + ); + } } public getPermissions(): DaemonPermission[] { @@ -1980,23 +2160,100 @@ export class Session { return this.authorization.allowsPermission(permission); } - public subscribesToAgent(agent: ManagedAgent): Promise { - return this.agentUpdates.includesLiveAgent(agent); + public subscribesToAgent(agent: ManagedAgent, source: object): Promise { + return this.agentUpdates.includesLiveAgent( + agent, + new Set(this.delivery.subscriptionIds(source, "agents")), + ); } - public subscribesToTerminalDirectory(input: { - cwd: string; - workspaceId?: string; - }): Promise { - return this.terminalController.hasDirectorySubscription(input); + public subscribesToTerminalDirectory( + input: { + cwd: string; + workspaceId?: string; + }, + source: object, + ): Promise { + return this.terminalController.hasDirectorySubscription(input, source); + } + + public publishToSource(source: object, message: SessionOutboundMessage): void { + if (!this.authorization.allowsOutbound(message)) return; + this.emitSubscribedEvent(message, source); + } + + public wantsSourceEvent(source: object, event: SessionEventSubscription): boolean { + return this.wantsEvent(event, source); + } + + public wantsSourceNotification(source: object, event: SessionEventSubscription): boolean { + // COMPAT(ownedSubscriptions): added in v0.8.0, remove legacy notification selection after 2027-03-09. + if (!this.delivery.isModern(source)) return true; + return [...this.eventSubscriptions.values()].some( + (subscription) => + subscription.owner.source === source && + subscription.notifications && + subscription.events.has(event), + ); } public publish(message: SessionOutboundMessage): void { this.emit(message); } + private async registerBrowserHost( + request: Extract, + respond = true, + ): Promise { + if (!this.browserToolsBroker) throw new Error("Browser hosting is unavailable"); + let unregister: (() => void) | undefined; + const owner = this.delivery.begin("browser-host", undefined, () => unregister?.()); + try { + unregister = this.browserToolsBroker.registerClient({ + id: owner.id, + hostKind: request.hostKind, + supportedCommands: request.supportedCommands, + sendBrowserAutomationRequest: (message) => owner.emit(message), + }); + if (respond) + this.emit({ + type: "browser.host.register.response", + payload: { requestId: request.requestId, subscriptionId: owner.responseId }, + }); + } catch (error) { + await owner.release(); + throw error; + } + } + + private dispatchSubscriptionMessage( + msg: SessionInboundMessage, + source?: object, + ): Promise | undefined { + if (msg.type === "browser.host.register.request") return this.registerBrowserHost(msg); + if (msg.type === "browser.automation.execute.response") { + if (source) + this.browserToolsBroker?.receiveResponse( + msg, + this.delivery.subscriptionIds(source, "browser-host"), + ); + return Promise.resolve(); + } + if (msg.type === "subscription.release.request") { + return this.delivery.release(msg.subscriptionId).then(() => { + this.emit({ + type: "subscription.release.response", + payload: { requestId: msg.requestId, subscriptionId: msg.subscriptionId }, + }); + return undefined; + }); + } + return undefined; + } + private async dispatchInboundMessage(msg: SessionInboundMessage, source?: object): Promise { const promise = + this.dispatchSubscriptionMessage(msg, source) ?? this.dispatchVoiceAndControlMessage(msg) ?? this.dispatchAgentRewindMessage(msg, source) ?? this.dispatchAgentRelationshipMessage(msg) ?? @@ -2251,28 +2508,14 @@ export class Session { private dispatchVoiceAndControlMessage(msg: SessionInboundMessage): Promise | undefined { switch (msg.type) { case "voice_audio_chunk": - return this.voiceSession.handleAudioChunk(msg); case "abort_request": - return this.voiceSession.handleAbort(); case "audio_played": - this.voiceSession.handleAudioPlayed(msg.id); - return undefined; case "set_voice_mode": - return this.voiceSession.handleSetVoiceMode(msg.enabled, msg.agentId, msg.requestId); case "dictation_stream_start": - return this.voiceSession.handleDictationStreamStart(msg); case "dictation_stream_chunk": - return this.voiceSession.handleDictationChunk({ - dictationId: msg.dictationId, - seq: msg.seq, - audioBase64: msg.audio, - format: msg.format, - }); case "dictation_stream_finish": - return this.voiceSession.handleDictationFinish(msg.dictationId, msg.finalSeq); case "dictation_stream_cancel": - this.voiceSession.handleDictationCancel(msg.dictationId); - return undefined; + return this.voiceSessions.handleMessage(msg); case "restart_server_request": return this.handleRestartServerRequest(msg.requestId, msg.reason); case "shutdown_server_request": @@ -2335,34 +2578,41 @@ export class Session { case "agent.provider_subagents.timeline.get.request": return this.handleProviderSubagentTimelineRequest(msg, source); case "session.events.set_subscription.request": { - this.eventSubscriptions.set( - source ?? this.defaultEventSubscriptionSource, - new Set(msg.events), - ); + const owner = this.delivery.begin("events", undefined, async (id) => { + this.eventSubscriptions.delete(id); + this.refreshObservationProducers(); + if (msg.events.includes("checkout_status_update")) + await this.reconcileWorkspaceGitObservers(); + }); + this.eventSubscriptions.set(owner.id, { + owner, + events: new Set(msg.events), + notifications: msg.notifications === true, + }); this.emitForSource( { type: "session.events.set_subscription.response", - payload: { requestId: msg.requestId }, + payload: { requestId: msg.requestId, subscriptionId: owner.responseId }, }, source, ); - return undefined; + this.refreshObservationProducers(); + if (!msg.events.includes("checkout_status_update")) return undefined; + return this.reconcileWorkspaceGitObservers().catch(async (error) => { + await owner.release(); + throw error; + }); } case "agent.timeline.set_subscription.request": { const agentIds = [...new Set(msg.agentIds)].sort(); - if ( - source - ? this.supportsForSource(CLIENT_CAPS.selectiveAgentTimeline, source) - : this.supports(CLIENT_CAPS.selectiveAgentTimeline) - ) { - this.replaceAgentTimelineSubscription(source, agentIds); - } - const response: SessionOutboundMessage = { - type: "agent.timeline.set_subscription.response", - payload: { agentIds, requestId: msg.requestId }, - }; - if (source && this.onMessageToSource) this.onMessageToSource(source, response); - else this.emit(response); + const owner = this.subscribeAgentTimelines(agentIds); + this.emitForSource( + { + type: "agent.timeline.set_subscription.response", + payload: { agentIds, requestId: msg.requestId, subscriptionId: owner.responseId }, + }, + source, + ); return undefined; } case "agent.fork_context.request": @@ -2497,11 +2747,12 @@ export class Session { return this.checkoutSession.handleBranchSuggestionsRequest(msg); case "directory_suggestions_request": return this.handleDirectorySuggestionsRequest(msg); + case "checkout.diff.get.request": + return this.checkoutSession.handleReadDiffRequest(msg); case "subscribe_checkout_diff_request": - return this.checkoutSession.handleSubscribeDiffRequest(msg); + return this.checkoutSession.handleSubscribeDiffRequest(msg, this.delivery); case "unsubscribe_checkout_diff_request": - this.checkoutSession.handleUnsubscribeDiffRequest(msg); - return undefined; + return this.checkoutSession.handleUnsubscribeDiffRequest(msg, this.delivery); case "checkout_switch_branch_request": return this.checkoutSession.handleCheckoutSwitchBranchRequest(msg); case "checkout.rename_branch.request": @@ -2627,10 +2878,9 @@ export class Session { case "file_explorer_request": return this.workspaceFilesSession.handleFileExplorerRequest(msg, source); case "fs.file.subscribe.request": - return this.workspaceFilesSession.handleFileSubscribeRequest(msg); + return this.workspaceFilesSession.handleFileSubscribeRequest(msg, this.delivery); case "fs.file.unsubscribe.request": - this.workspaceFilesSession.handleFileUnsubscribeRequest(msg); - return undefined; + return this.workspaceFilesSession.handleFileUnsubscribeRequest(msg, this.delivery); case "fs.file.write.request": return this.workspaceFilesSession.handleFileWriteRequest(msg); case "fs.entry.create.request": @@ -2648,7 +2898,7 @@ export class Session { case "file_download_token_request": return this.workspaceFilesSession.handleFileDownloadTokenRequest(msg); case "file.upload.request": - this.workspaceFilesSession.handleFileUploadRequest(msg); + this.workspaceFilesSession.handleFileUploadRequest(msg, this.delivery); return undefined; default: return undefined; @@ -2704,7 +2954,7 @@ export class Session { case "workspace.script.stop.request": return this.handleWorkspaceScriptStopRequest(msg); default: - return this.terminalController.dispatch(msg); + return this.terminalController.dispatch(msg, this.delivery); } } @@ -2743,8 +2993,8 @@ export class Session { return; case "push.unregister.request": this.pushNotifications.revoke(msg.token); - if (this.registeredPushToken?.trim() === msg.token.trim()) { - this.registeredPushToken = null; + if (this.currentClientMetadata().pushToken?.trim() === msg.token.trim()) { + this.currentClientMetadata().pushToken = null; } this.emit({ type: "push.unregister.response", @@ -2762,15 +3012,15 @@ export class Session { return this.sessionId; } - public async handleBinaryFrame(binaryFrame: BinaryFrame): Promise { + public async handleBinaryFrame(binaryFrame: BinaryFrame, source: object): Promise { if (!this.authorization.allowsPermission("workspace.write")) { return; } if (binaryFrame.kind === "file_transfer") { - await this.workspaceFilesSession.handleFileTransferFrame(binaryFrame.frame); + await this.workspaceFilesSession.handleFileTransferFrame(binaryFrame.frame, source); return; } - this.terminalController.handleBinaryFrame(binaryFrame.frame); + this.terminalController.handleBinaryFrame(binaryFrame.frame, source); } private async handleRestartServerRequest(requestId: string, reason?: string): Promise { @@ -4118,29 +4368,36 @@ export class Session { const timeline = this.agentManager.fetchTimeline(agentId, { limit: 0 }); const epoch = timeline.epoch; - if (this.clientCapabilitiesBySource.size === 0 || !this.onMessageToSource) { + if (this.clientSources.size === 0 || !this.onMessageToSource) { if (!this.supports(CLIENT_CAPS.timelineReplacementInvalidation)) { this.emitReconstructedTimelineRows(agentId, agent.provider, timeline.rows, epoch); } return; } - for (const [source, capabilities] of this.clientCapabilitiesBySource) { - const isInitiator = source === initiatingSource; - const supportsReplacement = capabilities.has(CLIENT_CAPS.timelineReplacementInvalidation); - const isSubscribed = this.viewedTimelineAgentIdsBySource.get(source)?.has(agentId) === true; - if (supportsReplacement) { - if (isSubscribed && !isInitiator) { - this.onMessageToSource(source, { - type: "agent.timeline.replacement", - payload: { agentId, epoch }, - }); - } - continue; + for (const subscription of this.timelineSubscriptions.values()) { + const source = subscription.owner.source; + if (!subscription.agentIds.has(agentId)) continue; + const modern = this.delivery.isModern(source); + if ( + modern || + (source !== initiatingSource && + this.supportsForSource(CLIENT_CAPS.timelineReplacementInvalidation, source)) + ) { + subscription.owner.emit({ + type: "agent.timeline.replacement", + payload: { agentId, epoch }, + }); + } + } + // COMPAT(timelineReplacementInvalidation): added in v0.5.0, remove reconstructed replay after 2027-02-21. + for (const [source, { capabilities }] of this.clientSources) { + if ( + !this.delivery.isModern(source) && + !capabilities.has(CLIENT_CAPS.timelineReplacementInvalidation) + ) { + this.emitReconstructedTimelineRows(agentId, agent.provider, timeline.rows, epoch, source); } - // COMPAT(timelineReplacementInvalidation): added in v0.5.0, replay reconstructed - // rows to legacy clients until the supported client floor is >= v0.5.0 after 2027-02-21. - this.emitReconstructedTimelineRows(agentId, agent.provider, timeline.rows, epoch, source); } } @@ -4289,7 +4546,8 @@ export class Session { const appVisibilityChangedAt = msg.appVisibilityChangedAt ? new Date(msg.appVisibilityChangedAt) : new Date(msg.lastActivityAt); - this.clientActivity = { + const metadata = this.currentClientMetadata(); + metadata.activity = { deviceType: msg.deviceType, focusedAgentId: msg.focusedAgentId, focusedTerminalId, @@ -4300,8 +4558,8 @@ export class Session { if (msg.appVisible && focusedTerminalId) { void this.clearFocusedTerminalAttention(focusedTerminalId); } - if (this.registeredPushToken) { - this.pushNotifications.renew(this.registeredPushToken); + if (metadata.pushToken) { + this.pushNotifications.renew(metadata.pushToken); } } @@ -4321,7 +4579,7 @@ export class Session { * Handle push token registration */ private handleRegisterPushToken(token: string): void { - this.registeredPushToken = token; + this.currentClientMetadata().pushToken = token; this.pushNotifications.renew(token); this.sessionLogger.info("Registered push token"); } @@ -4427,6 +4685,15 @@ export class Session { response, logger: this.sessionLogger, }); + // COMPAT(ownedSubscriptions): added in v0.8.0, remove after 2027-03-09. + // Legacy clients consume the single domain resolution; modern request outcomes + // are independent of whether this socket (or its logical Session) observes it. + if (this.delivery.isModern(this.delivery.currentSource)) { + this.delivery.reply({ + type: "agent_permission_resolved", + payload: { agentId, requestId, resolution: response }, + }); + } } catch (error) { this.sessionLogger.error( { err: error, agentId, requestId }, @@ -5182,6 +5449,8 @@ export class Session { subscription: WorkspaceUpdatesSubscriptionState, payload: WorkspaceUpdatePayload, ): void { + if (this.workspaceUpdatesSubscriptions.get(subscription.subscriptionId) !== subscription) + return; if (payload.kind === "upsert") { subscription.visibleEmptyProjectIds?.delete(payload.workspace.projectId); } else { @@ -5199,20 +5468,22 @@ export class Session { } const workspaceId = payload.kind === "upsert" ? payload.workspace.id : payload.id; subscription.lastEmittedByWorkspaceId.set(workspaceId, payload); - this.emit({ - type: "workspace_update", - payload, - }); + subscription.owner.emit({ type: "workspace_update", payload }); } - private flushBootstrappedWorkspaceUpdates(options?: { - snapshotByWorkspaceId?: Map< - string, - { status: string; statusEnteredAt: string | null; activityAtMs: number | null } - >; - }): void { - const subscription = this.workspaceUpdatesSubscription; - if (!subscription || !subscription.isBootstrapping) { + private flushBootstrappedWorkspaceUpdates( + subscription: WorkspaceUpdatesSubscriptionState, + options?: { + snapshotByWorkspaceId?: Map< + string, + { status: string; statusEnteredAt: string | null; activityAtMs: number | null } + >; + }, + ): void { + if ( + this.workspaceUpdatesSubscriptions.get(subscription.subscriptionId) !== subscription || + !subscription.isBootstrapping + ) { return; } @@ -5246,10 +5517,7 @@ export class Session { } const workspaceId = payload.kind === "upsert" ? payload.workspace.id : payload.id; subscription.lastEmittedByWorkspaceId.set(workspaceId, payload); - this.emit({ - type: "workspace_update", - payload, - }); + subscription.owner.emit({ type: "workspace_update", payload }); } } @@ -5387,17 +5655,11 @@ export class Session { workspaceIds: Iterable, options?: WorkspaceUpdateOptions, ): Promise { - const subscription = this.workspaceUpdatesSubscription; - if (!subscription) { - return; + const uniqueWorkspaceIds = new Set(workspaceIds); + if (uniqueWorkspaceIds.size === 0) return; + for (const subscription of this.workspaceUpdatesSubscriptions.values()) { + await this.enqueueWorkspaceUpdates(uniqueWorkspaceIds, subscription, options); } - - const uniqueWorkspaceIds = new Set(Array.from(workspaceIds)); - if (uniqueWorkspaceIds.size === 0) { - return; - } - - await this.enqueueWorkspaceUpdates(uniqueWorkspaceIds, subscription, options); } private enqueueWorkspaceUpdates( @@ -5409,7 +5671,9 @@ export class Session { (this.workspaceUpdateTails.get(workspaceId) ?? Promise.resolve()).catch(() => undefined), ); const next = Promise.all(previous).then(() => - this.emitWorkspaceUpdateBatch(workspaceIds, subscription, options), + this.delivery.forSource(subscription.owner.source, () => + this.emitWorkspaceUpdateBatch(workspaceIds, subscription, options), + ), ); for (const workspaceId of workspaceIds) { this.workspaceUpdateTails.set(workspaceId, next); @@ -5431,7 +5695,7 @@ export class Session { subscription: WorkspaceUpdatesSubscriptionState, options: WorkspaceUpdateOptions | undefined, ): Promise { - if (this.workspaceUpdatesSubscription !== subscription) { + if (this.workspaceUpdatesSubscriptions.get(subscription.subscriptionId) !== subscription) { return; } @@ -5441,7 +5705,7 @@ export class Session { }); for (const workspaceId of workspaceIds) { - if (this.workspaceUpdatesSubscription !== subscription) { + if (this.workspaceUpdatesSubscriptions.get(subscription.subscriptionId) !== subscription) { return; } const workspace = descriptorsByWorkspaceId.get(workspaceId); @@ -5454,18 +5718,12 @@ export class Session { options?.optimisticStatus, ); const lastEmitted = subscription.lastEmittedByWorkspaceId.get(workspaceId); - if ( - options?.dedupeGitState && - this.workspaceGitObserver.shouldSkipUpdate(workspaceId, nextWorkspace) - ) { - continue; - } this.workspaceGitObserver.recordDescriptorState(workspaceId, nextWorkspace); if (!nextWorkspace) { if (this.shouldSkipWorkspaceRemoval(lastEmitted, options?.removedProjectId)) { continue; } - if (this.workspaceUpdatesSubscription !== subscription) { + if (this.workspaceUpdatesSubscriptions.get(subscription.subscriptionId) !== subscription) { return; } subscription.lastEmittedByWorkspaceId.delete(workspaceId); @@ -5570,12 +5828,8 @@ export class Session { // and emits its own per-id descriptor. This is a deliberate same-folder fan, // not a cwd → id ownership lookup: git never resolves which workspace owns a // path. See `workspaceIdsOnCheckout`. - private async emitWorkspaceUpdateForCwd( - cwd: string, - options?: { - dedupeGitState?: boolean; - }, - ): Promise { + private async emitWorkspaceUpdateForCwd(cwd: string, options?: {}): Promise { + if (this.workspaceUpdatesSubscriptions.size === 0) return; const workspaceIds = workspaceIdsOnCheckout(await this.workspaceRegistry.list(), cwd); if (workspaceIds.length === 0) { return; @@ -5586,16 +5840,26 @@ export class Session { private async handleFetchAgents( request: Extract, ): Promise { - const requestedSubscriptionId = request.subscribe?.subscriptionId?.trim(); - const subscriptionId = resolveSubscriptionId(request.subscribe, requestedSubscriptionId); - + const owner = request.subscribe + ? this.delivery.begin("agents", request.subscribe.subscriptionId, (id) => { + this.agentUpdates.clearSubscription(id); + this.refreshObservationProducers(); + }) + : null; + const subscriptionId = owner?.responseId; try { - if (subscriptionId) { + if (owner) { this.agentUpdates.beginSubscription({ - subscriptionId, + subscriptionId: owner.id, + isProviderVisible: (provider) => + this.delivery.forSource(owner.source, () => this.isProviderVisibleToClient(provider)), filter: request.filter, syncEnabled: Boolean(request.sync), + emit: (message) => { + if (message.type === "agent_update") owner.emit(message); + }, }); + this.refreshObservationProducers(); } const payload = request.sync @@ -5619,11 +5883,11 @@ export class Session { }); if (subscriptionId) { - this.agentUpdates.flushBootstrapped(subscriptionId, { snapshotUpdatedAtByAgentId }); + this.agentUpdates.flushBootstrapped(owner!.id, { snapshotUpdatedAtByAgentId }); } } catch (error) { if (subscriptionId) { - this.agentUpdates.clearSubscription(subscriptionId); + await owner?.release(); } const code = error instanceof SessionRequestError ? error.code : "fetch_agents_failed"; const message = error instanceof Error ? error.message : "Failed to fetch agents"; @@ -5715,9 +5979,30 @@ export class Session { private async handleFetchWorkspacesRequest( request: Extract, ): Promise { - const requestedSubscriptionId = request.subscribe?.subscriptionId?.trim(); - const subscriptionId = resolveSubscriptionId(request.subscribe, requestedSubscriptionId); - + const owner = request.subscribe + ? this.delivery.begin("workspaces", request.subscribe.subscriptionId, async (id) => { + this.workspaceUpdatesSubscriptions.delete(id); + this.refreshObservationProducers(); + await this.reconcileWorkspaceGitObservers(); + }) + : null; + const subscriptionId = owner?.responseId; + const subscription: WorkspaceUpdatesSubscriptionState | null = owner + ? { + subscriptionId: owner.id, + owner, + syncEnabled: Boolean(request.sync), + filter: request.filter, + isBootstrapping: true, + pendingUpdatesByWorkspaceId: new Map(), + lastEmittedByWorkspaceId: new Map(), + visibleEmptyProjectIds: new Set(), + } + : null; + if (subscription) { + this.workspaceUpdatesSubscriptions.set(subscription.subscriptionId, subscription); + this.refreshObservationProducers(); + } try { this.sessionLogger.debug( { @@ -5729,22 +6014,10 @@ export class Session { }, "fetch_workspaces_request_received", ); - if (subscriptionId) { - this.workspaceUpdatesSubscription = { - subscriptionId, - syncEnabled: Boolean(request.sync), - filter: request.filter, - isBootstrapping: true, - pendingUpdatesByWorkspaceId: new Map(), - lastEmittedByWorkspaceId: new Map(), - visibleEmptyProjectIds: new Set(), - }; - } - const payload = request.sync ? await this.readWorkspaceDirectorySync(request) : await this.listFetchWorkspacesEntries(request); - this.workspaceGitObserver.syncObservers(payload.entries); + this.sessionLogger.debug( { requestId: request.requestId, @@ -5755,12 +6028,13 @@ export class Session { "fetch_workspaces_response_ready", ); const snapshot = this.buildBootstrapSnapshot(payload.entries); - this.seedWorkspaceSubscriptionSnapshot( - subscriptionId, - request.filter, - payload.entries, - payload.emptyProjects, - ); + if (subscription) { + this.seedWorkspaceSubscriptionSnapshot( + subscription, + payload.entries, + payload.emptyProjects, + ); + } this.emit({ type: "fetch_workspaces_response", @@ -5771,13 +6045,12 @@ export class Session { }, }); - if (subscriptionId && this.workspaceUpdatesSubscription?.subscriptionId === subscriptionId) { - this.flushBootstrappedWorkspaceUpdates(snapshot); + if (subscription) { + await this.reconcileWorkspaceGitObservers(); + this.flushBootstrappedWorkspaceUpdates(subscription, snapshot); } } catch (error) { - if (subscriptionId && this.workspaceUpdatesSubscription?.subscriptionId === subscriptionId) { - this.workspaceUpdatesSubscription = null; - } + await owner?.release(); const code = error instanceof SessionRequestError ? error.code : "fetch_workspaces_failed"; const message = error instanceof Error ? error.message : "Failed to fetch workspaces"; this.sessionLogger.error({ err: error }, "Failed to handle fetch_workspaces_request"); @@ -5805,7 +6078,6 @@ export class Session { const synchronized = request.sync ? this.directorySync.synchronizeProjects(projects, request.sync) : null; - if (synchronized) this.projectSyncEnabled = true; this.emit({ type: "project.list.response", payload: { @@ -5852,70 +6124,57 @@ export class Session { private async handleWorkspaceLabelList( request: Extract, ): Promise { - const owner = {}; + const service = this.requireWorkspaceLabels(); + if (!request.subscribe) { + this.emit({ + type: "workspace.label.list.response", + payload: { requestId: request.requestId, ...(await service.list(request.sync)) }, + }); + return; + } + let bootstrap: ReturnType | undefined; + const owner = this.delivery.begin("labels", request.subscribe.subscriptionId, async () => { + await bootstrap?.then( + (subscription) => subscription.unsubscribe(), + () => undefined, + ); + }); try { - this.workspaceLabelSubscription?.unsubscribe(); - this.workspaceLabelSubscription = { - owner, - id: request.subscribe.subscriptionId, - unsubscribe: () => undefined, - }; - const service = this.requireWorkspaceLabels(); type LiveChange = Parameters[0]["onChange"]>[0]; - const pending: LiveChange[] = []; + const pending = new Map(); let ready = false; const emitChange = (change: LiveChange): void => { - this.emit({ + owner.emit({ type: "workspace.label.update", - payload: - change.kind === "upsert" - ? { - kind: "upsert", - label: change.label, - ...(change.previousName ? { previousName: change.previousName } : {}), - generation: change.generation, - seq: change.seq, - } - : { - kind: "remove", - name: change.name, - generation: change.generation, - seq: change.seq, - }, + payload: change, }); }; - const subscription = await service.subscribe({ + bootstrap = service.subscribe({ cursor: request.sync, onChange: (change) => { - if (this.workspaceLabelSubscription?.owner !== owner) return; - if (!ready) pending.push(change); + if (owner.signal.aborted) return; + if (!ready) + pending.set(change.kind === "upsert" ? change.label.name : change.name, change); else emitChange(change); }, }); - const ownsSubscription = this.workspaceLabelSubscription?.owner === owner; - if (ownsSubscription) { - this.workspaceLabelSubscription = { - owner, - id: request.subscribe.subscriptionId, - unsubscribe: subscription.unsubscribe, - }; - } else { - subscription.unsubscribe(); - } + const subscription = await bootstrap; + if (owner.signal.aborted) return; this.emit({ type: "workspace.label.list.response", - payload: { requestId: request.requestId, ...subscription.snapshot }, + payload: { + requestId: request.requestId, + subscriptionId: owner.responseId, + ...subscription.snapshot, + }, }); ready = true; - if (ownsSubscription) { - for (const change of pending) { - if (change.seq > subscription.snapshot.sync.headSeq) emitChange(change); - } + for (const change of pending.values()) { + if (change.seq > subscription.snapshot.sync.headSeq) emitChange(change); } + pending.clear(); } catch (error) { - if (this.workspaceLabelSubscription?.owner === owner) { - this.workspaceLabelSubscription = null; - } + await owner.release(); this.emitWorkspaceLabelError(request, error); } } @@ -6036,16 +6295,27 @@ export class Session { return { snapshotByWorkspaceId }; } + private async reconcileWorkspaceGitObservers(): Promise { + const all = this.wantsEvent("checkout_status_update") + ? await this.workspaceDirectory.listObservationTargets() + : []; + const entries = [...this.workspaceUpdatesSubscriptions.values()].flatMap((subscription) => + [...subscription.lastEmittedByWorkspaceId.values()].flatMap((update) => + update.kind === "upsert" ? [update.workspace] : [], + ), + ); + this.workspaceGitObserver.reconcileObservers( + this.wantsEvent("checkout_status_update") ? [...entries, ...all] : entries, + ); + } + private seedWorkspaceSubscriptionSnapshot( - subscriptionId: string | null, - filter: FetchWorkspacesRequestFilter | undefined, + subscription: WorkspaceUpdatesSubscriptionState, entries: FetchWorkspacesResponseEntry[], emptyProjects: WorkspaceProjectDescriptorPayload[], ): void { - const subscription = this.workspaceUpdatesSubscription; - if (!subscription) return; - if (subscriptionId && subscription.subscriptionId !== subscriptionId) return; - if (!subscriptionId && !equal(subscription.filter, filter)) return; + if (this.workspaceUpdatesSubscriptions.get(subscription.subscriptionId) !== subscription) + return; for (const entry of entries) { subscription.lastEmittedByWorkspaceId.set(entry.id, { kind: "upsert", @@ -7609,7 +7879,11 @@ export class Session { logger: this.sessionLogger, }); if (result.disposition === "turn_started") { - await waitForAgentRunStartWithTimeout(this.agentManager, agentId); + await waitForAgentRunStartWithTimeout( + this.agentManager, + agentId, + this.delivery.requestSignal, + ); } }; if (msg.messageId) { @@ -7640,6 +7914,7 @@ export class Session { }, }); } catch (error) { + if (this.delivery.requestSignal.aborted) return; this.handleAgentRunError(resolved.agentId, error, "Failed to send agent message"); this.emit({ type: "send_agent_message_response", @@ -7658,6 +7933,8 @@ export class Session { requestId: string, timeoutMs?: number, ): Promise { + const sourceSignal = this.delivery.requestSignal; + sourceSignal.throwIfAborted(); const resolved = await this.resolveAgentIdentifier(agentIdOrIdentifier); if (!resolved.ok) { this.emit({ @@ -7717,7 +7994,7 @@ export class Session { try { let result = await this.agentManager.waitForAgentEvent(agentId, { - signal: abortController.signal, + signal: AbortSignal.any([abortController.signal, sourceSignal]), waitForActive: true, }); let final = await this.getAgentPayloadById(agentId); @@ -7740,6 +8017,7 @@ export class Session { payload: { requestId, status, final, error, lastMessage: result.lastMessage }, }); } catch (error) { + if (sourceSignal.aborted) return; const isAbort = error instanceof Error && (error.name === "AbortError" || error.message.toLowerCase().includes("aborted")); @@ -7780,41 +8058,80 @@ export class Session { */ // COMPAT(explicitEventSubscriptions): added in v0.8.0, remove legacy broadcasts after 2027-03-08. private wantsEvent(event: SessionEventSubscription, source?: object): boolean { - if (!source && this.clientCapabilitiesBySource.size > 0) { - return [...this.clientCapabilitiesBySource.keys()].some((candidate) => - this.wantsEvent(event, candidate), - ); + for (const subscription of this.eventSubscriptions.values()) { + if ((!source || subscription.owner.source === source) && subscription.events.has(event)) + return true; } + if (!source && this.clientSources.size > 0) { + return [...this.clientSources.keys()].some((candidate) => this.wantsEvent(event, candidate)); + } + if (source && this.delivery.isModern(source)) return false; + if (!source && !this.delivery.hasLegacySources()) return false; + // COMPAT(ownedSubscriptions): added in v0.8.0, remove implicit event delivery after 2027-03-09. const capabilities = source - ? this.clientCapabilitiesBySource.get(source)! + ? (this.clientSources.get(source)?.capabilities ?? this.clientCapabilities) : this.clientCapabilities; - if (event === "project.update" && !capabilities.has(CLIENT_CAPS.projectUpdates)) return false; - return ( - !capabilities.has(CLIENT_CAPS.explicitEventSubscriptions) || - this.eventSubscriptions.get(source ?? this.defaultEventSubscriptionSource)?.has(event) === - true + return legacyWantsEvent(event, capabilities); + } + + private emitEventToOwner( + subscription: { owner: OwnedSubscription; notifications: boolean }, + message: SessionOutboundMessage, + notified: Set, + ): void { + if ( + message.type !== "agent_attention_required" && + message.type !== "terminal_attention_required" + ) { + subscription.owner.emit(message); + return; + } + const shouldNotify = + message.payload.shouldNotify && + subscription.notifications && + !notified.has(subscription.owner.source); + subscription.owner.emit( + message.type === "agent_attention_required" + ? { ...message, payload: { ...message.payload, shouldNotify } } + : { ...message, payload: { ...message.payload, shouldNotify } }, ); + if (shouldNotify) notified.add(subscription.owner.source); + } + + private emitSubscribedEvent(message: SessionOutboundMessage, onlySource?: object): boolean { + const event = sessionEventCategory(message); + if (!event) return false; + if (!this.authorization.allowsOutbound(message)) return true; + const delivered = new Set(); + const notified = new Set(); + for (const subscription of this.eventSubscriptions.values()) { + if ( + !subscription.events.has(event) || + (onlySource && subscription.owner.source !== onlySource) + ) + continue; + this.emitEventToOwner(subscription, message, notified); + delivered.add(subscription.owner.source); + } + // COMPAT(ownedSubscriptions): added in v0.8.0, remove implicit event delivery after 2027-03-09. + if (this.onMessageToSource && this.clientSources.size > 0) { + for (const source of this.clientSources.keys()) { + if ( + (!onlySource || source === onlySource) && + !delivered.has(source) && + !this.delivery.isModern(source) && + this.wantsEvent(event, source) + ) + this.onMessageToSource(source, this.workspaceSetupMessageForClient(message, source)); + } + } else if (delivered.size === 0 && this.wantsEvent(event)) this.onMessage(message); + return true; } private emit(msg: SessionOutboundMessage): void { - if (!this.authorization.allowsOutbound(msg)) { - return; - } - if ( - msg.type === "project.update" || - msg.type === "providers_snapshot_update" || - msg.type === "agent_attention_required" || - msg.type === "agent_permission_request" || - msg.type === "agent_permission_resolved" - ) { - if (this.clientCapabilitiesBySource.size > 0 && this.onMessageToSource) { - for (const source of this.clientCapabilitiesBySource.keys()) { - if (this.wantsEvent(msg.type, source)) this.onMessageToSource(source, msg); - } - return; - } - if (!this.wantsEvent(msg.type)) return; - } + if (!this.authorization.allowsOutbound(msg)) return; + if (this.delivery.reply(msg)) return; + if (this.emitSubscribedEvent(msg)) return; // JSON.stringify(msg) is only computed when trace is enabled — it runs for // every outbound message otherwise, and trace is disabled by default. // Optional-chained because test logger stubs don't implement isLevelEnabled. @@ -7828,8 +8145,8 @@ export class Session { ); } if (msg.type === "workspace_setup_progress" || msg.type === "workspace_setup_status_response") { - if (this.clientCapabilitiesBySource.size > 0 && this.onMessageToSource) { - for (const [source] of this.clientCapabilitiesBySource) { + if (this.clientSources.size > 0 && this.onMessageToSource) { + for (const [source] of this.clientSources) { this.onMessageToSource(source, this.workspaceSetupMessageForClient(msg, source)); } return; @@ -7841,12 +8158,14 @@ export class Session { // COMPAT(workspaceSetupBlocked): added in v0.8.0, remove after 2027-03-07 once client floor >= v0.8.0. private workspaceSetupMessageForClient( - message: Extract< - SessionOutboundMessage, - { type: "workspace_setup_progress" | "workspace_setup_status_response" } - >, + message: SessionOutboundMessage, source?: object, ): SessionOutboundMessage { + if ( + message.type !== "workspace_setup_progress" && + message.type !== "workspace_setup_status_response" + ) + return message; const supportsBlocked = source ? this.supportsForSource(CLIENT_CAPS.workspaceSetupBlocked, source) : this.supports(CLIENT_CAPS.workspaceSetupBlocked); @@ -7877,6 +8196,7 @@ export class Session { private async emitBinaryForFileTransfer(frame: Uint8Array, source?: object): Promise { if (source && this.onBinaryMessageToSource) { + this.delivery.authorizeFileReply(frame, source); await this.onBinaryMessageToSource(source, frame); return; } @@ -7885,6 +8205,7 @@ export class Session { private emitForSource(msg: SessionOutboundMessage, source?: object): void { if (source && this.onMessageToSource) { + msg = this.delivery.authorizeReply(msg, source); this.onMessageToSource(source, msg); return; } @@ -7897,6 +8218,7 @@ export class Session { public async cleanup(): Promise { this.sessionLogger.trace({}, "agent.session.lifecycle.cleanup"); this.isCleanedUp = true; + await this.delivery.close(); if (this.unsubscribeAgentEvents) { this.unsubscribeAgentEvents(); @@ -7908,8 +8230,6 @@ export class Session { this.unsubscribePluginChanges = null; this.unsubscribeWorkspaceMutations?.(); this.unsubscribeWorkspaceMutations = null; - this.workspaceLabelSubscription?.unsubscribe(); - this.workspaceLabelSubscription = null; this.agentUpdates.dispose(); await this.hubExecutionController?.cleanup(); if (this.unsubscribeTerminalWorkspaceContributionEvents) { @@ -7918,14 +8238,11 @@ export class Session { } this.providerCatalogSession.dispose(); - await this.voiceSession.cleanup(); - this.terminalController.dispose(); this.checkoutSession.cleanup(); this.workspaceGitObserver.dispose(); - this.workspaceFilesSession.dispose(); } } @@ -7979,3 +8296,57 @@ function normalizeCloneRepository(input: { function isValidGitHubRepoSegment(value: string): boolean { return /^[A-Za-z0-9._-]+$/u.test(value); } + +function sessionEventCategory(message: SessionOutboundMessage): SessionEventSubscription | null { + switch (message.type) { + case "project.update": + case "providers_snapshot_update": + case "agent_attention_required": + case "agent_permission_request": + case "agent_permission_resolved": + case "checkout_status_update": + case "script_status_update": + case "workspace_setup_progress": + case "agent.provider_subagents.update": + case "terminal_attention_required": + case "activity_log": + case "hub.execution.agent.update": + case "hub.execution.agent.stream": + return message.type; + case "status": + switch (message.payload.status) { + case "server_info": + return "status.server_info"; + case "daemon_config_changed": + return "status.daemon_config_changed"; + case "plugin_catalog_changed": + return "status.plugin_catalog_changed"; + case "plugin_settings_changed": + return "status.plugin_settings_changed"; + default: + return null; + } + default: + return null; + } +} + +// COMPAT(ownedSubscriptions): added in v0.8.0, remove implicit event delivery after 2027-03-09. +function legacyWantsEvent( + event: SessionEventSubscription, + capabilities: ReadonlySet, +): boolean { + if (event === "project.update" && !capabilities.has(CLIENT_CAPS.projectUpdates)) return false; + switch (event) { + case "project.update": + case "providers_snapshot_update": + case "agent_attention_required": + case "agent_permission_request": + case "agent_permission_resolved": + return !capabilities.has(CLIENT_CAPS.explicitEventSubscriptions); + case "agent.provider_subagents.update": + return capabilities.has(CLIENT_CAPS.providerSubagents); + default: + return true; + } +} diff --git a/packages/server/src/server/session.workspace-git-watch.test.ts b/packages/server/src/server/session.workspace-git-watch.test.ts index 69c548b922..581a142572 100644 --- a/packages/server/src/server/session.workspace-git-watch.test.ts +++ b/packages/server/src/server/session.workspace-git-watch.test.ts @@ -24,13 +24,6 @@ import { createNoopWorkspaceGitService } from "./test-utils/workspace-git-servic import type { WorkspaceGitObserverService } from "./session/workspace-git-observer/workspace-git-observer-service.js"; interface SessionInternals { - workspaceUpdatesSubscription: { - subscriptionId: string; - filter: undefined; - isBootstrapping: boolean; - pendingUpdatesByWorkspaceId: Map; - lastEmittedByWorkspaceId: Map; - }; buildWorkspaceDescriptorMap: () => Promise>; workspaceGitObserver: WorkspaceGitObserverService; listAgentPayloads: () => Promise; @@ -203,6 +196,7 @@ function createSessionForWorkspaceGitWatchTests(options?: { agentManager: createStub({ subscribe: () => () => {}, listAgents: () => [], + listProviderSubagentActivity: () => [], getAgent: () => null, }), agentStorage: createStub({ @@ -331,13 +325,11 @@ describe("workspace git watch targets", () => { cwd: REPO_CWD, name: "main", }); - sessionAny.workspaceUpdatesSubscription = { - subscriptionId: "sub-1", - filter: undefined, - isBootstrapping: false, - pendingUpdatesByWorkspaceId: new Map(), - lastEmittedByWorkspaceId: new Map(), - }; + await session.handleMessage({ + type: "fetch_workspaces_request", + requestId: "workspace-demand", + subscribe: {}, + }); let descriptor = { id: "ws-10", @@ -394,7 +386,6 @@ describe("workspace git watch targets", () => { test("emits checkout_status_update to a client subscribed to the workspace git target", async () => { const { session, emitted, projects, workspaces, workspaceGitService, subscriptions } = createSessionForWorkspaceGitWatchTests(); - const sessionAny = asInternals(session); seedGitWorkspace({ projects, workspaces, @@ -403,13 +394,11 @@ describe("workspace git watch targets", () => { cwd: REPO_CWD, name: "main", }); - sessionAny.workspaceUpdatesSubscription = { - subscriptionId: "sub-1", - filter: undefined, - isBootstrapping: false, - pendingUpdatesByWorkspaceId: new Map(), - lastEmittedByWorkspaceId: new Map(), - }; + await session.handleMessage({ + type: "fetch_workspaces_request", + requestId: "workspace-demand", + subscribe: {}, + }); syncGitObserver(session, REPO_CWD, "ws-10"); emitted.length = 0; @@ -493,6 +482,11 @@ describe("workspace git watch targets", () => { name: "old-branch", }); + await session.handleMessage({ + type: "fetch_workspaces_request", + requestId: "branch-demand", + subscribe: {}, + }); await session.syncWorkspaceGitObserversForExternalWorkspaceIds(["ws-10"]); subscriptions[0]?.listener( @@ -575,7 +569,6 @@ describe("workspace git watch targets", () => { test("embeds PR status in checkout_status_update for GitHub-inclusive snapshot pushes", async () => { const { session, emitted, projects, workspaces, subscriptions } = createSessionForWorkspaceGitWatchTests(); - const sessionAny = asInternals(session); seedGitWorkspace({ projects, workspaces, @@ -584,13 +577,11 @@ describe("workspace git watch targets", () => { cwd: REPO_CWD, name: "main", }); - sessionAny.workspaceUpdatesSubscription = { - subscriptionId: "sub-1", - filter: undefined, - isBootstrapping: false, - pendingUpdatesByWorkspaceId: new Map(), - lastEmittedByWorkspaceId: new Map(), - }; + await session.handleMessage({ + type: "fetch_workspaces_request", + requestId: "workspace-demand", + subscribe: {}, + }); syncGitObserver(session, REPO_CWD, "ws-10"); emitted.length = 0; diff --git a/packages/server/src/server/session.workspaces.test.ts b/packages/server/src/server/session.workspaces.test.ts index 2914e58215..e8df2788cf 100644 --- a/packages/server/src/server/session.workspaces.test.ts +++ b/packages/server/src/server/session.workspaces.test.ts @@ -146,7 +146,6 @@ interface SessionTestAccess { upsert(record: unknown): Promise; }; agentUpdates: AgentUpdatesService; - workspaceUpdatesSubscription: unknown; interruptAgentIfRunning(agentId: string): unknown; reconcileWorkspaceRecord(workspaceId: string): Promise<{ changed: boolean; @@ -155,7 +154,7 @@ interface SessionTestAccess { [key: string]: unknown; }>; handleArchiveAgentRequest(agentId: string, requestId: string): Promise; - handleMessage(message: unknown): Promise; + handleMessage(message: unknown, source?: object): Promise; handleCreatePaseoWorktreeRequest(params: unknown): Promise; listAgentPayloads(...args: unknown[]): Promise; listFetchWorkspacesEntries(params: unknown): Promise; @@ -172,7 +171,7 @@ interface SessionTestAccess { emitWorkspaceUpdateForCwd(...args: unknown[]): Promise; emitWorkspaceUpdatesForWorkspaceIds(...args: unknown[]): Promise; emitWorkspaceUpdatesForExternalWorkspaceIds(workspaceIds: Iterable): Promise; - updateClientCapabilities(capabilities: Record | null): void; + updateClientCapabilities(capabilities: Record | null, source?: object): void; emit(message: unknown): void; onMessage(message: unknown): void; paseoHome: string; @@ -209,16 +208,17 @@ type AgentUpdatesSubscriptionFilter = Parameters< AgentUpdatesService["beginSubscription"] >[0]["filter"]; -// Drives the agent-updates module to a live (non-bootstrapping) subscription — -// the post-extraction equivalent of assigning a subscription with -// `isBootstrapping: false`. begin → flush leaves an empty buffer and emits nothing. -function activateAgentUpdatesSubscription( +async function activateAgentUpdatesSubscription( session: TestSession, subscriptionId: string, filter?: AgentUpdatesSubscriptionFilter, -): void { - session.agentUpdates.beginSubscription({ subscriptionId, filter }); - session.agentUpdates.flushBootstrapped(subscriptionId); +): Promise { + await session.handleMessage({ + type: "fetch_agents_request", + requestId: subscriptionId, + filter, + subscribe: { subscriptionId }, + }); } const AgentIdEntrySchema = z.object({ agent: z.object({ id: z.string() }) }); @@ -865,7 +865,7 @@ test("agent updates preserve queued live transitions across stored metadata read createdAt: "2026-07-31T10:00:00.000Z", updatedAt: "2026-07-31T10:00:00.000Z", }); - activateAgentUpdatesSubscription(session, "sub-coherent"); + await activateAgentUpdatesSubscription(session, "sub-coherent"); if (!forwardAgentEvent) throw new Error("Agent event listener was not installed"); forwardAgentEvent({ type: "agent_state", agent: running }); @@ -915,17 +915,21 @@ test("client heartbeat clears attention for the focused terminal", async () => { }), }); - await session.handleMessage({ - type: "client_heartbeat", - deviceType: "web", - focusedAgentId: null, - focusedTerminalId: "terminal-1", - lastActivityAt: "2026-06-13T12:00:00.000Z", - appVisible: true, - }); + const source = {}; + await session.handleMessage( + { + type: "client_heartbeat", + deviceType: "web", + focusedAgentId: null, + focusedTerminalId: "terminal-1", + lastActivityAt: "2026-06-13T12:00:00.000Z", + appVisible: true, + }, + source, + ); expect(clearedTerminalIds).toEqual(["terminal-1"]); - expect(session.getClientActivity()).toMatchObject({ + expect(session.getClientActivity(source)).toMatchObject({ focusedAgentId: null, focusedTerminalId: "terminal-1", appVisible: true, @@ -1462,7 +1466,7 @@ test("agent_update placement does not refresh git snapshots", async () => { session.workspaceRegistry.list = async () => [workspace]; session.workspaceRegistry.get = async (id: string) => id === workspace.workspaceId ? workspace : null; - activateAgentUpdatesSubscription(session, "sub-agents", {}); + await activateAgentUpdatesSubscription(session, "sub-agents", {}); await session.agentUpdates.forwardLiveAgent( makeManagedAgent({ @@ -1504,7 +1508,7 @@ test("agent_update emits remove when the agent has no workspaceId", async () => }), ); - activateAgentUpdatesSubscription(session, "sub-agents", {}); + await activateAgentUpdatesSubscription(session, "sub-agents", {}); await session.agentUpdates.forwardLiveAgent( makeManagedAgent({ @@ -1664,7 +1668,7 @@ test("archive emits an authoritative agent_update upsert for subscribed clients" }), ); - activateAgentUpdatesSubscription(session, "sub-agents", { includeArchived: true }); + await activateAgentUpdatesSubscription(session, "sub-agents", { includeArchived: true }); await session.handleArchiveAgentRequest("agent-1", "req-archive"); @@ -2145,7 +2149,7 @@ test("close_items_request archives agents and kills terminals in one batch", asy }), ); - activateAgentUpdatesSubscription(session, "sub-agents", { includeArchived: true }); + await activateAgentUpdatesSubscription(session, "sub-agents", { includeArchived: true }); await session.handleMessage({ type: "close_items_request", @@ -2328,7 +2332,7 @@ test("close_items_request archives stored agents that are not currently loaded", }), ); - activateAgentUpdatesSubscription(session, "sub-agents", { includeArchived: true }); + await activateAgentUpdatesSubscription(session, "sub-agents", { includeArchived: true }); await session.handleMessage({ type: "close_items_request", @@ -2479,7 +2483,7 @@ test("close_items_request continues after an archive failure", async () => { }), ); - activateAgentUpdatesSubscription(session, "sub-agents", { includeArchived: true }); + await activateAgentUpdatesSubscription(session, "sub-agents", { includeArchived: true }); await session.handleMessage({ type: "close_items_request", @@ -3731,13 +3735,13 @@ test("workspace update stream keeps persisted workspace visible after agents sto }), ); - session.workspaceUpdatesSubscription = { - subscriptionId: "sub-1", + await session.handleMessage({ + type: "fetch_workspaces_request", + requestId: "sub-1", filter: undefined, - isBootstrapping: false, - pendingUpdatesByWorkspaceId: new Map(), - lastEmittedByWorkspaceId: new Map(), - }; + subscribe: { subscriptionId: "sub-1" }, + }); + expect(filterByType(emitted, "rpc_error")).toEqual([]); session.buildWorkspaceDescriptorMap = async () => new Map([ [ @@ -3825,33 +3829,13 @@ test("archiving the last workspace emits a remove carrying the now-empty project session.workspaceRegistry.get = async (workspaceId: string) => workspaceId === archivedWorkspace.workspaceId ? archivedWorkspace : null; - session.workspaceUpdatesSubscription = { - subscriptionId: "sub-1", - filter: undefined, - isBootstrapping: false, - pendingUpdatesByWorkspaceId: new Map(), - lastEmittedByWorkspaceId: new Map([ - [ - archivedWorkspace.workspaceId, - { - kind: "upsert", - workspace: { - id: archivedWorkspace.workspaceId, - projectId: project.projectId, - projectDisplayName: project.displayName, - projectRootPath: project.rootPath, - workspaceDirectory: archivedWorkspace.cwd, - projectKind: project.kind, - workspaceKind: archivedWorkspace.kind, - name: archivedWorkspace.displayName, - status: "done", - activityAt: null, - diffStat: null, - }, - }, - ], - ]), - }; + archivedWorkspace.archivedAt = null; + await session.handleMessage({ + type: "fetch_workspaces_request", + requestId: "sub-1", + subscribe: { subscriptionId: "sub-1" }, + }); + archivedWorkspace.archivedAt = "2026-03-02T12:00:00.000Z"; // The archived workspace no longer resolves to an active descriptor. session.buildWorkspaceDescriptorMap = async () => new Map(); @@ -3915,13 +3899,12 @@ test("project.remove.request archives active workspaces and removes the project if (!existing) return; workspaces.set(workspaceId, { ...existing, updatedAt: archivedAt, archivedAt }); }; - session.workspaceUpdatesSubscription = { - subscriptionId: "sub-project-remove", + await session.handleMessage({ + type: "fetch_workspaces_request", + requestId: "sub-project-remove", filter: undefined, - isBootstrapping: false, - pendingUpdatesByWorkspaceId: new Map(), - lastEmittedByWorkspaceId: new Map(), - }; + subscribe: { subscriptionId: "sub-project-remove" }, + }); session.listAgentPayloads = async () => []; session.buildWorkspaceDescriptorMap = async (options: { workspaceIds?: Iterable }) => { const workspaceIds = Array.from(options.workspaceIds ?? workspaces.keys()); @@ -4012,15 +3995,11 @@ test("project.remove.request removes an already-empty project", async () => { if (!existing) return; workspaces.set(workspaceId, { ...existing, updatedAt: archivedAt, archivedAt }); }; - session.workspaceUpdatesSubscription = { - subscriptionId: "sub-empty-project-remove", - filter: undefined, - isBootstrapping: false, - pendingUpdatesByWorkspaceId: new Map(), - lastEmittedByWorkspaceId: new Map([ - [archivedWorkspace.workspaceId, { kind: "remove", id: archivedWorkspace.workspaceId }], - ]), - }; + await session.handleMessage({ + type: "fetch_workspaces_request", + requestId: "sub-empty-project-remove", + subscribe: { subscriptionId: "sub-empty-project-remove" }, + }); session.listAgentPayloads = async () => []; session.buildWorkspaceDescriptorMap = async () => new Map(); @@ -4229,13 +4208,12 @@ test("workspace updates stay scoped to the matching cwd", async () => { session.workspaceRegistry.archive = async (workspaceId) => { archivedWorkspaceIds.push(workspaceId); }; - session.workspaceUpdatesSubscription = { - subscriptionId: "sub-dedup", + await session.handleMessage({ + type: "fetch_workspaces_request", + requestId: "sub-dedup", filter: undefined, - isBootstrapping: false, - pendingUpdatesByWorkspaceId: new Map(), - lastEmittedByWorkspaceId: new Map(), - }; + subscribe: { subscriptionId: "sub-dedup" }, + }); session.buildWorkspaceDescriptorMap = async () => new Map([ [ @@ -4392,13 +4370,12 @@ test("import_agent_request registers a workspace for a never-seen cwd", async () session.agentStorage.get = async () => null; session.agentUpdates.forwardLiveAgent = async () => undefined; - session.workspaceUpdatesSubscription = { - subscriptionId: "sub-import", + await session.handleMessage({ + type: "fetch_workspaces_request", + requestId: "sub-import", filter: undefined, - isBootstrapping: false, - pendingUpdatesByWorkspaceId: new Map(), - lastEmittedByWorkspaceId: new Map(), - }; + subscribe: { subscriptionId: "sub-import" }, + }); session.buildWorkspaceDescriptorMap = async () => { const workspace = Array.from(workspaces.values()).find( (candidate) => candidate.cwd === importedCwd, @@ -4595,7 +4572,7 @@ test("open_project_response returns immediately even when the GitHub fetch is sl test("open_project_request emits a workspace_update with githubRuntime once the snapshot resolves", async () => { const emitted: SessionOutboundMessage[] = []; - const session = createSessionForWorkspaceTests(); + const session = createSessionForWorkspaceTests({ onMessage: (message) => emitted.push(message) }); const projects = new Map>(); const workspaces = new Map>(); const cwd = path.resolve("/tmp/github-runtime-repo"); @@ -4604,9 +4581,6 @@ test("open_project_request emits a workspace_update with githubRuntime once the let listener: ((snapshot: WorkspaceGitRuntimeSnapshot) => void) | null = null; const peeked = { value: null as WorkspaceGitRuntimeSnapshot | null }; - session.emit = (message) => { - if (isSessionOutboundMessage(message)) emitted.push(message); - }; session.projectRegistry.get = async (projectId: string) => projects.get(projectId) ?? null; session.projectRegistry.getOrCreateActiveByRoot = async (allocation) => { const project = createPersistedProjectRecord({ @@ -4656,13 +4630,12 @@ test("open_project_request emits a workspace_update with githubRuntime once the listener?.(snapshot); return snapshot; }; - session.workspaceUpdatesSubscription = { - subscriptionId: "sub-open-project", + await session.handleMessage({ + type: "fetch_workspaces_request", + requestId: "sub-open-project", filter: undefined, - isBootstrapping: false, - pendingUpdatesByWorkspaceId: new Map(), - lastEmittedByWorkspaceId: new Map(), - }; + subscribe: { subscriptionId: "sub-open-project" }, + }); await session.handleMessage({ type: "open_project_request", cwd, @@ -5303,13 +5276,12 @@ test("workspace recovery stays accepted when git observer warming fails", async session.workspaceRegistry.upsert = async (record: PersistedWorkspaceRecord) => { workspace = record; }; - session.workspaceUpdatesSubscription = { - subscriptionId: "sub-recovery-warm-failure", + await session.handleMessage({ + type: "fetch_workspaces_request", + requestId: "sub-recovery-warm-failure", filter: undefined, - isBootstrapping: false, - pendingUpdatesByWorkspaceId: new Map(), - lastEmittedByWorkspaceId: new Map(), - }; + subscribe: { subscriptionId: "sub-recovery-warm-failure" }, + }); session.listAgentPayloads = async () => []; await session.handleMessage({ @@ -6105,13 +6077,12 @@ test.skip("opening a new worktree reconciles older local workspaces into the rem session.emit = (message) => { if (isSessionOutboundMessage(message)) emitted.push(message); }; - session.workspaceUpdatesSubscription = { - subscriptionId: "sub-reconcile", + await session.handleMessage({ + type: "fetch_workspaces_request", + requestId: "sub-reconcile", filter: undefined, - isBootstrapping: false, - pendingUpdatesByWorkspaceId: new Map(), - lastEmittedByWorkspaceId: new Map(), - }; + subscribe: { subscriptionId: "sub-reconcile" }, + }); session.listAgentPayloads = async () => []; session.projectRegistry.get = async (projectId: string) => projects.get(projectId) ?? null; session.projectRegistry.list = async () => Array.from(projects.values()); @@ -6845,7 +6816,7 @@ test("buildWorkspaceDescriptorMap stamps workspace archiving state", async () => test("emitWorkspaceUpdatesForWorkspaceIds includes archiving state and dedupes unchanged emits", async () => { const emitted: SessionOutboundMessage[] = []; - const session = createSessionForWorkspaceTests(); + const session = createSessionForWorkspaceTests({ onMessage: (message) => emitted.push(message) }); const archivingAt = "2026-04-30T20:45:00.000Z"; const project = createPersistedProjectRecord({ projectId: "proj-archiving-emit", @@ -6865,20 +6836,17 @@ test("emitWorkspaceUpdatesForWorkspaceIds includes archiving state and dedupes u updatedAt: "2026-03-01T12:00:00.000Z", }); - session.emit = (message) => { - if (isSessionOutboundMessage(message)) emitted.push(message); - }; - session.workspaceUpdatesSubscription = { - subscriptionId: "sub-archiving", + await session.handleMessage({ + type: "fetch_workspaces_request", + requestId: "sub-archiving", filter: undefined, - isBootstrapping: false, - pendingUpdatesByWorkspaceId: new Map(), - lastEmittedByWorkspaceId: new Map(), - }; + subscribe: { subscriptionId: "sub-archiving" }, + }); session.listAgentPayloads = async () => []; session.projectRegistry.list = async () => [project]; session.workspaceRegistry.list = async () => [workspace]; + emitted.length = 0; session.markWorkspaceArchiving([workspace.workspaceId], archivingAt); await session.emitWorkspaceUpdatesForWorkspaceIds([workspace.workspaceId]); @@ -6937,13 +6905,16 @@ test("external workspace updates emit one deduplicated batch", async () => { return [main, feature]; }; session.listAgentPayloads = async () => []; - session.workspaceUpdatesSubscription = { - subscriptionId: "sub-observer-batch", + await session.handleMessage({ + type: "fetch_workspaces_request", + requestId: "sub-observer-batch", filter: undefined, - isBootstrapping: false, - pendingUpdatesByWorkspaceId: new Map(), - lastEmittedByWorkspaceId: new Map(), - }; + subscribe: { subscriptionId: "sub-observer-batch" }, + }); + main.displayName = "Updated main"; + feature.displayName = "Updated feature"; + snapshotReads.projects = 0; + snapshotReads.workspaces = 0; session.onMessage = (message) => { if (isSessionOutboundMessage(message)) emitted.push(message); }; @@ -7112,6 +7083,10 @@ test("fetch_workspaces_response emits before cold registration-triggered git wor const session = asTestSession( createSessionForWorkspaceTests({ workspaceGitService, + onMessage: (message) => { + if (message.type === "fetch_workspaces_response") events.push("response"); + emitted.push(message); + }, }), ); const project = createPersistedProjectRecord({ @@ -7132,13 +7107,6 @@ test("fetch_workspaces_response emits before cold registration-triggered git wor updatedAt: "2026-03-01T12:00:00.000Z", }); - session.emit = (message: unknown) => { - if (!isSessionOutboundMessage(message)) return; - if (message.type === "fetch_workspaces_response") { - events.push("response"); - } - emitted.push(message); - }; session.listAgentPayloads = async () => []; session.projectRegistry.list = async () => [project]; session.workspaceRegistry.list = async () => [workspace]; @@ -7178,6 +7146,7 @@ test("workspace_update includes updated runtime fields", async () => { const session = asTestSession( createSessionForWorkspaceTests({ workspaceGitService, + onMessage: (message) => emitted.push(message), }), ); const project = createPersistedProjectRecord({ @@ -7198,16 +7167,12 @@ test("workspace_update includes updated runtime fields", async () => { updatedAt: "2026-03-01T12:00:00.000Z", }); - session.emit = (message) => { - if (isSessionOutboundMessage(message)) emitted.push(message); - }; - session.workspaceUpdatesSubscription = { - subscriptionId: "sub-runtime", + await session.handleMessage({ + type: "fetch_workspaces_request", + requestId: "sub-runtime", filter: undefined, - isBootstrapping: false, - pendingUpdatesByWorkspaceId: new Map(), - lastEmittedByWorkspaceId: new Map(), - }; + subscribe: { subscriptionId: "sub-runtime" }, + }); session.listAgentPayloads = async () => []; session.projectRegistry.list = async () => [project]; session.workspaceRegistry.list = async () => [workspace]; @@ -7380,19 +7345,6 @@ test("workspace mutation handling does not let a delayed upsert recreate an arch createdAt: "2026-03-01T12:00:00.000Z", updatedAt: "2026-03-01T12:00:00.000Z", }); - const descriptor = { - id: workspace.workspaceId, - projectId: project.projectId, - projectDisplayName: project.displayName, - projectRootPath: project.rootPath, - workspaceDirectory: workspace.cwd, - projectKind: project.kind, - workspaceKind: workspace.kind, - name: workspace.displayName, - status: "done", - activityAt: null, - diffStat: null, - } as WorkspaceDescriptorPayload; let listedWorkspaces: PersistedWorkspaceRecord[] = []; const workspaceRegistry: SessionOptions["workspaceRegistry"] = { initialize: async () => {}, @@ -7444,12 +7396,12 @@ test("workspace mutation handling does not let a delayed upsert recreate an arch let resumeDescribe!: () => void; const describeStarted = new Promise((resolveStarted) => { - session.describeWorkspaceRecordWithGitData = async () => { + workspaceRegistry.list = async () => { resolveStarted(); await new Promise((resolveResume) => { resumeDescribe = resolveResume; }); - return descriptor; + return listedWorkspaces; }; }); @@ -7559,12 +7511,17 @@ test("workspace mutations outside a filtered subscription neither watch nor emit }); session.buildWorkspaceDescriptorMap = async () => new Map([[descriptor.id, descriptor]]); - await session.handleMessage({ - type: "fetch_workspaces_request", - requestId: "req-filtered-mutation", - filter: { projectId: "some-other-project" }, - subscribe: {}, - }); + const source = {}; + session.updateClientCapabilities({ [CLIENT_CAPS.ownedSubscriptions]: true }, source); + await session.handleMessage( + { + type: "fetch_workspaces_request", + requestId: "req-filtered-mutation", + filter: { projectId: "some-other-project" }, + subscribe: {}, + }, + source, + ); emitted.length = 0; await mutationListener?.({ @@ -7638,28 +7595,19 @@ test("project removal mutation broadcasts the final delta to another subscribed remove: async () => {}, }, }); - session.workspaceUpdatesSubscription = { - subscriptionId: "sub-global-remove", - filter: undefined, - isBootstrapping: false, - pendingUpdatesByWorkspaceId: new Map(), - lastEmittedByWorkspaceId: new Map([ - [ - workspace.workspaceId, - { - kind: "remove", - id: workspace.workspaceId, - emptyProject: { - projectId: project.projectId, - projectDisplayName: project.displayName, - projectCustomName: null, - projectRootPath: project.rootPath, - projectKind: project.kind, - }, - }, - ], - ]), - }; + workspace.archivedAt = null; + projectRegistry.list = async () => [project]; + projectRegistry.get = async () => project; + await session.handleMessage({ + type: "fetch_workspaces_request", + requestId: "sub-global-remove", + subscribe: { subscriptionId: "sub-global-remove" }, + }); + workspace.archivedAt = "2026-03-02T12:00:00.000Z"; + await session.emitWorkspaceUpdatesForWorkspaceIds([workspace.workspaceId]); + emitted.length = 0; + projectRegistry.list = async () => []; + projectRegistry.get = async () => null; await projectRegistry.remove(project.projectId); @@ -8072,13 +8020,11 @@ test("project.rename.request stores customName and emits an updated workspace de session.workspaceRegistry.get = async (id: string) => id === workspace.workspaceId ? workspace : null; - session.workspaceUpdatesSubscription = { - subscriptionId: "sub-workspaces", - filter: {}, - isBootstrapping: false, - lastEmittedByWorkspaceId: new Map(), - pendingUpdatesByWorkspaceId: new Map(), - }; + await session.handleMessage({ + type: "fetch_workspaces_request", + requestId: "sub-workspaces", + subscribe: { subscriptionId: "sub-workspaces" }, + }); await session.handleMessage({ type: "project.rename.request", @@ -8249,13 +8195,11 @@ test("workspace.title.set.request stores the title and emits an updated descript return updated; }; - session.workspaceUpdatesSubscription = { - subscriptionId: "sub-workspaces", - filter: {}, - isBootstrapping: false, - lastEmittedByWorkspaceId: new Map(), - pendingUpdatesByWorkspaceId: new Map(), - }; + await session.handleMessage({ + type: "fetch_workspaces_request", + requestId: "sub-workspaces", + subscribe: { subscriptionId: "sub-workspaces" }, + }); await session.handleMessage({ type: "workspace.title.set.request", @@ -8319,13 +8263,11 @@ test("workspace.pin.set.request stores the pin timestamp and emits an updated de workspaces.set(id, updated); return updated; }; - session.workspaceUpdatesSubscription = { - subscriptionId: "sub-workspaces", - filter: {}, - isBootstrapping: false, - lastEmittedByWorkspaceId: new Map(), - pendingUpdatesByWorkspaceId: new Map(), - }; + await session.handleMessage({ + type: "fetch_workspaces_request", + requestId: "sub-workspaces", + subscribe: { subscriptionId: "sub-workspaces" }, + }); await session.handleMessage({ type: "workspace.pin.set.request", @@ -8422,11 +8364,11 @@ test("workspace.title.set.request returns accepted=false when workspace is not f expect(response?.payload.error).toBeTruthy(); }); -function createSessionWithTerminalManager(options: { +async function createSessionWithTerminalManager(options: { workspaces: PersistedWorkspaceRecord[]; projects: PersistedProjectRecord[]; onMessage?: (message: SessionOutboundMessage) => void; -}): { session: TestSession; terminalManager: TerminalManager } { +}): Promise<{ session: TestSession; terminalManager: TerminalManager }> { const terminalManager = createTerminalManager(); terminalManagers.push(terminalManager); @@ -8459,13 +8401,11 @@ function createSessionWithTerminalManager(options: { workspaceRegistry, }); - session.workspaceUpdatesSubscription = { - subscriptionId: "sub-workspaces", - filter: undefined, - isBootstrapping: false, - lastEmittedByWorkspaceId: new Map(), - pendingUpdatesByWorkspaceId: new Map(), - }; + await session.handleMessage({ + type: "fetch_workspaces_request", + requestId: "sub-workspaces", + subscribe: { subscriptionId: "sub-workspaces" }, + }); return { session, terminalManager }; } @@ -8595,7 +8535,7 @@ test("title-only terminal change does not build workspace descriptors or emit wo createdAt: "2026-03-01T12:00:00.000Z", updatedAt: "2026-03-01T12:00:00.000Z", }); - const { session, terminalManager } = createSessionWithTerminalManager({ + const { session, terminalManager } = await createSessionWithTerminalManager({ workspaces: [workspace], projects: [project], onMessage: (message) => emitted.push(message), @@ -8639,7 +8579,7 @@ test("terminal activity contribution change updates the correct workspace", asyn createdAt: "2026-03-01T12:00:00.000Z", updatedAt: "2026-03-01T12:00:00.000Z", }); - const { terminalManager } = createSessionWithTerminalManager({ + const { terminalManager } = await createSessionWithTerminalManager({ workspaces: [workspace], projects: [project], onMessage: (message) => emitted.push(message), @@ -8697,7 +8637,7 @@ test("same-cwd terminal activity updates only the workspace that owns the termin createdAt: "2026-03-01T12:00:00.000Z", updatedAt: "2026-03-01T12:00:00.000Z", }); - const { terminalManager } = createSessionWithTerminalManager({ + const { terminalManager } = await createSessionWithTerminalManager({ workspaces: [workspaceA, workspaceB], projects: [project], onMessage: (message) => emitted.push(message), @@ -8761,7 +8701,7 @@ test("a worktree terminal updates only the workspace that owns it", async () => createdAt: "2026-03-01T12:00:00.000Z", updatedAt: "2026-03-01T12:00:00.000Z", }); - const { terminalManager } = createSessionWithTerminalManager({ + const { terminalManager } = await createSessionWithTerminalManager({ workspaces: [workspaceRoot, workspaceWorktree], projects: [project], onMessage: (message) => emitted.push(message), @@ -8814,7 +8754,7 @@ test("removing an idle terminal does not update workspace status", async () => { createdAt: "2026-03-01T12:00:00.000Z", updatedAt: "2026-03-01T12:00:00.000Z", }); - const { session, terminalManager } = createSessionWithTerminalManager({ + const { session, terminalManager } = await createSessionWithTerminalManager({ workspaces: [workspace], projects: [project], onMessage: (message) => emitted.push(message), @@ -8853,7 +8793,7 @@ test("removing a contributing terminal clears workspace status", async () => { createdAt: "2026-03-01T12:00:00.000Z", updatedAt: "2026-03-01T12:00:00.000Z", }); - const { terminalManager } = createSessionWithTerminalManager({ + const { terminalManager } = await createSessionWithTerminalManager({ workspaces: [workspace], projects: [project], onMessage: (message) => emitted.push(message), diff --git a/packages/server/src/server/session/agent-updates/agent-updates-service.ts b/packages/server/src/server/session/agent-updates/agent-updates-service.ts index a4b1579688..58521f336f 100644 --- a/packages/server/src/server/session/agent-updates/agent-updates-service.ts +++ b/packages/server/src/server/session/agent-updates/agent-updates-service.ts @@ -16,30 +16,20 @@ type AgentUpdatesFilter = NonNullable< interface AgentUpdatesSubscriptionState { subscriptionId: string; - syncEnabled?: boolean; + emit: (message: SessionOutboundMessage) => void; + syncEnabled: boolean; filter?: AgentUpdatesFilter; + isProviderVisible: (provider: string) => boolean; isBootstrapping: boolean; pendingUpdatesByAgentId: Map; } -/** - * Owns the single per-client `agent_update` subscription: when a client subscribes - * via `fetch_agents_request`, every later agent lifecycle change (live forward, - * stored-record archive/detach, delete) is filtered against the subscription's - * filter and either emitted or — while the initial snapshot is still being built — - * buffered and replayed on flush. Keeping the mutable subscription state, the - * bootstrap buffer, the provider-visibility gate, and the filter predicate behind - * one interface stops the rest of session.ts from poking the subscription shape or - * hand-rolling `agent_update` payloads, and the (previously untested) filter/buffer/ - * flush branches become exercisable through injected fakes. - * - * The snapshot listing path applies the SAME filter via the pure - * `matchesAgentUpdatesFilter` so a subscription's initial page and its live updates - * stay consistent. - */ +/** Owns filtered agent snapshots and live updates for independent observers. */ export interface AgentUpdatesService { beginSubscription(input: { subscriptionId: string; + isProviderVisible?: (provider: string) => boolean; + emit?: (message: SessionOutboundMessage) => void; filter?: AgentUpdatesFilter; syncEnabled?: boolean; }): void; @@ -49,7 +39,7 @@ export interface AgentUpdatesService { ): void; clearSubscription(subscriptionId: string): void; hasSubscription(): boolean; - includesLiveAgent(agent: ManagedAgent): Promise; + includesLiveAgent(agent: ManagedAgent, subscriptionIds?: ReadonlySet): Promise; forwardLiveAgent(agent: ManagedAgent): Promise; emitStoredRecord(record: StoredAgentRecord): Promise; removeAgent(agentId: string): Promise; @@ -162,7 +152,7 @@ function agentUpdateTargetId(update: AgentUpdatePayload): string { } export function createAgentUpdatesService(deps: AgentUpdatesServiceDeps): AgentUpdatesService { - let subscription: AgentUpdatesSubscriptionState | null = null; + const subscriptions = new Map(); const liveAgentUpdateTails = new Map>(); const sequence = ( sub: AgentUpdatesSubscriptionState, @@ -173,7 +163,8 @@ export function createAgentUpdatesService(deps: AgentUpdatesServiceDeps): AgentU ) => deps.sequenceAgentUpdate(payload, agent, project, agentId, sub.syncEnabled === true); function bufferOrEmit(sub: AgentUpdatesSubscriptionState, payload: AgentUpdatePayload): void { - if (payload.kind === "upsert" && !deps.isProviderVisibleToClient(payload.agent.provider)) { + if (subscriptions.get(sub.subscriptionId) !== sub) return; + if (payload.kind === "upsert" && !sub.isProviderVisible(payload.agent.provider)) { return; } if (sub.isBootstrapping) { @@ -181,194 +172,112 @@ export function createAgentUpdatesService(deps: AgentUpdatesServiceDeps): AgentU return; } - deps.emit({ - type: "agent_update", - payload, - }); + sub.emit({ type: "agent_update", payload }); } function beginSubscription(input: { subscriptionId: string; + isProviderVisible?: (provider: string) => boolean; + emit?: (message: SessionOutboundMessage) => void; filter?: AgentUpdatesFilter; syncEnabled?: boolean; }): void { - subscription = { - subscriptionId: input.subscriptionId, - syncEnabled: input.syncEnabled, - filter: input.filter, + subscriptions.set(input.subscriptionId, { + ...input, + emit: input.emit ?? deps.emit, + syncEnabled: input.syncEnabled ?? false, + isProviderVisible: input.isProviderVisible ?? deps.isProviderVisibleToClient, isBootstrapping: true, pendingUpdatesByAgentId: new Map(), - }; + }); } function flushBootstrapped( subscriptionId: string, options?: { snapshotUpdatedAtByAgentId?: Map }, ): void { - if (!subscription || subscription.subscriptionId !== subscriptionId) { - return; - } - if (!subscription.isBootstrapping) { - return; - } - - subscription.isBootstrapping = false; - const pending = Array.from(subscription.pendingUpdatesByAgentId.values()); - subscription.pendingUpdatesByAgentId.clear(); - + const sub = subscriptions.get(subscriptionId); + if (!sub?.isBootstrapping) return; + sub.isBootstrapping = false; + const pending = Array.from(sub.pendingUpdatesByAgentId.values()); + sub.pendingUpdatesByAgentId.clear(); for (const payload of pending) { if (payload.kind === "upsert") { const snapshotUpdatedAt = options?.snapshotUpdatedAtByAgentId?.get(payload.agent.id); - if (typeof snapshotUpdatedAt === "number") { - const updateUpdatedAt = Date.parse(payload.agent.updatedAt); - if (!Number.isNaN(updateUpdatedAt) && updateUpdatedAt < snapshotUpdatedAt) { - continue; - } - } + if ( + snapshotUpdatedAt !== undefined && + Date.parse(payload.agent.updatedAt) < snapshotUpdatedAt + ) + continue; } - - deps.emit({ - type: "agent_update", - payload, - }); + bufferOrEmit(sub, payload); } } function clearSubscription(subscriptionId: string): void { - if (subscription && subscription.subscriptionId === subscriptionId) { - subscription = null; - } + subscriptions.delete(subscriptionId); } function hasSubscription(): boolean { - return subscription !== null; + return subscriptions.size > 0; } - async function includesLiveAgent(agent: ManagedAgent): Promise { - const activeSubscription = subscription; - if (!activeSubscription) return false; - + async function includesLiveAgent( + agent: ManagedAgent, + subscriptionIds?: ReadonlySet, + ): Promise { + const observers = [...subscriptions.values()].filter( + (sub) => !subscriptionIds || subscriptionIds.has(sub.subscriptionId), + ); + if (observers.length === 0) return false; const payload = await deps.enrichAgentPayload(toAgentPayload(agent)); - if (subscription !== activeSubscription || !deps.isProviderVisibleToClient(payload.provider)) { - return false; - } const project = payload.workspaceId ? await deps.buildProjectPlacementForWorkspaceId(payload.workspaceId) : null; return ( - subscription === activeSubscription && project !== null && - matchesAgentUpdatesFilter({ - agent: payload, - project, - filter: activeSubscription.filter, - }) + observers.some( + (sub) => + sub.isProviderVisible(payload.provider) && + matchesAgentUpdatesFilter({ agent: payload, project, filter: sub.filter }), + ) ); } + async function publishPayload(payload: AgentSnapshotPayload): Promise { + const observers = [...subscriptions.values()]; + if (observers.length === 0) return; + const project = payload.workspaceId + ? await deps.buildProjectPlacementForWorkspaceId(payload.workspaceId) + : null; + for (const sub of observers) { + const matches = + project && matchesAgentUpdatesFilter({ agent: payload, project, filter: sub.filter }); + bufferOrEmit( + sub, + sequence( + sub, + matches + ? { kind: "upsert", agent: payload, project } + : { kind: "remove", agentId: payload.id }, + payload, + project, + payload.id, + ), + ); + } + } + async function emitStoredRecord(record: StoredAgentRecord): Promise { const payload = deps.buildStoredAgentPayload(record); - const sub = subscription; - if (!sub) { - return payload; - } - - const project = payload.workspaceId - ? await deps.buildProjectPlacementForWorkspaceId(payload.workspaceId) - : null; - if (!project) { - bufferOrEmit( - sub, - sequence(sub, { kind: "remove", agentId: payload.id }, null, null, payload.id), - ); - return payload; - } - - const matches = matchesAgentUpdatesFilter({ - agent: payload, - project, - filter: sub.filter, - }); - bufferOrEmit( - sub, - sequence( - sub, - matches - ? { - kind: "upsert", - agent: payload, - project, - } - : { - kind: "remove", - agentId: payload.id, - }, - payload, - project, - payload.id, - ), - ); + await publishPayload(payload); return payload; } async function emitLiveAgentUpdate(payload: AgentSnapshotPayload): Promise { try { - const sub = subscription; - payload = await deps.enrichAgentPayload(payload); - if (sub) { - const project = payload.workspaceId - ? await deps.buildProjectPlacementForWorkspaceId(payload.workspaceId) - : null; - if (!project) { - bufferOrEmit( - sub, - sequence(sub, { kind: "remove", agentId: payload.id }, null, null, payload.id), - ); - } else { - const matches = matchesAgentUpdatesFilter({ - agent: payload, - project, - filter: sub.filter, - }); - - if (matches) { - bufferOrEmit( - sub, - sequence( - sub, - { - kind: "upsert", - agent: payload, - project, - }, - payload, - project, - payload.id, - ), - ); - } else { - bufferOrEmit( - sub, - sequence( - sub, - { - kind: "remove", - agentId: payload.id, - }, - payload, - project, - payload.id, - ), - ); - } - } - } - - // A lifecycle change updates exactly the agent's owning workspace, never - // every workspace sharing its cwd. Ownership is the agent's workspaceId. - if (payload.workspaceId) { - await deps.emitWorkspaceUpdateForWorkspaceId(payload.workspaceId); - } + if (hasSubscription()) await publishPayload(await deps.enrichAgentPayload(payload)); + if (payload.workspaceId) await deps.emitWorkspaceUpdateForWorkspaceId(payload.workspaceId); } catch (error) { deps.logger.error({ err: error }, "Failed to emit agent update"); } @@ -390,7 +299,7 @@ export function createAgentUpdatesService(deps: AgentUpdatesServiceDeps): AgentU } function forwardLiveAgent(agent: ManagedAgent): Promise { - if (!subscription) { + if (!hasSubscription()) { const workspaceId = agent.workspaceId; return workspaceId ? enqueueAgentUpdate(agent.id, async () => { @@ -408,17 +317,14 @@ export function createAgentUpdatesService(deps: AgentUpdatesServiceDeps): AgentU function removeAgent(agentId: string): Promise { return enqueueAgentUpdate(agentId, () => { - if (subscription) { - bufferOrEmit( - subscription, - sequence(subscription, { kind: "remove", agentId }, null, null, agentId), - ); + for (const sub of subscriptions.values()) { + bufferOrEmit(sub, sequence(sub, { kind: "remove", agentId }, null, null, agentId)); } }); } function dispose(): void { - subscription = null; + subscriptions.clear(); } return { diff --git a/packages/server/src/server/session/checkout/checkout-session.test.ts b/packages/server/src/server/session/checkout/checkout-session.test.ts index d29418a9a5..98185ddc08 100644 --- a/packages/server/src/server/session/checkout/checkout-session.test.ts +++ b/packages/server/src/server/session/checkout/checkout-session.test.ts @@ -1,3 +1,4 @@ +import { SessionDelivery } from "../owned-subscriptions/index.js"; import { execFileSync } from "node:child_process"; import { mkdtempSync, readFileSync, realpathSync, rmSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; @@ -48,6 +49,7 @@ function createFakeDiffSubscriber(initial: CheckoutDiffSnapshotPayload) { const subscriptions: FakeDiffSubscription[] = []; const refreshedCwds: string[] = []; const subscriber: CheckoutDiffSubscriber = { + read: async (params) => ({ ...initial, cwd: params.cwd }), subscribe: async (params, listener) => { let isSubscribed = true; const subscription: FakeDiffSubscription = { @@ -173,7 +175,23 @@ function makeCheckoutSession(options?: { worktreesRoot: undefined, logger: pino({ level: "silent" }), }); - return { checkout, emitted, hostCalls, gitMutationCalls, generatorCalls }; + const delivery = new SessionDelivery((_source, message) => emitted.push(message)); + return { + checkout, + emitted, + hostCalls, + gitMutationCalls, + generatorCalls, + subscribe: (request: Parameters[0]) => + delivery.request(undefined, request, () => + checkout.handleSubscribeDiffRequest(request, delivery), + ), + unsubscribe: (request: Parameters[0]) => + delivery.request(undefined, request, () => + checkout.handleUnsubscribeDiffRequest(request, delivery), + ), + close: () => delivery.close(), + }; } function createGitSnapshot( @@ -532,9 +550,9 @@ describe("CheckoutSession", () => { files: [], error: null, }); - const { checkout, emitted } = makeCheckoutSession({ diff: subscriber }); + const { subscribe, unsubscribe, emitted } = makeCheckoutSession({ diff: subscriber }); - await checkout.handleSubscribeDiffRequest({ + await subscribe({ type: "subscribe_checkout_diff_request", subscriptionId: "s1", cwd: "/repo", @@ -566,7 +584,7 @@ describe("CheckoutSession", () => { }, }); - checkout.handleUnsubscribeDiffRequest({ + await unsubscribe({ type: "unsubscribe_checkout_diff_request", subscriptionId: "s1", }); @@ -574,22 +592,22 @@ describe("CheckoutSession", () => { expect(subscriptions[0].unsubscribeCalls).toBe(1); }); - it("replaces an existing subscription when the same id subscribes again", async () => { + it("preserves legacy replacement when an existing id when the same id subscribes again", async () => { const { subscriber, subscriptions } = createFakeDiffSubscriber({ cwd: "/repo", files: [], error: null, }); - const { checkout } = makeCheckoutSession({ diff: subscriber }); + const { subscribe } = makeCheckoutSession({ diff: subscriber }); - await checkout.handleSubscribeDiffRequest({ + await subscribe({ type: "subscribe_checkout_diff_request", subscriptionId: "s1", cwd: "/repo", compare: { mode: "uncommitted" }, requestId: "first", }); - await checkout.handleSubscribeDiffRequest({ + await subscribe({ type: "subscribe_checkout_diff_request", subscriptionId: "s1", cwd: "/repo", @@ -608,16 +626,16 @@ describe("CheckoutSession", () => { files: [], error: null, }); - const { checkout } = makeCheckoutSession({ diff: subscriber }); + const { subscribe, close } = makeCheckoutSession({ diff: subscriber }); - await checkout.handleSubscribeDiffRequest({ + await subscribe({ type: "subscribe_checkout_diff_request", subscriptionId: "s1", cwd: "/repo", compare: { mode: "uncommitted" }, requestId: "r", }); - await checkout.handleSubscribeDiffRequest({ + await subscribe({ type: "subscribe_checkout_diff_request", subscriptionId: "s2", cwd: "/repo", @@ -625,7 +643,7 @@ describe("CheckoutSession", () => { requestId: "r", }); - checkout.cleanup(); + await close(); expect(subscriptions[0].unsubscribeCalls).toBe(1); expect(subscriptions[1].unsubscribeCalls).toBe(1); diff --git a/packages/server/src/server/session/checkout/checkout-session.ts b/packages/server/src/server/session/checkout/checkout-session.ts index 789f1f0449..152734603c 100644 --- a/packages/server/src/server/session/checkout/checkout-session.ts +++ b/packages/server/src/server/session/checkout/checkout-session.ts @@ -1,4 +1,5 @@ import type pino from "pino"; +import type { SessionDelivery } from "../owned-subscriptions/index.js"; import { isAbsolute } from "node:path"; import { getErrorMessage } from "@getpaseo/protocol/error-utils"; import { getForgeDefinitionOrNeutral } from "@getpaseo/protocol/forge-manifest"; @@ -108,6 +109,9 @@ function toLegacyGithubSearchItems(items: ForgeSearchResultItem[]): LegacyGithub * real CheckoutDiffManager satisfies this structurally; tests supply a fake. */ export interface CheckoutDiffSubscriber { + read( + params: Omit, + ): Promise; subscribe( params: CheckoutDiffSubscriptionRequest, listener: (snapshot: CheckoutDiffSnapshotPayload) => void, @@ -152,7 +156,6 @@ export class CheckoutSession { private readonly paseoHome: string; private readonly worktreesRoot: string | undefined; private readonly logger: pino.Logger; - private readonly diffSubscriptions = new Map void>(); private readonly statusUpdateFingerprints = new Map(); constructor(options: CheckoutSessionOptions) { @@ -400,48 +403,77 @@ export class CheckoutSession { } } - async handleSubscribeDiffRequest(msg: SubscribeCheckoutDiffRequest): Promise { - const cwd = expandTilde(msg.cwd); - this.diffSubscriptions.get(msg.subscriptionId)?.(); - const abort = new AbortController(); - const unsubscribe = () => abort.abort(); - this.diffSubscriptions.set(msg.subscriptionId, unsubscribe); + async handleReadDiffRequest( + msg: Extract, + ): Promise { + const snapshot = await this.checkoutDiffManager.read({ + cwd: expandTilde(msg.cwd), + compare: msg.compare, + }); + this.host.emit({ + type: "checkout.diff.get.response", + payload: { ...snapshot, requestId: msg.requestId }, + }); + } + async handleSubscribeDiffRequest( + msg: SubscribeCheckoutDiffRequest, + ownership: SessionDelivery, + ): Promise { + let bootstrap: Promise | undefined; + const owner = ownership.begin( + "diffs", + msg.subscriptionId, + async () => { + await bootstrap?.then( + (subscription) => subscription.unsubscribe(), + () => undefined, + ); + }, + `diff:${msg.subscriptionId}`, + ); + let ready = false; + let pending: CheckoutDiffSnapshotPayload | null = null; + const emitSnapshot = (snapshot: CheckoutDiffSnapshotPayload) => + owner.emit({ + type: "checkout_diff_update", + payload: { ...snapshot, subscriptionId: owner.responseId }, + }); try { - const subscription = await this.checkoutDiffManager.subscribe( - { cwd, compare: msg.compare, signal: abort.signal }, + bootstrap = this.checkoutDiffManager.subscribe( + { cwd: expandTilde(msg.cwd), compare: msg.compare, signal: owner.signal }, (snapshot) => { - this.host.emit({ - type: "checkout_diff_update", - payload: { - subscriptionId: msg.subscriptionId, - ...snapshot, - }, - }); + if (owner.signal.aborted) return; + if (ready) emitSnapshot(snapshot); + else pending = snapshot; }, ); - + const subscription = await bootstrap; + if (owner.signal.aborted) { + subscription.unsubscribe(); + return; + } this.host.emit({ type: "subscribe_checkout_diff_response", payload: { - subscriptionId: msg.subscriptionId, ...subscription.initial, + subscriptionId: owner.responseId, requestId: msg.requestId, }, }); + ready = true; + if (pending) emitSnapshot(pending); } catch (error) { - if (this.diffSubscriptions.get(msg.subscriptionId) === unsubscribe) { - this.diffSubscriptions.delete(msg.subscriptionId); - } - unsubscribe(); + await owner.release(); throw error; } } - handleUnsubscribeDiffRequest(msg: UnsubscribeCheckoutDiffRequest): void { - const unsubscribe = this.diffSubscriptions.get(msg.subscriptionId); - this.diffSubscriptions.delete(msg.subscriptionId); - unsubscribe?.(); + async handleUnsubscribeDiffRequest( + msg: UnsubscribeCheckoutDiffRequest, + ownership: SessionDelivery, + ): Promise { + await ownership.release(msg.subscriptionId); } async handleRefreshRequest(msg: CheckoutRefreshRequest): Promise { @@ -1413,10 +1445,6 @@ export class CheckoutSession { } cleanup(): void { - for (const unsubscribe of this.diffSubscriptions.values()) { - unsubscribe(); - } - this.diffSubscriptions.clear(); this.statusUpdateFingerprints.clear(); } } diff --git a/packages/server/src/server/session/daemon/daemon-self-update-session-controller.test.ts b/packages/server/src/server/session/daemon/daemon-self-update-session-controller.test.ts index ff274d9019..81c41f3421 100644 --- a/packages/server/src/server/session/daemon/daemon-self-update-session-controller.test.ts +++ b/packages/server/src/server/session/daemon/daemon-self-update-session-controller.test.ts @@ -1,3 +1,4 @@ +import { SessionDelivery } from "../owned-subscriptions/index.js"; import { describe, expect, test } from "vitest"; import { createTestLogger } from "../../../test-utils/test-logger.js"; import type { SessionInboundMessage, SessionOutboundMessage } from "../../messages.js"; @@ -176,3 +177,48 @@ describe("DaemonSelfUpdateSessionController", () => { expect(restartIntents).toEqual([]); }); }); + +test("self-update progress is request-owned through the final delivery boundary", async () => { + const source = {}, + sibling = {}; + const frames: SessionOutboundMessage[] = []; + const delivery = new SessionDelivery((socket, message) => { + expect(socket).toBe(source); + expect(delivery.permits(source, message)).toBe(true); + expect(delivery.permits(sibling, message)).toBe(false); + frames.push(message); + }); + delivery.attach(source, true); + delivery.attach(sibling, true); + let lateProgress: (() => void) | undefined; + const controller = new DaemonSelfUpdateSessionController({ + clientId: "safe-update", + daemonVersion: "0.8.0", + sessionLogger: createTestLogger(), + emit: (message) => { + delivery.reply(message); + }, + emitLifecycleIntent: () => { + throw new Error("No restart is allowed in this test"); + }, + updater: { + async update(input) { + input.onProgress("starting"); + await Promise.resolve(); + input.onProgress("installing"); + lateProgress = () => input.onProgress("starting"); + return { success: false, error: "Safe injected update stopped", newVersion: null }; + }, + }, + }); + await delivery.request(source, updateRequest, async () => { + await controller.dispatch(updateRequest); + }); + expect(frames.map((message) => message.type)).toEqual([ + "daemon.update.progress", + "daemon.update.progress", + "daemon.update.response", + ]); + lateProgress?.(); + expect(frames).toHaveLength(3); +}); diff --git a/packages/server/src/server/session/daemon/daemon-session.ts b/packages/server/src/server/session/daemon/daemon-session.ts index 8c3aa2f167..95caafc58b 100644 --- a/packages/server/src/server/session/daemon/daemon-session.ts +++ b/packages/server/src/server/session/daemon/daemon-session.ts @@ -49,6 +49,7 @@ export interface DaemonSessionOptions { listWorkspaces: () => Promise; listProviderAvailability: () => Promise; getWebSocketRuntimeMetrics?: () => DaemonWebSocketRuntimeDiagnosticSnapshot | null; + getObservationMetrics?: () => Record; logger: pino.Logger; hubRelationships?: HubRelationshipManagement; reloadConfig: () => DaemonConfigReloadResult; @@ -73,6 +74,7 @@ export class DaemonSession { private readonly listWorkspaces: () => Promise; private readonly listProviderAvailability: () => Promise; private readonly getWebSocketRuntimeMetrics: () => DaemonWebSocketRuntimeDiagnosticSnapshot | null; + private readonly getObservationMetrics: DaemonSessionOptions["getObservationMetrics"]; private readonly logger: pino.Logger; private readonly selfUpdate: DaemonSelfUpdateSessionController; private readonly hubRelationships: HubRelationshipManagement | null; @@ -90,6 +92,7 @@ export class DaemonSession { this.listWorkspaces = options.listWorkspaces; this.listProviderAvailability = options.listProviderAvailability; this.getWebSocketRuntimeMetrics = options.getWebSocketRuntimeMetrics ?? (() => null); + this.getObservationMetrics = options.getObservationMetrics; this.logger = options.logger; this.hubRelationships = options.hubRelationships ?? null; this.reloadConfig = options.reloadConfig; @@ -283,6 +286,7 @@ export class DaemonSession { listWorkspaces: this.listWorkspaces, listProviderAvailability: this.listProviderAvailability, getWebSocketRuntimeMetrics: this.getWebSocketRuntimeMetrics, + getObservationMetrics: this.getObservationMetrics, logger: this.logger, }); this.host.emit({ diff --git a/packages/server/src/server/session/daemon/diagnostics.ts b/packages/server/src/server/session/daemon/diagnostics.ts index 87ea2b2faa..9cff35cb3b 100644 --- a/packages/server/src/server/session/daemon/diagnostics.ts +++ b/packages/server/src/server/session/daemon/diagnostics.ts @@ -25,6 +25,7 @@ export interface DaemonDiagnosticsOptions { listWorkspaces: () => Promise; listProviderAvailability: () => Promise; getWebSocketRuntimeMetrics: () => DaemonWebSocketRuntimeDiagnosticSnapshot | null; + getObservationMetrics?: () => Record; logger: pino.Logger; } @@ -68,6 +69,16 @@ export async function collectDaemonDiagnostics(options: DaemonDiagnosticsOptions ]), ]; + if (options.getObservationMetrics) + sections.push( + formatSection( + "Session observations", + Object.entries(options.getObservationMetrics()).map(([label, value]) => ({ + label, + value: String(value), + })), + ), + ); sections.push( await safeSection("Daemon process", () => collectProcessEntries(options), options.logger), ); diff --git a/packages/server/src/server/session/files/workspace-files-session.test.ts b/packages/server/src/server/session/files/workspace-files-session.test.ts index 6d27509fbe..43c517453b 100644 --- a/packages/server/src/server/session/files/workspace-files-session.test.ts +++ b/packages/server/src/server/session/files/workspace-files-session.test.ts @@ -1,3 +1,4 @@ +import { SessionDelivery } from "../owned-subscriptions/index.js"; import { existsSync, mkdtempSync, @@ -555,14 +556,22 @@ describe("WorkspaceFilesSession", () => { test("round-trips an upload through transfer frames", async () => { const { subsystem, emitted, paseoHome } = makeSubsystem(); - subsystem.handleFileUploadRequest({ + const source = {}; + const ownership = new SessionDelivery((_source, message) => { + emitted.push(message); + }); + ownership.attach(source, true); + const request = { type: "file.upload.request", fileName: "notes.txt", mimeType: "text/plain", size: 11, modifiedAt: "2026-05-02T00:00:00.000Z", requestId: "req-upload", - }); + } as const; + await ownership.request(source, request, async () => + subsystem.handleFileUploadRequest(request, ownership), + ); await subsystem.handleFileTransferFrame( uploadFrame({ opcode: FileTransferOpcode.FileBegin, @@ -575,6 +584,7 @@ describe("WorkspaceFilesSession", () => { fileName: "notes.txt", }, }), + source, ); await subsystem.handleFileTransferFrame( uploadFrame({ @@ -582,9 +592,11 @@ describe("WorkspaceFilesSession", () => { requestId: "req-upload", payload: new TextEncoder().encode("hello world"), }), + source, ); await subsystem.handleFileTransferFrame( uploadFrame({ opcode: FileTransferOpcode.FileEnd, requestId: "req-upload" }), + source, ); const message = emitted.find((entry) => entry.type === "file.upload.response"); @@ -593,8 +605,10 @@ describe("WorkspaceFilesSession", () => { } expect(message.payload.error).toBeNull(); expect(message.payload.file?.fileName).toBe("notes.txt"); - expect(readFileSync(join(paseoHome, "uploads", "upload_req-upload", "notes.txt"), "utf8")).toBe( - "hello world", - ); + const file = message.payload.file; + if (!file) throw new Error("Expected uploaded file"); + expect(file.path.startsWith(join(paseoHome, "uploads"))).toBe(true); + expect(readFileSync(file.path, "utf8")).toBe("hello world"); + await ownership.close(); }); }); diff --git a/packages/server/src/server/session/files/workspace-files-session.ts b/packages/server/src/server/session/files/workspace-files-session.ts index 0f833479c2..a0bcb6ed2d 100644 --- a/packages/server/src/server/session/files/workspace-files-session.ts +++ b/packages/server/src/server/session/files/workspace-files-session.ts @@ -1,4 +1,6 @@ import type pino from "pino"; +import type { SessionDelivery } from "../owned-subscriptions/index.js"; +import type { FileVersion } from "@getpaseo/protocol/messages"; import { getErrorMessage } from "@getpaseo/protocol/error-utils"; import { encodeFileTransferFrame, @@ -68,7 +70,6 @@ export class WorkspaceFilesSession { private readonly logger: pino.Logger; private readonly fileUploads: FileUploadStore; private readonly fileObserver: FileObserver; - private readonly fileSubscriptions = new Map void>(); constructor(options: WorkspaceFilesSessionOptions) { this.host = options.host; @@ -78,32 +79,59 @@ export class WorkspaceFilesSession { this.fileObserver = options.fileObserver ?? workspaceFileObserver; } - async handleFileSubscribeRequest(request: FileSubscribeRequest): Promise { - this.fileSubscriptions.get(request.subscriptionId)?.(); + async handleFileSubscribeRequest( + request: FileSubscribeRequest, + ownership: SessionDelivery, + ): Promise { + let bootstrap: ReturnType | undefined; + const owner = ownership.begin( + "files", + request.subscriptionId, + async () => { + await bootstrap?.then( + (subscription) => subscription.unsubscribe(), + () => undefined, + ); + }, + `file:${request.subscriptionId}`, + ); + let ready = false; + let pending: FileVersion | null = null; + const emitVersion = (version: FileVersion) => + owner.emit({ + type: "fs.file.update", + payload: { subscriptionId: owner.responseId, version }, + }); try { - const subscription = await this.fileObserver.subscribe( + bootstrap = this.fileObserver.subscribe( { cwd: request.cwd, path: request.path }, (version) => { - this.host.emit({ - type: "fs.file.update", - payload: { subscriptionId: request.subscriptionId, version }, - }); + if (owner.signal.aborted) return; + if (ready) emitVersion(version); + else pending = version; }, ); - this.fileSubscriptions.set(request.subscriptionId, subscription.unsubscribe); + const subscription = await bootstrap; + if (owner.signal.aborted) { + subscription.unsubscribe(); + return; + } this.host.emit({ type: "fs.file.subscribe.response", payload: { - subscriptionId: request.subscriptionId, + subscriptionId: owner.responseId, initial: subscription.initial, requestId: request.requestId, }, }); + ready = true; + if (pending) emitVersion(pending); } catch (error) { + await owner.release(); this.host.emit({ type: "fs.file.subscribe.response", payload: { - subscriptionId: request.subscriptionId, + subscriptionId: owner.responseId, initial: { status: "error", cwd: request.cwd, @@ -116,9 +144,11 @@ export class WorkspaceFilesSession { } } - handleFileUnsubscribeRequest(request: FileUnsubscribeRequest): void { - this.fileSubscriptions.get(request.subscriptionId)?.(); - this.fileSubscriptions.delete(request.subscriptionId); + async handleFileUnsubscribeRequest( + request: FileUnsubscribeRequest, + ownership: SessionDelivery, + ): Promise { + await ownership.release(request.subscriptionId); this.host.emit({ type: "fs.file.unsubscribe.response", payload: { subscriptionId: request.subscriptionId, requestId: request.requestId }, @@ -213,11 +243,6 @@ export class WorkspaceFilesSession { }); } - dispose(): void { - for (const unsubscribe of this.fileSubscriptions.values()) unsubscribe(); - this.fileSubscriptions.clear(); - } - async handleFileExplorerRequest(request: FileExplorerRequest, source?: object): Promise { const { cwd: workspaceCwd, path: requestedPath = ".", mode, requestId } = request; const cwd = workspaceCwd.trim(); @@ -349,15 +374,23 @@ export class WorkspaceFilesSession { } } - handleFileUploadRequest(request: FileUploadRequest): void { - this.fileUploads.beginUpload(request); + handleFileUploadRequest(request: FileUploadRequest, ownership: SessionDelivery): void { + let cancel: (() => Promise) | undefined; + const operation = ownership.operation( + (message) => + message.type === "file.upload.response" && message.payload.requestId === request.requestId, + () => cancel?.(), + ); + cancel = this.fileUploads.beginUpload(request, operation.source, (response) => { + if (response) operation.emit(response); + void operation + .release() + .catch((error) => this.logger.error({ err: error }, "Upload cleanup failed")); + }); } - async handleFileTransferFrame(frame: FileTransferFrame): Promise { - const response = await this.fileUploads.receiveFrame(frame); - if (response) { - this.host.emit(response); - } + async handleFileTransferFrame(frame: FileTransferFrame, source: object): Promise { + await this.fileUploads.receiveFrame(frame, source); } async handleProjectIconRequest( diff --git a/packages/server/src/server/session/owned-subscriptions/index.ts b/packages/server/src/server/session/owned-subscriptions/index.ts new file mode 100644 index 0000000000..9765d9c064 --- /dev/null +++ b/packages/server/src/server/session/owned-subscriptions/index.ts @@ -0,0 +1,433 @@ +import { isReply } from "./replies.js"; +import { AsyncLocalStorage } from "node:async_hooks"; +import { randomUUID } from "node:crypto"; +import type { SessionInboundMessage, SessionOutboundMessage } from "../../messages.js"; + +interface Source { + socket: object; + modern: boolean; + active: boolean; + handshakePending: boolean; + cancellation: AbortController; + subscriptions: Map; + operations: Set; +} +interface DeliveryOwner { + source: Source; + active: boolean; +} +interface RequestOwner extends DeliveryOwner { + request: SessionInboundMessage; +} +interface RetainedOwner extends DeliveryOwner { + cancellation: AbortController; + stop(): void | Promise; + forget(): void; + release?: Promise; +} +interface SubscriptionOwner extends RetainedOwner { + id: string; + family: string; + legacySlot: string; + responseId: string; +} + +export interface OwnedOperation { + readonly source: object; + readonly signal: AbortSignal; + emit(message: SessionOutboundMessage): void; + release(): Promise; +} + +export interface OwnedSubscription { + readonly id: string; + readonly responseId: string; + readonly source: object; + readonly signal: AbortSignal; + emit(message: SessionOutboundMessage): void; + emitBinary(frame: Uint8Array): void; + release(): Promise; +} + +/** Source provenance and lifetimes. Domain producers own snapshots, filters and buffering. */ +export class SessionDelivery { + private readonly requests = new AsyncLocalStorage(); + private readonly projectionSource = new AsyncLocalStorage(); + private readonly sources = new Map(); + private readonly proofs = new WeakMap(); + private readonly defaultSource = {}; + + constructor( + private readonly send: (source: object, message: SessionOutboundMessage) => void, + private readonly sendBinary: (source: object, frame: Uint8Array) => void = () => {}, + private readonly project: ( + source: object, + message: SessionOutboundMessage, + ) => SessionOutboundMessage = (_source, message) => message, + private readonly unexpectedReply: ( + request: SessionInboundMessage, + message: SessionOutboundMessage, + ) => void = () => {}, + ) {} + + attach(socket: object, modern: boolean): void { + const existing = this.sources.get(socket); + if (existing) { + if (existing.modern !== modern) + throw new Error("Cannot renegotiate subscription ownership on an active source"); + return; + } + this.sources.set(socket, { + socket, + modern, + active: true, + handshakePending: true, + cancellation: new AbortController(), + subscriptions: new Map(), + operations: new Set(), + }); + } + + get currentSource(): object | undefined { + return this.projectionSource.getStore() ?? this.requests.getStore()?.source.socket; + } + + get requestSignal(): AbortSignal { + const request = this.requests.getStore(); + if (!request) throw new Error("Operation has no requesting source"); + return request.source.cancellation.signal; + } + + forSource(source: object, project: () => T): T { + return this.projectionSource.run(source, project); + } + + hasLegacySources(): boolean { + return [...this.sources.values()].some((source) => source.active && !source.modern); + } + + hasDemand(family: string): boolean { + return [...this.sources.values()].some( + (source) => + source.active && + [...source.subscriptions.values()].some((owner) => owner.active && owner.family === family), + ); + } + + subscriptionIds(source: object, family: string): string[] { + return [...(this.sources.get(source)?.subscriptions.values() ?? [])] + .filter((owner) => owner.active && owner.family === family) + .map((owner) => owner.id); + } + + get registrationCount(): number { + return [...this.sources.values()].reduce( + (count, source) => count + source.subscriptions.size + source.operations.size, + 0, + ); + } + + sourceRegistrationCount(socket = this.currentSource): number { + const source = socket ? this.sources.get(socket) : undefined; + return source ? source.subscriptions.size + source.operations.size : 0; + } + + async releaseFamily(family: string): Promise { + await Promise.all( + [...this.sources.values()].flatMap((source) => + [...source.subscriptions.values()] + .filter((owner) => owner.family === family) + .map((owner) => this.releaseOwner(owner)), + ), + ); + } + + isModern(socket: object | undefined): boolean { + return socket ? this.sources.get(socket)?.modern === true : false; + } + + async detach(socket: object): Promise { + const source = this.sources.get(socket); + if (!source) return; + source.active = false; + source.cancellation.abort(); + await Promise.all( + [...source.subscriptions.values(), ...source.operations].map((owner) => + this.releaseOwner(owner), + ), + ); + this.sources.delete(socket); + } + + async close(): Promise { + await Promise.all([...this.sources.keys()].map((socket) => this.detach(socket))); + } + + async request( + socket: object | undefined, + request: SessionInboundMessage, + run: () => Promise, + ): Promise { + socket ??= this.defaultSource; + if (!this.sources.has(socket)) this.attach(socket, false); + const source = this.sources.get(socket)!; + const owner: RequestOwner = { source, request, active: true }; + try { + await this.requests.run(owner, run); + } finally { + owner.active = false; + } + } + + protocolFailure( + socket: object, + failure: { + requestId?: string; + requestType?: string; + error: string; + code: "invalid_message" | "unknown_schema"; + }, + ): void { + const source = this.sources.get(socket); + if (!source?.active) return; + const message: SessionOutboundMessage = + failure.requestId !== undefined + ? { type: "rpc_error", payload: { ...failure, requestId: failure.requestId } } + : { type: "status", payload: { status: "error", message: failure.error } }; + const owner: DeliveryOwner = { source, active: true }; + try { + this.sendOwned(owner, message); + } finally { + owner.active = false; + } + } + + authorizeReply(message: SessionOutboundMessage, socket: object): SessionOutboundMessage { + message = this.project(socket, message); + const owner = this.requests.getStore(); + if (owner?.active && owner.source.socket === socket && isReply(owner.request, message)) + this.proofs.set(message, owner); + return message; + } + + reply(message: SessionOutboundMessage): boolean { + const owner = this.requests.getStore(); + if (!owner?.active) return false; + if (!isReply(owner.request, message)) { + if ( + "requestId" in owner.request && + "payload" in message && + "requestId" in message.payload && + message.payload.requestId === owner.request.requestId + ) { + this.unexpectedReply(owner.request, message); + } + return false; + } + const reply = owner.source.modern ? message : legacyMessage(message); + this.sendOwned(owner, reply); + return true; + } + + begin( + family: string, + requestedId: string | undefined, + stop: (id: string) => void | Promise, + legacySlot = family, + ): OwnedSubscription { + const request = this.requests.getStore(); + if (!request?.active || !request.source.active) + throw new Error("Subscription source is closed"); + const source = request.source; + if (source.modern && (!("requestId" in request.request) || !request.request.requestId)) + throw new Error("Owned subscriptions require a requestId"); + if (source.modern && requestedId !== undefined) + throw new Error("Subscription IDs are assigned by the host"); + // COMPAT(ownedSubscriptions): added in v0.8.0, remove after 2027-03-09 once client floor >= v0.8.0. + if (!source.modern) { + for (const prior of source.subscriptions.values()) { + // Keep failed teardown registered so disconnect/close can report it. + if (prior.legacySlot === legacySlot) void this.releaseOwner(prior).catch(() => {}); + } + } + const id = randomUUID(); + const responseId = source.modern ? id : requestedId?.trim() || id; + const owner: SubscriptionOwner = { + id, + responseId, + source, + family, + legacySlot, + active: true, + cancellation: new AbortController(), + stop: () => stop(id), + forget: () => { + source.subscriptions.delete(id); + }, + }; + source.subscriptions.set(id, owner); + return { + id, + responseId, + source: source.socket, + signal: owner.cancellation.signal, + emit: (message) => { + if (!owner.active || !source.active) return; + const tagged = + source.modern || + message.type === "fs.file.update" || + message.type === "checkout_diff_update" + ? withSubscriptionId(message, responseId) + : legacyMessage(message); + this.sendOwned(owner, tagged); + }, + emitBinary: (frame) => { + if (!owner.active || !source.active) return; + this.proofs.set(frame, owner); + this.sendBinary(source.socket, frame); + }, + release: () => this.releaseOwner(owner), + }; + } + + /** A domain extends the originating request until its asynchronous operation ends. */ + operation( + accepts: (message: SessionOutboundMessage) => boolean, + stop: () => void | Promise, + ): OwnedOperation { + const request = this.requests.getStore(); + if (!request?.active || !request.source.active) throw new Error("Operation source is closed"); + const source = request.source; + const owner: RetainedOwner = { + source, + active: true, + cancellation: new AbortController(), + stop, + forget: () => { + source.operations.delete(owner); + }, + }; + source.operations.add(owner); + return { + source: source.socket, + signal: owner.cancellation.signal, + emit: (message) => { + if (!owner.active || !source.active || !accepts(message)) return; + this.sendOwned(owner, message); + }, + release: () => this.releaseOwner(owner), + }; + } + + async release(id: string): Promise { + const source = this.requests.getStore()?.source; + const owner = + source?.subscriptions.get(id) ?? + (source && !source.modern + ? [...source.subscriptions.values()].find((candidate) => candidate.responseId === id) + : undefined); + if (owner) await this.releaseOwner(owner); + } + + async releaseLegacySlot(slot: string): Promise { + const source = this.requests.getStore()?.source; + if (!source) return; + if (source.modern) + throw new Error("Release subscriptions using the server-assigned subscription ID"); + const owner = [...source.subscriptions.values()].find( + (candidate) => candidate.legacySlot === slot, + ); + if (owner) await this.releaseOwner(owner); + } + + authorizeFileReply(frame: Uint8Array, socket: object): void { + const owner = this.requests.getStore(); + if ( + owner?.active && + owner.source.socket === socket && + owner.request.type === "file_explorer_request" + ) + this.proofs.set(frame, owner); + } + + permitsBinary(socket: object, frame: Uint8Array): boolean { + const source = this.sources.get(socket); + if (!source?.active) return false; + // COMPAT(ownedSubscriptions): added in v0.8.0, remove after 2027-03-09 once client floor >= v0.8.0. + if (!source.modern) return true; + const owner = this.proofs.get(frame); + return owner?.active === true && owner.source === source; + } + + permits(socket: object, message: SessionOutboundMessage): boolean { + const source = this.sources.get(socket); + if (!source?.active) return false; + // COMPAT(ownedSubscriptions): added in v0.8.0, remove after 2027-03-09 once client floor >= v0.8.0. + if (!source.modern) return true; + if ( + message.type === "status" && + message.payload.status === "server_info" && + source.handshakePending + ) { + source.handshakePending = false; + return true; + } + const owner = this.proofs.get(message); + return owner?.active === true && owner.source === source; + } + + private sendOwned(owner: DeliveryOwner, message: SessionOutboundMessage): void { + const projected = this.project(owner.source.socket, message); + this.proofs.set(projected, owner); + this.send(owner.source.socket, projected); + } + + private releaseOwner(owner: RetainedOwner): Promise { + if (owner.release) return owner.release; + owner.active = false; + let resolve!: () => void; + let reject!: (error: unknown) => void; + owner.release = new Promise((accept, fail) => { + resolve = accept; + reject = fail; + }); + owner.cancellation.abort(); + try { + void Promise.resolve(owner.stop()).then(() => { + owner.forget(); + resolve(); + return undefined; + }, reject); + } catch (error) { + reject(error); + } + return owner.release; + } +} + +function withSubscriptionId( + message: T, + subscriptionId: string, +): T { + return "payload" in message + ? { ...message, payload: { ...message.payload, subscriptionId } } + : { ...message, subscriptionId }; +} + +// COMPAT(ownedSubscriptions): added in v0.8.0, remove after 2027-03-09 once client floor >= v0.8.0. +function legacyMessage(message: SessionOutboundMessage): SessionOutboundMessage { + if (message.type === "terminals_changed") { + const { workspaceId: _workspaceId, ...payload } = message.payload; + return { ...message, payload }; + } + if ( + message.type !== "subscribe_terminal_response" && + message.type !== "session.events.set_subscription.response" && + message.type !== "agent.timeline.set_subscription.response" && + message.type !== "workspace.label.list.response" + ) + return message; + if (!("subscriptionId" in message.payload)) return message; + const { subscriptionId: _subscriptionId, ...payload } = message.payload; + return { ...message, payload } as SessionOutboundMessage; +} diff --git a/packages/server/src/server/session/owned-subscriptions/replies.ts b/packages/server/src/server/session/owned-subscriptions/replies.ts new file mode 100644 index 0000000000..2fcfc1b97e --- /dev/null +++ b/packages/server/src/server/session/owned-subscriptions/replies.ts @@ -0,0 +1,69 @@ +import type { + KnownStatusPayload, + SessionInboundMessage, + SessionOutboundMessage, +} from "../../messages.js"; + +type Request = Extract; +type ConventionalReply = T extends `${infer P}.request` + ? `${P}.response` + : T extends `${infer P}_request` + ? `${P}_response` + : T extends `${string}/${string}` + ? `${T}/response` + : `${T}_response`; +type CorrelatedType = M extends { type: infer T; payload: infer P } + ? "requestId" extends keyof P + ? T + : never + : never; +type StatusReply = Extract["status"]; +type ExceptionalReply = Exclude< + CorrelatedType>, + ConventionalReply | "rpc_error" +>; + +// Naming is a protocol convention, not proof. Every nonconforming correlated output +// must declare its originating request; additions to the wire union fail typecheck here. +const exceptions = { + pong: ["ping"], + agent_archived: ["archive_agent_request"], + agent_deleted: ["delete_agent_request"], + agent_permission_resolved: ["agent_permission_response"], + terminals_changed: ["subscribe_terminals_request"], + "daemon.update.progress": ["daemon.update.request"], + // Transcription belongs to the explicitly retained voice operation, not a request ID. + transcription_result: [], + // This legacy requestId is a producer marker; checkout updates require event demand. + checkout_status_update: [], +} satisfies Record; +const statuses = { + agent_created: ["create_agent_request"], + agent_create_failed: ["create_agent_request", "import_agent_request"], + agent_resumed: ["resume_agent_request", "import_agent_request"], + agent_refreshed: ["refresh_agent_request"], + restart_requested: ["restart_server_request"], + shutdown_requested: ["shutdown_server_request"], +} satisfies Record; + +export function isReply(request: SessionInboundMessage, message: SessionOutboundMessage): boolean { + if ( + !("requestId" in request) || + !("payload" in message) || + !("requestId" in message.payload) || + message.payload.requestId !== request.requestId + ) + return false; + if (message.type === "rpc_error") return message.payload.requestType === request.type; + const declared: Readonly> = + message.type === "status" ? statuses : exceptions; + const key = message.type === "status" ? message.payload.status : message.type; + if (key in declared) return declared[key].includes(request.type); + if (message.type === "status") return false; + if (request.type.endsWith(".request")) + return message.type === request.type.replace(/\.request$/, ".response"); + if (request.type.endsWith("_request")) + return message.type === request.type.replace(/_request$/, "_response"); + if (request.type.includes("/")) return message.type === `${request.type}/response`; + return message.type === `${request.type}_response`; +} diff --git a/packages/server/src/server/session/provider/provider-catalog-session.test.ts b/packages/server/src/server/session/provider/provider-catalog-session.test.ts index b72a5142d9..a174f1db5a 100644 --- a/packages/server/src/server/session/provider/provider-catalog-session.test.ts +++ b/packages/server/src/server/session/provider/provider-catalog-session.test.ts @@ -54,7 +54,10 @@ function makeSubsystem(options: MakeOptions = {}) { isProviderVisibleToClient: (provider) => visible.has(provider), supportsCustomModeIcons: () => options.supportsCustomModeIcons ?? false, supportsProviderSnapshotReferences: () => false, - wantsSnapshotChanges: () => true, + publishSnapshot: (project) => { + const message = project(); + if (message) emitted.push(message); + }, supportsCompactProviderSnapshots: () => options.supportsCompactProviderSnapshots ?? false, listProviderAvailability: async () => [], listDraftFeatures: async () => [], @@ -396,7 +399,10 @@ it("announces shared content without retransmitting models or hashing discovery isProviderVisibleToClient: () => true, supportsCustomModeIcons: () => true, supportsCompactProviderSnapshots: () => true, - wantsSnapshotChanges: () => true, + publishSnapshot: (project) => { + const message = project(); + if (message) emitted.push(message); + }, supportsProviderSnapshotReferences: () => references, listProviderAvailability: async () => [], listDraftFeatures: async () => [], diff --git a/packages/server/src/server/session/provider/provider-catalog-session.ts b/packages/server/src/server/session/provider/provider-catalog-session.ts index 7175cabbc9..dd87543c19 100644 --- a/packages/server/src/server/session/provider/provider-catalog-session.ts +++ b/packages/server/src/server/session/provider/provider-catalog-session.ts @@ -48,7 +48,7 @@ export interface ProviderCatalogSessionHost { supportsCustomModeIcons(): boolean; supportsCompactProviderSnapshots(): boolean; supportsProviderSnapshotReferences(): boolean; - wantsSnapshotChanges(): boolean; + publishSnapshot(project: () => SessionOutboundMessage | null): void; listProviderAvailability(): Promise; listDraftFeatures(config: AgentSessionConfig): Promise; } @@ -81,15 +81,18 @@ export class ProviderCatalogSession { this.logger = options.logger; } + get isObserving(): boolean { + return this.unsubscribeSnapshotEvents !== null; + } + start(): void { + if (this.unsubscribeSnapshotEvents) return; const handleProviderSnapshotChange = (transition: ProviderSnapshotTransition) => { - if (!this.host.wantsSnapshotChanges()) return; - const previous = this.visibleSnapshot(transition.previous); - const current = this.visibleSnapshot(transition.current); - if (sameSnapshotRecords(previous.records, current.records)) return; - this.host.emit({ - type: "providers_snapshot_update", - payload: this.snapshotPayload(current), + this.host.publishSnapshot(() => { + const previous = this.visibleSnapshot(transition.previous); + const current = this.visibleSnapshot(transition.current); + if (sameSnapshotRecords(previous.records, current.records)) return null; + return { type: "providers_snapshot_update", payload: this.snapshotPayload(current) }; }); }; this.providerSnapshotManager.on("change", handleProviderSnapshotChange); diff --git a/packages/server/src/server/session/voice/index.test.ts b/packages/server/src/server/session/voice/index.test.ts new file mode 100644 index 0000000000..043b759d1e --- /dev/null +++ b/packages/server/src/server/session/voice/index.test.ts @@ -0,0 +1,58 @@ +import { EventEmitter } from "node:events"; +import pino from "pino"; +import { expect, test } from "vitest"; +import { SessionDelivery } from "../owned-subscriptions/index.js"; +import { VoiceSessions } from "./index.js"; +import type { StreamingTranscriptionSession } from "../../speech/speech-provider.js"; + +test("a failed speech close reports the failure without retaining observation demand", async () => { + class BrokenCloseStream extends EventEmitter implements StreamingTranscriptionSession { + readonly requiredSampleRate = 16000; + async connect() {} + appendPcm16() {} + commit() {} + clear() {} + close() { + throw new Error("Speech worker close failed"); + } + } + const source = {}; + const delivery = new SessionDelivery(() => {}); + delivery.attach(source, true); + const demands: boolean[] = []; + const voice = new VoiceSessions( + { + sessionId: "failed-speech-close", + logger: pino({ level: "silent" }), + host: { + emit() {}, + async loadAgent() { + throw new Error("Unexpected agent load"); + }, + async reloadAgentSession() { + throw new Error("Unexpected agent reload"); + }, + async sendSpokenInput() { + throw new Error("Unexpected spoken input"); + }, + async interruptAgentIfRunning() {}, + hasActiveAgentRun: () => false, + }, + stt: null, + tts: null, + dictation: { stt: { id: "local", createSession: () => new BrokenCloseStream() } }, + }, + delivery, + () => demands.push(voice.hasDemand), + ); + const request = { + type: "dictation_stream_start" as const, + dictationId: "recording", + format: "audio/pcm;rate=16000;bits=16", + }; + await delivery.request(source, request, () => voice.handleMessage(request)); + expect(voice.hasDemand).toBe(true); + await expect(delivery.detach(source)).rejects.toThrow(); + expect(voice.hasDemand).toBe(false); + expect(demands.at(-1)).toBe(false); +}); diff --git a/packages/server/src/server/session/voice/index.ts b/packages/server/src/server/session/voice/index.ts new file mode 100644 index 0000000000..1832da1c65 --- /dev/null +++ b/packages/server/src/server/session/voice/index.ts @@ -0,0 +1,146 @@ +import type { SessionInboundMessage, SessionOutboundMessage } from "../../messages.js"; +import type { OwnedOperation, SessionDelivery } from "../owned-subscriptions/index.js"; +import { VoiceSession, type VoiceSessionOptions } from "./voice-session.js"; + +type VoiceMessage = Extract< + SessionInboundMessage, + { + type: + | "voice_audio_chunk" + | "abort_request" + | "audio_played" + | "set_voice_mode" + | "dictation_stream_start" + | "dictation_stream_chunk" + | "dictation_stream_finish" + | "dictation_stream_cancel"; + } +>; + +interface ActiveVoiceSource { + voice: VoiceSession; + owner: OwnedOperation; + pending: Set>; + closing: boolean; +} + +/** Audio conversations belong to their requesting socket, including bootstrap and finalization. */ +export class VoiceSessions { + private readonly sources = new Map(); + + constructor( + private readonly options: VoiceSessionOptions, + private readonly delivery: SessionDelivery, + private readonly demandChanged: () => void, + ) {} + + get hasDemand(): boolean { + return [...this.sources.values()].some((source) => !source.closing); + } + + isActiveForAgent(agentId: string): boolean { + return [...this.sources.values()].some( + (source) => !source.closing && source.voice.isActiveForAgent(agentId), + ); + } + + handleMessage(message: VoiceMessage): Promise { + const source = this.delivery.currentSource; + if (!source) throw new Error("Voice request has no source"); + const existing = this.sources.get(source); + if (existing?.closing) return existing.owner.release().then(() => this.handleMessage(message)); + const active = existing ?? this.startSource(source); + const pending = Promise.resolve().then(() => { + return active.closing ? undefined : this.dispatch(active.voice, message); + }); + active.pending.add(pending); + const finished = () => { + active.pending.delete(pending); + this.releaseIdle(active); + return undefined; + }; + void pending.then(finished, finished); + return pending; + } + + private startSource(source: object): ActiveVoiceSource { + const owner = this.delivery.operation(isVoiceOutput, async () => { + active.closing = true; + this.demandChanged(); + // Cancel input immediately; restore agent configuration after bootstrap has settled. + try { + active.voice.cancel(); + } finally { + await Promise.allSettled(active.pending); + await active.voice.cleanup(); + } + if (this.sources.get(source) === active) this.sources.delete(source); + }); + const active: ActiveVoiceSource = { + owner, + pending: new Set(), + closing: false, + voice: new VoiceSession({ + ...this.options, + host: { ...this.options.host, emit: (message) => owner.emit(message) }, + onIdle: () => this.releaseIdle(active), + }), + }; + this.sources.set(source, active); + this.demandChanged(); + return active; + } + + private releaseIdle(source: ActiveVoiceSource): void { + if (source.closing || source.pending.size > 0 || source.voice.hasDemand) return; + void source.owner + .release() + .catch((error) => + this.options.logger.error({ err: error }, "Failed to release voice source"), + ); + } + + private async dispatch(voice: VoiceSession, message: VoiceMessage): Promise { + switch (message.type) { + case "voice_audio_chunk": + return voice.handleAudioChunk(message); + case "abort_request": + return voice.handleAbort(); + case "audio_played": + return voice.handleAudioPlayed(message.id); + case "set_voice_mode": + return voice.handleSetVoiceMode(message.enabled, message.agentId, message.requestId); + case "dictation_stream_start": + return voice.handleDictationStreamStart(message); + case "dictation_stream_chunk": + return voice.handleDictationChunk({ + dictationId: message.dictationId, + seq: message.seq, + audioBase64: message.audio, + format: message.format, + }); + case "dictation_stream_finish": + return voice.handleDictationFinish(message.dictationId, message.finalSeq); + case "dictation_stream_cancel": + return voice.handleDictationCancel(message.dictationId); + } + } +} + +function isVoiceOutput(message: SessionOutboundMessage): boolean { + switch (message.type) { + case "audio_output": + case "transcription_result": + case "voice_input_state": + case "set_voice_mode_response": + case "dictation_stream_ack": + case "dictation_stream_partial": + case "dictation_stream_final": + case "dictation_stream_error": + case "dictation_stream_finish_accepted": + case "activity_log": + return true; + default: + return false; + } +} diff --git a/packages/server/src/server/session/voice/voice-session.ts b/packages/server/src/server/session/voice/voice-session.ts index f6331dba3f..b374e759ae 100644 --- a/packages/server/src/server/session/voice/voice-session.ts +++ b/packages/server/src/server/session/voice/voice-session.ts @@ -135,6 +135,7 @@ export interface VoiceSessionOptions { host: VoiceSessionHost; logger: pino.Logger; sessionId: string; + onIdle?: () => void; sttLanguage?: string; tts: Resolvable; stt: Resolvable; @@ -169,6 +170,8 @@ export class VoiceSession { private readonly sttLanguage: string; private abortController: AbortController; + private closed = false; + private readonly onIdle: (() => void) | undefined; private processingPhase: ProcessingPhase = "idle"; private isVoiceMode = false; @@ -211,6 +214,7 @@ export class VoiceSession { const { host, logger, sessionId, sttLanguage, tts, stt, voice, voiceBridge, dictation } = options; this.host = host; + this.onIdle = options.onIdle; this.sessionLogger = logger; this.sessionId = sessionId; this.sttLanguage = sttLanguage ?? "en"; @@ -234,9 +238,20 @@ export class VoiceSession { stt: dictation?.stt ?? null, language: dictation?.sttLanguage, finalTimeoutMs: dictation?.finalTimeoutMs, + onIdle: this.onIdle, }); } + get hasDemand(): boolean { + return ( + this.isVoiceMode || + this.dictationStreamManager.hasDemand || + this.processingPhase !== "idle" || + this.audioBuffer !== null || + this.pendingAudioSegments.length > 0 + ); + } + isActiveForAgent(agentId: string): boolean { return this.isVoiceMode && this.voiceModeAgentId === agentId; } @@ -374,6 +389,10 @@ export class VoiceSession { ); const refreshedAgentId = await this.enableVoiceModeForAgent(normalizedAgentId); this.voiceModeAgentId = refreshedAgentId; + if (this.closed) { + await this.disableVoiceModeForActiveAgent(true); + return; + } this.sessionLogger.info( { agentId: refreshedAgentId, elapsedMs: Date.now() - startedAt }, "set_voice_mode agent enable complete", @@ -389,7 +408,7 @@ export class VoiceSession { { agentId: this.voiceModeAgentId, elapsedMs: Date.now() - startedAt }, "set_voice_mode voice turn controller started", ); - this.isVoiceMode = true; + this.isVoiceMode = !this.closed; this.sessionLogger.info( { agentId: this.voiceModeAgentId, @@ -473,6 +492,7 @@ export class VoiceSession { const startedAt = Date.now(); this.sessionLogger.info({ agentId }, "enableVoiceModeForAgent.ensureAgentLoaded.start"); const existing = await this.host.loadAgent(agentId); + if (this.closed) throw new Error("Voice source is closed"); this.sessionLogger.info( { agentId, elapsedMs: Date.now() - startedAt }, "enableVoiceModeForAgent.ensureAgentLoaded.done", @@ -516,9 +536,6 @@ export class VoiceSession { return; } - this.unregisterVoiceSpeakHandler?.(agentId); - this.unregisterVoiceCallerContext?.(agentId); - if (restoreAgentConfig && this.voiceModeBaseConfig) { const baseConfig = this.voiceModeBaseConfig; try { @@ -533,6 +550,8 @@ export class VoiceSession { } } + this.unregisterVoiceSpeakHandler?.(agentId); + this.unregisterVoiceCallerContext?.(agentId); this.voiceModeBaseConfig = null; this.voiceModeAgentId = null; } @@ -636,8 +655,9 @@ export class VoiceSession { }); this.sessionLogger.info("startVoiceTurnController connecting controller"); - await controller.start(); this.voiceTurnController = controller; + await controller.start(); + if (this.closed) await controller.stop(); this.sessionLogger.info("startVoiceTurnController connected"); } @@ -1191,6 +1211,7 @@ export class VoiceSession { */ private setPhase(phase: ProcessingPhase): void { this.processingPhase = phase; + if (phase === "idle") this.onIdle?.(); this.sessionLogger.debug({ phase }, `Phase: ${phase}`); } @@ -1238,6 +1259,7 @@ export class VoiceSession { * debug persistence before forwarding to the session emitter. */ private emit(msg: SessionOutboundMessage): void { + if (this.closed) return; if ( msg.type === "audio_output" && (process.env.TTS_DEBUG_AUDIO_DIR || isPaseoDictationDebugEnabled()) && @@ -1289,21 +1311,40 @@ export class VoiceSession { this.host.emit(msg); } - /** - * Tear down all voice resources. - */ - async cleanup(): Promise { + /** Stop input synchronously, including a source disconnected during bootstrap. */ + cancel(): void { + if (this.closed) return; + this.closed = true; this.abortController.abort(); this.clearBufferTimeout(); this.pendingAudioSegments = []; this.audioBuffer = null; - await this.stopVoiceTurnController(); + const failures: unknown[] = []; + for (const cleanup of [ + () => this.ttsManager.cleanup(), + () => this.sttManager.cleanup(), + () => this.dictationStreamManager.cleanupAll(), + ]) { + try { + cleanup(); + } catch (error) { + failures.push(error); + } + } + if (failures.length) throw new AggregateError(failures, "Voice input cleanup failed"); + } - this.ttsManager.cleanup(); - this.sttManager.cleanup(); - this.dictationStreamManager.cleanupAll(); - - await this.disableVoiceModeForActiveAgent(true); - this.isVoiceMode = false; + /** Restore the agent only after the caller has drained its in-flight requests. */ + async cleanup(): Promise { + try { + this.cancel(); + } finally { + try { + await this.stopVoiceTurnController(); + } finally { + await this.disableVoiceModeForActiveAgent(true); + this.isVoiceMode = false; + } + } } } diff --git a/packages/server/src/server/session/workspace-git-observer/workspace-git-observer-service.test.ts b/packages/server/src/server/session/workspace-git-observer/workspace-git-observer-service.test.ts index 1eb08a7022..04bcb75996 100644 --- a/packages/server/src/server/session/workspace-git-observer/workspace-git-observer-service.test.ts +++ b/packages/server/src/server/session/workspace-git-observer/workspace-git-observer-service.test.ts @@ -7,7 +7,6 @@ import type { WorkspaceGitRuntimeSnapshot, WorkspaceGitService, } from "../../workspace-git-service.js"; -import type { PersistedWorkspaceRecord } from "../../workspace-registry.js"; import { createWorkspaceGitObserverService } from "./workspace-git-observer-service.js"; // Watch targets are keyed by resolve(cwd), which is platform-dependent (POSIX vs Windows @@ -46,10 +45,6 @@ function makeSnapshot(cwd: string, currentBranch: string | null): WorkspaceGitRu return { cwd, git: { currentBranch } } as unknown as WorkspaceGitRuntimeSnapshot; } -function makeRecord(workspaceId: string): PersistedWorkspaceRecord { - return { workspaceId } as unknown as PersistedWorkspaceRecord; -} - function flushMicrotasks(): Promise { return new Promise((done) => setImmediate(done)); } @@ -59,12 +54,9 @@ function buildHarness(opts: { emitCwdRejects?: boolean } = {}) { const registerCalls: string[] = []; const unsubscribeCalls: string[] = []; const emitCwdCalls: string[] = []; - const emitWorkspaceIdCalls: string[] = []; const statusCalls: Array<{ cwd: string; branch: string | null }> = []; const branchChanges: Array<[string, string | null, string | null]> = []; const warnCalls: unknown[][] = []; - const describeCalls: PersistedWorkspaceRecord[] = []; - let describeResult: WorkspaceDescriptorPayload | null = null; const workspaceGitService: Pick = { registerWorkspace({ cwd }, listener) { @@ -81,22 +73,12 @@ function buildHarness(opts: { emitCwdRejects?: boolean } = {}) { const service = createWorkspaceGitObserverService({ workspaceGitService, - describeWorkspaceRecordWithGitData: async (workspace) => { - describeCalls.push(workspace); - if (!describeResult) { - throw new Error("describeResult not set"); - } - return describeResult; - }, emitWorkspaceUpdateForCwd: async (cwd) => { emitCwdCalls.push(cwd); if (opts.emitCwdRejects) { throw new Error("emit boom"); } }, - emitWorkspaceUpdateForWorkspaceId: async (workspaceId) => { - emitWorkspaceIdCalls.push(workspaceId); - }, emitStatusUpdate: (cwd, snapshot) => { statusCalls.push({ cwd, branch: snapshot.git.currentBranch ?? null }); }, @@ -120,14 +102,9 @@ function buildHarness(opts: { emitCwdRejects?: boolean } = {}) { registerCalls, unsubscribeCalls, emitCwdCalls, - emitWorkspaceIdCalls, statusCalls, branchChanges, warnCalls, - describeCalls, - setDescribeResult: (descriptor: WorkspaceDescriptorPayload) => { - describeResult = descriptor; - }, }; } @@ -267,30 +244,6 @@ describe("git snapshot listener", () => { }); }); -describe("shouldSkipUpdate", () => { - test("returns false when no observer exists for the workspace", () => { - const h = buildHarness(); - expect(h.service.shouldSkipUpdate("unknown", null)).toBe(false); - }); - - test("skips a repeat descriptor state and re-emits when it changes", () => { - const h = buildHarness(); - h.service.syncObservers([makeDescriptor({ id: "ws1", workspaceDirectory: WS1 })]); - const a = makeDescriptor({ id: "ws1", workspaceDirectory: WS1, name: "main" }); - const b = makeDescriptor({ id: "ws1", workspaceDirectory: WS1, name: "feature" }); - expect(h.service.shouldSkipUpdate("ws1", a)).toBe(false); - expect(h.service.shouldSkipUpdate("ws1", a)).toBe(true); - expect(h.service.shouldSkipUpdate("ws1", b)).toBe(false); - }); - - test("starts from the descriptor state recorded during observer sync", () => { - const h = buildHarness(); - const descriptor = makeDescriptor({ id: "ws1", workspaceDirectory: WS1, name: "main" }); - h.service.syncObservers([descriptor]); - expect(h.service.shouldSkipUpdate("ws1", descriptor)).toBe(true); - }); -}); - describe("recordDescriptorState", () => { test("does not treat a workspace title as a branch change", () => { const h = buildHarness(); @@ -385,26 +338,7 @@ describe("teardown", () => { h.service.syncObservers([makeDescriptor({ id: "ws1", workspaceDirectory: WS1 })]); h.service.dispose(); const descriptor = makeDescriptor({ id: "ws1", workspaceDirectory: WS1, name: "main" }); - expect(h.service.shouldSkipUpdate("ws1", descriptor)).toBe(false); h.service.recordDescriptorState("ws1", descriptor); expect(h.branchChanges).toEqual([]); }); }); - -describe("syncObserverForWorkspace / warmGitData", () => { - test("describes the record then registers the observer", async () => { - const h = buildHarness(); - h.setDescribeResult(makeDescriptor({ id: "ws1", workspaceDirectory: WS1 })); - await h.service.syncObserverForWorkspace(makeRecord("ws1")); - expect(h.describeCalls).toEqual([makeRecord("ws1")]); - expect(h.registerCalls).toEqual([WS1]); - }); - - test("warmGitData registers the observer and emits a workspace update", async () => { - const h = buildHarness(); - h.setDescribeResult(makeDescriptor({ id: "ws1", workspaceDirectory: WS1 })); - await h.service.warmGitData(makeRecord("ws1")); - expect(h.registerCalls).toEqual([WS1]); - expect(h.emitWorkspaceIdCalls).toEqual(["ws1"]); - }); -}); diff --git a/packages/server/src/server/session/workspace-git-observer/workspace-git-observer-service.ts b/packages/server/src/server/session/workspace-git-observer/workspace-git-observer-service.ts index dfc87b312b..d657c63957 100644 --- a/packages/server/src/server/session/workspace-git-observer/workspace-git-observer-service.ts +++ b/packages/server/src/server/session/workspace-git-observer/workspace-git-observer-service.ts @@ -5,9 +5,6 @@ import type { WorkspaceGitRuntimeSnapshot, WorkspaceGitService, } from "../../workspace-git-service.js"; -import type { PersistedWorkspaceRecord } from "../../workspace-registry.js"; - -const WORKSPACE_GIT_WATCH_REMOVED_STATE_KEY = "__removed__"; interface WorkspaceGitWatchTarget { workspaceIds: Set; @@ -15,7 +12,6 @@ interface WorkspaceGitWatchTarget { interface WorkspaceGitWatchState { cwd: string; - latestDescriptorStateKey: string | null; lastBranchName: string | null; } @@ -39,11 +35,11 @@ export interface WorkspaceGitObserverMetrics { */ export interface WorkspaceGitObserverService { syncObservers(workspaces: Iterable): void; - syncObserverForWorkspace(workspace: PersistedWorkspaceRecord): Promise; - warmGitData(workspace: PersistedWorkspaceRecord): Promise; - // Check-and-record dedupe gate: returns true when the descriptor state is unchanged - // for this workspace, and otherwise advances the recorded state key as a side effect. - shouldSkipUpdate(workspaceId: string, workspace: WorkspaceDescriptorPayload | null): boolean; + reconcileObservers( + workspaces: Iterable< + Pick + >, + ): void; recordDescriptorState(workspaceId: string, workspace: WorkspaceDescriptorPayload | null): void; handleBranchSnapshot(cwd: string, branchName: string | null): void; getMetrics(): WorkspaceGitObserverMetrics; @@ -53,11 +49,7 @@ export interface WorkspaceGitObserverService { export function createWorkspaceGitObserverService(deps: { workspaceGitService: Pick; - describeWorkspaceRecordWithGitData: ( - workspace: PersistedWorkspaceRecord, - ) => Promise; emitWorkspaceUpdateForCwd: (cwd: string) => Promise; - emitWorkspaceUpdateForWorkspaceId: (workspaceId: string) => Promise; emitStatusUpdate: (cwd: string, snapshot: WorkspaceGitRuntimeSnapshot) => void; onBranchChanged?: ( workspaceId: string, @@ -68,9 +60,7 @@ export function createWorkspaceGitObserverService(deps: { }): WorkspaceGitObserverService { const { workspaceGitService, - describeWorkspaceRecordWithGitData, emitWorkspaceUpdateForCwd, - emitWorkspaceUpdateForWorkspaceId, emitStatusUpdate, onBranchChanged, logger, @@ -80,16 +70,6 @@ export function createWorkspaceGitObserverService(deps: { const workspaceStates = new Map(); const subscriptions = new Map void>(); - function descriptorStateKey(workspace: WorkspaceDescriptorPayload | null): string { - if (!workspace) { - return WORKSPACE_GIT_WATCH_REMOVED_STATE_KEY; - } - return JSON.stringify([ - workspace.name, - workspace.diffStat ? [workspace.diffStat.additions, workspace.diffStat.deletions] : null, - ]); - } - function rememberDescriptorState( workspaceId: string, workspace: WorkspaceDescriptorPayload | null, @@ -98,7 +78,6 @@ export function createWorkspaceGitObserverService(deps: { if (!state) { return; } - state.latestDescriptorStateKey = descriptorStateKey(workspace); const currentBranch = workspace?.gitRuntime?.currentBranch; if (currentBranch !== undefined) { state.lastBranchName = currentBranch; @@ -168,7 +147,6 @@ export function createWorkspaceGitObserverService(deps: { if (!workspaceStates.has(options.workspaceId)) { workspaceStates.set(options.workspaceId, { cwd: normalizedCwd, - latestDescriptorStateKey: null, lastBranchName: null, }); } @@ -206,33 +184,20 @@ export function createWorkspaceGitObserverService(deps: { } } - async function syncObserverForWorkspace(workspace: PersistedWorkspaceRecord): Promise { - const descriptor = await describeWorkspaceRecordWithGitData(workspace); - syncObservers([descriptor]); - } - return { + reconcileObservers(workspaces) { + const retained = new Map([...workspaces].map((workspace) => [workspace.id, workspace])); + for (const workspaceId of workspaceStates.keys()) { + if (!retained.has(workspaceId)) removeForWorkspaceId(workspaceId); + } + for (const workspace of retained.values()) { + syncObserver(workspace.workspaceDirectory, { + isGit: workspace.workspaceKind !== "directory", + workspaceId: workspace.id, + }); + } + }, syncObservers, - syncObserverForWorkspace, - - async warmGitData(workspace) { - await syncObserverForWorkspace(workspace); - await emitWorkspaceUpdateForWorkspaceId(workspace.workspaceId); - }, - - shouldSkipUpdate(workspaceId, workspace) { - const state = workspaceStates.get(workspaceId); - if (!state) { - return false; - } - const nextStateKey = descriptorStateKey(workspace); - if (state.latestDescriptorStateKey === nextStateKey) { - return true; - } - state.latestDescriptorStateKey = nextStateKey; - return false; - }, - recordDescriptorState(workspaceId, nextWorkspace) { const state = workspaceStates.get(workspaceId); const newBranchName = nextWorkspace?.gitRuntime?.currentBranch; diff --git a/packages/server/src/server/session/workspace-scripts/workspace-scripts-service.ts b/packages/server/src/server/session/workspace-scripts/workspace-scripts-service.ts index 7dee9364f9..2116e82b11 100644 --- a/packages/server/src/server/session/workspace-scripts/workspace-scripts-service.ts +++ b/packages/server/src/server/session/workspace-scripts/workspace-scripts-service.ts @@ -66,6 +66,7 @@ export function createWorkspaceScriptsService(deps: { globalServicePorts?: PaseoServicePortAllocation; logger: pino.Logger; emit: (message: SessionOutboundMessage) => void; + wantsStatusUpdates?: () => boolean; spawnWorkspaceScript: (options: SpawnWorkspaceScriptOptions) => Promise; assertAutomationAllowed: (workspaceId: string) => Promise; }): WorkspaceScriptsService { @@ -129,6 +130,7 @@ export function createWorkspaceScriptsService(deps: { } async function emitStatusUpdate(workspaceId: string, _workspaceDirectory: string): Promise { + if (deps.wantsStatusUpdates && !deps.wantsStatusUpdates()) return; try { const workspace = await workspaceRegistry.get(workspaceId); if (!workspace) return; diff --git a/packages/server/src/server/test-utils/daemon-test-context.ts b/packages/server/src/server/test-utils/daemon-test-context.ts index 44c6f28457..c872a686dd 100644 --- a/packages/server/src/server/test-utils/daemon-test-context.ts +++ b/packages/server/src/server/test-utils/daemon-test-context.ts @@ -43,7 +43,7 @@ export async function createDaemonTestContext( url: `ws://127.0.0.1:${daemon.port}/ws`, }); await client.connect(); - await client.fetchAgents({ subscribe: { subscriptionId: "test" } }); + await client.fetchAgents({ subscribe: {} }); return { daemon, diff --git a/packages/server/src/server/test-utils/fake-agent-client.ts b/packages/server/src/server/test-utils/fake-agent-client.ts index 4d16f0302e..323e9944c7 100644 --- a/packages/server/src/server/test-utils/fake-agent-client.ts +++ b/packages/server/src/server/test-utils/fake-agent-client.ts @@ -737,6 +737,16 @@ class FakeAgentSession implements AgentSession { await this.appendHistoryEvent(turnStarted); this.notifySubscribers(turnStarted); + if (textPrompt === "Emit a provider child") { + const child: AgentStreamEvent = { + type: "provider_subagent", + provider: this.providerName, + event: { type: "upsert", id: "fixture-child", title: "Fixture child", status: "running" }, + }; + await this.appendHistoryEvent(child); + this.notifySubscribers(child); + } + if (textPrompt.toLowerCase().includes("emit a turn failure")) { const failed: AgentStreamEvent = { type: "turn_failed", diff --git a/packages/server/src/server/test-utils/session-stubs.ts b/packages/server/src/server/test-utils/session-stubs.ts index 15ed7ecc09..a6f404e62a 100644 --- a/packages/server/src/server/test-utils/session-stubs.ts +++ b/packages/server/src/server/test-utils/session-stubs.ts @@ -28,7 +28,10 @@ export function asSessionLogger(stub: { export function asAgentManager(stub: { [K in keyof SessionOptions["agentManager"]]?: unknown; }): SessionOptions["agentManager"] { - return createStub(stub); + return createStub({ + listProviderSubagentActivity: () => [], + ...stub, + }); } export function asAgentStorage(stub: { diff --git a/packages/server/src/server/voice-local-agent.e2e.test.ts b/packages/server/src/server/voice-local-agent.e2e.test.ts index 29841b7938..c78759459a 100644 --- a/packages/server/src/server/voice-local-agent.e2e.test.ts +++ b/packages/server/src/server/voice-local-agent.e2e.test.ts @@ -31,8 +31,10 @@ function makeErrorHandler(reject: (error: Error) => void) { }; } -function makeSpeakToolHandler(resolve: (value: string) => void) { - return (msg: SessionMessage<"agent_stream" | "agent.timeline.replacement">) => { +function makeSpeakToolHandler( + resolve: (value: string) => void, +): Parameters[1] { + return (msg) => { if (msg.type !== "agent_stream") return; if (msg.payload.event.type !== "timeline") return; const item = msg.payload.event.item; diff --git a/packages/server/src/server/websocket-server.browser-tools.test.ts b/packages/server/src/server/websocket-server.browser-tools.test.ts index ba83e979d1..df0b02ad43 100644 --- a/packages/server/src/server/websocket-server.browser-tools.test.ts +++ b/packages/server/src/server/websocket-server.browser-tools.test.ts @@ -139,10 +139,11 @@ describe("WebSocketServer browser tools wiring", () => { it("keeps browser automation registered when a browser host client resumes", async () => { const harness = await startBrowserToolsDaemonHarness(); const clientId = "browser-host-client-1"; - await harness.connectBrowserHostClient({ + const originalBrowserHost = await harness.connectBrowserHostClient({ clientId, capabilities: browserHostCapabilities(), }); + await originalBrowserHost.disconnect(); const resumedBrowserHost = await harness.connectBrowserHostClient({ clientId, @@ -182,6 +183,7 @@ describe("WebSocketServer browser tools wiring", () => { await browserHost.nextBrowserRequest(); expect(harness.broker.getPendingRequestCount()).toBe(1); + await browserHost.disconnect(); await harness.connectBrowserHostClient({ clientId, capabilities: browserHostCapabilities(["list_tabs"]), @@ -225,6 +227,11 @@ async function startBrowserToolsDaemonHarness(): Promise client.sendBrowserAutomationExecuteResponse(response), async disconnect() { + await observation.release(); requests.close(); clients.delete(client); await client.close(); diff --git a/packages/server/src/server/websocket-server.notifications.test.ts b/packages/server/src/server/websocket-server.notifications.test.ts index 15f4aa38d2..8ff692d580 100644 --- a/packages/server/src/server/websocket-server.notifications.test.ts +++ b/packages/server/src/server/websocket-server.notifications.test.ts @@ -1,3 +1,4 @@ +import { SessionDelivery } from "./session/owned-subscriptions/index.js"; import { afterEach, describe, expect, it, vi } from "vitest"; import type { Server as HTTPServer } from "http"; import type pino from "pino"; @@ -191,9 +192,15 @@ function connectClient( options: { subscribed?: boolean } = {}, ) { const ws = createOpenSocket(); + const delivery = new SessionDelivery(() => {}); + delivery.attach(ws, false); asInternals(server).sessions.set(ws, { kind: "trusted", - session: createSessionWithActivity(activity, options.subscribed ?? true), + session: { + ...createSessionWithActivity(activity, options.subscribed ?? true), + delivery, + wantsSourceNotification: () => true, + }, clientId: "client-test", appVersion: null, connectionLogger: createLogger(), diff --git a/packages/server/src/server/websocket-server.relay-reconnect.test.ts b/packages/server/src/server/websocket-server.relay-reconnect.test.ts index c470476913..79577d6889 100644 --- a/packages/server/src/server/websocket-server.relay-reconnect.test.ts +++ b/packages/server/src/server/websocket-server.relay-reconnect.test.ts @@ -1,3 +1,4 @@ +import { SessionDelivery } from "./session/owned-subscriptions/index.js"; import { afterEach, beforeEach, describe, expect, test, vi } from "vitest"; import type { Server as HTTPServer } from "http"; import type pino from "pino"; @@ -46,15 +47,27 @@ const sessionMock = vi.hoisted(() => { const instances: MockSession[] = []; class MockSession { - cleanup = vi.fn(async () => {}); + readonly delivery = new SessionDelivery((source, message) => { + const send = this.args.onMessageToSource as (source: object, message: unknown) => void; + send(source, message); + }); + cleanup = vi.fn(async () => { + await this.delivery.close(); + }); handleMessage = vi.fn(async () => {}); handleBinaryFrame = vi.fn((_frame: unknown) => {}); supports = vi.fn((capability: string) => this.args.clientCapabilities?.[capability] === true); - updateClientCapabilities = vi.fn((capabilities: Record | null) => { - this.args.clientCapabilities = capabilities; + updateClientCapabilities = vi.fn( + (capabilities: Record | null, source: object) => { + this.args.clientCapabilities = capabilities; + this.delivery.attach(source, capabilities?.owned_subscriptions === true); + }, + ); + clearAgentTimelineSubscription = vi.fn((source: object) => { + void this.delivery.detach(source); }); - clearAgentTimelineSubscription = vi.fn(); getClientActivity = vi.fn(() => null); + wantsSourceEvent = (source: object) => !this.delivery.isModern(source); getSessionId = vi.fn(() => "mock-session-id"); getPermissions = vi.fn(() => this.args.permissions as string[]); allowsInbound = vi.fn(() => true); @@ -889,7 +902,7 @@ describe("relay external socket reconnect behavior", () => { payload: { requestId: "failing-provider-diagnostic", requestType: "provider_diagnostic_request", - error: "Invalid message", + error: "Invalid message: handler exploded", code: "invalid_message", }, }, diff --git a/packages/server/src/server/websocket-server.terminal-notifications.test.ts b/packages/server/src/server/websocket-server.terminal-notifications.test.ts index 0f4e7f25ff..3f77a0482e 100644 --- a/packages/server/src/server/websocket-server.terminal-notifications.test.ts +++ b/packages/server/src/server/websocket-server.terminal-notifications.test.ts @@ -1,3 +1,4 @@ +import { SessionDelivery } from "./session/owned-subscriptions/index.js"; import { afterEach, describe, expect, it, vi } from "vitest"; import type { Server as HTTPServer } from "http"; import type pino from "pino"; @@ -194,9 +195,13 @@ function createOpenSocket() { function connectClient(server: VoiceAssistantWebSocketServer, subscribed = true) { const ws = createOpenSocket(); + const delivery = new SessionDelivery(() => {}); + delivery.attach(ws, false); asInternals<{ sessions: Map }>(server).sessions.set(ws, { kind: "trusted", session: { + delivery, + wantsSourceNotification: () => true, getClientActivity: vi.fn(() => null), subscribesToTerminalDirectory: vi.fn(async () => subscribed), }, diff --git a/packages/server/src/server/websocket-server.ts b/packages/server/src/server/websocket-server.ts index 30fce35b13..8b0ab3879f 100644 --- a/packages/server/src/server/websocket-server.ts +++ b/packages/server/src/server/websocket-server.ts @@ -92,11 +92,7 @@ import { normalizeClientRestartRpcReason, } from "./lifecycle-reasons.js"; import { CLIENT_CAPS } from "@getpaseo/protocol/client-capabilities"; -import type { BrowserAutomationExecuteResponse } from "@getpaseo/protocol/browser-automation/rpc-schemas"; -import { - BrowserAutomationHostCapabilitySchema, - type BrowserAutomationHostCapability, -} from "@getpaseo/protocol/browser-automation/capabilities"; + import type { BrowserToolsBroker } from "./browser-tools/broker.js"; import type { DaemonRuntimeConfig } from "./session/daemon/daemon-session.js"; import { DirectorySyncService } from "./directory-sync/index.js"; @@ -418,15 +414,6 @@ function bufferFromWsData(data: Buffer | ArrayBuffer | Buffer[] | string): Buffe return Buffer.from(data); } -function getBrowserHostCapability( - capabilities: Record | null, -): BrowserAutomationHostCapability | null { - const parsed = BrowserAutomationHostCapabilitySchema.safeParse( - capabilities?.[CLIENT_CAPS.browserHost], - ); - return parsed.success ? parsed.data : null; -} - export interface WebSocketLike { readyState: number; bufferedAmount?: number; @@ -463,11 +450,6 @@ interface PluginSessionConnection extends SessionConnectionBase { type SessionConnection = ReconnectableSessionConnection | PluginSessionConnection; -interface BrowserToolsRegistration { - capabilitySignature: string; - unregister: () => void; -} - interface SocketSessionOptions { clientId: string; appVersion: string | null; @@ -478,7 +460,7 @@ interface SocketSessionOptions { onMessageToSource?: (source: object, message: SessionOutboundMessage) => void; onBinaryMessage?: (frame: Uint8Array) => void; onBinaryMessageToSource?: (source: object, frame: Uint8Array) => Promise; - getTransportBufferedAmount?: () => number | null; + getTransportBufferedAmount?: (source?: object) => number | null; onLifecycleIntent?: (intent: SessionLifecycleIntent) => void; hubExecutionAgents?: HubExecutionAgents; hubRelationships?: HubRelationshipManagement; @@ -596,7 +578,6 @@ export class VoiceAssistantWebSocketServer { private unsubscribeTerminalActivity: (() => void) | null = null; private readonly browserToolsBroker: BrowserToolsBroker | null; private readonly hubRelationships: HubRelationshipManagement | null; - private readonly browserToolsRegistrations = new Map(); private connectionLifecycle: "starting" | "accepting" | "stopping" = "accepting"; private readonly advertiseDaemonStatusRpc: boolean; private readonly advertiseRelayConfig: boolean; @@ -1010,7 +991,6 @@ export class VoiceAssistantWebSocketServer { for (const connection of new Set(this.externalSessionsByKey.values())) { if (connection.principalId === principalId) { connection.session.setPermissions(permissions); - this.syncBrowserToolsClientRegistration(connection); } } } @@ -1103,18 +1083,38 @@ export class VoiceAssistantWebSocketServer { this.sessions.clear(); this.socketIdentities.clear(); this.externalSessionsByKey.clear(); - for (const clientId of this.browserToolsRegistrations.keys()) { - this.unregisterBrowserToolsClient(clientId); - } this.wss.close(); } private sendToClient(ws: WebSocketLike, message: WSOutboundMessage): void { - this.sendMessageToSockets([ws], message); + this.sendMessageToSockets([ws], message, true); } - private sendMessageToSockets(sockets: Iterable, message: WSOutboundMessage): void { - const writableSockets = [...sockets].filter((ws) => this.ensureOutboundCapacity(ws, 0)); + private sendMessageToSockets( + sockets: Iterable, + message: WSOutboundMessage, + reportRejection = false, + ): void { + const writableSockets = [...sockets].filter((ws) => { + if ( + message.type === "session" && + this.sessions.get(ws)?.session.delivery.permits(ws, message.message) !== true + ) { + this.incrementRuntimeCounter("applicationJsonRejected"); + // Legacy broadcasts intentionally exclude modern sockets. Targeted failures are bugs. + if (reportRejection) + this.logger.warn( + { + messageType: message.message.type, + status: + message.message.type === "status" ? message.message.payload.status : undefined, + }, + "ws_application_delivery_denied", + ); + return false; + } + return this.ensureOutboundCapacity(ws, 0); + }); if (writableSockets.length === 0) { return; } @@ -1135,13 +1135,24 @@ export class VoiceAssistantWebSocketServer { } } - private sendBinaryToClient(ws: WebSocketLike, frame: Uint8Array): void { + private sendBinaryToClient(ws: WebSocketLike, frame: Uint8Array, reportRejection = true): void { + if (!this.sessions.get(ws)?.session.delivery.permitsBinary(ws, frame)) { + this.incrementRuntimeCounter("applicationBinaryRejected"); + if (reportRejection) + this.logger.warn({ bytes: frame.byteLength }, "ws_application_binary_delivery_denied"); + return; + } this.sendFrameToClient(ws, frame, outboundFrameByteLength(frame), () => { this.runtimeMetrics.recordOutboundBinaryFrame(ws.bufferedAmount); }); } private async sendBinaryToClientAndWait(ws: WebSocketLike, frame: Uint8Array): Promise { + if (!this.sessions.get(ws)?.session.delivery.permitsBinary(ws, frame)) { + this.incrementRuntimeCounter("applicationBinaryRejected"); + this.logger.warn({ bytes: frame.byteLength }, "ws_application_binary_delivery_denied"); + return; + } try { const sent = await sendBoundedPhysicalFrameAndWait({ socket: ws, @@ -1232,7 +1243,7 @@ export class VoiceAssistantWebSocketServer { private sendBinaryToConnection(connection: SessionConnection, frame: Uint8Array): void { for (const ws of connection.sockets) { - this.sendBinaryToClient(ws, frame); + this.sendBinaryToClient(ws, frame, false); } } @@ -1345,10 +1356,11 @@ export class VoiceAssistantWebSocketServer { } await this.sendBinaryToClientAndWait(source as WebSocketLike, frame); }, - getTransportBufferedAmount: () => { + getTransportBufferedAmount: (source) => { if (!connection) { return null; } + if (source) return (source as WebSocketLike).bufferedAmount ?? null; // Relay-attached sockets are a WebSocketLike that doesn't expose // bufferedAmount. Return null when no socket gives a signal so the // terminal fallback can't mistake "no signal" for "client keeping up"; @@ -1382,12 +1394,13 @@ export class VoiceAssistantWebSocketServer { lifecycle.kind === "ephemeral-plugin" ? { ...base, lifecycle: "ephemeral-plugin", pluginId: lifecycle.pluginId } : { ...base, lifecycle: "reconnectable", externalDisconnectCleanupTimeout: null }; - session.updateClientCapabilities(clientCapabilities, ws); + session.updateClientCapabilities(clientCapabilities, ws, appVersion); return connection; } private createSocketSession(options: SocketSessionOptions): Session { return new Session({ + browserToolsBroker: this.browserToolsBroker, clientId: options.clientId, appVersion: options.appVersion, clientCapabilities: options.clientCapabilities, @@ -1448,6 +1461,8 @@ export class VoiceAssistantWebSocketServer { }, voiceBridge: { registerVoiceSpeakHandler: (agentId, handler) => { + if (this.voiceSpeakHandlers.has(agentId)) + throw new Error("Voice mode is already active for this agent"); this.voiceSpeakHandlers.set(agentId, handler); }, unregisterVoiceSpeakHandler: (agentId) => { @@ -1565,7 +1580,6 @@ export class VoiceAssistantWebSocketServer { this.externalSessionsByKey.set(sessionKey, connection); } pending.identity.sessionId = connection.session.getSessionId(); - this.syncBrowserToolsClientRegistration(connection); this.sendToClient(ws, this.createServerInfoMessage(connection.session)); connection.connectionLogger.info( { @@ -1598,18 +1612,16 @@ export class VoiceAssistantWebSocketServer { // COMPAT(selectiveAgentTimeline): added in v0.1.106. Every capable resumed // hello resets membership before server_info so stale retained-session // state cannot leak. Remove after 2027-01-12. - existing.session.updateClientCapabilities(newClientCapabilities, ws); + existing.session.updateClientCapabilities(newClientCapabilities, ws, newAppVersion); if ( JSON.stringify(existing.clientCapabilities ?? null) !== JSON.stringify(newClientCapabilities ?? null) ) { existing.clientCapabilities = newClientCapabilities; - this.syncBrowserToolsClientRegistration(existing); } existing.sockets.add(ws); this.sessions.set(ws, existing); pending.identity.sessionId = existing.session.getSessionId(); - this.syncBrowserToolsClientRegistration(existing); this.sendToClient(ws, this.createServerInfoMessage(existing.session)); pending.connectionLogger.info( { @@ -1632,6 +1644,7 @@ export class VoiceAssistantWebSocketServer { desktopManaged: this.daemonRuntimeConfig?.desktopManaged === true, ...(this.serverCapabilities ? { capabilities: this.serverCapabilities } : {}), features: { + ownedSubscriptions: true, agentRequestReceipts: true, hubAgentRpc: true, // COMPAT(directorySync): added in v0.3.x, remove gate after 2027-02-12. @@ -1802,7 +1815,16 @@ export class VoiceAssistantWebSocketServer { private broadcastCapabilitiesUpdate(): void { for (const connection of new Set(this.sessions.values())) { - this.sendToConnection(connection, this.createServerInfoMessage(connection.session)); + if ( + ![...connection.sockets].some((socket) => + connection.session.wantsSourceEvent(socket, "status.server_info"), + ) + ) + continue; + connection.session.publish({ + type: "status", + payload: this.buildServerInfoStatusPayload(connection.session), + }); } } @@ -1894,7 +1916,6 @@ export class VoiceAssistantWebSocketServer { this.socketIdentities.delete(ws); if (connection.sockets.size === 0) { - this.unregisterBrowserToolsClient(connection); if (connection.lifecycle === "ephemeral-plugin") { this.pluginSocketIds.delete(ws); await this.cleanupConnection(connection, "Plugin session disconnected"); @@ -1972,7 +1993,6 @@ export class VoiceAssistantWebSocketServer { this.externalSessionsByKey.delete(connection.sessionKey); } } - this.unregisterBrowserToolsClient(connection); connection.connectionLogger.trace( { clientId: connection.clientId, totalSessions: this.sessions.size }, @@ -1981,54 +2001,6 @@ export class VoiceAssistantWebSocketServer { await connection.session.cleanup(); } - private syncBrowserToolsClientRegistration(connection: SessionConnection): void { - if (!this.browserToolsBroker) { - return; - } - const registrationKey = connection.sessionKey; - if (!connection.session.allowsPermission("workspace.write")) { - this.unregisterBrowserToolsClient(registrationKey); - return; - } - const browserHostCapability = getBrowserHostCapability(connection.clientCapabilities); - if (!browserHostCapability) { - this.unregisterBrowserToolsClient(registrationKey); - return; - } - const capabilitySignature = JSON.stringify(browserHostCapability); - const existing = this.browserToolsRegistrations.get(registrationKey); - if (existing?.capabilitySignature === capabilitySignature) { - return; - } - if (existing) { - this.browserToolsRegistrations.delete(registrationKey); - existing.unregister(); - } - - const unregister = this.browserToolsBroker.registerClient({ - id: connection.principalId === "owner" ? connection.clientId : registrationKey, - hostKind: browserHostCapability.hostKind, - supportedCommands: browserHostCapability.supportedCommands, - sendBrowserAutomationRequest: (request) => { - this.sendToConnection(connection, wrapSessionMessage(request)); - }, - }); - this.browserToolsRegistrations.set(registrationKey, { - capabilitySignature, - unregister, - }); - } - - private unregisterBrowserToolsClient(connection: SessionConnection | string): void { - const registrationKey = typeof connection === "string" ? connection : connection.sessionKey; - const registration = this.browserToolsRegistrations.get(registrationKey); - if (!registration) { - return; - } - this.browserToolsRegistrations.delete(registrationKey); - registration.unregister(); - } - private handleInvalidInboundMessage(args: { ws: WebSocketLike; parsed: unknown; @@ -2071,35 +2043,13 @@ export class VoiceAssistantWebSocketServer { "WS inbound message validation failed", ); - if (requestInfo) { - this.sendToClient( - ws, - wrapSessionMessage({ - type: "rpc_error", - payload: { - requestId: requestInfo.requestId, - requestType: requestInfo.requestType, - error: isUnknownSchema - ? `Unknown request, try upgrading the daemon (currently v${this.daemonVersion})` - : "Invalid message", - code: isUnknownSchema ? "unknown_schema" : "invalid_message", - }, - }), - ); - return; - } - - const errorMessage = `Invalid message: ${parsedMessage.error.message}`; - this.sendToClient( - ws, - wrapSessionMessage({ - type: "status", - payload: { - status: "error", - message: errorMessage, - }, - }), - ); + activeConnection?.session.delivery.protocolFailure(ws, { + ...requestInfo, + error: isUnknownSchema + ? `Unknown request, try upgrading the daemon (currently v${this.daemonVersion})` + : `Invalid message: ${parsedMessage.error.message}`, + code: isUnknownSchema ? "unknown_schema" : "invalid_message", + }); } private maybeHandleBinaryFrame(params: { @@ -2128,7 +2078,7 @@ export class VoiceAssistantWebSocketServer { } return true; } - void Promise.resolve(activeConnection.session.handleBinaryFrame(decodedFrame)).catch( + void Promise.resolve(activeConnection.session.handleBinaryFrame(decodedFrame, ws)).catch( (error: unknown) => { this.handleRawMessageError({ ws, @@ -2287,15 +2237,6 @@ export class VoiceAssistantWebSocketServer { "ws_control_rpc_received", ); } - if (message.message.type === "browser.automation.execute.response") { - if (!activeConnection.session.allowsInbound(message.message)) { - await activeConnection.session.handleMessage(message.message, ws); - return; - } - this.browserToolsBroker?.receiveResponse(message.message as BrowserAutomationExecuteResponse); - return; - } - const startMs = performance.now(); await activeConnection.session.handleMessage(message.message, ws); const durationMs = performance.now() - startMs; @@ -2348,32 +2289,11 @@ export class VoiceAssistantWebSocketServer { } const requestInfo = extractRequestInfoFromUnknownWsInbound(parsedPayload); - if (requestInfo) { - this.sendToClient( - ws, - wrapSessionMessage({ - type: "rpc_error", - payload: { - requestId: requestInfo.requestId, - requestType: requestInfo.requestType, - error: "Invalid message", - code: "invalid_message", - }, - }), - ); - return; - } - - this.sendToClient( - ws, - wrapSessionMessage({ - type: "status", - payload: { - status: "error", - message: `Invalid message: ${err.message}`, - }, - }), - ); + this.sessions.get(ws)?.session.delivery.protocolFailure(ws, { + ...requestInfo, + error: `Invalid message: ${err.message}`, + code: "invalid_message", + }); } private decodeRawMessagePayloadForError(data: Buffer | ArrayBuffer | Buffer[] | string): { @@ -2498,8 +2418,8 @@ export class VoiceAssistantWebSocketServer { this.logger.info(loggedMetrics, "ws_runtime_metrics"); } - private getClientActivityState(session: Session): ClientPresenceState { - const activity = session.getClientActivity(); + private getClientActivityState(session: Session, source: object): ClientPresenceState { + const activity = session.getClientActivity(source); if (!activity) { return { appVisible: false, @@ -2532,14 +2452,22 @@ export class VoiceAssistantWebSocketServer { }> = []; for (const [ws, connection] of this.sessions) { - if (!(await connection.session.subscribesToAgent(agent))) continue; + if ( + connection.session.delivery.isModern(ws) + ? !connection.session.wantsSourceEvent(ws, "agent_attention_required") + : !(await connection.session.subscribesToAgent(agent, ws)) + ) + continue; clientEntries.push({ ws, - state: this.getClientActivityState(connection.session), + state: this.getClientActivityState(connection.session, ws), }); } - const allStates = clientEntries.map((e) => e.state); + const notificationEntries = clientEntries.filter(({ ws }) => + this.sessions.get(ws)?.session.wantsSourceNotification(ws, "agent_attention_required"), + ); + const allStates = notificationEntries.map((e) => e.state); const nowMs = Date.now(); const assistantMessage = await this.agentManager.getLastAssistantMessage(params.agentId); const notification = buildAgentAttentionNotificationPayload({ @@ -2564,8 +2492,10 @@ export class VoiceAssistantWebSocketServer { }); } - for (const [clientIndex, { ws }] of clientEntries.entries()) { - const shouldNotify = clientIndex === plan.inAppRecipientIndex; + for (const { ws } of clientEntries) { + const shouldNotify = + plan.inAppRecipientIndex !== null && + notificationEntries[plan.inAppRecipientIndex]?.ws === ws; const timestamp = new Date().toISOString(); const connection = this.sessions.get(ws); const attentionPayload = { @@ -2576,7 +2506,8 @@ export class VoiceAssistantWebSocketServer { notification, }; const message = wrapSessionMessage( - connection?.session.supportsForSource(CLIENT_CAPS.selectiveAgentTimeline, ws) + connection?.session.delivery.isModern(ws) || + connection?.session.supportsForSource(CLIENT_CAPS.selectiveAgentTimeline, ws) ? { type: "agent_attention_required", payload: attentionPayload, @@ -2598,7 +2529,9 @@ export class VoiceAssistantWebSocketServer { }, ); - this.sendToClient(ws, message); + if (message.type === "session" && this.sessions.get(ws)?.session.delivery.isModern(ws)) + this.sessions.get(ws)!.session.publishToSource(ws, message.message); + else this.sendToClient(ws, message); } } @@ -2616,20 +2549,28 @@ export class VoiceAssistantWebSocketServer { for (const [ws, connection] of this.sessions) { if ( - !(await connection.session.subscribesToTerminalDirectory({ - cwd: params.cwd, - ...(params.workspaceId ? { workspaceId: params.workspaceId } : {}), - })) + connection.session.delivery.isModern(ws) + ? !connection.session.wantsSourceEvent(ws, "terminal_attention_required") + : !(await connection.session.subscribesToTerminalDirectory( + { + cwd: params.cwd, + ...(params.workspaceId ? { workspaceId: params.workspaceId } : {}), + }, + ws, + )) ) { continue; } clientEntries.push({ ws, - state: this.getClientActivityState(connection.session), + state: this.getClientActivityState(connection.session, ws), }); } - const allStates = clientEntries.map((e) => e.state); + const notificationEntries = clientEntries.filter(({ ws }) => + this.sessions.get(ws)?.session.wantsSourceNotification(ws, "terminal_attention_required"), + ); + const allStates = notificationEntries.map((e) => e.state); const nowMs = Date.now(); const workspaceId = params.workspaceId; @@ -2663,8 +2604,10 @@ export class VoiceAssistantWebSocketServer { }); } - for (const [clientIndex, { ws }] of clientEntries.entries()) { - const shouldNotify = clientIndex === plan.inAppRecipientIndex; + for (const { ws } of clientEntries) { + const shouldNotify = + plan.inAppRecipientIndex !== null && + notificationEntries[plan.inAppRecipientIndex]?.ws === ws; const message = wrapSessionMessage({ type: "terminal_attention_required", payload: { @@ -2678,7 +2621,9 @@ export class VoiceAssistantWebSocketServer { shouldNotify, }, }); - this.sendToClient(ws, message); + if (message.type === "session" && this.sessions.get(ws)?.session.delivery.isModern(ws)) + this.sessions.get(ws)!.session.publishToSource(ws, message.message); + else this.sendToClient(ws, message); } } } diff --git a/packages/server/src/server/websocket/runtime-metrics.ts b/packages/server/src/server/websocket/runtime-metrics.ts index 8e7c5c6ffd..0031ad69bb 100644 --- a/packages/server/src/server/websocket/runtime-metrics.ts +++ b/packages/server/src/server/websocket/runtime-metrics.ts @@ -17,6 +17,8 @@ export interface WebSocketRuntimeCounters { relayExternalSocketAttached: number; originRejected: number; hostRejected: number; + applicationJsonRejected: number; + applicationBinaryRejected: number; } export interface WebSocketRuntimeMetricsSnapshot { @@ -232,6 +234,8 @@ function createRuntimeCounters(): WebSocketRuntimeCounters { relayExternalSocketAttached: 0, originRejected: 0, hostRejected: 0, + applicationJsonRejected: 0, + applicationBinaryRejected: 0, }; } diff --git a/packages/server/src/server/wire-compat.test.ts b/packages/server/src/server/wire-compat.test.ts index baf720b51b..aca7046728 100644 --- a/packages/server/src/server/wire-compat.test.ts +++ b/packages/server/src/server/wire-compat.test.ts @@ -213,7 +213,7 @@ function createSessionForWireCompatTest(options?: { permissions: OWNER_PERMISSIONS, clientCapabilities: options?.clientCapabilities ?? null, onMessage: (message) => messages.push(message), - onMessageToSource: options?.onMessageToSource, + onMessageToSource: options?.onMessageToSource ?? ((_source, message) => messages.push(message)), logger: pino({ level: "silent" }), downloadTokenStore: {} as SessionOptions["downloadTokenStore"], pushNotifications: {} as SessionOptions["pushNotifications"], @@ -277,6 +277,7 @@ function createSessionForWireCompatTest(options?: { terminalManager: null, }); + session.updateClientCapabilities(options?.clientCapabilities ?? null, {}); return session; } @@ -342,6 +343,8 @@ describe("wire compatibility", () => { { type: "project.update", payload: { + generation: expect.any(String), + seq: 1, kind: "upsert", project: { projectId: "project-1", @@ -356,7 +359,7 @@ describe("wire compatibility", () => { }, { type: "project.update", - payload: { kind: "remove", projectId: "project-1" }, + payload: { kind: "remove", projectId: "project-1", generation: expect.any(String), seq: 2 }, }, ]); }); @@ -549,13 +552,40 @@ describe("wire compatibility", () => { test("setup progress is adapted per socket without changing the canonical snapshot", async () => { const legacy = {}; const capable = {}; + const modern = {}; + const modernCapable = {}; const delivered = new Map(); const session = createSessionForWireCompatTest({ onMessageToSource: (source, message) => delivered.set(source, [...(delivered.get(source) ?? []), message]), }); - session.updateClientCapabilities({}, legacy); - session.updateClientCapabilities({ workspace_setup_blocked: true }, capable); + for (const [source, blocked, owned] of [ + [legacy, false, false], + [capable, true, false], + [modern, false, true], + [modernCapable, true, true], + ] as const) { + session.updateClientCapabilities( + { + explicit_event_subscriptions: true, + owned_subscriptions: owned, + workspace_setup_blocked: blocked, + }, + source, + ); + await session.handleMessage( + { + type: "session.events.set_subscription.request", + requestId: "setup", + events: ["workspace_setup_progress"], + }, + source, + ); + expect(delivered.get(source)).toContainEqual( + expect.objectContaining({ type: "session.events.set_subscription.response" }), + ); + } + delivered.clear(); const message = { type: "workspace_setup_progress" as const, payload: { @@ -590,6 +620,20 @@ test("setup progress is adapted per socket without changing the canonical snapsh }, }, ]); + expect(delivered.get(modernCapable)).toEqual([ + { ...message, payload: { ...message.payload, subscriptionId: expect.any(String) } }, + ]); + expect(delivered.get(modern)).toEqual([ + { + ...message, + payload: { + ...message.payload, + status: "failed", + error: expect.stringContaining("Update Paseo"), + subscriptionId: expect.any(String), + }, + }, + ]); expect(message.payload.status).toBe("blocked"); await session.cleanup(); }); diff --git a/packages/server/src/server/workspace-archive-record-scoped.e2e.test.ts b/packages/server/src/server/workspace-archive-record-scoped.e2e.test.ts index 7e2b0c2064..cc05d496ba 100644 --- a/packages/server/src/server/workspace-archive-record-scoped.e2e.test.ts +++ b/packages/server/src/server/workspace-archive-record-scoped.e2e.test.ts @@ -205,8 +205,8 @@ test("archiving a workspace removes it from every subscribed client", async () = const removals = collectWorkspaceRemovals(observer); try { - await ctx.client.fetchWorkspaces({ subscribe: { subscriptionId: "workspace-initiator" } }); - await observer.fetchWorkspaces({ subscribe: { subscriptionId: "workspace-observer" } }); + await ctx.client.fetchWorkspaces({ subscribe: {} }); + await observer.fetchWorkspaces({ subscribe: {} }); const archive = await ctx.client.archiveWorkspace(workspaceId); await observer.ping({ requestId: "archive-observer-barrier" }); @@ -233,7 +233,7 @@ test("renaming a workspace updates every subscribed client", async () => { const titles = collectWorkspaceTitles(observer); try { - await observer.fetchWorkspaces({ subscribe: { subscriptionId: "workspace-observer" } }); + await observer.fetchWorkspaces({ subscribe: {} }); const renamed = await ctx.client.setWorkspaceTitle(workspaceId, "Renamed workspace"); await observer.ping({ requestId: "rename-observer-barrier" }); diff --git a/packages/server/src/server/workspace-create-git-fanout.local.e2e.test.ts b/packages/server/src/server/workspace-create-git-fanout.local.e2e.test.ts index 82f580ef1b..30e7043b64 100644 --- a/packages/server/src/server/workspace-create-git-fanout.local.e2e.test.ts +++ b/packages/server/src/server/workspace-create-git-fanout.local.e2e.test.ts @@ -145,10 +145,10 @@ async function startObservedFixture(siblingCount: number): Promise file.path)).toEqual(["README.md"]); startGitCommandMetrics(); await waitForGitCommandMetricsIdle({ quietMs: 1_500, timeoutMs: 30_000 }); @@ -460,7 +459,7 @@ test("refreshes every workspace that depends on a changed shared base ref", asyn status: 2, "symbolic-ref": 3, }); - client?.unsubscribeCheckoutDiff(subscriptionId); + await subscription.release(); }, 120_000); test("records the Git command ledger for repository metadata business rules", async () => { @@ -627,7 +626,7 @@ test("workspace archive is admitted while 52 sibling observations hydrate", asyn appVersion: "0.3.0-beta.2", }); await client.connect(); - await client.fetchAgents({ subscribe: { subscriptionId: "agents" } }); + await client.fetchAgents({ subscribe: {} }); const created = await client.createWorkspace({ source: { kind: "worktree", @@ -644,7 +643,7 @@ test("workspace archive is admitted while 52 sibling observations hydrate", asyn await client.fetchWorkspaces({ page: { limit: 200 }, - subscribe: { subscriptionId: "workspaces" }, + subscribe: {}, }); expect(snapshotGitCommandRuntimeMetrics().pending).toBeGreaterThan(0); const archiveStartedAt = Date.now(); @@ -670,10 +669,10 @@ test("workspace create is admitted while 100 sibling observations hydrate", asyn appVersion: "0.3.0-beta.2", }); await client.connect(); - await client.fetchAgents({ subscribe: { subscriptionId: "agents" } }); + await client.fetchAgents({ subscribe: {} }); const snapshot = await client.fetchWorkspaces({ page: { limit: 200 }, - subscribe: { subscriptionId: "workspaces" }, + subscribe: {}, }); expect(snapshot.entries).toHaveLength(SIBLING_COUNT); expect(snapshotGitCommandRuntimeMetrics().pending).toBeGreaterThan(0); diff --git a/packages/server/src/server/workspace-directory.test.ts b/packages/server/src/server/workspace-directory.test.ts index 3f2a150b99..50c226cfb4 100644 --- a/packages/server/src/server/workspace-directory.test.ts +++ b/packages/server/src/server/workspace-directory.test.ts @@ -645,3 +645,52 @@ describe("WorkspaceDirectory empty projects", () => { expect(result.emptyProjects.map((p) => p.projectId)).toEqual(["empty"]); }); }); + +test("Git observation targets exclude archived records without hydrating app descriptors", async () => { + const workspace = ( + id: string, + projectId: string, + archivedAt: string | null = null, + ): PersistedWorkspaceRecord => ({ + workspaceId: id, + projectId, + cwd: `/workspace/${id}`, + kind: "local_checkout", + displayName: id, + createdAt: NOW, + updatedAt: NOW, + archivedAt, + }); + const project = (id: string, archivedAt: string | null = null): PersistedProjectRecord => ({ + projectId: id, + rootPath: `/workspace/${id}`, + kind: "git", + displayName: id, + customName: null, + createdAt: NOW, + updatedAt: NOW, + archivedAt, + }); + const unexpectedHydration = async (): Promise => { + throw new Error("Watcher reconciliation hydrated app data"); + }; + const directory = new WorkspaceDirectory({ + logger: createTestLogger(), + projectRegistry: { list: async () => [project("active"), project("archived", NOW)] }, + workspaceRegistry: { + list: async () => [ + workspace("observed", "active"), + workspace("hidden", "active", NOW), + workspace("hidden-project", "archived"), + ], + }, + listAgentPayloads: unexpectedHydration, + listProviderSubagentActivity: unexpectedHydration, + listTerminalActivityContributions: unexpectedHydration, + buildWorkspaceDescriptor: unexpectedHydration, + isProviderVisibleToClient: () => true, + }); + expect(await directory.listObservationTargets()).toEqual([ + { id: "observed", workspaceDirectory: "/workspace/observed", workspaceKind: "local_checkout" }, + ]); +}); diff --git a/packages/server/src/server/workspace-directory.ts b/packages/server/src/server/workspace-directory.ts index 8817576885..13b9996489 100644 --- a/packages/server/src/server/workspace-directory.ts +++ b/packages/server/src/server/workspace-directory.ts @@ -153,6 +153,18 @@ export function workspaceIdsForProjects( return Array.from(workspaceIds); } +function activeWorkspaceRecords( + workspaces: PersistedWorkspaceRecord[], + projects: PersistedProjectRecord[], +): PersistedWorkspaceRecord[] { + const archivedProjects = new Set( + projects.filter((project) => project.archivedAt).map((project) => project.projectId), + ); + return workspaces.filter( + (workspace) => !workspace.archivedAt && !archivedProjects.has(workspace.projectId), + ); +} + export class WorkspaceDirectory { private readonly archivingByWorkspaceId = new Map(); /** @@ -223,12 +235,7 @@ export class WorkspaceDirectory { .filter((project) => !project.archivedAt) .map((project) => [project.projectId, project] as const), ); - const archivedProjectIds = new Set( - persistedProjects.filter((project) => project.archivedAt).map((project) => project.projectId), - ); - const activeRecords = persistedWorkspaces.filter( - (workspace) => !workspace.archivedAt && !archivedProjectIds.has(workspace.projectId), - ); + const activeRecords = activeWorkspaceRecords(persistedWorkspaces, persistedProjects); const descriptorsByWorkspaceId = new Map(); const workspaceIds = options.workspaceIds ? new Set(options.workspaceIds) : null; const activeWorkspaceIds = new Set(activeRecords.map((workspace) => workspace.workspaceId)); @@ -575,6 +582,20 @@ export class WorkspaceDirectory { })); } + async listObservationTargets(): Promise< + Pick[] + > { + const [workspaces, projects] = await Promise.all([ + this.deps.workspaceRegistry.list(), + this.deps.projectRegistry.list(), + ]); + return activeWorkspaceRecords(workspaces, projects).map((workspace) => ({ + id: workspace.workspaceId, + workspaceDirectory: workspace.cwd, + workspaceKind: workspace.kind, + })); + } + async listDescriptors(): Promise { return Array.from( ( diff --git a/packages/server/src/server/workspace-labels/internal/service.ts b/packages/server/src/server/workspace-labels/internal/service.ts index f560959833..aad0ea2047 100644 --- a/packages/server/src/server/workspace-labels/internal/service.ts +++ b/packages/server/src/server/workspace-labels/internal/service.ts @@ -55,6 +55,12 @@ export class WorkspaceLabelService { await this.catalog.initialize(); } + async list( + cursor?: WorkspaceLabelCursor, + ): Promise> { + return this.exclusive(async () => this.sequence.synchronize(await this.catalog.list(), cursor)); + } + async subscribe(input: { cursor?: WorkspaceLabelCursor; onChange: (change: WorkspaceLabelChange & { generation: string; seq: number }) => void; diff --git a/packages/server/src/server/workspace-same-cwd-isolation.e2e.test.ts b/packages/server/src/server/workspace-same-cwd-isolation.e2e.test.ts index 58bf79c66b..139f8a3795 100644 --- a/packages/server/src/server/workspace-same-cwd-isolation.e2e.test.ts +++ b/packages/server/src/server/workspace-same-cwd-isolation.e2e.test.ts @@ -374,6 +374,7 @@ test("local workspace auto-title does not broadcast provider snapshot warm-up to try { await client.connect(); + await client.observeEvents(["providers_snapshot_update"]).ready; await client.getProvidersSnapshot({ cwd }); await expect .poll( diff --git a/packages/server/src/terminal/terminal-session-controller.resize.test.ts b/packages/server/src/terminal/terminal-session-controller.resize.test.ts index 63181b2482..c84b03cab7 100644 --- a/packages/server/src/terminal/terminal-session-controller.resize.test.ts +++ b/packages/server/src/terminal/terminal-session-controller.resize.test.ts @@ -5,7 +5,12 @@ import { encodeTerminalResizePayload, type TerminalStreamFrame, } from "@getpaseo/protocol/binary-frames/index"; -import type { SessionOutboundMessage, TerminalState } from "@getpaseo/protocol/messages"; +import { SessionDelivery } from "../server/session/owned-subscriptions/index.js"; +import type { + SessionInboundMessage, + SessionOutboundMessage, + TerminalState, +} from "@getpaseo/protocol/messages"; import type pino from "pino"; import { TerminalSessionController } from "./terminal-session-controller.js"; @@ -80,44 +85,57 @@ describe("terminal session controller size ownership", () => { const { terminalManager, appliedSizes } = createFixture(); const controllerA = createController(terminalManager); const controllerB = createController(terminalManager); + const sourceA = {}; + const sourceB = {}; + const ownership = new SessionDelivery(() => {}); + // Legacy binary resize and attach sizing remain source scoped. + const dispatch = ( + controller: TerminalSessionController, + source: object, + message: SessionInboundMessage, + ) => + ownership.request(source, message, async () => { + await controller.dispatch(message, ownership); + }); - controllerA.dispatch({ + await dispatch(controllerA, sourceA, { type: "terminal_input", terminalId: "terminal-1", message: { type: "resize", rows: 30, cols: 100, intent: "claim" }, }); - controllerA.dispatch({ + await dispatch(controllerA, sourceA, { type: "terminal_input", terminalId: "terminal-1", message: { type: "resize", rows: 31, cols: 101, intent: "update" }, }); - controllerB.dispatch({ + await dispatch(controllerB, sourceB, { type: "terminal_input", terminalId: "terminal-1", message: { type: "resize", rows: 32, cols: 102, intent: "update" }, }); - await controllerB.dispatch({ + await dispatch(controllerB, sourceB, { type: "subscribe_terminal_request", terminalId: "terminal-1", requestId: "subscribe-b", restore: { mode: "live" }, }); - controllerB.handleBinaryFrame(resizeFrame({ rows: 33, cols: 103, intent: "claim" })); - controllerA.dispatch({ + controllerB.handleBinaryFrame(resizeFrame({ rows: 33, cols: 103, intent: "claim" }), sourceB); + await dispatch(controllerA, sourceA, { type: "terminal_input", terminalId: "terminal-1", message: { type: "resize", rows: 34, cols: 104, intent: "update" }, }); - await controllerA.dispatch({ + await dispatch(controllerA, sourceA, { type: "subscribe_terminal_request", terminalId: "terminal-1", requestId: "subscribe-a", restore: { mode: "live", size: { rows: 35, cols: 105 } }, }); - controllerB.handleBinaryFrame(resizeFrame({ rows: 36, cols: 106, intent: "update" })); + controllerB.handleBinaryFrame(resizeFrame({ rows: 36, cols: 106, intent: "update" }), sourceB); + await ownership.close(); expect(appliedSizes).toEqual(["100x30", "101x31", "103x33", "105x35"]); }); }); diff --git a/packages/server/src/terminal/terminal-session-controller.test.ts b/packages/server/src/terminal/terminal-session-controller.test.ts index 9fbd7e3d2a..301292edeb 100644 --- a/packages/server/src/terminal/terminal-session-controller.test.ts +++ b/packages/server/src/terminal/terminal-session-controller.test.ts @@ -1,7 +1,7 @@ import { describe, expect, test, vi } from "vitest"; -import type pino from "pino"; +import pino from "pino"; -import type { SessionOutboundMessage } from "../server/messages.js"; +import type { SessionInboundMessage, SessionOutboundMessage } from "../server/messages.js"; import { TerminalStreamOpcode, decodeTerminalStreamFrame, @@ -9,11 +9,41 @@ import { } from "@getpaseo/protocol/binary-frames/index"; import type { TerminalCell, TerminalState } from "@getpaseo/protocol/messages"; import type { ServerMessage, TerminalSession, TerminalStateSnapshot } from "./terminal.js"; -import { TerminalSessionController } from "./terminal-session-controller.js"; +import { + TerminalSessionController, + type TerminalSessionControllerOptions, +} from "./terminal-session-controller.js"; +import { SessionDelivery } from "../server/session/owned-subscriptions/index.js"; import type { TerminalManager, TerminalsChangedEvent } from "./terminal-manager.js"; import { isSameOrDescendantPath } from "../server/path-utils.js"; import { PluginSessionSocket } from "../server/plugins/session-socket.js"; +function createController( + options: TerminalSessionControllerOptions & { emitBinary(frame: Uint8Array): void }, +) { + const source = {}; + const ownership = new SessionDelivery( + (_source, message) => options.emit(message), + (_source, frame) => options.emitBinary(frame), + ); + ownership.attach(source, false); + const controller = new TerminalSessionController({ + ...options, + emit: (message) => { + if (!ownership.reply(message)) options.emit(message); + }, + }); + return { + start: () => controller.start(), + dispatch: (message: SessionInboundMessage) => + ownership.request(source, message, async () => { + await controller.dispatch(message, ownership); + }), + hasDirectorySubscription: (input: { cwd: string; workspaceId?: string }) => + controller.hasDirectorySubscription(input), + }; +} + function deferred(): { promise: Promise; resolve: (value: T) => void; @@ -102,7 +132,7 @@ describe("terminal-session-controller restore", () => { subscribeTerminalActivity: vi.fn(() => vi.fn()), subscribeTerminalWorkspaceContributionChanged: vi.fn(() => vi.fn()), }; - const controller = new TerminalSessionController({ + const controller = createController({ terminalManager, emit: (message) => outboundMessages.push(message), emitBinary: (bytes) => { @@ -218,7 +248,7 @@ describe("terminal-session-controller legacy terminal creation", () => { subscribeTerminalActivity: vi.fn(() => vi.fn()), subscribeTerminalWorkspaceContributionChanged: vi.fn(() => vi.fn()), }; - const controller = new TerminalSessionController({ + const controller = createController({ terminalManager, emit: (message) => outboundMessages.push(message), emitBinary: vi.fn(), @@ -293,7 +323,7 @@ describe("terminal-session-controller legacy terminal creation", () => { subscribeTerminalActivity: vi.fn(() => vi.fn()), subscribeTerminalWorkspaceContributionChanged: vi.fn(() => vi.fn()), }; - const controller = new TerminalSessionController({ + const controller = createController({ terminalManager, emit: (message) => outboundMessages.push(message), emitBinary: vi.fn(), @@ -329,7 +359,7 @@ async function waitForCoalescerFlush(): Promise { describe("terminal-session-controller wrap-flag gating", () => { function setup(clientSupportsWrapReflow?: () => boolean): { - controller: TerminalSessionController; + controller: ReturnType; getTerminalState: ReturnType; } { const terminal: TerminalSession = { @@ -379,7 +409,7 @@ describe("terminal-session-controller wrap-flag gating", () => { subscribeTerminalActivity: vi.fn(() => vi.fn()), subscribeTerminalWorkspaceContributionChanged: vi.fn(() => vi.fn()), } as unknown as TerminalManager; - const controller = new TerminalSessionController({ + const controller = createController({ terminalManager, emit: vi.fn(), emitBinary: vi.fn(), @@ -391,7 +421,7 @@ describe("terminal-session-controller wrap-flag gating", () => { return { controller, getTerminalState }; } - async function subscribe(controller: TerminalSessionController): Promise { + async function subscribe(controller: ReturnType): Promise { await controller.dispatch({ type: "subscribe_terminal_request", terminalId: "term-1", @@ -457,7 +487,7 @@ describe("terminal-session-controller subdirectory aggregation", () => { }; const outboundMessages: SessionOutboundMessage[] = []; - const controller = new TerminalSessionController({ + const controller = createController({ terminalManager, emit: (message) => outboundMessages.push(message), emitBinary: vi.fn(), @@ -521,7 +551,7 @@ describe("terminal-session-controller subdirectory aggregation", () => { subscribeTerminalWorkspaceContributionChanged: vi.fn(() => vi.fn()), }; const outboundMessages: SessionOutboundMessage[] = []; - const controller = new TerminalSessionController({ + const controller = createController({ terminalManager, emit: (message) => outboundMessages.push(message), emitBinary: vi.fn(), @@ -617,7 +647,7 @@ describe("terminal-session-controller workspace-scoped subscriptions", () => { }; const outboundMessages: SessionOutboundMessage[] = []; - const controller = new TerminalSessionController({ + const controller = createController({ terminalManager, emit: (message) => outboundMessages.push(message), emitBinary: vi.fn(), @@ -714,7 +744,7 @@ describe("terminal-session-controller backpressure snapshot fallback", () => { } as unknown as TerminalManager; const frames: TerminalStreamFrame[] = []; - const controller = new TerminalSessionController({ + const controller = createController({ terminalManager, emit: vi.fn(), emitBinary: (bytes) => { @@ -807,3 +837,404 @@ describe("terminal-session-controller backpressure snapshot fallback", () => { expect(frames.some((frame) => frame.opcode === TerminalStreamOpcode.Snapshot)).toBe(true); }); }); + +function exitFixture(readSnapshot: () => Promise, backedUp = false) { + const outputs = new Set<(message: ServerMessage) => void>(); + const exits = new Set[0]>(); + const terminal: TerminalSession = { + id: "exit-terminal", + name: "Terminal", + cwd: "/unused", + workspaceId: "workspace", + send() {}, + subscribe(listener) { + outputs.add(listener); + return () => { + outputs.delete(listener); + }; + }, + onExit(listener) { + exits.add(listener); + return () => { + exits.delete(listener); + }; + }, + onCommandFinished: () => () => {}, + onTitleChange: () => () => {}, + onActivityChange: () => () => {}, + getSize: () => ({ rows: 1, cols: 80 }), + getState: () => terminalState("snapshot"), + getStateSnapshot: () => ({ state: terminalState("snapshot"), revision: 0 }), + getReplayPreamble: () => "", + getTitle: () => undefined, + getActivity: () => null, + setActivity() {}, + clearActivityAttention: () => false, + setTitle() {}, + getExitInfo: () => null, + kill() {}, + async killAndWait() {}, + }; + const manager: TerminalManager = { + getTerminal: () => terminal, + getTerminalState: readSnapshot, + getTerminals: async () => [terminal], + createTerminal: async () => terminal, + registerCwdEnv() {}, + validateTerminalActivityToken: () => "unknown", + setTerminalTitle: () => true, + setTerminalActivity: async () => true, + clearTerminalAttention: async () => false, + killTerminal() {}, + async killTerminalAndWait() {}, + captureTerminal: async () => ({ lines: [], totalLines: 0 }), + listDirectories: () => [], + killAll() {}, + subscribeTerminalsChanged: () => () => {}, + subscribeTerminalActivity: () => () => {}, + subscribeTerminalWorkspaceContributionChanged: () => () => {}, + }; + const frames: Array<{ + source: object; + message?: SessionOutboundMessage; + binary?: TerminalStreamFrame; + }> = []; + const delivery = new SessionDelivery( + (source, message) => frames.push({ source, message }), + (source, bytes) => { + const binary = decodeTerminalStreamFrame(bytes); + if (binary) frames.push({ source, binary }); + }, + ); + const controller = new TerminalSessionController({ + terminalManager: manager, + emit: (message) => { + delivery.reply(message); + }, + hasBinaryChannel: () => true, + isPathWithinRoot: () => false, + sessionLogger: pino({ level: "silent" }), + getClientBufferedAmount: () => (backedUp ? 8 * 1024 * 1024 : 0), + }); + return { + frames, + delivery, + controller, + outputs, + exits, + subscribe: async (source: object, modern: boolean, requestId: string) => { + delivery.attach(source, modern); + const request = { + type: "subscribe_terminal_request" as const, + terminalId: terminal.id, + requestId, + }; + await delivery.request(source, request, async () => { + await controller.dispatch(request, delivery); + }); + const reply = frames.find( + (row) => + row.message?.type === "subscribe_terminal_response" && + row.message.payload.requestId === requestId, + )?.message; + if (reply?.type !== "subscribe_terminal_response" || reply.payload.error !== null) + throw new Error("Missing stream acknowledgement"); + return { source, slot: reply.payload.slot, id: reply.payload.subscriptionId }; + }, + output: (data: string, revision: number) => { + for (const listener of outputs) listener({ type: "output", data, revision }); + }, + exit: () => { + for (const listener of exits) listener({ exitCode: 0, signal: null, lastOutputLines: [] }); + }, + }; +} + +describe("terminal output before natural exit", () => { + test.each([true, false])( + "flushes each observer's final bytes before exit (modern=%s)", + async (modern) => { + vi.useFakeTimers(); + const f = exitFixture(async () => ({ state: terminalState("snapshot"), revision: 0 })); + try { + const source = {}; + const owners = [ + await f.subscribe(source, modern, "first"), + await f.subscribe(modern ? source : {}, modern, "second"), + ]; + await vi.advanceTimersByTimeAsync(0); + f.output("FIRST-OUTPUT", 1); + await vi.advanceTimersByTimeAsync(5); + f.output("FINAL-BYTES", 2); + f.exit(); + await vi.advanceTimersByTimeAsync(0); + for (const owner of owners) { + const ordered = f.frames.filter( + (row) => + row.source === owner.source && + (row.binary?.slot === owner.slot || + (row.message?.type === "terminal_stream_exit" && + row.message.payload.subscriptionId === owner.id)), + ); + expect(ordered.map((row) => row.binary?.opcode ?? row.message?.type)).toEqual([ + TerminalStreamOpcode.Snapshot, + TerminalStreamOpcode.Output, + TerminalStreamOpcode.Output, + "terminal_stream_exit", + ]); + expect(Buffer.from(ordered[2].binary!.payload).toString()).toBe("FINAL-BYTES"); + } + if (modern) expect(new Set(owners.map((owner) => owner.id)).size).toBe(2); + expect(f.delivery.registrationCount).toBe(0); + expect(f.controller.getMetrics().streamSubscriptionCount).toBe(0); + expect(f.outputs.size + f.exits.size).toBe(0); + } finally { + await f.delivery.close(); + vi.useRealTimers(); + } + }, + ); + + test("waits for bootstrap snapshot and flushes buffered bytes without starting backpressure work", async () => { + vi.useFakeTimers(); + const snapshot = deferred(); + let reads = 0; + const f = exitFixture(() => { + reads++; + return snapshot.promise; + }, true); + try { + await f.subscribe({}, true, "bootstrap"); + f.output("x".repeat(300 * 1024), 1); + f.output("FINAL-BYTES", 2); + f.exit(); + await vi.advanceTimersByTimeAsync(0); + expect(f.frames.map((row) => row.message?.type)).toEqual(["subscribe_terminal_response"]); + snapshot.resolve({ state: terminalState("snapshot"), revision: 0 }); + await vi.advanceTimersByTimeAsync(0); + expect(f.frames.slice(1).map((row) => row.binary?.opcode ?? row.message?.type)).toEqual([ + TerminalStreamOpcode.Snapshot, + TerminalStreamOpcode.Output, + "terminal_stream_exit", + ]); + expect(Buffer.from(f.frames[2].binary!.payload).toString()).toBe( + "x".repeat(300 * 1024) + "FINAL-BYTES", + ); + expect(reads).toBe(1); + expect(f.delivery.registrationCount).toBe(0); + } finally { + snapshot.resolve(null); + await f.delivery.close(); + vi.useRealTimers(); + } + }); + + test.each([true, false, "rejected"])( + "finishes an in-flight backpressure snapshot (available=%s)", + async (available) => { + vi.useFakeTimers(); + const snapshot = deferred(); + let reads = 0; + const f = exitFixture( + async () => + ++reads === 1 ? { state: terminalState("initial"), revision: 0 } : snapshot.promise, + true, + ); + try { + await f.subscribe({}, true, "pressure"); + await vi.advanceTimersByTimeAsync(0); + f.output("x".repeat(300 * 1024), 1); + await vi.advanceTimersByTimeAsync(5); + expect(reads).toBe(2); + f.output("FINAL-BYTES", 2); + f.exit(); + await vi.advanceTimersByTimeAsync(0); + expect(f.frames.some((row) => row.message?.type === "terminal_stream_exit")).toBe(false); + if (available === "rejected") snapshot.reject(new Error("Snapshot failed during exit")); + else + snapshot.resolve( + available ? { state: terminalState("pressure-snapshot"), revision: 1 } : null, + ); + await vi.advanceTimersByTimeAsync(0); + const output = f.frames + .filter((row) => row.binary?.opcode === TerminalStreamOpcode.Output) + .map((row) => Buffer.from(row.binary!.payload).toString()) + .join(""); + expect(output).toBe((available === true ? "" : "x".repeat(300 * 1024)) + "FINAL-BYTES"); + expect(f.frames.at(-1)?.message?.type).toBe("terminal_stream_exit"); + expect(f.delivery.registrationCount).toBe(0); + expect(reads).toBe(2); + } finally { + snapshot.resolve(null); + await f.delivery.close(); + vi.useRealTimers(); + } + }, + ); + + test("release during exit's pending snapshot suppresses late delivery", async () => { + vi.useFakeTimers(); + const snapshot = deferred(); + const f = exitFixture(() => snapshot.promise); + const source = {}; + try { + const owner = await f.subscribe(source, true, "pending"); + f.output("FINAL-BYTES", 1); + f.exit(); + const request = { + type: "subscription.release.request" as const, + requestId: "release", + subscriptionId: owner.id!, + }; + const release = f.delivery.request(source, request, () => f.delivery.release(owner.id!)); + snapshot.resolve({ state: terminalState("snapshot"), revision: 0 }); + await release; + await vi.advanceTimersByTimeAsync(0); + expect(f.frames.map((row) => row.message?.type)).toEqual(["subscribe_terminal_response"]); + expect(f.delivery.registrationCount).toBe(0); + expect(f.outputs.size + f.exits.size).toBe(0); + } finally { + snapshot.resolve(null); + await f.delivery.close(); + vi.useRealTimers(); + } + }); + + test("explicit release discards only that observer's trailing output", async () => { + vi.useFakeTimers(); + const f = exitFixture(async () => ({ state: terminalState("snapshot"), revision: 0 })); + const source = {}; + try { + const a = await f.subscribe(source, true, "a"); + const b = await f.subscribe(source, true, "b"); + await vi.advanceTimersByTimeAsync(0); + f.output("FINAL-BYTES", 1); + const request = { + type: "subscription.release.request" as const, + requestId: "release", + subscriptionId: a.id!, + }; + await f.delivery.request(source, request, () => f.delivery.release(a.id!)); + f.exit(); + await vi.advanceTimersByTimeAsync(0); + expect( + f.frames.some( + (row) => + row.binary && + row.binary.slot === a.slot && + row.binary.opcode === TerminalStreamOpcode.Output, + ), + ).toBe(false); + expect( + f.frames.some( + (row) => + row.binary && + row.binary.slot === b.slot && + row.binary.opcode === TerminalStreamOpcode.Output && + Buffer.from(row.binary.payload).toString() === "FINAL-BYTES", + ), + ).toBe(true); + expect(f.delivery.registrationCount).toBe(0); + } finally { + await f.delivery.close(); + vi.useRealTimers(); + } + }); +}); + +describe("terminal snapshot failure", () => { + test.each(["initial", "legacy initial", "backpressure"])( + "releases only the failed observer after %s rejection", + async (phase) => { + vi.useFakeTimers(); + let reads = 0; + const f = exitFixture(async () => { + if (++reads === (phase === "backpressure" ? 2 : 1)) + throw new Error("Terminal worker request timed out: getTerminalState"); + return { state: terminalState("healthy"), revision: 0 }; + }, phase === "backpressure"); + try { + const source = {}; + const failed = await f.subscribe(source, phase !== "legacy initial", "failed"); + await vi.advanceTimersByTimeAsync(0); + if (phase === "backpressure") { + f.output("x".repeat(300 * 1024), 1); + await vi.advanceTimersByTimeAsync(5); + } + const healthy = await f.subscribe( + phase === "legacy initial" ? {} : source, + phase !== "legacy initial", + "healthy", + ); + await vi.advanceTimersByTimeAsync(0); + f.output("SIBLING-ALIVE", 2); + await vi.advanceTimersByTimeAsync(5); + expect(f.delivery.registrationCount).toBe(1); + expect(f.controller.getMetrics().streamSubscriptionCount).toBe(1); + expect(f.outputs.size).toBe(1); + expect(f.exits.size).toBe(1); + expect( + f.frames + .filter((row) => row.message?.type === "terminal_stream_exit") + .map((row) => row.message), + ).toEqual([ + { + type: "terminal_stream_exit", + payload: { + terminalId: "exit-terminal", + ...(phase === "legacy initial" ? {} : { subscriptionId: failed.id }), + error: "Terminal worker request timed out: getTerminalState", + }, + }, + ]); + expect( + f.frames.some( + (row) => + row.binary?.slot === healthy.slot && + row.binary?.opcode === TerminalStreamOpcode.Output && + Buffer.from(row.binary.payload).toString() === "SIBLING-ALIVE", + ), + ).toBe(true); + f.exit(); + await vi.advanceTimersByTimeAsync(0); + expect(f.frames.at(-1)?.message).toEqual({ + type: "terminal_stream_exit", + payload: { + terminalId: "exit-terminal", + ...(phase === "legacy initial" ? {} : { subscriptionId: healthy.id }), + }, + }); + expect(f.delivery.registrationCount).toBe(0); + expect(f.outputs.size + f.exits.size).toBe(0); + } finally { + await f.delivery.close(); + vi.useRealTimers(); + } + }, + ); + + test("release during pending rejection suppresses the failure and completes cleanup", async () => { + const snapshot = deferred(); + const f = exitFixture(() => snapshot.promise); + const source = {}; + try { + const owner = await f.subscribe(source, true, "pending"); + f.output("BUFFERED", 1); + const request = { + type: "subscription.release.request" as const, + requestId: "release", + subscriptionId: owner.id!, + }; + const release = f.delivery.request(source, request, () => f.delivery.release(owner.id!)); + snapshot.reject(new Error("Terminal worker request timed out: getTerminalState")); + await release; + expect(f.frames.map((row) => row.message?.type)).toEqual(["subscribe_terminal_response"]); + expect(f.delivery.registrationCount).toBe(0); + expect(f.controller.getMetrics().streamSubscriptionCount).toBe(0); + expect(f.outputs.size + f.exits.size).toBe(0); + } finally { + snapshot.resolve(null); + await f.delivery.close(); + } + }); +}); diff --git a/packages/server/src/terminal/terminal-session-controller.ts b/packages/server/src/terminal/terminal-session-controller.ts index 998c5c93e8..9925ed1846 100644 --- a/packages/server/src/terminal/terminal-session-controller.ts +++ b/packages/server/src/terminal/terminal-session-controller.ts @@ -1,4 +1,8 @@ import type pino from "pino"; +import type { + OwnedSubscription, + SessionDelivery, +} from "../server/session/owned-subscriptions/index.js"; import type { CaptureTerminalRequest, CreateTerminalRequest, @@ -39,6 +43,14 @@ import { terminalSubscriptionKey } from "@getpaseo/protocol/terminal-subscriptio const MAX_TERMINAL_STREAM_SLOTS = 256; +interface TerminalDirectorySubscription { + cwd: string; + workspaceId: string | undefined; + owner: OwnedSubscription; + refresh: Promise | null; + pending: boolean; +} + interface BufferedTerminalOutput { data: string; revision?: number; @@ -47,6 +59,10 @@ interface BufferedTerminalOutput { interface ActiveTerminalStream { terminalId: string; slot: number; + owner: OwnedSubscription; + snapshotTask?: Promise; + snapshotOutput?: Buffer; + exiting: boolean; unsubscribe: () => void; needsSnapshot: boolean; snapshotInFlight: boolean; @@ -65,7 +81,6 @@ interface SnapshotSendResult { export interface TerminalSessionControllerOptions { terminalManager: TerminalManager | null; emit: (msg: SessionOutboundMessage) => void; - emitBinary: (frame: Uint8Array) => void; hasBinaryChannel: () => boolean; isPathWithinRoot: (rootPath: string, candidatePath: string) => boolean; sessionLogger: pino.Logger; @@ -74,7 +89,7 @@ export interface TerminalSessionControllerOptions { // Whether the connected client can reflow restored snapshots. When true the // daemon attaches per-row soft-wrap flags to snapshots; otherwise it omits them // so old (strict-schema) clients still parse the snapshot. - clientSupportsWrapReflow?: () => boolean; + clientSupportsWrapReflow?: (source: object) => boolean; // Current max bytes queued on the client's transport(s) but not yet sent. // Drives the snapshot catch-up fallback: a keeping-up client reports ~0 and // keeps streaming; a backed-up client trips the snapshot path. Defaults to a @@ -82,7 +97,7 @@ export interface TerminalSessionControllerOptions { // stream. // Bytes queued on the client transport but not yet sent, or null when the // transport exposes no backpressure signal (e.g. the multiplexed relay socket). - getClientBufferedAmount?: () => number | null; + getClientBufferedAmount?: (source: object) => number | null; } interface TerminalWorkspaceRef { @@ -123,34 +138,22 @@ const TERMINAL_MESSAGE_TYPES: ReadonlySet = export class TerminalSessionController { private readonly terminalManager: TerminalManager | null; private readonly emit: (msg: SessionOutboundMessage) => void; - private readonly emitBinary: (frame: Uint8Array) => void; private readonly hasBinaryChannel: () => boolean; private readonly isPathWithinRoot: (rootPath: string, candidatePath: string) => boolean; private readonly sessionLogger: pino.Logger; private readonly listTerminalWorkspaceRefs: () => Promise; private readonly listTerminalWorkspaceRoots: () => Promise; - private readonly clientSupportsWrapReflow: () => boolean; - private readonly getClientBufferedAmount: () => number | null; - private readonly terminalSizeOwner = {}; + private readonly clientSupportsWrapReflow: (source: object) => boolean; + private readonly getClientBufferedAmount: (source: object) => number | null; - // A subscription is scoped to a (cwd, workspaceId) pair, keyed by - // terminalSubscriptionKey: two workspaces sharing a cwd subscribe and unsub - // independently, and each only receives its own workspace's terminals. The - // workspaceId is absent for old clients, which key to the cwd alone. - private readonly subscribedDirectories = new Map< - string, - { cwd: string; workspaceId: string | undefined } - >(); + private readonly subscribedDirectories = new Map(); private unsubscribeTerminalsChanged: (() => void) | null = null; - private readonly exitSubscriptions = new Map void>(); private readonly activeStreams = new Map(); - private readonly idToSlot = new Map(); private nextSlot = 0; constructor(options: TerminalSessionControllerOptions) { this.terminalManager = options.terminalManager; this.emit = options.emit; - this.emitBinary = options.emitBinary; this.hasBinaryChannel = options.hasBinaryChannel; this.isPathWithinRoot = options.isPathWithinRoot; this.sessionLogger = options.sessionLogger; @@ -163,9 +166,7 @@ export class TerminalSessionController { } start(): void { - if (!this.terminalManager) { - return; - } + if (!this.terminalManager || this.unsubscribeTerminalsChanged) return; this.unsubscribeTerminalsChanged = this.terminalManager.subscribeTerminalsChanged((event) => { void this.handleTerminalsChanged(event); }); @@ -178,9 +179,16 @@ export class TerminalSessionController { }; } - async hasDirectorySubscription(input: { cwd: string; workspaceId?: string }): Promise { + async hasDirectorySubscription( + input: { cwd: string; workspaceId?: string }, + source?: object, + ): Promise { + const subscriptions = [...this.subscribedDirectories.values()].filter( + (subscription) => !source || subscription.owner.source === source, + ); + if (subscriptions.length === 0) return false; const workspaceRoots = await this.listTerminalWorkspaceRoots(); - return Array.from(this.subscribedDirectories.values()).some((subscription) => { + return subscriptions.some((subscription) => { if ( subscription.workspaceId !== undefined && subscription.workspaceId !== input.workspaceId @@ -191,28 +199,28 @@ export class TerminalSessionController { }); } - dispatch(msg: SessionInboundMessage): Promise | undefined { + dispatch(msg: SessionInboundMessage, ownership: SessionDelivery): Promise | undefined { if (!isTerminalMessage(msg)) { return undefined; } switch (msg.type) { case "subscribe_terminals_request": - this.handleSubscribeTerminalsRequest(msg); - return undefined; + return this.handleSubscribeTerminalsRequest(msg, ownership); case "unsubscribe_terminals_request": - this.handleUnsubscribeTerminalsRequest(msg); - return undefined; + return ownership.releaseLegacySlot( + `terminal-directory:${terminalSubscriptionKey(msg.cwd, msg.workspaceId)}`, + ); case "list_terminals_request": return this.handleListTerminalsRequest(msg); case "create_terminal_request": return this.handleCreateTerminalRequest(msg); case "subscribe_terminal_request": - return this.handleSubscribeTerminalRequest(msg); + return this.handleSubscribeTerminalRequest(msg, ownership); case "unsubscribe_terminal_request": - this.handleUnsubscribeTerminalRequest(msg); - return undefined; + return ownership.releaseLegacySlot(`terminal-output:${msg.terminalId}`); case "terminal_input": - this.handleTerminalInput(msg); + if (!ownership.currentSource) throw new Error("Terminal input requires a source"); + this.handleTerminalInput(msg, ownership.currentSource); return undefined; case "kill_terminal_request": return this.handleKillTerminalRequest(msg); @@ -225,9 +233,9 @@ export class TerminalSessionController { } } - handleBinaryFrame(frame: TerminalStreamFrame): void { + handleBinaryFrame(frame: TerminalStreamFrame, source: object): void { const activeStream = this.activeStreams.get(frame.slot); - if (!activeStream || !this.terminalManager) { + if (!activeStream || activeStream.owner.source !== source || !this.terminalManager) { return; } const terminal = this.terminalManager.getTerminal(activeStream.terminalId); @@ -254,7 +262,7 @@ export class TerminalSessionController { if (!resize) { return; } - applyTerminalSize(terminal, this.terminalSizeOwner, resize); + applyTerminalSize(terminal, source, resize); return; } @@ -289,52 +297,13 @@ export class TerminalSessionController { } this.subscribedDirectories.clear(); - for (const unsubscribeExit of this.exitSubscriptions.values()) { - unsubscribeExit(); + for (const stream of this.activeStreams.values()) { + void stream.owner + .release() + .catch((error) => + this.sessionLogger.warn({ err: error }, "Failed to release terminal stream"), + ); } - this.exitSubscriptions.clear(); - - for (const terminalId of Array.from(this.idToSlot.keys())) { - this.detachStream(terminalId, { emitExit: false }); - } - } - - private ensureExitSubscription(terminal: TerminalSession): void { - if (this.exitSubscriptions.has(terminal.id)) { - return; - } - const unsubscribeExit = terminal.onExit(() => { - this.handleTerminalExited(terminal.id); - }); - this.exitSubscriptions.set(terminal.id, unsubscribeExit); - } - - private handleTerminalExited(terminalId: string): void { - const unsubscribeExit = this.exitSubscriptions.get(terminalId); - if (unsubscribeExit) { - unsubscribeExit(); - this.exitSubscriptions.delete(terminalId); - } - this.detachStream(terminalId, { emitExit: true }); - } - - private emitTerminalsChangedSnapshot(input: { - cwd: string; - terminals: Array<{ - id: string; - name: string; - workspaceId: string; - title?: string; - activity: TerminalActivity | null; - }>; - }): void { - this.emit({ - type: "terminals_changed", - payload: { - cwd: input.cwd, - terminals: input.terminals, - }, - }); } private toTerminalInfo( @@ -371,47 +340,78 @@ export class TerminalSessionController { } } - private handleSubscribeTerminalsRequest(msg: SubscribeTerminalsRequest): void { - const subscription = { cwd: msg.cwd, workspaceId: msg.workspaceId }; - this.subscribedDirectories.set(terminalSubscriptionKey(msg.cwd, msg.workspaceId), subscription); - void this.emitTerminalsSnapshotForSubscription(subscription); - } - - private handleUnsubscribeTerminalsRequest(msg: UnsubscribeTerminalsRequest): void { - this.subscribedDirectories.delete(terminalSubscriptionKey(msg.cwd, msg.workspaceId)); - } - - private async emitTerminalsSnapshotForSubscription(subscription: { - cwd: string; - workspaceId: string | undefined; - }): Promise { - const key = terminalSubscriptionKey(subscription.cwd, subscription.workspaceId); - if (!this.terminalManager || !this.subscribedDirectories.has(key)) { - return; - } + private async handleSubscribeTerminalsRequest( + msg: SubscribeTerminalsRequest, + ownership: SessionDelivery, + ): Promise { + if (!this.terminalManager) throw new Error("Terminal manager not available"); + let subscription: TerminalDirectorySubscription | undefined; + const owner = ownership.begin( + "terminal-directories", + undefined, + async (id) => { + this.subscribedDirectories.delete(id); + if (this.subscribedDirectories.size === 0) { + this.unsubscribeTerminalsChanged?.(); + this.unsubscribeTerminalsChanged = null; + } + await subscription?.refresh?.catch(() => undefined); + }, + `terminal-directory:${terminalSubscriptionKey(msg.cwd, msg.workspaceId)}`, + ); + subscription = { + cwd: msg.cwd, + workspaceId: msg.workspaceId, + owner, + refresh: null, + pending: false, + }; + this.subscribedDirectories.set(owner.id, subscription); + this.start(); try { - const terminals = await this.getTerminalsForWorkspaceRoot( - subscription.cwd, - subscription.workspaceId, - ); - for (const terminal of terminals) { - this.ensureExitSubscription(terminal); - } - if (!this.subscribedDirectories.has(key)) { - return; - } - this.emitTerminalsChangedSnapshot({ - cwd: subscription.cwd, - terminals: terminals.map((terminal) => this.toTerminalInfo(terminal)), - }); + await this.emitTerminalsSnapshotForSubscription(subscription, msg.requestId); } catch (error) { - this.sessionLogger.warn( - { err: error, cwd: subscription.cwd }, - "Failed to emit initial terminal snapshot", - ); + await owner.release(); + throw error; } } + private emitTerminalsSnapshotForSubscription( + subscription: TerminalDirectorySubscription, + requestId?: string, + ): Promise { + if (subscription.refresh) { + subscription.pending = true; + return subscription.refresh; + } + const refresh = (async () => { + do { + subscription.pending = false; + const terminals = await this.getTerminalsForWorkspaceRoot( + subscription.cwd, + subscription.workspaceId, + ); + if (subscription.owner.signal.aborted) return; + subscription.owner.emit({ + type: "terminals_changed", + payload: { + cwd: subscription.cwd, + workspaceId: subscription.workspaceId, + terminals: terminals.map((terminal) => this.toTerminalInfo(terminal)), + ...(requestId ? { requestId } : {}), + }, + }); + requestId = undefined; + } while (subscription.pending); + })(); + subscription.refresh = refresh; + const settled = () => { + subscription.refresh = null; + }; + void refresh.then(settled, settled); + return refresh; + } + private async handleListTerminalsRequest(msg: ListTerminalsRequest): Promise { if (!this.terminalManager) { this.emit({ @@ -436,9 +436,6 @@ export class TerminalSessionController { } else { terminals = await this.getAllTerminalSessions(); } - for (const terminal of terminals) { - this.ensureExitSubscription(terminal); - } this.emit({ type: "list_terminals_response", payload: { @@ -582,7 +579,6 @@ export class TerminalSessionController { rows: msg.size?.rows, cols: msg.size?.cols, }); - this.ensureExitSubscription(session); this.emit({ type: "create_terminal_response", payload: { @@ -662,7 +658,10 @@ export class TerminalSessionController { respond(renamed, renamed ? null : "Terminal not found"); } - private async handleSubscribeTerminalRequest(msg: SubscribeTerminalRequest): Promise { + private async handleSubscribeTerminalRequest( + msg: SubscribeTerminalRequest, + ownership: SessionDelivery, + ): Promise { if (!this.terminalManager) { this.emit({ type: "subscribe_terminal_response", @@ -687,17 +686,29 @@ export class TerminalSessionController { }); return; } - this.ensureExitSubscription(session); - if (msg.restore?.size) { - applyTerminalSize(session, this.terminalSizeOwner, { + let stream: ActiveTerminalStream | undefined; + const owner = ownership.begin( + "terminal-output", + undefined, + async () => { + if (!stream) return; + this.detachRegistration(stream); + await stream.snapshotTask; + }, + `terminal-output:${msg.terminalId}`, + ); + // COMPAT(ownedSubscriptions): added in v0.8.0, remove after 2027-03-09 once client floor >= v0.8.0. + if (!ownership.isModern(owner.source) && msg.restore?.size) { + applyTerminalSize(session, owner.source, { ...msg.restore.size, intent: "claim", }); } - const slot = this.bindActiveStream(session, { restore: msg.restore }); + const slot = this.bindActiveStream(session, owner, { restore: msg.restore }); if (slot === null) { + await owner.release(); this.sessionLogger.warn( { terminalId: msg.terminalId, @@ -721,22 +732,17 @@ export class TerminalSessionController { payload: { terminalId: msg.terminalId, slot, + subscriptionId: owner.responseId, error: null, requestId: msg.requestId, }, }); - const activeStream = this.activeStreams.get(slot); - if (activeStream) { - void this.trySendSnapshot(activeStream); - } + stream = this.activeStreams.get(slot); + if (stream) void this.trySendSnapshot(stream); } - private handleUnsubscribeTerminalRequest(msg: UnsubscribeTerminalRequest): void { - this.detachStream(msg.terminalId, { emitExit: false }); - } - - private handleTerminalInput(msg: TerminalInput): void { + private handleTerminalInput(msg: TerminalInput, source: object): void { if (!this.terminalManager) { return; } @@ -745,10 +751,9 @@ export class TerminalSessionController { this.sessionLogger.warn({ terminalId: msg.terminalId }, "Terminal not found for input"); return; } - this.ensureExitSubscription(session); if (msg.message.type === "resize") { - applyTerminalSize(session, this.terminalSizeOwner, msg.message); + applyTerminalSize(session, source, msg.message); return; } @@ -812,8 +817,6 @@ export class TerminalSessionController { return; } - this.ensureExitSubscription(session); - try { const capture = await this.terminalManager.captureTerminal(msg.terminalId, { start: msg.start, @@ -848,23 +851,13 @@ export class TerminalSessionController { private bindActiveStream( terminal: TerminalSession, + owner: OwnedSubscription, options?: { restore?: TerminalRestoreOptions }, ): number | null { if (!this.hasBinaryChannel()) { return null; } - const existingSlot = this.idToSlot.get(terminal.id); - if (typeof existingSlot === "number") { - const existingStream = this.activeStreams.get(existingSlot); - if (existingStream) { - existingStream.needsSnapshot = true; - existingStream.restore = options?.restore; - return existingSlot; - } - this.idToSlot.delete(terminal.id); - } - const slot = this.allocateSlot(); if (slot === null) { return null; @@ -873,9 +866,11 @@ export class TerminalSessionController { const activeStream: ActiveTerminalStream = { terminalId: terminal.id, slot, + owner, unsubscribe: () => {}, needsSnapshot: true, snapshotInFlight: false, + exiting: false, readyRevision: undefined, restore: options?.restore, bufferedOutputs: [], @@ -897,17 +892,21 @@ export class TerminalSessionController { // (e.g. the multiplexed relay socket); there we can't tell a slow client // from a fast one, so fall back unconditionally at the byte threshold to // keep a slow relay client from falling unboundedly behind. - const clientBufferedAmount = this.getClientBufferedAmount(); + const clientBufferedAmount = this.getClientBufferedAmount(owner.source); if ( + !activeStream.exiting && activeStream.outputBytesSinceSnapshot > MAX_TERMINAL_OUTPUT_FRAME_BYTES && (clientBufferedAmount === null || clientBufferedAmount > MAX_CLIENT_BUFFERED_BYTES) ) { + // The snapshot replaces this batch only after it succeeds. If the + // terminal disappears during the read, completion still owns these bytes. + activeStream.snapshotOutput = payload; activeStream.restore = resolveRestoreAfterOutputOverflow(activeStream.restore); activeStream.needsSnapshot = true; void this.trySendSnapshot(activeStream); return; } - this.emitBinary( + activeStream.owner.emitBinary( encodeTerminalStreamFrame({ opcode: TerminalStreamOpcode.Output, slot, @@ -919,9 +918,8 @@ export class TerminalSessionController { }; this.activeStreams.set(slot, activeStream); - this.idToSlot.set(terminal.id, slot); - activeStream.unsubscribe = terminal.subscribe( + const unsubscribeOutput = terminal.subscribe( (message) => { if (this.activeStreams.get(slot) !== activeStream) { return; @@ -950,10 +948,24 @@ export class TerminalSessionController { }, { initialSnapshot: resolveTerminalSubscriptionSnapshotMode(options?.restore) }, ); + const unsubscribeExit = terminal.onExit(() => + this.detachStream(terminal.id, { emitExit: true }), + ); + activeStream.unsubscribe = () => { + unsubscribeOutput(); + unsubscribeExit(); + }; return slot; } - private async trySendSnapshot(activeStream: ActiveTerminalStream): Promise { + private trySendSnapshot(activeStream: ActiveTerminalStream): Promise { + if (activeStream.snapshotInFlight) return activeStream.snapshotTask ?? Promise.resolve(); + const task = this.sendSnapshot(activeStream); + activeStream.snapshotTask = task; + return task; + } + + private async sendSnapshot(activeStream: ActiveTerminalStream): Promise { if ( this.activeStreams.get(activeStream.slot) !== activeStream || !activeStream.needsSnapshot || @@ -986,15 +998,32 @@ export class TerminalSessionController { if (!snapshotResult.shouldContinue) { return; } + activeStream.snapshotOutput = undefined; this.replayTerminalOutputAfterSnapshot(activeStream, terminal, snapshotResult.replayRevision); activeStream.needsSnapshot = false; activeStream.outputBytesSinceSnapshot = 0; } catch (error) { + if (this.activeStreams.get(activeStream.slot) !== activeStream) return; this.sessionLogger.warn( { err: error, terminalId: activeStream.terminalId }, "Failed to pull terminal snapshot", ); - activeStream.needsSnapshot = true; + // Natural completion owns the buffered final bytes and waits for this read. + if (activeStream.exiting) return; + activeStream.owner.emit({ + type: "terminal_stream_exit", + payload: { + terminalId: activeStream.terminalId, + error: error instanceof Error ? error.message : "Unable to read terminal snapshot", + }, + }); + // Cleanup removes this slot/listeners synchronously, then awaits this task. + // Awaiting release here would make the snapshot task wait for itself. + void activeStream.owner + .release() + .catch((releaseError) => + this.sessionLogger.warn({ err: releaseError }, "Failed to release terminal stream"), + ); } finally { activeStream.snapshotInFlight = false; } @@ -1005,7 +1034,7 @@ export class TerminalSessionController { terminalManager: TerminalManager, ): Promise { const snapshot = await terminalManager.getTerminalState(activeStream.terminalId, { - includeWrapFlags: this.clientSupportsWrapReflow(), + includeWrapFlags: this.clientSupportsWrapReflow(activeStream.owner.source), }); if (this.activeStreams.get(activeStream.slot) !== activeStream) { return { shouldContinue: false }; @@ -1015,7 +1044,7 @@ export class TerminalSessionController { return { shouldContinue: false }; } - this.emitBinary( + activeStream.owner.emitBinary( encodeLegacyTerminalSnapshotFrame({ slot: activeStream.slot, snapshot, @@ -1039,7 +1068,7 @@ export class TerminalSessionController { const snapshot = await terminalManager.getTerminalState(activeStream.terminalId, { ...snapshotOptions, - includeWrapFlags: this.clientSupportsWrapReflow(), + includeWrapFlags: this.clientSupportsWrapReflow(activeStream.owner.source), }); if (this.activeStreams.get(activeStream.slot) !== activeStream) { return { shouldContinue: false }; @@ -1049,7 +1078,7 @@ export class TerminalSessionController { return { shouldContinue: false }; } - this.emitBinary( + activeStream.owner.emitBinary( encodeTerminalRestoreFrame({ slot: activeStream.slot, snapshot, @@ -1100,33 +1129,44 @@ export class TerminalSessionController { } private detachStream(terminalId: string, options?: { emitExit: boolean }): boolean { - const slot = this.idToSlot.get(terminalId); - if (typeof slot !== "number") { - return false; + let detached = false; + for (const stream of this.activeStreams.values()) { + if (stream.terminalId !== terminalId) continue; + detached = true; + if (stream.exiting) continue; + stream.exiting = true; + const completion = options?.emitExit ? this.completeStream(stream) : stream.owner.release(); + void completion.catch((error) => + this.sessionLogger.warn({ err: error }, "Failed to release terminal stream"), + ); } - const activeStream = this.activeStreams.get(slot); - if (!activeStream) { - this.idToSlot.delete(terminalId); - return false; + return detached; + } + + private async completeStream(stream: ActiveTerminalStream): Promise { + // Preserve bootstrap/snapshot ordering. Explicit release can remove this + // registration while the read is pending, in which case it discards delivery. + await stream.snapshotTask; + if (this.activeStreams.get(stream.slot) !== stream) return; + if (stream.snapshotOutput) { + stream.outputCoalescer.handle(stream.snapshotOutput.toString("utf8")); + stream.snapshotOutput = undefined; } - activeStream.outputCoalescer.flush(); - activeStream.bufferedOutputs.length = 0; - this.activeStreams.delete(slot); - this.idToSlot.delete(terminalId); - try { - activeStream.unsubscribe(); - } catch (error) { - this.sessionLogger.warn({ err: error }, "Failed to unsubscribe terminal stream"); + for (const output of stream.bufferedOutputs.splice(0)) { + stream.outputCoalescer.handle(output.data); } - if (options?.emitExit) { - this.emit({ - type: "terminal_stream_exit", - payload: { - terminalId: activeStream.terminalId, - }, - }); - } - return true; + // Completion must not turn this final flush into another backpressure read. + stream.outputCoalescer.flush(); + stream.owner.emit({ type: "terminal_stream_exit", payload: { terminalId: stream.terminalId } }); + await stream.owner.release(); + } + + private detachRegistration(stream: ActiveTerminalStream): void { + this.activeStreams.delete(stream.slot); + stream.outputCoalescer.dispose(); + stream.snapshotOutput = undefined; + stream.bufferedOutputs.length = 0; + stream.unsubscribe(); } } diff --git a/packages/server/src/terminal/terminal-size-ownership.ts b/packages/server/src/terminal/terminal-size-ownership.ts index 626ae8a634..788a771a0c 100644 --- a/packages/server/src/terminal/terminal-size-ownership.ts +++ b/packages/server/src/terminal/terminal-size-ownership.ts @@ -6,7 +6,7 @@ interface TerminalSizeRequest { intent?: "claim" | "update"; } -const terminalSizeOwners = new WeakMap(); +const terminalSizeOwners = new WeakMap>(); export function applyTerminalSize( terminal: TerminalSession, @@ -14,12 +14,12 @@ export function applyTerminalSize( request: TerminalSizeRequest, ): boolean { const intent = resolveTerminalSizeIntent(request.intent); - if (intent === "update" && terminalSizeOwners.get(terminal) !== owner) { + if (intent === "update" && terminalSizeOwners.get(terminal)?.deref() !== owner) { return false; } if (intent === "claim") { - terminalSizeOwners.set(terminal, owner); + terminalSizeOwners.set(terminal, new WeakRef(owner)); } const currentSize = terminal.getSize(); diff --git a/plugin-examples/local-plugin/client/main.tsx b/plugin-examples/local-plugin/client/main.tsx index 172639e183..2bd574dc26 100644 --- a/plugin-examples/local-plugin/client/main.tsx +++ b/plugin-examples/local-plugin/client/main.tsx @@ -13,6 +13,7 @@ import { incrementRpc } from "../shared/increment"; export function contributeClient(client: PluginClientContext) { const pills = new Map(); let stopped = false; + const lifetime = new AbortController(); const register = (agent: { id: string; workspaceId?: string | null }) => { if (stopped || !agent.workspaceId) return; pills.get(agent.id)?.remove(); @@ -39,20 +40,30 @@ export function contributeClient(client: PluginClientContext) { pills.get(agentId)?.remove(); pills.delete(agentId); }; - const unsubscribe = client.paseo.agents.subscribe((update) => { - if (update.kind === "remove") remove(update.agentId); - else register(update.agent); - }); void client.paseo.agents - .list() - .then(({ entries }) => { - for (const { agent } of entries) register(agent); + .list({ subscribe: {}, signal: lifetime.signal }) + .then(({ subscription }) => { + subscription.subscribe({ + snapshot: ({ entries }) => { + for (const pill of pills.values()) pill.remove(); + pills.clear(); + for (const { agent } of entries) register(agent); + }, + update: (message) => { + if (message.type !== "agent_update") return; + const update = message.payload; + if (update.kind === "remove") remove(update.agentId); + else register(update.agent); + }, + }); return undefined; }) - .catch(() => undefined); + .catch((error) => { + if (!stopped) console.error("Agent observation failed", error); + }); return () => { stopped = true; - unsubscribe(); + lifetime.abort(); for (const pill of pills.values()) pill.remove(); pills.clear(); }; diff --git a/public-docs/plugins/v0.8/reference.md b/public-docs/plugins/v0.8/reference.md index 3677956a75..b8049c27d0 100644 --- a/public-docs/plugins/v0.8/reference.md +++ b/public-docs/plugins/v0.8/reference.md @@ -1357,6 +1357,10 @@ const pill = client.addComposerPill({ }); ``` +For pills that follow the agent directory, use an explicit [owned list subscription](/docs/sdk/events#follow-one-agents-status). +The [local plugin example](https://github.com/getpaseo/paseo/blob/main/plugin-examples/local-plugin/client/main.tsx) +replaces registrations on each snapshot and aborts the observation during entry cleanup, including pending bootstrap. + ## Button descriptor These contracts are exported from `@getpaseo/plugin/client`. diff --git a/public-docs/sdk/agents.md b/public-docs/sdk/agents.md index 565bedbef8..fa3ed80c00 100644 --- a/public-docs/sdk/agents.md +++ b/public-docs/sdk/agents.md @@ -156,12 +156,17 @@ The handle exposes `status`, `capabilities`, `availableModes`, `pendingPermissio A handle from `ref()` has observed nothing, so every one of them is `null` until `refresh()`, `run()`, `waitForFinish()`, a timeline refetch, or `subscribe()` delivers a snapshot. Optional values in an observed snapshot also read as `null`. Use `current()` when you need the whole snapshot or need to distinguish those states. -`subscribe()` keeps the properties current, so a long-lived handle can poll them without another RPC: +`subscribe()` is a local listener. An owned agent-directory observation supplies its updates: ```ts const unsubscribe = agent.subscribe(() => { if (agent.status === "error") console.error(agent.lastError); }); +const directory = await client.agents.list({ subscribe: {} }); + +// When this view closes: +unsubscribe(); +await directory.subscription.release(); ``` ## List the commands a session loaded diff --git a/public-docs/sdk/events.md b/public-docs/sdk/events.md index dafba6bcb3..03f69907fa 100644 --- a/public-docs/sdk/events.md +++ b/public-docs/sdk/events.md @@ -8,39 +8,55 @@ category: TypeScript SDK # SDK events -Subscriptions report changes after they happen. Fetch an initial snapshot first, then apply updates to it. - -Every `subscribe()` method returns an unsubscribe function. Timeline, project, and provider subscriptions establish network demand and restore it after reconnect. Unsubscribing releases that demand when its last listener leaves. Agent and workspace directories use the explicit `list({ subscribe })` bootstrap shown below. Unsubscribing never stops or archives the underlying resource. +Use an owned subscription to fetch a snapshot and follow its changes. Connecting, plain reads, and local listeners do not start observation. Each observation gets a new server-issued ID, even when its filter matches another observation. ## Follow one agent's status -The daemon sends agent-directory updates only after the connection opens an agent-list subscription: - ```ts -await client.agents.list({ +const directory = await client.agents.list({ filter: { includeArchived: false }, - subscribe: { subscriptionId: "issue-board-agents" }, + subscribe: {}, }); -const agent = client.agents.ref(agentId); -await agent.refresh(); - -const unsubscribe = agent.subscribe((update) => { - if (update.kind === "upsert") { - console.log(update.agent.status); - } else { - console.log("Agent removed from the active directory"); - } +directory.subscription.subscribe({ + snapshot({ entries }) { + const entry = entries.find(({ agent }) => agent.id === agentId); + console.log(entry?.agent.status); + }, + update(message) { + if (message.type !== "agent_update") return; + const update = message.payload; + if (update.kind === "upsert" && update.agent.id === agentId) { + console.log(update.agent.status); + } else if (update.kind === "remove" && update.agentId === agentId) { + console.log("Agent removed from this directory"); + } + }, }); + +// When this view closes: +await directory.subscription.release(); ``` -The handle updates its properties and `current()` value before it calls your handler. +`list({ subscribe: {} })` returns the snapshot, `subscriptionId`, and `subscription`. Omitting `subscribe` returns only a snapshot. Do not supply a subscription ID. The subscription delivers its snapshot before updates and receives a new ID and snapshot after reconnect. Releasing it leaves other observations and the underlying agents intact. + +`client.agents.subscribe()` and agent-handle `subscribe()` add local listeners to observations owned by that API instance. They do not request data. Use the returned subscription's callbacks when multiple filtered views need separate updates. ## Follow timeline events ```ts const unsubscribe = agent.timeline.subscribe((update) => { const { event } = update; + if (event.type === "snapshot") { + // Replace your recent history view; these are complete projected entries, + // not additional live message fragments. + console.log("Reconnected", event.page.epoch, event.page.entries); + return; + } + if (event.type === "error") { + console.error("Timeline observation stopped:", event.error); + return; + } if (event.type === "replacement") { // Previously fetched history belongs to an old epoch. Fetch the page your UI needs. void agent.timeline.refetch().then((page) => console.log(page.entries)); @@ -56,7 +72,11 @@ const unsubscribe = agent.timeline.subscribe((update) => { }); ``` -Await `unsubscribe.ready` before starting work whose events you need to observe. It resolves when the daemon acknowledges the subscription and rejects if establishment fails. Call `unsubscribe()` to release it. +Await `unsubscribe.ready` before starting work whose events you need to observe. It acknowledges the initial live subscription; initial history is a separate [read](#fetch-timeline-history). Call `unsubscribe()` to release demand, or await `unsubscribe.release()` for teardown. + +After reconnect, the same handle receives `{ agentId, subscriptionId, event: { type: "snapshot", reason: "reconnect", page } }` before subsequent updates. The host assigns a fresh subscription ID. `page` is the latest 100 projected history entries, with the same epoch, cursors and paging flags as `timeline.refetch()`. Replace your recent view with this page; do not concatenate it with earlier message fragments. If `hasOlder` is true, fetch older pages with `page.startCursor`. Compare epochs before retaining any previously loaded history: a reconnect may preserve or replace the epoch. A live `replacement` event still means the previous epoch is invalid. + +Persisted history is recovered; transient events such as an offline turn-completion notification are not replayed. Recovery buffers at most 128 current-connection updates. Overflow or a concurrent replacement discards the obsolete buffer and reads a fresh page. A recovery read failure delivers `{ agentId, event: { type: "error", error } }` and releases that observation; establish a new subscription when ready to retry. Release suppresses pending recovery callbacks. Each same-agent subscription recovers independently. Assistant messages can arrive in pieces. Concatenate their text when you need a complete message, or use `run()` and read `lastMessage` when you only need the final reply. @@ -72,7 +92,7 @@ const page = await agent.timeline.refetch({ }); for (const entry of page.entries) { - console.log(entry.seq, entry.event.type); + console.log(entry.seqStart, entry.seqEnd, entry.item.type); } ``` @@ -80,24 +100,25 @@ Use `startCursor`, `endCursor`, `hasOlder`, and `hasNewer` from the result to pa ## Follow workspace updates -Workspace updates also require a directory subscription: - ```ts -await client.workspaces.list({ - subscribe: { subscriptionId: "issue-board-workspaces" }, +const directory = await client.workspaces.list({ subscribe: {} }); +directory.subscription.subscribe({ + snapshot({ entries }) { + console.log(entries); + }, + update(message) { + if (message.type === "workspace_update") console.log(message.payload); + }, }); -const workspace = client.workspaces.ref(workspaceId); -const unsubscribe = workspace.subscribe((update) => { - if (update.kind === "upsert") { - console.log(update.workspace.status); - } -}); +// When this view closes: +await directory.subscription.release(); ``` ## Follow provider catalog changes ```ts +const observation = client.observeEvents(["providers_snapshot_update"]); const unsubscribe = client.providers.subscribe((update) => { const ready = update.entries.filter((entry) => entry.status === "ready"); console.log( @@ -105,6 +126,14 @@ const unsubscribe = client.providers.subscribe((update) => { ready.map((entry) => entry.provider), ); }); +await observation.ready; +console.log(await client.providers.snapshot()); + +// When this view closes: +unsubscribe(); +await observation.release(); ``` -Always call the returned unsubscribe functions before discarding the owning object. Call `client.close()` when the application no longer needs the daemon connection. +Project listeners work the same way: request `client.observeEvents(["project.update"])` before relying on `client.projects.subscribe()`. For an initial project cache, buffer updates while awaiting `client.projects.list()`, then apply them after the snapshot. + +Call `client.close()` when the application no longer needs the connection. Observation requires a host that advertises independent subscriptions; an older host returns an update-host error. Plain reads remain available. diff --git a/public-docs/sdk/reference.md b/public-docs/sdk/reference.md index 85e4d60b9d..960404c58a 100644 --- a/public-docs/sdk/reference.md +++ b/public-docs/sdk/reference.md @@ -53,7 +53,9 @@ Create a new client after `close()`. | `list(options?)` | `PaseoAgentListResult` | Lists a page of agents. `scope`, `filter`, `sort`, `page`, and `subscribe` match the daemon directory query. | | `create(options)` | `PaseoAgentHandle` | Creates an agent and a fresh workspace for `cwd`. Requires `config`. | | `ref(agentOrId)` | `PaseoAgentHandle` | Creates a local handle without fetching. | -| `subscribe(handler)` | Unsubscribe function | Listens for connection-local agent directory updates. Call `list({ subscribe })` first. | +| `subscribe(handler)` | Unsubscribe function | Local listener for this API instance. Requires an owned `list({ subscribe: {} })` observation. | + +`list({ subscribe: {} })` also returns a server-issued `subscriptionId` and an owned `subscription`. Its `subscribe({ snapshot, update, error? })` callbacks receive the snapshot before scoped wire updates; `release()` ends that observation. Plain lists create no observation. The same contract applies to workspace lists. See [events](./events.md). Creation options include `config`, `cwd`, `parent`, `title`, `prompt`, `env`, `outputSchema`, `images`, `attachments`, `git`, `worktree`, `autoArchive`, and `labels`. @@ -108,27 +110,27 @@ Creation options include `config`, `cwd`, `parent`, `title`, `prompt`, `env`, `o `agent.timeline.refetch(options?)` fetches a page. Options are `direction`, `cursor`, `limit`, `projection`, and `requestId`. -`agent.timeline.subscribe(handler)` establishes network demand for this agent and restores it after reconnect. Its unsubscribe function releases that demand; await `unsubscribe.ready` for initial daemon acknowledgement before starting work. The handler also receives `{ agentId, event: { type: "replacement", epoch } }` when history is replaced; refetch the page you need. See [events](./events.md#follow-timeline-events). +`agent.timeline.subscribe(handler)` establishes network demand for this agent and restores it after reconnect. Its unsubscribe function releases that demand; await `unsubscribe.ready` for initial daemon acknowledgement before starting work. Initial delivery is live-only. Reconnect delivers a bounded projected history snapshot before later updates; a live replacement invalidates the previous epoch. See the callback shapes, paging and failure behavior in [timeline events](./events.md#follow-timeline-events). ## `client.projects` -| Method | Result | Behavior | -| -------------------- | ------------------------ | --------------------------------------------------------------------------------------- | -| `list(options?)` | `PaseoProjectListResult` | Lists every registered project, including projects with no active workspaces. | -| `subscribe(handler)` | Unsubscribe function | Listens only for future project upserts/removals. Pair with `list()` for initial state. | +| Method | Result | Behavior | +| -------------------- | ------------------------ | ---------------------------------------------------------------------------------------------- | +| `list(options?)` | `PaseoProjectListResult` | Lists every registered project, including projects with no active workspaces. | +| `subscribe(handler)` | Unsubscribe function | Local listener. Requires `observeEvents(["project.update"])`; `list()` supplies initial state. | -To build a complete project cache without an initialization gap, subscribe and buffer updates before awaiting `list()`. Initialize the cache from the list result, then apply buffered updates in arrival order. +See [events](./events.md#follow-provider-catalog-changes) for explicit event observation and cleanup. ## `client.workspaces` -| Method | Result | Behavior | -| ------------------------ | ----------------------------- | --------------------------------------------------------------------------------- | -| `list(options?)` | `PaseoWorkspaceListResult` | Lists, filters, pages, or subscribes to the workspace directory. | -| `open(cwd)` | `PaseoWorkspaceHandle` | Reuses the active workspace for a directory or creates one. | -| `create(options)` | `PaseoWorkspaceHandle` | Always creates a fresh directory-backed or Paseo-worktree workspace. | -| `ref(workspaceOrId)` | `PaseoWorkspaceHandle` | Creates a local handle. | -| `archive(workspaceOrId)` | `PaseoWorkspaceArchiveResult` | Archives without first creating a handle. | -| `subscribe(handler)` | Unsubscribe function | Listens for connection-local workspace updates. Call `list({ subscribe })` first. | +| Method | Result | Behavior | +| ------------------------ | ----------------------------- | ---------------------------------------------------------------------------------------------- | +| `list(options?)` | `PaseoWorkspaceListResult` | Lists, filters, pages, or subscribes to the workspace directory. | +| `open(cwd)` | `PaseoWorkspaceHandle` | Reuses the active workspace for a directory or creates one. | +| `create(options)` | `PaseoWorkspaceHandle` | Always creates a fresh directory-backed or Paseo-worktree workspace. | +| `ref(workspaceOrId)` | `PaseoWorkspaceHandle` | Creates a local handle. | +| `archive(workspaceOrId)` | `PaseoWorkspaceArchiveResult` | Archives without first creating a handle. | +| `subscribe(handler)` | Unsubscribe function | Local listener for this API instance. Requires an owned `list({ subscribe: {} })` observation. | A workspace handle exposes `id`, `projectId`, `directory`, `name`, `status`, `current()`, `refresh()`, `setTitle(title)`, `archive()`, and `subscribe()`. Pass `null` to `setTitle` to restore the derived workspace name. Use `workspace.agents.create(options)` to create an agent without repeating the workspace ID or directory. @@ -185,7 +187,7 @@ Use `workspace.terminals.create(options?)` and `workspace.terminals.list(options | `listFeatures(draftConfig)` | Features result | Discovers features for the current draft provider configuration. | | `diagnostic(provider)` | Diagnostic result | Returns human-readable setup diagnostics. | | `listUsage(options?)` | `PaseoProviderUsageResult` | Returns normalized subscription windows, balances, and provider details. Rejects with an update-host error when unsupported. Options: `requestId`. | -| `subscribe(handler)` | Unsubscribe function | Listens for catalog updates. | +| `subscribe(handler)` | Unsubscribe function | Local listener. Requires `observeEvents(["providers_snapshot_update"])`. | ## `client.config` From 4006f974fb40833704bb745166007efe504383d6 Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Fri, 11 Sep 2026 08:09:05 +0200 Subject: [PATCH 18/46] Reload to the project picker after an app error (#4598) * fix(app): reload to the project picker after a render error Keep the recovery boundary above the router so Reload starts a new navigation tree at the picker instead of remounting the failed workspace. Preserve saved layouts and normal startup restoration. * test(app): exercise reload through fixture routes --- docs/expo-router.md | 9 ++ .../e2e/browser/root-error-recovery.spec.ts | 96 +++++++++++++++++++ packages/app/e2e/support/recovery-app.tsx | 55 +++++++++++ packages/app/index.ts | 9 +- packages/app/src/app/_layout.tsx | 21 +--- .../components/root-error-boundary.test.tsx | 13 +-- .../src/components/root-error-boundary.tsx | 34 +++---- packages/app/src/i18n/resources/ar.ts | 1 + packages/app/src/i18n/resources/en.ts | 1 + packages/app/src/i18n/resources/es.ts | 1 + packages/app/src/i18n/resources/fr.ts | 1 + packages/app/src/i18n/resources/ja.ts | 1 + packages/app/src/i18n/resources/ko.ts | 1 + packages/app/src/i18n/resources/pt-BR.ts | 1 + packages/app/src/i18n/resources/ru.ts | 1 + packages/app/src/i18n/resources/zh-CN.ts | 1 + packages/app/src/root-app.tsx | 40 ++++++++ 17 files changed, 238 insertions(+), 48 deletions(-) create mode 100644 packages/app/e2e/browser/root-error-recovery.spec.ts create mode 100644 packages/app/e2e/support/recovery-app.tsx create mode 100644 packages/app/src/root-app.tsx diff --git a/docs/expo-router.md b/docs/expo-router.md index 4ccdc14d39..6ff9d14c9c 100644 --- a/docs/expo-router.md +++ b/docs/expo-router.md @@ -47,6 +47,15 @@ state. This split is deliberate. The host layout must mount first so native local dynamic params exist before any nested workspace leaf is selected. +## Error Recovery + +The root error boundary lives above `ExpoRoot` in `src/root-app.tsx`. Reload +remounts the router at `/open-project`, preserving saved workspace layouts and +the remembered selection. Do not move the boundary inside the root layout: +catching a render error there unmounts the navigator, so the recovery button +cannot reliably dispatch a navigation reset. Starting recovery at `/` would +restore the same workspace and could repeat the crash. + ## App-Wide Route Hops When app-wide routes such as `/new`, `/settings`, or `/sessions` navigate back diff --git a/packages/app/e2e/browser/root-error-recovery.spec.ts b/packages/app/e2e/browser/root-error-recovery.spec.ts new file mode 100644 index 0000000000..26fc70c4bb --- /dev/null +++ b/packages/app/e2e/browser/root-error-recovery.spec.ts @@ -0,0 +1,96 @@ +import { createServer, type Server } from "node:http"; +import { expect, test, type Page, type TestInfo } from "@playwright/test"; + +let server: Server; +let appUrl: string; +test.beforeAll(async () => { + const metroPort = process.env.E2E_METRO_PORT; + if (!metroPort) throw new Error("E2E_METRO_PORT is required"); + const bundle = `http://localhost:${metroPort}/packages/app/e2e/support/recovery-app.bundle?platform=web&dev=true&minify=false&hot=false&lazy=true&transform.engine=hermes&transform.routerRoot=src%2Fapp`; + server = createServer((_request, response) => { + response.setHeader("Content-Type", "text/html"); + response.end( + `
`, + ); + }); + await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve)); + const address = server.address(); + if (!address || typeof address === "string") throw new Error("Fixture server did not start"); + appUrl = `http://127.0.0.1:${address.port}`; +}); + +test.afterAll(async () => { + await new Promise((resolve, reject) => + server.close((error) => { + if (error) { + reject(error); + return; + } + resolve(); + }), + ); +}); + +async function openBrokenStartup(page: Page) { + await page.addInitScript(() => { + localStorage.setItem( + "workspace-layout-state", + JSON.stringify({ state: { layoutByWorkspace: {} }, version: 1 }), + ); + localStorage.setItem( + "paseo:last-workspace-route-selection", + JSON.stringify({ serverId: "fixture-host", workspaceId: "broken" }), + ); + }); + await page.goto(appUrl); + await expect(page.getByText("Paseo ran into a problem.", { exact: true })).toBeVisible(); +} +async function readSavedWorkspaceState(page: Page) { + return page.evaluate(() => ({ + layout: localStorage.getItem("workspace-layout-state"), + selection: localStorage.getItem("paseo:last-workspace-route-selection"), + })); +} +async function reloadToPicker(page: Page) { + await page.getByRole("button", { name: "Reload", exact: true }).click(); + await expect(page).toHaveURL(`${appUrl}/open-project`); + await expect(page.getByText("Project picker", { exact: true })).toBeVisible(); + await expect(page.getByText("Paseo ran into a problem.", { exact: true })).toHaveCount(0); +} +async function openHealthyWorkspace(page: Page) { + await page.getByRole("link", { name: "Open healthy workspace" }).click(); + await expect(page.getByText("Healthy workspace", { exact: true })).toBeVisible(); +} +async function breakCurrentWorkspace(page: Page) { + await page.getByRole("button", { name: "Break this workspace" }).click(); + await expect(page.getByText("Paseo ran into a problem.", { exact: true })).toBeVisible(); +} +async function captureRecoveryScreen(page: Page, testInfo: TestInfo, name: string) { + const path = testInfo.outputPath(`${name}.png`); + await page.screenshot({ path }); + await testInfo.attach(name, { path, contentType: "image/png" }); +} + +test("Reload escapes a startup redirect and supports another recovery", async ({ + page, +}, testInfo) => { + await openBrokenStartup(page); + const saved = await readSavedWorkspaceState(page); + await captureRecoveryScreen(page, testInfo, "error-screen"); + await reloadToPicker(page); + expect(await readSavedWorkspaceState(page)).toEqual(saved); + await captureRecoveryScreen(page, testInfo, "recovered-picker"); + await openHealthyWorkspace(page); + await breakCurrentWorkspace(page); + await reloadToPicker(page); +}); + +test.describe("compact error recovery", () => { + test.use({ viewport: { width: 390, height: 844 } }); + test("Reload remains reachable and returns to the picker", async ({ page }, testInfo) => { + await openBrokenStartup(page); + await captureRecoveryScreen(page, testInfo, "compact-error-screen"); + await reloadToPicker(page); + await captureRecoveryScreen(page, testInfo, "compact-recovered-picker"); + }); +}); diff --git a/packages/app/e2e/support/recovery-app.tsx b/packages/app/e2e/support/recovery-app.tsx new file mode 100644 index 0000000000..4ec02edd26 --- /dev/null +++ b/packages/app/e2e/support/recovery-app.tsx @@ -0,0 +1,55 @@ +// Test-only entry: the production recovery boundary and Expo router with failing routes. +import "../../src/styles/unistyles"; +// oxlint-disable-next-line import/no-unassigned-import -- Match the Metro web entry runtime. +import "@expo/metro-runtime"; +import { registerRootComponent } from "expo"; +import { Link, Redirect, Stack, type Href } from "expo-router"; +import { useCallback, useState, type ComponentProps } from "react"; +import { Button, Text, View } from "react-native"; +import { RootRouter } from "../../src/root-app"; + +const screenOptions = { headerShown: false }; +function Layout() { + return ; +} +// Fixture routes are absent from Expo's generated production route types. +function Startup() { + return ; +} +function BrokenWorkspace(): never { + throw new Error("Workspace recovery fixture"); +} +function Picker() { + return ( + + Project picker + Open healthy workspace + + ); +} +function HealthyWorkspace() { + const [broken, setBroken] = useState(false); + const breakWorkspace = useCallback(() => setBroken(true), []); + if (broken) return ; + return ( + + Healthy workspace + ); @@ -100,9 +92,9 @@ export function RootErrorFallback({ error, onRetry }: RootErrorFallbackProps) { {error} - {isCompact ? null : retry} + {isCompact ? null : reload} - {isCompact ? {retry} : null} + {isCompact ? {reload} : null} ); } diff --git a/packages/app/src/i18n/resources/ar.ts b/packages/app/src/i18n/resources/ar.ts index 1f3a5b3b02..30adccccd7 100644 --- a/packages/app/src/i18n/resources/ar.ts +++ b/packages/app/src/i18n/resources/ar.ts @@ -12,6 +12,7 @@ export const ar: TranslationResources = { copy: "ينسخ", copyLine: "نسخ السطر", dismiss: "رفض", + reload: "إعادة التحميل", retry: "أعد المحاولة", search: "يبحث", select: "يختار", diff --git a/packages/app/src/i18n/resources/en.ts b/packages/app/src/i18n/resources/en.ts index 831bc8eb18..e738f13e82 100644 --- a/packages/app/src/i18n/resources/en.ts +++ b/packages/app/src/i18n/resources/en.ts @@ -9,6 +9,7 @@ export const en = { copy: "Copy", copyLine: "Copy line", dismiss: "Dismiss", + reload: "Reload", retry: "Retry", search: "Search", select: "Select", diff --git a/packages/app/src/i18n/resources/es.ts b/packages/app/src/i18n/resources/es.ts index 810a29c86e..2c4abbc907 100644 --- a/packages/app/src/i18n/resources/es.ts +++ b/packages/app/src/i18n/resources/es.ts @@ -12,6 +12,7 @@ export const es: TranslationResources = { copy: "Copiar", copyLine: "Copiar línea", dismiss: "Despedir", + reload: "Volver a cargar", retry: "Rever", search: "Buscar", select: "Seleccionar", diff --git a/packages/app/src/i18n/resources/fr.ts b/packages/app/src/i18n/resources/fr.ts index 56e11702c1..6b26d97812 100644 --- a/packages/app/src/i18n/resources/fr.ts +++ b/packages/app/src/i18n/resources/fr.ts @@ -12,6 +12,7 @@ export const fr: TranslationResources = { copy: "Copie", copyLine: "Copier la ligne", dismiss: "Rejeter", + reload: "Recharger", retry: "Réessayer", search: "Recherche", select: "Sélectionner", diff --git a/packages/app/src/i18n/resources/ja.ts b/packages/app/src/i18n/resources/ja.ts index 7ab9c3f3a4..bcb85cd9a6 100644 --- a/packages/app/src/i18n/resources/ja.ts +++ b/packages/app/src/i18n/resources/ja.ts @@ -12,6 +12,7 @@ export const ja: TranslationResources = { copy: "コピー", copyLine: "行をコピー", dismiss: "閉じる", + reload: "再読み込み", retry: "再試行", search: "検索", select: "選択", diff --git a/packages/app/src/i18n/resources/ko.ts b/packages/app/src/i18n/resources/ko.ts index 394c8474e3..6b995ffc72 100644 --- a/packages/app/src/i18n/resources/ko.ts +++ b/packages/app/src/i18n/resources/ko.ts @@ -12,6 +12,7 @@ export const ko: TranslationResources = { copy: "복사", copyLine: "줄 복사", dismiss: "닫기", + reload: "다시 로드", retry: "다시 시도", search: "검색", select: "선택", diff --git a/packages/app/src/i18n/resources/pt-BR.ts b/packages/app/src/i18n/resources/pt-BR.ts index b14a5e61bc..e6b1acdb32 100644 --- a/packages/app/src/i18n/resources/pt-BR.ts +++ b/packages/app/src/i18n/resources/pt-BR.ts @@ -12,6 +12,7 @@ export const ptBR: TranslationResources = { copy: "Copiar", copyLine: "Copiar linha", dismiss: "Dispensar", + reload: "Recarregar", retry: "Tentar novamente", search: "Buscar", select: "Selecionar", diff --git a/packages/app/src/i18n/resources/ru.ts b/packages/app/src/i18n/resources/ru.ts index 9ebde808b0..23b29edb13 100644 --- a/packages/app/src/i18n/resources/ru.ts +++ b/packages/app/src/i18n/resources/ru.ts @@ -12,6 +12,7 @@ export const ru: TranslationResources = { copy: "Копировать", copyLine: "Копировать строку", dismiss: "Отклонить", + reload: "Перезагрузить", retry: "Повторить", search: "Поиск", select: "Выбрать", diff --git a/packages/app/src/i18n/resources/zh-CN.ts b/packages/app/src/i18n/resources/zh-CN.ts index 73ef534bd9..32442aba5f 100644 --- a/packages/app/src/i18n/resources/zh-CN.ts +++ b/packages/app/src/i18n/resources/zh-CN.ts @@ -12,6 +12,7 @@ export const zhCN: TranslationResources = { copy: "复制", copyLine: "复制行", dismiss: "关闭", + reload: "重新加载", retry: "重试", search: "搜索", select: "选择", diff --git a/packages/app/src/root-app.tsx b/packages/app/src/root-app.tsx new file mode 100644 index 0000000000..cae9b169d6 --- /dev/null +++ b/packages/app/src/root-app.tsx @@ -0,0 +1,40 @@ +import { useCallback, useState, type ComponentProps } from "react"; +import { ExpoRoot } from "expo-router"; +import Head from "expo-router/head"; +import { QueryClientProvider } from "@tanstack/react-query"; +import { SafeAreaProvider } from "react-native-safe-area-context"; +import { queryClient } from "@/data/query-client"; +import { I18nProvider } from "@/i18n/provider"; +import { RootErrorBoundary } from "@/components/root-error-boundary"; + +// Keep Expo's platform filtering, route root, and lazy import configuration. +const { ctx: context } = require("expo-router/_ctx") as { + ctx: ComponentProps["context"]; +}; + +export function RootApp() { + return ; +} + +export function RootRouter({ context: routes }: Pick, "context">) { + const [generation, setGeneration] = useState(0); + const reload = useCallback(() => setGeneration((value) => value + 1), []); + + return ( + + + + + + {/* Recreate the router at a safe destination before a failed route can mount. */} + + + + + + + ); +} From 0110302b6fd172933ef9ba615fc9f0a08e420668 Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Fri, 11 Sep 2026 08:33:22 +0200 Subject: [PATCH 19/46] fix(daemon): keep lifecycle operations on the selected instance (#4575) * fix(daemon): keep lifecycle operations on the selected instance Restart reconstructed launch inputs and address fallback could redirect a home-selected shutdown to another daemon. Let supervisors publish bound endpoints, retain their worker restart contract, and share captured local lifecycle ownership between CLI and Desktop. * test(daemon): verify bounded desktop readiness and cancellable artifacts * test(cli): verify reload target hints and durable offline pairing * test(daemon): align lifecycle fixtures with ownership and targeting * fix(daemon): preserve reachability when status details time out Provider discovery can outlast a status request after authentication succeeds. Keep local connection facts separate from unavailable details, while explicit remote queries retain their error outcome. Migrate CLI and packaged Desktop fixtures to persistent managed configuration and explicit targets. Isolate generic Git observation from forge discovery and reuse disposable CLI homes on Windows. * test(daemon): observe restart through the worker status RPC The bounded CLI status observation may omit worker details while an authenticated connection remains healthy. Exercise the app-style restart through its full RPC and retain the replacement observation, with a real slow provider probe covering the distinction. --- .github/workflows/ci.yml | 25 +- docs/architecture.md | 10 +- docs/development.md | 9 +- docs/docker.md | 2 + .../app/e2e/browser/daemon-lifecycle.spec.ts | 27 + packages/app/e2e/support/global-setup.ts | 3 +- .../e2e/support/helpers/daemon-lifecycle.ts | 42 + .../support/helpers/isolated-host-daemon.ts | 2 +- .../support/helpers/packaged-web-daemon.ts | 34 +- .../components/desktop-updates-section.tsx | 37 +- .../daemon/daemon-management-toggle.test.ts | 12 +- .../daemon/daemon-management-toggle.ts | 4 +- .../app/src/desktop/daemon/desktop-daemon.ts | 33 + .../hooks/use-built-in-daemon-management.ts | 12 +- .../desktop-diagnostic-report.test.ts | 2 + packages/app/src/i18n/resources/ar.ts | 1 + packages/app/src/i18n/resources/en.ts | 17 + packages/app/src/i18n/resources/es.ts | 1 + packages/app/src/i18n/resources/fr.ts | 1 + packages/app/src/i18n/resources/ja.ts | 1 + packages/app/src/i18n/resources/ko.ts | 1 + packages/app/src/i18n/resources/pt-BR.ts | 1 + packages/app/src/i18n/resources/ru.ts | 1 + packages/app/src/i18n/resources/zh-CN.ts | 1 + .../src/runtime/daemon-start-service.test.ts | 2 + .../screens/settings/daemon-lifecycle.test.ts | 98 +++ .../src/screens/settings/daemon-lifecycle.ts | 94 +++ .../screens/settings/daemon-restart.test.ts | 131 --- .../src/screens/settings/daemon-restart.ts | 57 -- .../app/src/screens/settings/host-page.tsx | 219 ++--- packages/cli/src/cli.ts | 78 +- packages/cli/src/commands/agent/archive.ts | 18 +- packages/cli/src/commands/agent/attach.ts | 16 +- .../cli/src/commands/agent/delete.test.ts | 4 +- packages/cli/src/commands/agent/delete.ts | 19 +- packages/cli/src/commands/agent/detach.ts | 15 +- .../cli/src/commands/agent/import.test.ts | 1 + packages/cli/src/commands/agent/import.ts | 21 +- packages/cli/src/commands/agent/inspect.ts | 17 +- packages/cli/src/commands/agent/logs.ts | 15 +- packages/cli/src/commands/agent/ls.ts | 26 +- packages/cli/src/commands/agent/mode.ts | 4 +- packages/cli/src/commands/agent/open.ts | 9 +- packages/cli/src/commands/agent/reload.ts | 18 +- packages/cli/src/commands/agent/run.test.ts | 17 +- packages/cli/src/commands/agent/run.ts | 26 +- packages/cli/src/commands/agent/send.ts | 19 +- packages/cli/src/commands/agent/stop.ts | 18 +- packages/cli/src/commands/agent/update.ts | 17 +- packages/cli/src/commands/agent/wait.ts | 18 +- packages/cli/src/commands/clone.ts | 10 +- packages/cli/src/commands/daemon/config.ts | 116 +++ packages/cli/src/commands/daemon/index.ts | 95 +-- .../src/commands/daemon/lifecycle.e2e.test.ts | 609 ++++++++++++++ .../daemon/local-daemon.supervision.test.ts | 214 ----- .../cli/src/commands/daemon/local-daemon.ts | 777 ++---------------- packages/cli/src/commands/daemon/pair.test.ts | 163 +--- packages/cli/src/commands/daemon/pair.ts | 131 ++- .../cli/src/commands/daemon/reload.test.ts | 7 +- packages/cli/src/commands/daemon/reload.ts | 17 +- packages/cli/src/commands/daemon/restart.ts | 235 +++--- .../cli/src/commands/daemon/set-password.ts | 14 +- packages/cli/src/commands/daemon/start.ts | 112 ++- .../cli/src/commands/daemon/status.test.ts | 31 - packages/cli/src/commands/daemon/status.ts | 570 +++---------- packages/cli/src/commands/daemon/stop.ts | 151 ++-- packages/cli/src/commands/heartbeat/index.ts | 6 +- packages/cli/src/commands/hub/connect.ts | 3 +- .../cli/src/commands/hub/daemon-client.ts | 9 +- packages/cli/src/commands/hub/disconnect.ts | 3 +- packages/cli/src/commands/hub/index.ts | 10 +- packages/cli/src/commands/hub/init.ts | 59 +- packages/cli/src/commands/hub/login.ts | 10 +- packages/cli/src/commands/hub/logout.ts | 13 +- packages/cli/src/commands/hub/permissions.ts | 5 +- packages/cli/src/commands/onboard.ts | 332 ++------ packages/cli/src/commands/open.ts | 1 + packages/cli/src/commands/permit/allow.ts | 17 +- packages/cli/src/commands/permit/deny.ts | 17 +- packages/cli/src/commands/permit/ls.ts | 17 +- .../cli/src/commands/plugin/index.test.ts | 16 +- packages/cli/src/commands/plugin/index.ts | 18 +- packages/cli/src/commands/plugin/shared.ts | 17 +- packages/cli/src/commands/project/create.ts | 7 +- packages/cli/src/commands/project/delete.ts | 5 +- packages/cli/src/commands/project/ls.ts | 4 +- .../cli/src/commands/project/project.test.ts | 24 +- packages/cli/src/commands/project/rename.ts | 5 +- .../cli/src/commands/provider/diagnostic.ts | 2 +- packages/cli/src/commands/provider/ls.ts | 33 +- packages/cli/src/commands/provider/models.ts | 2 +- packages/cli/src/commands/schedule/create.ts | 4 +- packages/cli/src/commands/schedule/delete.ts | 2 +- packages/cli/src/commands/schedule/inspect.ts | 2 +- packages/cli/src/commands/schedule/logs.ts | 2 +- packages/cli/src/commands/schedule/ls.ts | 2 +- packages/cli/src/commands/schedule/pause.ts | 2 +- packages/cli/src/commands/schedule/resume.ts | 2 +- .../cli/src/commands/schedule/run-once.ts | 2 +- .../cli/src/commands/schedule/shared.test.ts | 44 +- packages/cli/src/commands/schedule/shared.ts | 13 +- packages/cli/src/commands/schedule/update.ts | 2 +- packages/cli/src/commands/script/ls.ts | 2 +- packages/cli/src/commands/script/shared.ts | 7 +- packages/cli/src/commands/script/start.ts | 2 +- packages/cli/src/commands/script/stop.ts | 2 +- packages/cli/src/commands/terminal/capture.ts | 8 +- packages/cli/src/commands/terminal/create.ts | 2 +- packages/cli/src/commands/terminal/kill.ts | 2 +- packages/cli/src/commands/terminal/ls.ts | 2 +- .../cli/src/commands/terminal/send-keys.ts | 8 +- packages/cli/src/commands/terminal/shared.ts | 8 +- .../cli/src/commands/workspace/archive.ts | 7 +- packages/cli/src/commands/workspace/create.ts | 5 +- packages/cli/src/commands/workspace/ls.ts | 6 +- packages/cli/src/commands/workspace/rename.ts | 6 +- packages/cli/src/commands/workspace/setup.ts | 6 +- .../cli/src/commands/worktree/archive.test.ts | 23 +- packages/cli/src/commands/worktree/archive.ts | 5 +- packages/cli/src/commands/worktree/create.ts | 16 +- packages/cli/src/commands/worktree/ls.ts | 38 +- packages/cli/src/output/with-output.ts | 2 + packages/cli/src/run.ts | 13 +- packages/cli/src/utils/client-id.ts | 27 +- packages/cli/src/utils/client.ssh.test.ts | 4 +- packages/cli/src/utils/client.ts | 227 ++--- .../cli/src/utils/command-options.test.ts | 25 +- packages/cli/src/utils/command-options.ts | 27 +- packages/cli/src/utils/daemon-target.test.ts | 32 + packages/cli/src/utils/daemon-target.ts | 51 ++ packages/cli/tests/03-daemon.test.ts | 88 +- packages/cli/tests/04-agent-ls.test.ts | 36 +- packages/cli/tests/05-agent-run.test.ts | 37 +- packages/cli/tests/06-agent-send.test.ts | 23 +- packages/cli/tests/07-agent-stop.test.ts | 17 +- packages/cli/tests/08-agent-logs.test.ts | 19 +- packages/cli/tests/09-agent-inspect.test.ts | 15 +- packages/cli/tests/10-agent-mode.test.ts | 17 +- packages/cli/tests/11-agent-archive.test.ts | 15 +- packages/cli/tests/11-agent-wait.test.ts | 15 +- packages/cli/tests/12-permit-ls.test.ts | 11 +- .../cli/tests/13-permit-allow-deny.test.ts | 23 +- packages/cli/tests/14-worktree.test.ts | 19 +- packages/cli/tests/16-agent-update.test.ts | 14 +- packages/cli/tests/17-onboard.test.ts | 22 +- .../cli/tests/18-local-daemon-utils.test.ts | 53 -- .../cli/tests/20-worktree-ls-paths.test.ts | 38 - .../tests/22-daemon-stop-supervisor.test.ts | 5 +- .../25-daemon-restart-supervisor.test.ts | 201 +++-- packages/cli/tests/27-agent-delete.test.ts | 19 +- .../cli/tests/28-client-ipc-targets.test.ts | 76 +- .../cli/tests/32-daemon-set-password.test.ts | 7 +- .../tests/33-daemon-stop-tree-kill.test.ts | 6 +- .../cli/tests/34-daemon-status-auth.test.ts | 81 +- .../34-daemon-stop-stale-reachable.test.ts | 43 +- ...emon-status-windows-process-lookup.test.ts | 44 +- .../cli/tests/e2e/plugin-lifecycle.test.ts | 4 +- packages/cli/tests/helpers/local-cli.ts | 14 +- packages/cli/tests/helpers/test-daemon.ts | 54 +- packages/cli/tests/run-all.ts | 8 +- packages/cli/tests/setup.ts | 38 +- packages/client/src/daemon-client.test.ts | 2 +- packages/client/src/daemon-client.ts | 47 +- packages/desktop/bin/paseo | 4 +- .../daemon-lifecycle-renderer.electron.mjs | 164 ++++ packages/desktop/e2e/daemon-lifecycle.e2e.mjs | 212 +++++ packages/desktop/e2e/packaged-app-smoke.js | 25 +- packages/desktop/e2e/support/runtime.ts | 28 +- packages/desktop/e2e/updates.spec.ts | 72 +- packages/desktop/package.json | 3 +- .../desktop/src/daemon/daemon-manager.test.ts | 427 +--------- packages/desktop/src/daemon/daemon-manager.ts | 313 +++---- .../server/scripts/supervisor-entrypoint.ts | 11 +- .../server/scripts/supervisor.logging.test.ts | 5 +- .../scripts/supervisor.readiness.test.ts | 122 +++ packages/server/scripts/supervisor.ts | 93 ++- packages/server/src/server/bootstrap.ts | 4 +- .../server/src/server/config-environment.ts | 90 ++ packages/server/src/server/config.ts | 3 +- packages/server/src/server/daemon-instance.ts | 305 +++++++ packages/server/src/server/daemon-worker.ts | 7 +- packages/server/src/server/exports.ts | 20 + packages/server/src/server/paseo-home.test.ts | 16 +- packages/server/src/server/paseo-home.ts | 2 - .../src/server/persisted-config-edit.test.ts | 45 + .../src/server/persisted-config.test.ts | 8 +- .../server/src/server/persisted-config.ts | 78 ++ packages/server/src/server/pid-lock.test.ts | 36 +- packages/server/src/server/pid-lock.ts | 96 +-- .../workspace-git-service.observation.test.ts | 25 +- public-docs/cli.md | 57 +- public-docs/configuration.md | 38 +- public-docs/connectivity.md | 2 +- public-docs/docker.md | 2 + public-docs/web-ui.md | 18 +- 195 files changed, 4418 insertions(+), 4807 deletions(-) create mode 100644 packages/app/e2e/browser/daemon-lifecycle.spec.ts create mode 100644 packages/app/e2e/support/helpers/daemon-lifecycle.ts create mode 100644 packages/app/src/screens/settings/daemon-lifecycle.test.ts create mode 100644 packages/app/src/screens/settings/daemon-lifecycle.ts delete mode 100644 packages/app/src/screens/settings/daemon-restart.test.ts delete mode 100644 packages/app/src/screens/settings/daemon-restart.ts create mode 100644 packages/cli/src/commands/daemon/config.ts create mode 100644 packages/cli/src/commands/daemon/lifecycle.e2e.test.ts delete mode 100644 packages/cli/src/commands/daemon/local-daemon.supervision.test.ts delete mode 100644 packages/cli/src/commands/daemon/status.test.ts create mode 100644 packages/cli/src/utils/daemon-target.test.ts create mode 100644 packages/cli/src/utils/daemon-target.ts delete mode 100644 packages/cli/tests/18-local-daemon-utils.test.ts delete mode 100644 packages/cli/tests/20-worktree-ls-paths.test.ts create mode 100644 packages/desktop/e2e/daemon-lifecycle-renderer.electron.mjs create mode 100644 packages/desktop/e2e/daemon-lifecycle.e2e.mjs create mode 100644 packages/server/scripts/supervisor.readiness.test.ts create mode 100644 packages/server/src/server/config-environment.ts create mode 100644 packages/server/src/server/daemon-instance.ts create mode 100644 packages/server/src/server/persisted-config-edit.test.ts diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index df4b86f9f1..9a2e293848 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -201,12 +201,35 @@ jobs: - name: Install virtual display if: ${{ runner.os == 'Linux' }} - run: sudo apt-get update && sudo apt-get install -y xvfb xauth + run: sudo apt-get update && sudo apt-get install -y xvfb xauth xdotool - name: Install Playwright browser if: ${{ runner.os == 'Linux' }} run: npx playwright install chromium + - name: Verify Windows daemon process control + if: ${{ runner.os == 'Windows' }} + run: npx vitest run packages/cli/src/commands/daemon/lifecycle.e2e.test.ts --bail=1 --maxWorkers=1 -t "Windows unbound|managed two-home" + + - name: Run desktop lifecycle E2E (Linux) + if: ${{ runner.os == 'Linux' }} + run: xvfb-run -a --server-args="-screen 0 1280x800x24" npm run test:e2e:lifecycle --workspace=@getpaseo/desktop + env: + PASEO_DESKTOP_LIFECYCLE_ARTIFACT_DIR: ${{ runner.temp }}/desktop-lifecycle + + - name: Upload desktop lifecycle screenshots + uses: actions/upload-artifact@v4 + if: ${{ !cancelled() && runner.os == 'Linux' }} + with: + name: desktop-lifecycle + path: ${{ runner.temp }}/desktop-lifecycle + if-no-files-found: ignore + retention-days: 7 + + - name: Run desktop lifecycle E2E (Windows) + if: ${{ runner.os == 'Windows' }} + run: npm run test:e2e:lifecycle --workspace=@getpaseo/desktop + - name: Run desktop renderer E2E if: ${{ runner.os == 'Linux' }} run: npm run test:e2e:renderer --workspace=@getpaseo/desktop diff --git a/docs/architecture.md b/docs/architecture.md index f305407903..96d8cdcc56 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -145,7 +145,7 @@ traffic. Workspace assignments stay on the workspace directory sequence. Commander.js CLI with Docker-style commands. Common agent operations are also exposed at the top level (e.g. `paseo ls`, `paseo run`). - `paseo agent ls/run/import/attach/logs/stop/delete/send/inspect/wait/archive/reload/update/mode` -- `paseo daemon start/stop/restart/status/pair/set-password` +- `paseo daemon start/run/stop/restart/status/reload/config/pair/set-password` - `paseo terminal ls/create/capture/send-keys/kill` - `paseo script ls/start/stop` - `paseo schedule create/ls/inspect/update/pause/resume/run-once/logs/delete` @@ -429,12 +429,16 @@ $PASEO_HOME/ ├── config.json # Daemon config (mutable) ├── daemon-keypair.json # Daemon identity for relay/E2EE ├── push-tokens.json # Mobile push tokens -├── paseo.sock / paseo.pid # Local IPC socket and pidfile +├── paseo.pid # Supervisor identity and published bound endpoint └── daemon.log # Daemon trace logs (rotated) ``` +The supervisor alone publishes its ready worker's endpoint in `paseo.pid`, clears it before respawn, and fails if any worker exits before first readiness. CLI home selection trusts only that live record; config expresses desired state, never an endpoint fallback. POSIX home stop signals the captured supervisor without TCP. Windows graceful stop and ordinary RPCs trust the published endpoint. This metadata is not cryptographic listener ownership: edited endpoints, copied identities, PID reuse, and address takeover races remain outside that guarantee. A stale heartbeat never permits reclaiming a live lock. + +Worker restart retains supervisor arguments/environment and rereads the configuration file. Updating a package and observing its new worker version do not refresh the running supervisor code; the launcher owns full-process replacement. See [CLI lifecycle contracts](../public-docs/cli.md#daemon-management). + ## Deployment models 1. **Local daemon** (default): `paseo daemon start` on `127.0.0.1:6767` -2. **Managed desktop**: Electron app spawns daemon as subprocess, and stops it again on quit so that "restart the app" is a complete reset. Settings > Host > "Keep daemon running after quit" opts out. Only a daemon the desktop started is stopped — a daemon you started yourself with `paseo daemon start` is left alone (`paseo.pid` records `desktopManaged`). +2. **Managed desktop**: Electron uses the server package’s local-instance lifecycle capability for sanitized launch, published readiness, and captured-PID stop. Only a matching `{pid, startedAt}` from a spawn in the current Desktop session authorizes automatic stop or binary replacement. Preexisting instances, including legacy `desktopManaged` records, are attach-only. Keep-running survivors attach in the next session. Explicit attached Stop requires a confirmation naming the captured home/PID. Ordinary Restart uses the worker RPC on every app platform. 3. **Remote + relay**: Daemon behind firewall, relay bridges with E2E encryption diff --git a/docs/development.md b/docs/development.md index 2d8d86aa79..caba38adc9 100644 --- a/docs/development.md +++ b/docs/development.md @@ -444,16 +444,17 @@ so the port may already be bound. `portScript` takes precedence when both values The daemon can optionally serve the browser web client from the same HTTP server. This is disabled by default. -Enable it for a running daemon with: +Enable it in persistent configuration: ```bash -paseo daemon start --web-ui +paseo daemon config set features.webUi.enabled true +paseo daemon start ``` Or set the environment variable: ```bash -PASEO_WEB_UI_ENABLED=true paseo daemon start +PASEO_WEB_UI_ENABLED=true paseo daemon run ``` Or persist it in `config.json`: @@ -564,7 +565,7 @@ npm run cli -- --host ssh://user@host ls -a ``` Set `PASEO_HOST` to use the same target across invocations. An explicit -`--host` overrides the environment variable. +selector overrides both environment selectors. With both `PASEO_HOME` and `PASEO_HOST` set, pass an explicit selector. See [CLI target selection](../public-docs/cli.md#select-one-daemon). In an SSH URI, the URL port is the SSH server port. The remote daemon defaults to `127.0.0.1:6767`; use `?daemonPort=7777` to override it. The transport runs non-interactively through the local OpenSSH client and never installs, starts, or configures the remote daemon. User-facing setup and troubleshooting live in [public-docs/connectivity.md](../public-docs/connectivity.md#ssh). diff --git a/docs/docker.md b/docs/docker.md index 15e22081bb..058dee929a 100644 --- a/docs/docker.md +++ b/docs/docker.md @@ -22,6 +22,8 @@ the web UI. The served app receives a same-origin connection hint and connects back to that daemon. Static UI files load without daemon auth; API and WebSocket requests still require `PASEO_PASSWORD` when one is configured. +Host-side CLI commands select the container explicitly, for example `paseo project ls --host 127.0.0.1:6767`. Without an endpoint selector the CLI looks for a local home’s supervisor. Container environment settings are deployment overrides; worker restart preserves them. Your container manager owns full supervisor replacement. + ## Quick Start ```bash diff --git a/packages/app/e2e/browser/daemon-lifecycle.spec.ts b/packages/app/e2e/browser/daemon-lifecycle.spec.ts new file mode 100644 index 0000000000..440b22d3e0 --- /dev/null +++ b/packages/app/e2e/browser/daemon-lifecycle.spec.ts @@ -0,0 +1,27 @@ +import { randomUUID } from "node:crypto"; +import { expect, test } from "../support/fixtures"; +import { startIsolatedHostDaemon } from "../support/helpers/isolated-host-daemon"; +import { + openDaemonOverview, + restartDaemonInSettings, + expectDaemonRestartComplete, + readWorkerPid, +} from "../support/helpers/daemon-lifecycle"; + +test("settings restart waits for a replacement worker while retaining the supervisor", async ({ + page, +}, testInfo) => { + const daemon = await startIsolatedHostDaemon(randomUUID()); + try { + const supervisor = daemon.getPid(); + const previousWorker = await readWorkerPid(daemon); + await openDaemonOverview(page, daemon); + await restartDaemonInSettings(page); + await expectDaemonRestartComplete(page); + expect(await readWorkerPid(daemon)).not.toBe(previousWorker); + expect(daemon.getPid()).toBe(supervisor); + await page.screenshot({ path: testInfo.outputPath("restart-confirmed.png"), fullPage: true }); + } finally { + await daemon.close(); + } +}); diff --git a/packages/app/e2e/support/global-setup.ts b/packages/app/e2e/support/global-setup.ts index a7e25fb50b..a2511b6190 100644 --- a/packages/app/e2e/support/global-setup.ts +++ b/packages/app/e2e/support/global-setup.ts @@ -16,7 +16,8 @@ export interface WaitForServerOptions { type ServerProbe = (host: string, port: number) => Promise; const RESERVED_LOCAL_PORTS = new Set([ - 6767, // Developer daemon. + 6767, // Installed daemon. + 6768, // Developer daemon. 61680, // OpenCode's default local server. ]); diff --git a/packages/app/e2e/support/helpers/daemon-lifecycle.ts b/packages/app/e2e/support/helpers/daemon-lifecycle.ts new file mode 100644 index 0000000000..6779039f1f --- /dev/null +++ b/packages/app/e2e/support/helpers/daemon-lifecycle.ts @@ -0,0 +1,42 @@ +import { expect, type Page } from "@playwright/test"; +import type { DaemonClient } from "@getpaseo/client/internal/daemon-client"; +import { connectDaemonClient } from "./daemon-client-loader"; +import { openSettings } from "./app"; +import { openHostSection, openSettingsHost, seedSavedSettingsHosts } from "./settings"; +import type { IsolatedHostDaemon } from "./isolated-host-daemon"; + +export async function openDaemonOverview(page: Page, daemon: IsolatedHostDaemon) { + await seedSavedSettingsHosts(page, [ + { serverId: daemon.serverId, label: "Lifecycle host", endpoint: `127.0.0.1:${daemon.port}` }, + ]); + await page.reload(); + await openSettings(page); + await openSettingsHost(page, daemon.serverId); + await openHostSection(page, daemon.serverId, "host"); +} + +export async function restartDaemonInSettings(page: Page) { + const button = page.getByRole("button", { name: "Restart", exact: true }); + await expect(button).toBeEnabled(); + page.once("dialog", (dialog) => dialog.accept()); + await button.click(); + await expect(page.getByRole("button", { name: "Restarting...", exact: true })).toBeDisabled(); +} + +export async function expectDaemonRestartComplete(page: Page) { + await expect(page.getByRole("button", { name: "Restart", exact: true })).toBeEnabled({ + timeout: 30_000, + }); +} + +export async function readWorkerPid(daemon: IsolatedHostDaemon): Promise { + const client = await connectDaemonClient({ + port: daemon.port, + clientIdPrefix: "lifecycle-observer", + }); + try { + return (await client.getDaemonStatus()).pid; + } finally { + await client.close(); + } +} diff --git a/packages/app/e2e/support/helpers/isolated-host-daemon.ts b/packages/app/e2e/support/helpers/isolated-host-daemon.ts index da22a9ce16..1467f3d572 100644 --- a/packages/app/e2e/support/helpers/isolated-host-daemon.ts +++ b/packages/app/e2e/support/helpers/isolated-host-daemon.ts @@ -83,7 +83,7 @@ export async function startIsolatedHostDaemon( ): Promise { const primaryPort = Number(process.env.E2E_DAEMON_PORT ?? 0); let port = await getAvailablePort(); - while (port === 6767 || port === primaryPort) port = await getAvailablePort(); + while (port === 6767 || port === 6768 || port === primaryPort) port = await getAvailablePort(); const metroPort = process.env.E2E_METRO_PORT; if (!metroPort) throw new Error("E2E_METRO_PORT is required to start an isolated host daemon"); diff --git a/packages/app/e2e/support/helpers/packaged-web-daemon.ts b/packages/app/e2e/support/helpers/packaged-web-daemon.ts index 33182df0d0..cd622ff27a 100644 --- a/packages/app/e2e/support/helpers/packaged-web-daemon.ts +++ b/packages/app/e2e/support/helpers/packaged-web-daemon.ts @@ -1,5 +1,5 @@ import { execFile } from "node:child_process"; -import { mkdtemp, readFile, rm } from "node:fs/promises"; +import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; import net from "node:net"; import { tmpdir } from "node:os"; import path from "node:path"; @@ -59,23 +59,37 @@ export async function startPackagedWebDaemon(input: { const serverId = `relay-deployment-${Date.now().toString(36)}`; const paseo = path.resolve(__dirname, "../../../../../node_modules/.bin/paseo"); const env: NodeJS.ProcessEnv = { - ...process.env, + ...Object.fromEntries(Object.entries(process.env).filter(([key]) => !key.startsWith("PASEO_"))), + HOME: home, + USERPROFILE: home, CI: "true", NODE_ENV: "development", PASEO_NODE_ENV: "development", PASEO_SERVER_ID: serverId, - PASEO_RELAY_ENDPOINT: input.relayEndpoint, - PASEO_RELAY_PUBLIC_ENDPOINT: input.relayEndpoint, - PASEO_RELAY_USE_TLS: "false", - PASEO_RELAY_PUBLIC_USE_TLS: "false", }; try { - await execFileAsync( - paseo, - ["daemon", "start", "--home", home, "--port", String(port), "--relay", "--web-ui"], - { env }, + await writeFile( + path.join(home, "config.json"), + JSON.stringify({ + daemon: { + listen: `127.0.0.1:${port}`, + relay: { + enabled: true, + endpoint: input.relayEndpoint, + publicEndpoint: input.relayEndpoint, + useTls: false, + publicUseTls: false, + }, + }, + features: { + webUi: { enabled: true }, + dictation: { enabled: false }, + voiceMode: { enabled: false }, + }, + }), ); + await execFileAsync(paseo, ["daemon", "start", "--home", home], { env }); const origin = `http://127.0.0.1:${port}`; await waitForWebUi(origin, home); diff --git a/packages/app/src/desktop/components/desktop-updates-section.tsx b/packages/app/src/desktop/components/desktop-updates-section.tsx index 1dec26d5dd..bb5c1536a6 100644 --- a/packages/app/src/desktop/components/desktop-updates-section.tsx +++ b/packages/app/src/desktop/components/desktop-updates-section.tsx @@ -12,7 +12,11 @@ import { Button } from "@/components/ui/button"; import { Switch } from "@/components/ui/switch"; import { openExternalUrl } from "@/utils/open-external-url"; import { isVersionMismatch } from "@/desktop/updates/desktop-updates"; -import { getCliDaemonStatus, shouldUseDesktopDaemon } from "@/desktop/daemon/desktop-daemon"; +import { + getCliDaemonStatus, + shouldUseDesktopDaemon, + confirmAndStopDesktopDaemon, +} from "@/desktop/daemon/desktop-daemon"; import { useBuiltInDaemonManagement } from "@/desktop/hooks/use-built-in-daemon-management"; import { useDaemonStatus } from "@/desktop/hooks/use-daemon-status"; import { useDesktopSettings, type DesktopSettings } from "@/desktop/settings/desktop-settings"; @@ -330,6 +334,17 @@ export function LocalDaemonSection() { const daemonStatus = data?.status ?? null; const daemonLogs = data?.logs ?? null; const daemonVersion = daemonStatus?.version ?? null; + const [isStopping, setIsStopping] = useState(false); + const handleStop = useCallback(() => { + setIsStopping(true); + void confirmAndStopDesktopDaemon() + .then((status) => { + if (status) setStatus(status); + return undefined; + }) + .catch((error) => Alert.alert(t("desktop.daemon.lifecycle.stopFailed"), String(error))) + .finally(() => setIsStopping(false)); + }, [setStatus, t]); const daemonVersionMismatch = isVersionMismatch(appVersion, daemonVersion); const daemonStatusStateText = @@ -445,6 +460,26 @@ export function LocalDaemonSection() { isLoadingCliStatus={isLoadingCliStatus} /> + {daemonStatus?.pid ? ( + + + + + {daemonStatus.ownedByDesktop + ? t("desktop.daemon.lifecycle.owned") + : t("desktop.daemon.lifecycle.attached")} + + {daemonStatus.home} + + + + + ) : null} + {daemonVersionMismatch ? ( {t("desktop.daemon.versionMismatch")} diff --git a/packages/app/src/desktop/daemon/daemon-management-toggle.test.ts b/packages/app/src/desktop/daemon/daemon-management-toggle.test.ts index c63f4e13de..831e40f25f 100644 --- a/packages/app/src/desktop/daemon/daemon-management-toggle.test.ts +++ b/packages/app/src/desktop/daemon/daemon-management-toggle.test.ts @@ -11,10 +11,16 @@ const runningManagedStatus: DesktopDaemonStatus = { home: "/tmp/paseo", version: "1.0.0", desktopManaged: true, + ownedByDesktop: true, + startedAt: "2026-01-01T00:00:00.000Z", error: null, }; -const stoppedStatus: DesktopDaemonStatus = { ...runningManagedStatus, status: "stopped" }; +const stoppedStatus: DesktopDaemonStatus = { + ...runningManagedStatus, + status: "stopped", + ownedByDesktop: false, +}; function makeDeps(overrides?: { confirm?: () => Promise; @@ -128,9 +134,9 @@ describe("executeDaemonManagementToggle", () => { expect(result).toEqual({ kind: "disabled", newStatus: stoppedStatus }); }); - it("skips stop when daemon is running but not desktop-managed", async () => { + it("skips stop for an attached daemon even with a legacy desktop-managed flag", async () => { const { deps, calls } = makeDeps(); - const manuallyManagedStatus = { ...runningManagedStatus, desktopManaged: false }; + const manuallyManagedStatus = { ...runningManagedStatus, ownedByDesktop: false }; const result = await executeDaemonManagementToggle(true, manuallyManagedStatus, deps); diff --git a/packages/app/src/desktop/daemon/daemon-management-toggle.ts b/packages/app/src/desktop/daemon/daemon-management-toggle.ts index efec0ad20c..ea08cbfcdb 100644 --- a/packages/app/src/desktop/daemon/daemon-management-toggle.ts +++ b/packages/app/src/desktop/daemon/daemon-management-toggle.ts @@ -14,7 +14,7 @@ export type DaemonManagementToggleResult = export async function executeDaemonManagementToggle( currentlyManaging: boolean, - daemonStatus: Pick | null, + daemonStatus: Pick | null, deps: DaemonManagementToggleDeps, ): Promise { if (!currentlyManaging) { @@ -32,7 +32,7 @@ export async function executeDaemonManagementToggle( // state reflects what was actually applied if the stop fails. await deps.persistSettings({ manageBuiltInDaemon: false }); - if (daemonStatus?.status === "running" && daemonStatus.desktopManaged) { + if (daemonStatus?.ownedByDesktop && ["running", "starting"].includes(daemonStatus.status)) { const newStatus = await deps.stopDaemon(); return { kind: "disabled", newStatus }; } diff --git a/packages/app/src/desktop/daemon/desktop-daemon.ts b/packages/app/src/desktop/daemon/desktop-daemon.ts index f465c32ac1..cc7574dc21 100644 --- a/packages/app/src/desktop/daemon/desktop-daemon.ts +++ b/packages/app/src/desktop/daemon/desktop-daemon.ts @@ -1,3 +1,5 @@ +import { i18n } from "@/i18n/i18next"; +import { confirmDialog } from "@/utils/confirm-dialog"; import { getDesktopHost, isElectronRuntime } from "@/desktop/host"; import { invokeDesktopCommand } from "@/desktop/electron/invoke"; import type { AgentSkillSelection } from "@getpaseo/protocol/messages"; @@ -21,6 +23,8 @@ export interface DesktopDaemonStatus { home: string; version: string | null; desktopManaged: boolean; + ownedByDesktop: boolean; + startedAt: string | null; error: string | null; } @@ -128,6 +132,8 @@ function parseDesktopDaemonStatus(raw: unknown): DesktopDaemonStatus { home: toStringOrNull(raw.home) ?? "", version: toStringOrNull(raw.version), desktopManaged: raw.desktopManaged === true, + ownedByDesktop: raw.ownedByDesktop === true, + startedAt: typeof raw.startedAt === "string" ? raw.startedAt : null, error: toStringOrNull(raw.error), }; } @@ -190,6 +196,33 @@ export async function stopDesktopDaemon( return parseDesktopDaemonStatus(await invokeDesktopCommand("stop_desktop_daemon", { reason })); } +export async function confirmAndStopDesktopDaemon(): Promise { + const captured = await getDesktopDaemonStatus(); + if (!captured.pid || !captured.startedAt) return captured; + const ownership = captured.ownedByDesktop + ? i18n.t("desktop.daemon.lifecycle.ownedMessage") + : i18n.t("desktop.daemon.lifecycle.attachedMessage"); + const confirmed = await confirmDialog({ + title: i18n.t("desktop.daemon.lifecycle.stopTitle"), + message: i18n.t("desktop.daemon.lifecycle.stopMessage", { + ownership, + home: captured.home, + pid: captured.pid, + }), + confirmLabel: i18n.t("desktop.daemon.lifecycle.stop"), + cancelLabel: i18n.t("common.actions.cancel"), + destructive: true, + }); + if (!confirmed) return null; + return parseDesktopDaemonStatus( + await invokeDesktopCommand("stop_desktop_daemon", { + reason: "manual_ipc", + pid: captured.pid, + startedAt: captured.startedAt, + }), + ); +} + export async function restartDesktopDaemon(): Promise { return parseDesktopDaemonStatus(await invokeDesktopCommand("restart_desktop_daemon")); } diff --git a/packages/app/src/desktop/hooks/use-built-in-daemon-management.ts b/packages/app/src/desktop/hooks/use-built-in-daemon-management.ts index 16076b4d6d..71bbce378c 100644 --- a/packages/app/src/desktop/hooks/use-built-in-daemon-management.ts +++ b/packages/app/src/desktop/hooks/use-built-in-daemon-management.ts @@ -58,8 +58,16 @@ export function useBuiltInDaemonManagement( confirm: () => confirmDialog({ title: t("desktop.daemon.management.pauseTitle"), - message: t("desktop.daemon.management.pauseMessage"), - confirmLabel: t("desktop.daemon.management.pauseAndStop"), + message: t( + daemonStatus?.ownedByDesktop + ? "desktop.daemon.management.pauseMessage" + : "desktop.daemon.lifecycle.pauseAttached", + ), + confirmLabel: t( + daemonStatus?.ownedByDesktop + ? "desktop.daemon.management.pauseAndStop" + : "desktop.daemon.lifecycle.pause", + ), cancelLabel: t("common.actions.cancel"), destructive: true, }), diff --git a/packages/app/src/diagnostics/desktop-diagnostic-report.test.ts b/packages/app/src/diagnostics/desktop-diagnostic-report.test.ts index 1ffa37b950..34c4482a4c 100644 --- a/packages/app/src/diagnostics/desktop-diagnostic-report.test.ts +++ b/packages/app/src/diagnostics/desktop-diagnostic-report.test.ts @@ -15,6 +15,8 @@ function makeSources(): DesktopDiagnosticSources { home: "/paseo/home", version: "1.2.3", desktopManaged: true, + ownedByDesktop: true, + startedAt: "2026-01-01T00:00:00.000Z", error: null, }), getDaemonLogs: async () => ({ diff --git a/packages/app/src/i18n/resources/ar.ts b/packages/app/src/i18n/resources/ar.ts index 30adccccd7..728374a242 100644 --- a/packages/app/src/i18n/resources/ar.ts +++ b/packages/app/src/i18n/resources/ar.ts @@ -1274,6 +1274,7 @@ export const ar: TranslationResources = { detail: "إيقاف البرنامج الخفي المحلي.", }, daemon: { + lifecycle: en.desktop.daemon.lifecycle, title: "Daemon", status: { title: "حالة", diff --git a/packages/app/src/i18n/resources/en.ts b/packages/app/src/i18n/resources/en.ts index e738f13e82..3046971400 100644 --- a/packages/app/src/i18n/resources/en.ts +++ b/packages/app/src/i18n/resources/en.ts @@ -1284,6 +1284,23 @@ export const en = { }, daemon: { title: "Daemon", + lifecycle: { + owned: "Launched by this Desktop session", + attached: "Attached to an existing daemon", + ownedMessage: "This daemon was launched by this Desktop session.", + attachedMessage: "This daemon was not launched by this Desktop session.", + stopTitle: "Stop local daemon?", + stopMessage: + "{{ownership}}\nHome: {{home}}\nSupervisor PID: {{pid}}\nRunning agent work will be interrupted.", + stop: "Stop daemon", + stopping: "Stopping…", + stopFailed: "Unable to stop daemon", + pauseAttached: "Pause automatic daemon management? The attached daemon will keep running.", + pause: "Pause management", + workerUpdated: "Worker updated to {{version}}", + supervisorRefresh: + "The running supervisor retains its original code. Its launcher must stop and start it to refresh the supervisor.", + }, status: { title: "Status", builtInOnly: "Only the built-in desktop daemon is shown here", diff --git a/packages/app/src/i18n/resources/es.ts b/packages/app/src/i18n/resources/es.ts index 2c4abbc907..0eb0e3b092 100644 --- a/packages/app/src/i18n/resources/es.ts +++ b/packages/app/src/i18n/resources/es.ts @@ -1308,6 +1308,7 @@ export const es: TranslationResources = { detail: "Deteniendo el demonio local.", }, daemon: { + lifecycle: en.desktop.daemon.lifecycle, title: "Daemon", status: { title: "Estado", diff --git a/packages/app/src/i18n/resources/fr.ts b/packages/app/src/i18n/resources/fr.ts index 6b26d97812..e46c5701ec 100644 --- a/packages/app/src/i18n/resources/fr.ts +++ b/packages/app/src/i18n/resources/fr.ts @@ -1308,6 +1308,7 @@ export const fr: TranslationResources = { detail: "Arrêt du démon local.", }, daemon: { + lifecycle: en.desktop.daemon.lifecycle, title: "Daemon", status: { title: "Statut", diff --git a/packages/app/src/i18n/resources/ja.ts b/packages/app/src/i18n/resources/ja.ts index bcb85cd9a6..09d32471a3 100644 --- a/packages/app/src/i18n/resources/ja.ts +++ b/packages/app/src/i18n/resources/ja.ts @@ -1286,6 +1286,7 @@ export const ja: TranslationResources = { detail: "ローカルデーモンを停止中。", }, daemon: { + lifecycle: en.desktop.daemon.lifecycle, title: "デーモン", status: { title: "ステータス", diff --git a/packages/app/src/i18n/resources/ko.ts b/packages/app/src/i18n/resources/ko.ts index 6b995ffc72..8d9ca884b0 100644 --- a/packages/app/src/i18n/resources/ko.ts +++ b/packages/app/src/i18n/resources/ko.ts @@ -1281,6 +1281,7 @@ export const ko: TranslationResources = { detail: "로컬 데몬을 중지하는 중입니다.", }, daemon: { + lifecycle: en.desktop.daemon.lifecycle, title: "데몬", status: { title: "상태", diff --git a/packages/app/src/i18n/resources/pt-BR.ts b/packages/app/src/i18n/resources/pt-BR.ts index e6b1acdb32..75dcd62990 100644 --- a/packages/app/src/i18n/resources/pt-BR.ts +++ b/packages/app/src/i18n/resources/pt-BR.ts @@ -1299,6 +1299,7 @@ export const ptBR: TranslationResources = { detail: "Parando o daemon local.", }, daemon: { + lifecycle: en.desktop.daemon.lifecycle, title: "Daemon", status: { title: "Status", diff --git a/packages/app/src/i18n/resources/ru.ts b/packages/app/src/i18n/resources/ru.ts index 23b29edb13..5cd4942ef4 100644 --- a/packages/app/src/i18n/resources/ru.ts +++ b/packages/app/src/i18n/resources/ru.ts @@ -1291,6 +1291,7 @@ export const ru: TranslationResources = { detail: "Остановка локального демона.", }, daemon: { + lifecycle: en.desktop.daemon.lifecycle, title: "Демон", status: { title: "Статус", diff --git a/packages/app/src/i18n/resources/zh-CN.ts b/packages/app/src/i18n/resources/zh-CN.ts index 32442aba5f..6c908d90ad 100644 --- a/packages/app/src/i18n/resources/zh-CN.ts +++ b/packages/app/src/i18n/resources/zh-CN.ts @@ -1263,6 +1263,7 @@ export const zhCN: TranslationResources = { detail: "正在停止本地 daemon。", }, daemon: { + lifecycle: en.desktop.daemon.lifecycle, title: "Daemon", status: { title: "状态", diff --git a/packages/app/src/runtime/daemon-start-service.test.ts b/packages/app/src/runtime/daemon-start-service.test.ts index a988a09e0e..e9e76fe1ad 100644 --- a/packages/app/src/runtime/daemon-start-service.test.ts +++ b/packages/app/src/runtime/daemon-start-service.test.ts @@ -36,6 +36,8 @@ function makeStatus(overrides: Partial = {}): DesktopDaemon home: "/home", version: "0.0.0", desktopManaged: true, + ownedByDesktop: true, + startedAt: "2026-01-01T00:00:00.000Z", error: null, ...overrides, }; diff --git a/packages/app/src/screens/settings/daemon-lifecycle.test.ts b/packages/app/src/screens/settings/daemon-lifecycle.test.ts new file mode 100644 index 0000000000..c50006534a --- /dev/null +++ b/packages/app/src/screens/settings/daemon-lifecycle.test.ts @@ -0,0 +1,98 @@ +import { expect, test } from "vitest"; +import { restartDaemonFromSettings, updateDaemonFromSettings } from "./daemon-lifecycle"; + +test("settings restart completes when a replacement worker is observed without a sampled disconnect", async () => { + let pid = 10; + await restartDaemonFromSettings("daemon", "settings", { + getStatus: async () => ({ pid, version: "1.0.0", serverId: "daemon" }), + restartServer: async () => { + pid = 11; + }, + }); + expect(pid).toBe(11); +}); + +test("a status permission failure prevents the restart request", async () => { + let restarted = false; + await expect( + restartDaemonFromSettings("daemon", "settings", { + getStatus: async () => { + throw new Error("Permission denied"); + }, + restartServer: async () => { + restarted = true; + }, + }), + ).rejects.toThrow("Permission denied"); + expect(restarted).toBe(false); +}); + +test("a different responder fails confirmation immediately", async () => { + let serverId = "daemon"; + await expect( + restartDaemonFromSettings("daemon", "settings", { + getStatus: async () => ({ pid: 10, version: "1.0.0", serverId }), + restartServer: async () => { + serverId = "other"; + }, + }), + ).rejects.toThrow("identity changed"); +}); + +test("a lost restart acknowledgment can still confirm the replacement", async () => { + let pid = 10; + await restartDaemonFromSettings("daemon", "settings", { + getStatus: async () => ({ pid, version: "1.0.0", serverId: "daemon" }), + restartServer: async () => { + pid = 11; + throw Object.assign(new Error("Connection lost"), { code: "DAEMON_CONNECTION_LOST" }); + }, + }); + expect(pid).toBe(11); +}); + +test("installation and worker version confirmation are separate outcomes", async () => { + let pid = 10; + await expect( + updateDaemonFromSettings("daemon", { + getStatus: async () => ({ pid, version: "1.0.0", serverId: "daemon" }), + updateDaemon: async () => { + pid = 11; + return { success: true, error: null, newVersion: "2.0.0" }; + }, + }), + ).rejects.toThrow("Package installed; replacement worker version was not confirmed"); +}); + +test("an installed version is confirmed only in its replacement worker", async () => { + let pid = 10, + version = "1.0.0"; + await expect( + updateDaemonFromSettings("daemon", { + getStatus: async () => ({ pid, version, serverId: "daemon" }), + updateDaemon: async () => { + pid = 11; + version = "2.0.0"; + return { success: true, error: null, newVersion: version }; + }, + }), + ).resolves.toEqual({ workerVersion: "2.0.0" }); +}); + +test("RPC errors mentioning transport are not retried", async () => { + let requested = false; + await expect( + restartDaemonFromSettings("daemon", "settings", { + getStatus: async () => { + if (requested) + throw Object.assign(new Error("Connection policy denied by plugin"), { + code: "permission_denied", + }); + return { pid: 10, version: "1.0.0", serverId: "daemon" }; + }, + restartServer: async () => { + requested = true; + }, + }), + ).rejects.toThrow("Connection policy denied by plugin"); +}); diff --git a/packages/app/src/screens/settings/daemon-lifecycle.ts b/packages/app/src/screens/settings/daemon-lifecycle.ts new file mode 100644 index 0000000000..c4e9d83f5a --- /dev/null +++ b/packages/app/src/screens/settings/daemon-lifecycle.ts @@ -0,0 +1,94 @@ +interface WorkerStatus { + pid: number; + serverId: string; + version: string | null; +} +interface StatusReader { + getStatus: () => Promise; +} + +export interface SettingsDaemonRestartDeps extends StatusReader { + restartServer: (reason: string) => Promise; +} + +/** Acknowledgment starts the wait; only a different ready worker completes it. */ +export async function restartDaemonFromSettings( + hostServerId: string, + reason: string, + deps: SettingsDaemonRestartDeps, +): Promise { + const previous = await readSelectedWorker(hostServerId, deps); + let acknowledged = false; + try { + await deps.restartServer(reason); + acknowledged = true; + } catch (error) { + if (!isReconnectFailure(error)) throw error; + } + try { + await observeReplacement(previous, deps); + } catch (error) { + throw new Error(`Restart acknowledged: ${acknowledged}. ${String(error)}`, { cause: error }); + } +} + +export async function updateDaemonFromSettings( + hostServerId: string, + deps: StatusReader & { + updateDaemon: () => Promise<{ + success: boolean; + error: string | null; + newVersion: string | null; + }>; + }, +): Promise<{ workerVersion: string }> { + const previous = await readSelectedWorker(hostServerId, deps); + const installed = await deps.updateDaemon(); + if (!installed.success) throw new Error(installed.error ?? "Package installation failed"); + try { + const worker = await observeReplacement(previous, deps); + if (!worker.version || !installed.newVersion || worker.version !== installed.newVersion) { + throw new Error( + `Expected installed version ${installed.newVersion ?? "unknown"}; observed worker ${worker.version}.`, + ); + } + return { workerVersion: worker.version }; + } catch (error) { + throw new Error( + `Package installed; replacement worker version was not confirmed. ${String(error)}`, + { cause: error }, + ); + } +} + +async function readSelectedWorker(serverId: string, deps: StatusReader): Promise { + const worker = await deps.getStatus(); + if (worker.serverId !== serverId) throw new Error("Daemon identity changed"); + return worker; +} + +async function observeReplacement( + previous: WorkerStatus, + deps: StatusReader, +): Promise { + const deadline = Date.now() + 600_000; + while (Date.now() < deadline) { + try { + const current = await readSelectedWorker(previous.serverId, deps); + if (current.pid !== previous.pid) return current; + } catch (error) { + if (!isReconnectFailure(error)) throw error; + } + await new Promise((resolve) => setTimeout(resolve, 250)); + } + throw new Error("Replacement worker could not be confirmed. Check daemon status and logs."); +} + +function isReconnectFailure(error: unknown): boolean { + return Boolean( + error && + typeof error === "object" && + "code" in error && + ["DAEMON_CONNECTION_LOST", "DAEMON_REQUEST_TIMEOUT"].includes(String(error.code)), + ); +} diff --git a/packages/app/src/screens/settings/daemon-restart.test.ts b/packages/app/src/screens/settings/daemon-restart.test.ts deleted file mode 100644 index 51b6c4d121..0000000000 --- a/packages/app/src/screens/settings/daemon-restart.test.ts +++ /dev/null @@ -1,131 +0,0 @@ -import { describe, expect, it } from "vitest"; -import type { DesktopDaemonStatus } from "@/desktop/daemon/desktop-daemon"; -import { restartDaemonFromSettings, type SettingsDaemonRestartDeps } from "./daemon-restart"; - -const runningDesktopDaemonStatus: DesktopDaemonStatus = { - serverId: "local-desktop", - status: "running", - listen: null, - hostname: null, - pid: 123, - home: "/tmp/paseo", - version: "1.0.0", - desktopManaged: true, - error: null, -}; - -const desktopSettings = { - daemon: { - manageBuiltInDaemon: true, - }, -}; - -function makeDeps(overrides?: { - isElectron?: boolean; - desktopDaemonStatus?: DesktopDaemonStatus; - desktopSettings?: typeof desktopSettings; - desktopSettingsError?: Error; - restartDesktopDaemon?: () => Promise; - restartServer?: (reason: string) => Promise; -}) { - const calls: string[] = []; - const deps: SettingsDaemonRestartDeps = { - getIsElectron: () => overrides?.isElectron ?? true, - getDesktopDaemonStatus: async () => { - calls.push("desktop-status"); - return overrides?.desktopDaemonStatus ?? runningDesktopDaemonStatus; - }, - getDesktopSettings: async () => { - calls.push("desktop-settings"); - if (overrides?.desktopSettingsError) { - throw overrides.desktopSettingsError; - } - return overrides?.desktopSettings ?? desktopSettings; - }, - restartDesktopDaemon: - overrides?.restartDesktopDaemon ?? - (async () => { - calls.push("desktop-restart"); - return runningDesktopDaemonStatus; - }), - restartServer: - overrides?.restartServer ?? - (async (reason) => { - calls.push(`rpc-restart:${reason}`); - }), - }; - return { calls, deps }; -} - -describe("restartDaemonFromSettings", () => { - it("restarts the local desktop-managed daemon through the desktop bridge", async () => { - const { calls, deps } = makeDeps(); - - await restartDaemonFromSettings(" local-desktop ", "settings_daemon_restart_local", deps); - - expect(calls).toEqual(["desktop-status", "desktop-settings", "desktop-restart"]); - }); - - it("restarts remote hosts over the daemon RPC without reading desktop settings", async () => { - const { calls, deps } = makeDeps({ - desktopSettingsError: new Error("Unreadable desktop settings."), - }); - - await restartDaemonFromSettings("remote-host", "settings_daemon_restart_remote", deps); - - expect(calls).toEqual(["desktop-status", "rpc-restart:settings_daemon_restart_remote"]); - }); - - it("keeps manually managed local daemons on the RPC path without reading desktop settings", async () => { - const { calls, deps } = makeDeps({ - desktopDaemonStatus: { ...runningDesktopDaemonStatus, desktopManaged: false }, - desktopSettingsError: new Error("Unreadable desktop settings."), - }); - - await restartDaemonFromSettings("local-desktop", "settings_daemon_restart_local", deps); - - expect(calls).toEqual(["desktop-status", "rpc-restart:settings_daemon_restart_local"]); - }); - - it("keeps the RPC path when built-in daemon management is disabled", async () => { - const { calls, deps } = makeDeps({ - desktopSettings: { - ...desktopSettings, - daemon: { ...desktopSettings.daemon, manageBuiltInDaemon: false }, - }, - }); - - await restartDaemonFromSettings("local-desktop", "settings_daemon_restart_local", deps); - - expect(calls).toEqual([ - "desktop-status", - "desktop-settings", - "rpc-restart:settings_daemon_restart_local", - ]); - }); - - it("uses the RPC path outside Electron without reading desktop daemon status or settings", async () => { - const { calls, deps } = makeDeps({ - isElectron: false, - }); - - await restartDaemonFromSettings("local-desktop", "settings_daemon_restart_local", deps); - - expect(calls).toEqual(["rpc-restart:settings_daemon_restart_local"]); - }); - - it("surfaces desktop restart failures without falling back to worker recycle", async () => { - const { calls, deps } = makeDeps({ - restartDesktopDaemon: async () => { - calls.push("desktop-restart"); - throw new Error("Desktop restart failed."); - }, - }); - - await expect( - restartDaemonFromSettings("local-desktop", "settings_daemon_restart_local", deps), - ).rejects.toThrow("Desktop restart failed."); - - expect(calls).toEqual(["desktop-status", "desktop-settings", "desktop-restart"]); - }); -}); diff --git a/packages/app/src/screens/settings/daemon-restart.ts b/packages/app/src/screens/settings/daemon-restart.ts deleted file mode 100644 index 61cce091e2..0000000000 --- a/packages/app/src/screens/settings/daemon-restart.ts +++ /dev/null @@ -1,57 +0,0 @@ -import type { DesktopDaemonStatus } from "@/desktop/daemon/desktop-daemon"; - -interface DesktopDaemonRestartStatus { - desktopManaged: boolean; - serverId: string; -} - -interface DesktopDaemonRestartSettings { - daemon: { - manageBuiltInDaemon: boolean; - }; -} - -export interface SettingsDaemonRestartDeps { - getIsElectron: () => boolean; - getDesktopDaemonStatus: () => Promise; - getDesktopSettings: () => Promise; - restartDesktopDaemon: () => Promise; - restartServer: (reason: string) => Promise; -} - -async function isLocalDesktopManagedDaemon( - hostServerId: string, - deps: SettingsDaemonRestartDeps, -): Promise { - if (!deps.getIsElectron()) { - return false; - } - - const desktopDaemonStatus = await deps.getDesktopDaemonStatus(); - if (!desktopDaemonStatus.desktopManaged) { - return false; - } - - const normalizedHostServerId = hostServerId.trim(); - const normalizedDesktopServerId = desktopDaemonStatus.serverId.trim(); - - if (normalizedHostServerId.length === 0 || normalizedHostServerId !== normalizedDesktopServerId) { - return false; - } - - const desktopSettings = await deps.getDesktopSettings(); - return desktopSettings.daemon.manageBuiltInDaemon; -} - -export async function restartDaemonFromSettings( - hostServerId: string, - reason: string, - deps: SettingsDaemonRestartDeps, -): Promise { - if (await isLocalDesktopManagedDaemon(hostServerId, deps)) { - await deps.restartDesktopDaemon(); - return; - } - - await deps.restartServer(reason); -} diff --git a/packages/app/src/screens/settings/host-page.tsx b/packages/app/src/screens/settings/host-page.tsx index ffe07c308e..23c7815294 100644 --- a/packages/app/src/screens/settings/host-page.tsx +++ b/packages/app/src/screens/settings/host-page.tsx @@ -33,16 +33,10 @@ import { ProfileDraft, TerminalProfileEditModal, } from "@/screens/settings/terminal-profile-edit-modal"; -import { getIsElectron } from "@/constants/platform"; -import { - getDesktopDaemonStatus, - restartDesktopDaemon, - startDesktopDaemon, - stopDesktopDaemon, -} from "@/desktop/daemon/desktop-daemon"; +import { startDesktopDaemon, stopDesktopDaemon } from "@/desktop/daemon/desktop-daemon"; import { LocalDaemonSection } from "@/desktop/components/desktop-updates-section"; import { useDaemonStatus } from "@/desktop/hooks/use-daemon-status"; -import { loadDesktopSettings, useDesktopSettings } from "@/desktop/settings/desktop-settings"; +import { useDesktopSettings } from "@/desktop/settings/desktop-settings"; import { PairDeviceModal } from "@/desktop/components/pair-device-modal"; import { useDaemonConfig } from "@/hooks/use-daemon-config"; import { useIsLocalDaemon } from "@/hooks/use-is-local-daemon"; @@ -72,8 +66,16 @@ import { ICON_SIZE } from "@/styles/theme"; import type { Theme } from "@/styles/theme"; import { getProviderIcon } from "@/components/provider-icons"; import { BrowserToolsOptInCard } from "./browser-tools-card"; -import { hasDaemonReconnectedAfter, type DaemonConnectionMarker } from "./daemon-reconnect"; -import { restartDaemonFromSettings } from "./daemon-restart"; +import { restartDaemonFromSettings, updateDaemonFromSettings } from "./daemon-lifecycle"; + +const ThemedRestart = withUnistyles(RotateCw); +const ThemedUpdate = withUnistyles(ArrowUpToLine); +const lifecycleIconProps = (theme: Theme) => ({ + size: theme.iconSize.sm, + color: theme.colors.foreground, +}); +const restartIcon = ; +const updateIcon = ; const ThemedArrowUp = withUnistyles(ArrowUp); const ThemedArrowDown = withUnistyles(ArrowDown); @@ -553,14 +555,8 @@ function ConnectionRow({ ); } -const delay = (ms: number) => - new Promise((resolve) => { - setTimeout(resolve, ms); - }); - function RestartDaemonCard({ host }: { host: HostProfile }) { const { t } = useTranslation(); - const { theme } = useUnistyles(); const daemonClient = useHostRuntimeClient(host.serverId); const isConnected = useHostRuntimeIsConnected(host.serverId); const runtime = getHostRuntimeStore(); @@ -578,68 +574,20 @@ function RestartDaemonCard({ host }: { host: HostProfile }) { [host.serverId, runtime], ); - const waitForCondition = useCallback( - async (predicate: () => boolean, timeoutMs: number, intervalMs = 250) => { - const deadline = Date.now() + timeoutMs; - while (Date.now() < deadline) { - if (!isMountedRef.current) return false; - if (predicate()) return true; - await delay(intervalMs); - } - return predicate(); - }, - [], - ); - const waitForDaemonRestart = useCallback( - async (restartRequest: Promise) => { - const disconnectTimeoutMs = 30000; - const reconnectTimeoutMs = 30000; - const requestFailureDisconnectGraceMs = 2000; - const disconnectedPromise = isHostConnected() - ? waitForCondition(() => !isHostConnected(), disconnectTimeoutMs) - : Promise.resolve(true); - const restartResult = await restartRequest.then( - () => ({ status: "accepted" as const }), - async (error) => ({ - status: "rejected" as const, - error, - disconnectedAfterFailure: await waitForCondition( - () => !isHostConnected(), - requestFailureDisconnectGraceMs, - 100, - ), - }), - ); - if (!isMountedRef.current) return; - - if (restartResult.status === "rejected" && !restartResult.disconnectedAfterFailure) { - console.error(`[HostPage] Failed to restart daemon ${host.label}`, restartResult.error); - setIsRestarting(false); + async (request: Promise) => { + try { + await request; + } catch (error) { Alert.alert( t("settings.host.daemon.restart.requestFailedTitle"), - t("settings.host.daemon.restart.requestFailedMessage"), + error instanceof Error ? error.message : String(error), ); - return; - } - - const disconnected = - restartResult.status === "rejected" - ? restartResult.disconnectedAfterFailure - : await disconnectedPromise; - const reconnected = - disconnected && (await waitForCondition(() => isHostConnected(), reconnectTimeoutMs)); - if (isMountedRef.current) { - setIsRestarting(false); - if (!reconnected) { - Alert.alert( - t("settings.host.daemon.restart.unableToReconnectTitle"), - t("settings.host.daemon.restart.unableToReconnectMessage", { name: host.label }), - ); - } + } finally { + if (isMountedRef.current) setIsRestarting(false); } }, - [host.label, isHostConnected, t, waitForCondition], + [t], ); const handleRestart = useCallback(() => { @@ -672,11 +620,12 @@ function RestartDaemonCard({ host }: { host: HostProfile }) { host.serverId, `settings_daemon_restart_${host.serverId}`, { - getIsElectron, - getDesktopDaemonStatus, - getDesktopSettings: loadDesktopSettings, - restartDesktopDaemon, restartServer: (reason) => daemonClient.restartServer(reason), + getStatus: async () => ({ + ...(await daemonClient.getDaemonStatus({ timeout: 1500 })), + serverId: daemonClient.getLastServerInfoMessage()?.serverId ?? "", + version: daemonClient.getLastServerInfoMessage()?.version ?? null, + }), }, ); void waitForDaemonRestart(restartRequest); @@ -691,11 +640,6 @@ function RestartDaemonCard({ host }: { host: HostProfile }) { }); }, [daemonClient, host.label, host.serverId, isHostConnected, t, waitForDaemonRestart]); - const restartIcon = useMemo( - () => , - [theme.iconSize.sm, theme.colors.foreground], - ); - return ( @@ -722,12 +666,12 @@ function RestartDaemonCard({ host }: { host: HostProfile }) { type DaemonUpdateState = | { status: "idle" } + | { status: "complete"; workerVersion: string } | { status: "updating"; phase: string } | { status: "failed"; title: string; message: string }; function UpdateDaemonCard({ host }: { host: HostProfile }) { const { t } = useTranslation(); - const { theme } = useUnistyles(); const daemonClient = useHostRuntimeClient(host.serverId); const isConnected = useHostRuntimeIsConnected(host.serverId); const runtime = getHostRuntimeStore(); @@ -759,55 +703,6 @@ function UpdateDaemonCard({ host }: { host: HostProfile }) { () => isHostRuntimeConnected(runtime.getSnapshot(host.serverId)), [host.serverId, runtime], ); - const hasReconnectedAfter = useCallback( - (startMarker: DaemonConnectionMarker | null) => - hasDaemonReconnectedAfter(runtime.getSnapshot(host.serverId), startMarker), - [host.serverId, runtime], - ); - - const waitForCondition = useCallback( - async (predicate: () => boolean, timeoutMs: number, intervalMs = 250) => { - const deadline = Date.now() + timeoutMs; - while (Date.now() < deadline) { - if (!isMountedRef.current) return false; - if (predicate()) return true; - await delay(intervalMs); - } - return predicate(); - }, - [], - ); - - const waitForDaemonRestart = useCallback( - async (startMarker: DaemonConnectionMarker | null) => { - const disconnectTimeoutMs = 15000; - const reconnectTimeoutMs = 120000; // 2 minutes — npm update + restart can take a while - if (!hasReconnectedAfter(startMarker) && isHostConnected()) { - await waitForCondition( - () => !isHostConnected() || hasReconnectedAfter(startMarker), - disconnectTimeoutMs, - ); - } - const reconnected = - hasReconnectedAfter(startMarker) || - (await waitForCondition(() => hasReconnectedAfter(startMarker), reconnectTimeoutMs)); - if (isMountedRef.current) { - if (!reconnected) { - setUpdateState({ - status: "failed", - title: t("settings.host.daemon.update.unableToReconnectTitle"), - message: t("settings.host.daemon.update.unableToReconnectMessage", { - name: host.label, - }), - }); - return; - } - setUpdateState({ status: "idle" }); - } - }, - [hasReconnectedAfter, host.label, isHostConnected, t, waitForCondition], - ); - const handleUpdate = useCallback(() => { if (!daemonClient) { setUpdateState({ @@ -835,13 +730,6 @@ function UpdateDaemonCard({ host }: { host: HostProfile }) { }) .then((confirmed) => { if (!confirmed || !isMountedRef.current) return; - const startSnapshot = runtime.getSnapshot(host.serverId); - const startMarker = startSnapshot - ? { - clientGeneration: startSnapshot.clientGeneration, - lastOnlineAt: startSnapshot.lastOnlineAt, - } - : null; setUpdateState({ status: "updating", phase: t("settings.host.daemon.update.phaseStarting"), @@ -875,24 +763,18 @@ function UpdateDaemonCard({ host }: { host: HostProfile }) { }); unsubscribeRef.current = unsubscribe; - void daemonClient - .updateDaemon(requestId) - .then((response) => { + void updateDaemonFromSettings(host.serverId, { + updateDaemon: () => daemonClient.updateDaemon(requestId), + getStatus: async () => ({ + ...(await daemonClient.getDaemonStatus({ timeout: 1500 })), + serverId: daemonClient.getLastServerInfoMessage()?.serverId ?? "", + version: daemonClient.getLastServerInfoMessage()?.version ?? null, + }), + }) + .then(({ workerVersion }) => { unsubscribeRef.current = null; unsubscribe(); - if (!response.success) { - if (!isMountedRef.current) return undefined; - setUpdateState({ - status: "failed", - title: t("settings.host.daemon.update.requestFailedTitle"), - message: t("settings.host.daemon.update.requestFailedMessage", { - error: response.error ?? "Unknown error", - }), - }); - return undefined; - } - // Update succeeded — wait for daemon to restart and reconnect - void waitForDaemonRestart(startMarker); + if (isMountedRef.current) setUpdateState({ status: "complete", workerVersion }); return undefined; }) .catch((error) => { @@ -919,15 +801,10 @@ function UpdateDaemonCard({ host }: { host: HostProfile }) { message: t("settings.host.daemon.update.dialogFailedMessage"), }); }); - }, [daemonClient, host.label, host.serverId, isHostConnected, runtime, t, waitForDaemonRestart]); - - const updateIcon = useMemo( - () => , - [theme.iconSize.sm, theme.colors.foreground], - ); + }, [daemonClient, host.label, host.serverId, isHostConnected, t]); const shouldShowUpdate = hasVersionMismatch && (supportsSelfUpdate || desktopManaged); - if (!shouldShowUpdate) { + if (!shouldShowUpdate && updateState.status !== "complete") { return null; } @@ -956,6 +833,15 @@ function UpdateDaemonCard({ host }: { host: HostProfile }) { {buttonLabel} + {updateState.status === "complete" ? ( + + ) : null} {updateState.status === "failed" ? ( ( () => ({ - title: isLocalDaemon + title: stopsOwnedDaemon ? t("settings.host.daemon.remove.localConfirmTitle") : t("settings.host.daemon.remove.title"), }), - [isLocalDaemon, t], + [stopsOwnedDaemon, t], ); const destructiveTextStyle = useMemo( @@ -1292,7 +1179,7 @@ function RemoveHostSection({ try { await updateSettings({ daemon: { manageBuiltInDaemon: false } }); didDisableDaemonManagement = true; - if (daemonStatus?.status === "running" && daemonStatus.desktopManaged) { + if (daemonStatus?.status === "running" && daemonStatus.ownedByDesktop) { setStatus(await stopDesktopDaemon("host_remove")); didStopDaemon = true; } @@ -1360,7 +1247,7 @@ function RemoveHostSection({ : t("settings.host.daemon.remove.title")} - {isLocalDaemon + {stopsOwnedDaemon ? t("settings.host.daemon.remove.localHint") : t("settings.host.daemon.remove.hint")} @@ -1386,7 +1273,7 @@ function RemoveHostSection({ testID="remove-host-confirm-modal" > - {isLocalDaemon + {stopsOwnedDaemon ? t("settings.host.daemon.remove.localConfirmMessage") : t("settings.host.daemon.remove.confirmMessage", { name: host.label })} diff --git a/packages/cli/src/cli.ts b/packages/cli/src/cli.ts index 275d8f6170..5e965601d9 100644 --- a/packages/cli/src/cli.ts +++ b/packages/cli/src/cli.ts @@ -1,3 +1,4 @@ +import { pairCommand } from "./commands/daemon/pair.js"; import { Command, Option } from "commander"; import { createAgentCommand } from "./commands/agent/index.js"; import { createDaemonCommand } from "./commands/daemon/index.js"; @@ -15,9 +16,9 @@ import { createHeartbeatCommand } from "./commands/heartbeat/index.js"; import { createHubCommand } from "./commands/hub/index.js"; import { createHooksCommand } from "./commands/hooks.js"; import { startCommand as daemonStartCommand } from "./commands/daemon/start.js"; -import { runStatusCommand as runDaemonStatusCommand } from "./commands/daemon/status.js"; -import { runRestartCommand as runDaemonRestartCommand } from "./commands/daemon/restart.js"; -import { runDaemonReloadCommand } from "./commands/daemon/reload.js"; +import { daemonStatusCommand } from "./commands/daemon/status.js"; +import { daemonRestartCommand } from "./commands/daemon/restart.js"; +import { daemonReloadCommand } from "./commands/daemon/reload.js"; import { addLsOptions, runLsCommand } from "./commands/agent/ls.js"; import { addRunOptions, runRunCommand } from "./commands/agent/run.js"; import { addLogsOptions, runLogsCommand } from "./commands/agent/logs.js"; @@ -35,19 +36,12 @@ import { onboardCommand } from "./commands/onboard.js"; import { addDaemonHostOption, addJsonAndDaemonHostOptions, - addJsonOption, withGlobalOptions, } from "./utils/command-options.js"; import { resolveCliVersion } from "./version.js"; const VERSION = resolveCliVersion(); -function resolveHostnamesOption(hostnames: unknown, allowedHosts: unknown): string | undefined { - if (typeof hostnames === "string") return hostnames; - if (typeof allowedHosts === "string") return allowedHosts; - return undefined; -} - export function createCli(): Command { const program = new Command(); @@ -125,51 +119,10 @@ export function createCli(): Command { program.addCommand(daemonStartCommand()); program.addCommand(createHooksCommand()); - addJsonOption( - program - .command("status") - .description('Show local daemon status (alias for "paseo daemon status")'), - ) - .option("--home ", "Paseo home directory (default: ~/.paseo)") - .action(withOutput(runDaemonStatusCommand)); - - addJsonAndDaemonHostOptions( - program.command("reload").description('Reload daemon config (alias for "paseo daemon reload")'), - ).action(withOutput(runDaemonReloadCommand)); - - addJsonOption( - program - .command("restart") - .description('Restart local daemon (alias for "paseo daemon restart")'), - ) - .option("--home ", "Paseo home directory (default: ~/.paseo)") - .option("--timeout ", "Wait timeout before force step (default: 15)") - .option("--force", "Send SIGKILL if graceful stop times out") - .option( - "--listen ", - "Listen target for restarted daemon (host:port, port, or unix socket)", - ) - .option("--port ", "Port for restarted daemon listen target") - .option("--relay", "Enable relay on restarted daemon") - .option("--no-relay", "Disable relay on restarted daemon") - .option("--no-mcp", "Disable Agent MCP on restarted daemon") - .option( - "--hostnames ", - 'Daemon hostnames (comma-separated, e.g. "myhost,.example.com" or "true" for any)', - ) - .addOption(new Option("--allowed-hosts ").hideHelp()) - .action( - withOutput((...args) => { - const [options, command] = args.slice(-2) as [(typeof args)[number], Command]; - return runDaemonRestartCommand( - { - ...options, - hostnames: resolveHostnamesOption(options.hostnames, options.allowedHosts), - }, - command, - ); - }), - ); + program.addCommand(daemonStatusCommand()); + program.addCommand(daemonRestartCommand()); + program.addCommand(daemonReloadCommand()); + program.addCommand(pairCommand()); // Advanced agent commands (less common operations) program.addCommand(createAgentCommand()); @@ -207,5 +160,20 @@ export function createCli(): Command { // Added in v0.2.0; remove after 2027-01-17. program.addCommand(createWorktreeCommand(), { hidden: true }); + const enforceSelectorDuplicates = (command: Command) => { + for (const option of command.options) { + if (option.long !== "--home" && option.long !== "--host") continue; + option.argParser((value: string, previous: string | undefined) => { + if (previous !== undefined && previous !== value) + throw { + code: "TARGET_AMBIGUOUS", + message: `Conflicting duplicate ${option.long} selectors.`, + }; + return value; + }); + } + command.commands.forEach(enforceSelectorDuplicates); + }; + enforceSelectorDuplicates(program); return program; } diff --git a/packages/cli/src/commands/agent/archive.ts b/packages/cli/src/commands/agent/archive.ts index ff9f80afa2..c7809ba565 100644 --- a/packages/cli/src/commands/agent/archive.ts +++ b/packages/cli/src/commands/agent/archive.ts @@ -1,6 +1,5 @@ import { Command } from "commander"; -import type { DaemonClient } from "@getpaseo/client/internal/daemon-client"; -import { connectToDaemon, getDaemonHost, resolveAgentId } from "../../utils/client.js"; +import { connectToDaemon, resolveAgentId } from "../../utils/client.js"; import type { CommandOptions, SingleResult, @@ -44,8 +43,6 @@ export async function runArchiveCommand( options: AgentArchiveOptions, _command: Command, ): Promise { - const host = getDaemonHost({ host: options.host }); - // Validate arguments if (!agentIdArg || agentIdArg.trim().length === 0) { const error: CommandError = { @@ -56,18 +53,7 @@ export async function runArchiveCommand( throw error; } - let client: DaemonClient; - try { - client = await connectToDaemon({ host: options.host }); - } catch (err) { - const message = err instanceof Error ? err.message : String(err); - const error: CommandError = { - code: "DAEMON_NOT_RUNNING", - message: `Cannot connect to daemon at ${host}: ${message}`, - details: "Start the daemon with: paseo daemon start", - }; - throw error; - } + const client = await connectToDaemon({ target: options.daemonTarget }); try { const agentsPayload = await client.fetchAgents({ filter: { includeArchived: true } }); diff --git a/packages/cli/src/commands/agent/attach.ts b/packages/cli/src/commands/agent/attach.ts index 1118d4c905..36cb8e4fc0 100644 --- a/packages/cli/src/commands/agent/attach.ts +++ b/packages/cli/src/commands/agent/attach.ts @@ -5,17 +5,17 @@ export function addAttachOptions(cmd: Command): Command { .description("Attach to a running agent's output stream") .argument("", "Agent ID (or prefix)"); } -import { connectToDaemon, getDaemonHost } from "../../utils/client.js"; +import { connectToDaemon } from "../../utils/client.js"; import { fetchProjectedTimelineItems, LIVE_HISTORY_FETCH_TIMEOUT_MS, } from "../../utils/timeline.js"; -import type { DaemonClient } from "@getpaseo/client/internal/daemon-client"; import type { AgentTimelineItem } from "@getpaseo/protocol/agent-types"; import type { AgentStreamEventPayload } from "@getpaseo/protocol/messages"; export interface AgentAttachOptions { host?: string; + daemonTarget: import("../../utils/daemon-target.js").DaemonTarget; [key: string]: unknown; } @@ -105,23 +105,13 @@ export async function runAttachCommand( options: AgentAttachOptions, _command: Command, ): Promise { - const host = getDaemonHost({ host: options.host }); - if (!id) { console.error("Error: Agent ID required"); console.error("Usage: paseo attach "); process.exit(1); } - let client: DaemonClient; - try { - client = await connectToDaemon({ host: options.host }); - } catch (err) { - const message = err instanceof Error ? err.message : String(err); - console.error(`Error: Cannot connect to daemon at ${host}: ${message}`); - console.error("Start the daemon with: paseo daemon start"); - process.exit(1); - } + const client = await connectToDaemon({ target: options.daemonTarget }); try { const fetchResult = await client.fetchAgent({ agentId: id }); diff --git a/packages/cli/src/commands/agent/delete.test.ts b/packages/cli/src/commands/agent/delete.test.ts index 56aa83dc65..60d5cec50f 100644 --- a/packages/cli/src/commands/agent/delete.test.ts +++ b/packages/cli/src/commands/agent/delete.test.ts @@ -1,5 +1,7 @@ import { describe, expect, it, vi } from "vitest"; +const daemonTarget = { kind: "endpoint" as const, host: "example.test:12345" }; + import { runDeleteCommand } from "./delete.js"; const agent = { @@ -27,7 +29,7 @@ vi.mock("../../utils/client.js", () => ({ describe("runDeleteCommand", () => { it("force-deletes a running agent when graceful cancellation is refused", async () => { - const result = await runDeleteCommand(agent.id, {}, {} as never); + const result = await runDeleteCommand(agent.id, { daemonTarget }, {} as never); expect(cancelAgent).toHaveBeenCalledWith(agent.id); expect(deleteAgent).toHaveBeenCalledWith(agent.id); diff --git a/packages/cli/src/commands/agent/delete.ts b/packages/cli/src/commands/agent/delete.ts index c8502c9a19..f1499b51f4 100644 --- a/packages/cli/src/commands/agent/delete.ts +++ b/packages/cli/src/commands/agent/delete.ts @@ -1,6 +1,5 @@ import type { Command } from "commander"; -import type { DaemonClient } from "@getpaseo/client/internal/daemon-client"; -import { connectToDaemon, getDaemonHost } from "../../utils/client.js"; +import { connectToDaemon } from "../../utils/client.js"; import { isSameOrDescendantPath } from "../../utils/paths.js"; export function addDeleteOptions(cmd: Command): Command { @@ -39,8 +38,6 @@ export async function runDeleteCommand( options: AgentDeleteOptions, _command: Command, ): Promise { - const host = getDaemonHost({ host: options.host }); - if (!id && !options.all && !options.cwd) { const error: CommandError = { code: "MISSING_ARGUMENT", @@ -50,18 +47,7 @@ export async function runDeleteCommand( throw error; } - let client: DaemonClient; - try { - client = await connectToDaemon({ host: options.host }); - } catch (err) { - const message = err instanceof Error ? err.message : String(err); - const error: CommandError = { - code: "DAEMON_NOT_RUNNING", - message: `Cannot connect to daemon at ${host}: ${message}`, - details: "Start the daemon with: paseo daemon start", - }; - throw error; - } + const client = await connectToDaemon({ target: options.daemonTarget }); try { const fetchPayload = await client.fetchAgents({ filter: { includeArchived: true } }); @@ -97,6 +83,7 @@ export async function runDeleteCommand( await client.deleteAgent(agent.id); return { ok: true as const, id: agent.id }; } catch (err) { + if (err && typeof err === "object" && "code" in err) throw err; const message = err instanceof Error ? err.message : String(err); return { ok: false as const, id: agent.id, message }; } diff --git a/packages/cli/src/commands/agent/detach.ts b/packages/cli/src/commands/agent/detach.ts index 5de8ae8c9a..9de223dffb 100644 --- a/packages/cli/src/commands/agent/detach.ts +++ b/packages/cli/src/commands/agent/detach.ts @@ -1,6 +1,5 @@ import type { Command } from "commander"; -import type { DaemonClient } from "@getpaseo/client/internal/daemon-client"; -import { connectToDaemon, getDaemonHost, resolveAgentId } from "../../utils/client.js"; +import { connectToDaemon, resolveAgentId } from "../../utils/client.js"; import type { CommandError, CommandOptions, @@ -26,17 +25,7 @@ export async function runDetachCommand( options: CommandOptions, _command: Command, ): Promise> { - const host = getDaemonHost({ host: options.host }); - let client: DaemonClient; - try { - client = await connectToDaemon({ host: options.host }); - } catch (error) { - const message = error instanceof Error ? error.message : String(error); - throw { - code: "DAEMON_NOT_RUNNING", - message: `Cannot connect to daemon at ${host}: ${message}`, - } satisfies CommandError; - } + const client = await connectToDaemon({ target: options.daemonTarget }); try { const payload = await client.fetchAgents({ filter: { includeArchived: true } }); diff --git a/packages/cli/src/commands/agent/import.test.ts b/packages/cli/src/commands/agent/import.test.ts index 48a678de4a..761b2a0f1e 100644 --- a/packages/cli/src/commands/agent/import.test.ts +++ b/packages/cli/src/commands/agent/import.test.ts @@ -43,6 +43,7 @@ describe("resolveImportCwd", () => { const result = await runImportCommand( "pi-session-1", { + daemonTarget: { kind: "endpoint", host: "example.test:12345" }, provider: "pi", cwd: "/tmp/project", }, diff --git a/packages/cli/src/commands/agent/import.ts b/packages/cli/src/commands/agent/import.ts index d3d27d627b..fa42b6860a 100644 --- a/packages/cli/src/commands/agent/import.ts +++ b/packages/cli/src/commands/agent/import.ts @@ -1,5 +1,5 @@ import type { Command } from "commander"; -import { connectToDaemon, getDaemonHost } from "../../utils/client.js"; +import { connectToDaemon } from "../../utils/client.js"; import { collectMultiple } from "../../utils/command-options.js"; import type { CommandError, CommandOptions, SingleResult } from "../../output/index.js"; import { agentRunSchema, type AgentRunResult } from "./run.js"; @@ -94,28 +94,11 @@ export function resolveImportCwd(explicitCwd: string | undefined, defaultCwd: st return cwd; } -async function connectToDaemonOrThrow( - hostOption: string | undefined, - host: string, -): Promise>> { - try { - return await connectToDaemon({ host: hostOption }); - } catch (err) { - const message = err instanceof Error ? err.message : String(err); - throw { - code: "DAEMON_NOT_RUNNING", - message: `Cannot connect to daemon at ${host}: ${message}`, - details: "Start the daemon with: paseo daemon start", - } satisfies CommandError; - } -} - export async function runImportCommand( sessionIdArg: string, options: AgentImportOptions, _command: Command, ): Promise { - const host = getDaemonHost({ host: options.host }); const sessionId = sessionIdArg.trim(); if (!sessionId) { throw { @@ -129,7 +112,7 @@ export async function runImportCommand( const cwd = resolveImportCwd(options.cwd, process.cwd()); const labels = parseImportLabels(options.label); - const client = await connectToDaemonOrThrow(options.host, host); + const client = await connectToDaemon({ target: options.daemonTarget }); try { const agent = await client.importAgent({ diff --git a/packages/cli/src/commands/agent/inspect.ts b/packages/cli/src/commands/agent/inspect.ts index 93f1da3d4d..bad8808526 100644 --- a/packages/cli/src/commands/agent/inspect.ts +++ b/packages/cli/src/commands/agent/inspect.ts @@ -1,7 +1,7 @@ import type { Command } from "commander"; import { PARENT_AGENT_ID_LABEL } from "@getpaseo/protocol/agent-labels"; import type { AgentSnapshotPayload } from "@getpaseo/protocol/messages"; -import { connectToDaemon, getDaemonHost } from "../../utils/client.js"; +import { connectToDaemon } from "../../utils/client.js"; import type { CommandOptions, ListResult, OutputSchema, CommandError } from "../../output/index.js"; export function addInspectOptions(cmd: Command): Command { @@ -217,8 +217,6 @@ export async function runInspectCommand( options: AgentInspectOptions, _command: Command, ): Promise { - const host = getDaemonHost({ host: options.host }); - // Validate arguments if (!agentIdArg || agentIdArg.trim().length === 0) { const error: CommandError = { @@ -229,18 +227,7 @@ export async function runInspectCommand( throw error; } - let client; - try { - client = await connectToDaemon({ host: options.host }); - } catch (err) { - const message = err instanceof Error ? err.message : String(err); - const error: CommandError = { - code: "DAEMON_NOT_RUNNING", - message: `Cannot connect to daemon at ${host}: ${message}`, - details: "Start the daemon with: paseo daemon start", - }; - throw error; - } + const client = await connectToDaemon({ target: options.daemonTarget }); try { const fetchResult = await client.fetchAgent({ agentId: agentIdArg }); diff --git a/packages/cli/src/commands/agent/logs.ts b/packages/cli/src/commands/agent/logs.ts index 32652d3e0e..d32ab549a8 100644 --- a/packages/cli/src/commands/agent/logs.ts +++ b/packages/cli/src/commands/agent/logs.ts @@ -1,5 +1,5 @@ import { Command } from "commander"; -import { connectToDaemon, getDaemonHost } from "../../utils/client.js"; +import { connectToDaemon } from "../../utils/client.js"; import type { CommandOptions } from "../../output/index.js"; import { fetchProjectedTimelineItems, @@ -91,23 +91,13 @@ export async function runLogsCommand( options: AgentLogsOptions, _command: Command, ): Promise { - const host = getDaemonHost({ host: options.host }); - if (!id) { console.error("Error: Agent ID required"); console.error("Usage: paseo agent logs "); process.exit(1); } - let client: DaemonClient; - try { - client = await connectToDaemon({ host: options.host }); - } catch (err) { - const message = err instanceof Error ? err.message : String(err); - console.error(`Error: Cannot connect to daemon at ${host}: ${message}`); - console.error("Start the daemon with: paseo daemon start"); - process.exit(1); - } + const client = await connectToDaemon({ target: options.daemonTarget }); try { const fetchResult = await client.fetchAgent({ agentId: id }); @@ -157,6 +147,7 @@ export async function runLogsCommand( const transcript = formatAgentActivityTranscript(timelineItems, tailCount); console.log(transcript); } catch (err) { + if (err && typeof err === "object" && "code" in err) throw err; const message = err instanceof Error ? err.message : String(err); console.error(`Error: Failed to get logs: ${message}`); await client.close().catch(() => {}); diff --git a/packages/cli/src/commands/agent/ls.ts b/packages/cli/src/commands/agent/ls.ts index 37978d9011..bb2189c7c4 100644 --- a/packages/cli/src/commands/agent/ls.ts +++ b/packages/cli/src/commands/agent/ls.ts @@ -1,6 +1,6 @@ import type { Command } from "commander"; import type { AgentSnapshotPayload } from "@getpaseo/protocol/messages"; -import { connectToDaemon, getDaemonHost } from "../../utils/client.js"; +import { connectToDaemon } from "../../utils/client.js"; import type { CommandOptions, ListResult, OutputSchema, CommandError } from "../../output/index.js"; import { collectMultiple } from "../../utils/command-options.js"; import { isSameOrDescendantPath } from "../../utils/paths.js"; @@ -104,21 +104,6 @@ function toListItem(agent: AgentSnapshotPayload): AgentListItem { export type AgentLsResult = ListResult; -function daemonConnectionFailure(host: string, cause: unknown): CommandError { - const reason = cause instanceof Error ? cause.message : String(cause); - const isSsh = host.trim().startsWith("ssh://"); - return { - code: "DAEMON_NOT_RUNNING", - message: `Cannot reach the daemon at ${host}: ${reason}`, - details: isSsh - ? "Start the Paseo daemon on the SSH host; SSH transport does not install or start it." - : [ - "Start a local daemon with: paseo daemon start", - "To use another daemon, pass --host or set PASEO_HOST.", - ].join("\n"), - }; -} - export interface AgentLsOptions extends CommandOptions { /** -a: Include archived agents */ all?: boolean; @@ -185,14 +170,7 @@ export async function runLsCommand( options: AgentLsOptions, _command: Command, ): Promise { - const host = getDaemonHost({ host: options.host }); - - let client; - try { - client = await connectToDaemon({ host: options.host }); - } catch (err) { - throw daemonConnectionFailure(host, err); - } + const client = await connectToDaemon({ target: options.daemonTarget }); try { const normalizedThinkingOptionId = options.thinking?.trim(); diff --git a/packages/cli/src/commands/agent/mode.ts b/packages/cli/src/commands/agent/mode.ts index 46229b580c..cbd7bfbd56 100644 --- a/packages/cli/src/commands/agent/mode.ts +++ b/packages/cli/src/commands/agent/mode.ts @@ -55,7 +55,7 @@ export async function runModeCommand( _command: Command, ): Promise { const normalizedMode = mode?.trim(); - const host = getDaemonHost({ host: options.host }); + const host = getDaemonHost({ target: options.daemonTarget }); // Validate arguments if (!options.list && !normalizedMode) { @@ -64,7 +64,7 @@ export async function runModeCommand( let client: Awaited> | undefined; try { - client = await connectToDaemon({ host: options.host }); + client = await connectToDaemon({ target: options.daemonTarget }); const fetchResult = await client.fetchAgent({ agentId: id }); if (!fetchResult) { const error: CommandError = { diff --git a/packages/cli/src/commands/agent/open.ts b/packages/cli/src/commands/agent/open.ts index 333ec61b4e..2119bd130b 100644 --- a/packages/cli/src/commands/agent/open.ts +++ b/packages/cli/src/commands/agent/open.ts @@ -1,5 +1,5 @@ import type { Command } from "commander"; -import { buildDaemonConnectionCommandError, connectToDaemon } from "../../utils/client.js"; +import { connectToDaemon } from "../../utils/client.js"; import { openDesktopWithAgent } from "../open.js"; import type { CommandError, @@ -36,12 +36,7 @@ async function resolveServerId(options: CommandOptions): Promise { return explicitServerId; } - let client; - try { - client = await connectToDaemon({ host: options.host }); - } catch (error) { - throw buildDaemonConnectionCommandError({ host: options.host, error }); - } + const client = await connectToDaemon({ target: options.daemonTarget }); try { const serverId = client.getLastServerInfoMessage()?.serverId.trim(); if (!serverId) { diff --git a/packages/cli/src/commands/agent/reload.ts b/packages/cli/src/commands/agent/reload.ts index 607dfa278a..f6cda480e2 100644 --- a/packages/cli/src/commands/agent/reload.ts +++ b/packages/cli/src/commands/agent/reload.ts @@ -1,6 +1,5 @@ import { Command } from "commander"; -import type { DaemonClient } from "@getpaseo/client/internal/daemon-client"; -import { connectToDaemon, getDaemonHost, resolveAgentId } from "../../utils/client.js"; +import { connectToDaemon, resolveAgentId } from "../../utils/client.js"; import type { CommandOptions, SingleResult, @@ -40,8 +39,6 @@ export async function runReloadCommand( options: AgentReloadOptions, _command: Command, ): Promise { - const host = getDaemonHost({ host: options.host }); - if (!agentIdArg || agentIdArg.trim().length === 0) { const error: CommandError = { code: "MISSING_AGENT_ID", @@ -51,18 +48,7 @@ export async function runReloadCommand( throw error; } - let client: DaemonClient; - try { - client = await connectToDaemon({ host: options.host }); - } catch (err) { - const message = err instanceof Error ? err.message : String(err); - const error: CommandError = { - code: "DAEMON_NOT_RUNNING", - message: `Cannot connect to daemon at ${host}: ${message}`, - details: "Start the daemon with: paseo daemon start", - }; - throw error; - } + const client = await connectToDaemon({ target: options.daemonTarget }); try { const agentsPayload = await client.fetchAgents({ filter: { includeArchived: true } }); diff --git a/packages/cli/src/commands/agent/run.test.ts b/packages/cli/src/commands/agent/run.test.ts index 755638d4d3..8f707c5e0c 100644 --- a/packages/cli/src/commands/agent/run.test.ts +++ b/packages/cli/src/commands/agent/run.test.ts @@ -6,6 +6,8 @@ import { type AgentRunOptions, } from "./run"; +const daemonTarget = { kind: "endpoint" as const, host: "example.test:12345" }; + describe("managed agent caller context", () => { it("propagates a trimmed PASEO_AGENT_ID", () => { expect(resolveRunCallerAgentId({ PASEO_AGENT_ID: " parent-agent " })).toBe("parent-agent"); @@ -65,8 +67,13 @@ describe("runRunCommand option validation", () => { } }); - async function expectInvalidOptions(options: AgentRunOptions, messageMatch: RegExp) { - await expect(runRunCommand("do something", options, {} as never)).rejects.toMatchObject({ + async function expectInvalidOptions( + options: Omit, + messageMatch: RegExp, + ) { + await expect( + runRunCommand("do something", { ...options, daemonTarget }, {} as never), + ).rejects.toMatchObject({ code: "INVALID_OPTIONS", message: expect.stringMatching(messageMatch), }); @@ -84,7 +91,11 @@ describe("runRunCommand option validation", () => { // must clear validation. It still fails later (provider resolution), which // is enough to prove the new guard did not reject it. await expect( - runRunCommand("do something", { newWorkspace: "worktree", provider: undefined }, {} as never), + runRunCommand( + "do something", + { newWorkspace: "worktree", provider: undefined, daemonTarget }, + {} as never, + ), ).rejects.not.toMatchObject({ code: "INVALID_OPTIONS" }); }); diff --git a/packages/cli/src/commands/agent/run.ts b/packages/cli/src/commands/agent/run.ts index a642909952..c72049347d 100644 --- a/packages/cli/src/commands/agent/run.ts +++ b/packages/cli/src/commands/agent/run.ts @@ -1,7 +1,7 @@ import { Command, Option } from "commander"; import { getStructuredAgentResponse, StructuredAgentResponseError } from "@getpaseo/server"; import type { AgentSnapshotPayload } from "@getpaseo/protocol/messages"; -import { connectToDaemon, getDaemonHost } from "../../utils/client.js"; +import { connectToDaemon } from "../../utils/client.js"; import type { CommandOptions, SingleResult, @@ -140,6 +140,7 @@ function resolveNewWorkspaceKind(options: AgentRunOptions): string | undefined { function buildRunWorkspaceSource(options: AgentRunOptions, cwd: string) { const newWorkspace = resolveNewWorkspaceKind(options) ?? "local"; return buildWorkspaceSource({ + daemonTarget: options.daemonTarget, isolation: newWorkspace, path: cwd, mode: options.worktreeMode, @@ -181,6 +182,7 @@ function loadOutputSchema(value: string): Record { try { source = readFileSync(resolve(trimmed), "utf8"); } catch (err) { + if (err && typeof err === "object" && "code" in err) throw err; const message = err instanceof Error ? err.message : String(err); const error: CommandError = { code: "INVALID_OUTPUT_SCHEMA", @@ -195,6 +197,7 @@ function loadOutputSchema(value: string): Record { try { parsed = JSON.parse(source); } catch (err) { + if (err && typeof err === "object" && "code" in err) throw err; const message = err instanceof Error ? err.message : String(err); const error: CommandError = { code: "INVALID_OUTPUT_SCHEMA", @@ -406,6 +409,7 @@ function parseWaitTimeoutOption(waitTimeout: string | undefined): number { } return ms; } catch (err) { + if (err && typeof err === "object" && "code" in err) throw err; const message = err instanceof Error ? err.message : String(err); throw { code: "INVALID_TIMEOUT", @@ -432,6 +436,7 @@ function loadRunImages( mimeType, }; } catch (err) { + if (err && typeof err === "object" && "code" in err) throw err; const message = err instanceof Error ? err.message : String(err); throw new Error(`Failed to read image ${imagePath}: ${message}`, { cause: err }); } @@ -482,22 +487,6 @@ function parseKeyValueFlags( return labels; } -async function connectToDaemonOrThrow( - hostOption: string | undefined, - host: string, -): Promise { - try { - return await connectToDaemon({ host: hostOption }); - } catch (err) { - const message = err instanceof Error ? err.message : String(err); - throw { - code: "DAEMON_NOT_RUNNING", - message: `Cannot connect to daemon at ${host}: ${message}`, - details: "Start the daemon with: paseo daemon start", - } satisfies CommandError; - } -} - // A workspace is the explicit home of a run: it owns the directory the agent // runs in. The CLI resolves one before creating any agent, so no run leans on // createAgent's legacy cwd->workspace fallback. @@ -587,7 +576,6 @@ export async function runRunCommand( options: AgentRunOptions, _command: Command, ): Promise> { - const host = getDaemonHost({ host: options.host }); const outputSchema = options.outputSchema ? loadOutputSchema(options.outputSchema) : undefined; validateRunOptions(prompt, options, outputSchema); @@ -596,7 +584,7 @@ export async function runRunCommand( const resolvedProviderModel = resolveProviderAndModel(options); const resolvedTitle = options.title ?? options.name; - const client = await connectToDaemonOrThrow(options.host, host); + const client = await connectToDaemon({ target: options.daemonTarget }); try { // Resolve working directory diff --git a/packages/cli/src/commands/agent/send.ts b/packages/cli/src/commands/agent/send.ts index 06249289b7..aa9b5a24cc 100644 --- a/packages/cli/src/commands/agent/send.ts +++ b/packages/cli/src/commands/agent/send.ts @@ -1,6 +1,6 @@ import { type Command } from "commander"; import { collectMultiple } from "../../utils/command-options.js"; -import { connectToDaemon, getDaemonHost } from "../../utils/client.js"; +import { connectToDaemon } from "../../utils/client.js"; import type { CommandOptions, SingleResult, @@ -78,6 +78,7 @@ async function readImageFiles( return { data: buffer.toString("base64"), mimeType }; } catch (err) { + if (err && typeof err === "object" && "code" in err) throw err; const message = err instanceof Error ? err.message : String(err); throw { code: "IMAGE_READ_ERROR", @@ -128,6 +129,7 @@ async function resolvePromptInput(options: { try { return await readFile(resolve(promptFilePath), "utf8"); } catch (err) { + if (err && typeof err === "object" && "code" in err) throw err; const message = err instanceof Error ? err.message : String(err); const error: CommandError = { code: "PROMPT_FILE_READ_ERROR", @@ -166,8 +168,6 @@ export async function runSendCommand( options: AgentSendOptions, _command: Command, ): Promise> { - const host = getDaemonHost({ host: options.host }); - // Validate arguments if (!agentIdArg || agentIdArg.trim().length === 0) { const error: CommandError = { @@ -184,18 +184,7 @@ export async function runSendCommand( promptFile: options.promptFile, }); - let client; - try { - client = await connectToDaemon({ host: options.host }); - } catch (err) { - const message = err instanceof Error ? err.message : String(err); - const error: CommandError = { - code: "DAEMON_NOT_RUNNING", - message: `Cannot connect to daemon at ${host}: ${message}`, - details: "Start the daemon with: paseo daemon start", - }; - throw error; - } + const client = await connectToDaemon({ target: options.daemonTarget }); try { // Read image files if provided diff --git a/packages/cli/src/commands/agent/stop.ts b/packages/cli/src/commands/agent/stop.ts index 42d269fcf2..3207bca4f8 100644 --- a/packages/cli/src/commands/agent/stop.ts +++ b/packages/cli/src/commands/agent/stop.ts @@ -1,6 +1,5 @@ import { Command } from "commander"; -import type { DaemonClient } from "@getpaseo/client/internal/daemon-client"; -import { connectToDaemon, getDaemonHost } from "../../utils/client.js"; +import { connectToDaemon } from "../../utils/client.js"; import { isSameOrDescendantPath } from "../../utils/paths.js"; import type { CommandOptions, @@ -42,8 +41,6 @@ export async function runStopCommand( options: AgentStopOptions, _command: Command, ): Promise { - const host = getDaemonHost({ host: options.host }); - // Validate arguments - need either an id, --all, or --cwd if (!id && !options.all && !options.cwd) { const error: CommandError = { @@ -54,18 +51,7 @@ export async function runStopCommand( throw error; } - let client: DaemonClient; - try { - client = await connectToDaemon({ host: options.host }); - } catch (err) { - const message = err instanceof Error ? err.message : String(err); - const error: CommandError = { - code: "DAEMON_NOT_RUNNING", - message: `Cannot connect to daemon at ${host}: ${message}`, - details: "Start the daemon with: paseo daemon start", - }; - throw error; - } + const client = await connectToDaemon({ target: options.daemonTarget }); try { const fetchPayload = await client.fetchAgents({ filter: { includeArchived: true } }); diff --git a/packages/cli/src/commands/agent/update.ts b/packages/cli/src/commands/agent/update.ts index d23ad2d3ac..f41150b563 100644 --- a/packages/cli/src/commands/agent/update.ts +++ b/packages/cli/src/commands/agent/update.ts @@ -1,7 +1,7 @@ import type { Command } from "commander"; import type { AgentProviderNotice } from "@getpaseo/protocol/agent-types"; import type { AgentSnapshotPayload } from "@getpaseo/protocol/messages"; -import { connectToDaemon, getDaemonHost } from "../../utils/client.js"; +import { connectToDaemon } from "../../utils/client.js"; import type { CommandOptions, SingleResult, @@ -203,8 +203,6 @@ export async function runUpdateCommand( options: AgentUpdateOptions, _command: Command, ): Promise { - const host = getDaemonHost({ host: options.host }); - // Validate arguments if (!agentIdArg || agentIdArg.trim().length === 0) { const error: CommandError = { @@ -217,18 +215,7 @@ export async function runUpdateCommand( const changes = parseAgentChanges(options); - let client; - try { - client = await connectToDaemon({ host: options.host }); - } catch (err) { - const message = err instanceof Error ? err.message : String(err); - const error: CommandError = { - code: "DAEMON_NOT_RUNNING", - message: `Cannot connect to daemon at ${host}: ${message}`, - details: "Start the daemon with: paseo daemon start", - }; - throw error; - } + const client = await connectToDaemon({ target: options.daemonTarget }); try { const fetchResult = await client.fetchAgent({ agentId: agentIdArg }); diff --git a/packages/cli/src/commands/agent/wait.ts b/packages/cli/src/commands/agent/wait.ts index 6c800746cf..3e5e5b533a 100644 --- a/packages/cli/src/commands/agent/wait.ts +++ b/packages/cli/src/commands/agent/wait.ts @@ -1,5 +1,5 @@ import { Command } from "commander"; -import { connectToDaemon, getDaemonHost } from "../../utils/client.js"; +import { connectToDaemon } from "../../utils/client.js"; import type { CommandOptions, SingleResult, @@ -72,6 +72,7 @@ function parseWaitTimeout(timeout: string | undefined): { timeoutLabel: `${timeoutSeconds} second${timeoutSeconds === 1 ? "" : "s"}`, }; } catch (err) { + if (err && typeof err === "object" && "code" in err) throw err; const message = err instanceof Error ? err.message : String(err); throw { code: "INVALID_TIMEOUT", @@ -142,8 +143,6 @@ export async function runWaitCommand( options: AgentWaitOptions, _command: Command, ): Promise> { - const host = getDaemonHost({ host: options.host }); - if (!agentIdArg || agentIdArg.trim().length === 0) { throw { code: "MISSING_AGENT_ID", @@ -154,18 +153,7 @@ export async function runWaitCommand( const { timeoutMs, timeoutLabel } = parseWaitTimeout(options.timeout); - let client; - try { - client = await connectToDaemon({ host: options.host }); - } catch (err) { - const message = err instanceof Error ? err.message : String(err); - const error: CommandError = { - code: "DAEMON_NOT_RUNNING", - message: `Cannot connect to daemon at ${host}: ${message}`, - details: "Start the daemon with: paseo daemon start", - }; - throw error; - } + const client = await connectToDaemon({ target: options.daemonTarget }); try { try { diff --git a/packages/cli/src/commands/clone.ts b/packages/cli/src/commands/clone.ts index 6986934c30..cf3001b775 100644 --- a/packages/cli/src/commands/clone.ts +++ b/packages/cli/src/commands/clone.ts @@ -1,7 +1,6 @@ import type { Command } from "commander"; import { isCompleteGitRemote } from "@getpaseo/protocol/git-remote"; -import type { DaemonClient } from "@getpaseo/client/internal/daemon-client"; -import { buildDaemonConnectionCommandError, connectToDaemon } from "../utils/client.js"; +import { connectToDaemon } from "../utils/client.js"; import type { CommandError, OutputSchema, SingleResult } from "../output/index.js"; import type { CommandOptions } from "../output/with-output.js"; @@ -45,12 +44,7 @@ export async function runCloneCommand( throw cmdError("INVALID_ARGUMENT", "--protocol is required for owner/repo repository names"); } - let client: DaemonClient; - try { - client = await connectToDaemon({ host: options.host }); - } catch (err) { - throw buildDaemonConnectionCommandError({ host: options.host, error: err }); - } + const client = await connectToDaemon({ target: options.daemonTarget }); if (client.getLastServerInfoMessage()?.features?.projectGithubClone !== true) { await client.close().catch(() => {}); diff --git a/packages/cli/src/commands/daemon/config.ts b/packages/cli/src/commands/daemon/config.ts new file mode 100644 index 0000000000..c07354c5d6 --- /dev/null +++ b/packages/cli/src/commands/daemon/config.ts @@ -0,0 +1,116 @@ +import { addLocalDaemonOptions } from "../../utils/command-options.js"; +import { Command } from "commander"; +import { + readDaemonInstance, + readPersistedConfig, + getPersistedConfigValue, + editPersistedConfig, +} from "@getpaseo/server"; +import { connectToDaemon } from "../../utils/client.js"; +import { withOutput, type CommandOptions } from "../../output/index.js"; + +function redact(value: unknown): unknown { + if (Array.isArray(value)) return value.map(redact); + if (!value || typeof value !== "object") return value; + return Object.fromEntries( + Object.entries(value).map(([key, item]) => [ + key, + /password|(?:api|private|access)[_-]?key|token|secret|authorization/i.test(key) + ? "[redacted]" + : redact(item), + ]), + ); +} + +function result(data: Record) { + return { + type: "single" as const, + data, + schema: { + idField: () => "configuration", + columns: [], + renderHuman: () => JSON.stringify(data, null, 2), + }, + }; +} + +function homeOf(options: CommandOptions) { + if (options.daemonTarget.kind !== "instance") + throw new Error("Configuration edits require --home"); + return options.daemonTarget.home; +} + +async function applySaved(home: string, options: CommandOptions) { + const nextCommand = `paseo daemon start --home ${JSON.stringify(home)}`; + const instance = await readDaemonInstance(home); + if (!instance?.listen) + return result({ + action: "saved", + applied: false, + message: "Saved; not applied to a running daemon", + nextCommand, + }); + let client; + try { + client = await connectToDaemon({ target: options.daemonTarget, timeout: 1_500 }); + } catch { + return result({ + action: "saved", + applied: false, + message: "Saved; not applied to a running daemon", + nextCommand: `paseo daemon reload --home ${JSON.stringify(home)}`, + }); + } + try { + const reload = await client.reloadDaemonConfig(); + return result({ action: "saved", ...reload }); + } catch (error) { + throw { code: "CONFIG_SAVED_RELOAD_FAILED", message: `Saved; reload failed: ${String(error)}` }; + } finally { + await client.close(); + } +} + +export function daemonConfigCommand(): Command { + const config = new Command("config").description( + "Read or edit persistent local configuration (local operation)", + ); + addLocalDaemonOptions(config.command("get [path]")).action( + withOutput(async (field: string | undefined, options: CommandOptions, _command: Command) => { + const persisted = readPersistedConfig(homeOf(options)); + const value = field ? getPersistedConfigValue(persisted, field) : persisted; + return result({ + source: "configured", + path: field ?? null, + set: value !== undefined, + value: + field && /password|(?:api|private|access)[_-]?key|token|secret|authorization/i.test(field) + ? "[redacted]" + : redact(value), + }); + }), + ); + addLocalDaemonOptions(config.command("set ")) + .option("--string", "Interpret value literally as a string") + .action( + withOutput(async (field: string, raw: string, options: CommandOptions, _command: Command) => { + let value: unknown = raw; + if (!options.string) { + try { + value = JSON.parse(raw); + } catch { + /* Unquoted input is a string. */ + } + } + editPersistedConfig(homeOf(options), field, { value }); + return applySaved(homeOf(options), options); + }), + ); + addLocalDaemonOptions(config.command("unset ")).action( + withOutput(async (field: string, options: CommandOptions, _command: Command) => { + editPersistedConfig(homeOf(options), field, { unset: true }); + return applySaved(homeOf(options), options); + }), + ); + return config; +} diff --git a/packages/cli/src/commands/daemon/index.ts b/packages/cli/src/commands/daemon/index.ts index 869d87607c..5f9a8f842d 100644 --- a/packages/cli/src/commands/daemon/index.ts +++ b/packages/cli/src/commands/daemon/index.ts @@ -1,81 +1,32 @@ -import { Command, Option } from "commander"; -import { startCommand } from "./start.js"; -import { runStatusCommand } from "./status.js"; -import { runStopCommand } from "./stop.js"; -import { runRestartCommand } from "./restart.js"; +import { Command } from "commander"; +import { startCommand, daemonRunCommand } from "./start.js"; +import { daemonStatusCommand } from "./status.js"; +import { daemonStopCommand } from "./stop.js"; +import { daemonRestartCommand } from "./restart.js"; import { runSetPasswordCommand } from "./set-password.js"; import { pairCommand } from "./pair.js"; -import { runDaemonReloadCommand } from "./reload.js"; +import { daemonReloadCommand } from "./reload.js"; +import { daemonConfigCommand } from "./config.js"; import { withOutput } from "../../output/index.js"; -import { addJsonAndDaemonHostOptions, addJsonOption } from "../../utils/command-options.js"; - -function resolveHostnamesOption(hostnames: unknown, allowedHosts: unknown): string | undefined { - if (typeof hostnames === "string") return hostnames; - if (typeof allowedHosts === "string") return allowedHosts; - return undefined; -} +import { addJsonOption, addLocalDaemonOptions } from "../../utils/command-options.js"; export function createDaemonCommand(): Command { const daemon = new Command("daemon").description("Manage the Paseo daemon"); - - daemon.addCommand(startCommand()); - daemon.addCommand(pairCommand()); - - addJsonAndDaemonHostOptions( - daemon.command("reload").description("Reload config.json without restarting the daemon"), - ).action(withOutput(runDaemonReloadCommand)); - - addJsonOption(daemon.command("status").description("Show local daemon status")) - .option("--home ", "Paseo home directory (default: ~/.paseo)") - .action(withOutput(runStatusCommand)); - - addJsonOption(daemon.command("stop").description("Stop the local daemon")) - .option("--home ", "Paseo home directory (default: ~/.paseo)") - .option("--timeout ", "Wait timeout before failing (default: 15)") - .option("--force", "Send SIGKILL if graceful stop times out") - .option("--kill-timeout ", "Wait after SIGKILL before failing (default: 3)") - .action(withOutput(runStopCommand)); - - addJsonOption(daemon.command("restart").description("Restart the local daemon")) - .option("--home ", "Paseo home directory (default: ~/.paseo)") - .option("--timeout ", "Wait timeout before force step (default: 15)") - .option("--force", "Send SIGKILL if graceful stop times out") - .option( - "--listen ", - "Listen target for restarted daemon (host:port, port, or unix socket)", - ) - .option("--port ", "Port for restarted daemon listen target") - .option("--relay", "Enable relay on restarted daemon") - .option("--no-relay", "Disable relay on restarted daemon") - .option("--no-mcp", "Disable Agent MCP on restarted daemon") - .option("--no-inject-mcp", "Disable auto-injecting the Paseo MCP into created agents") - .option("--web-ui", "Enable the bundled daemon web UI on restarted daemon") - .option("--no-web-ui", "Disable the bundled daemon web UI on restarted daemon") - .option( - "--hostnames ", - 'Daemon hostnames (comma-separated, e.g. "myhost,.example.com" or "true" for any)', - ) - .addOption(new Option("--allowed-hosts ").hideHelp()) - .action( - withOutput((...args) => { - const [options, command] = args.slice(-2) as [(typeof args)[number], Command]; - return runRestartCommand( - { - ...options, - hostnames: resolveHostnamesOption(options.hostnames, options.allowedHosts), - }, - command, - ); - }), - ); - + for (const command of [ + startCommand(), + daemonRunCommand(), + daemonStatusCommand(), + daemonStopCommand(), + daemonRestartCommand(), + daemonReloadCommand(), + pairCommand(), + daemonConfigCommand(), + ]) + daemon.addCommand(command); addJsonOption( - daemon - .command("set-password") - .description("Prompt for and save a hashed daemon password to config.json"), - ) - .option("--home ", "Paseo home directory (default: ~/.paseo)") - .action(withOutput(runSetPasswordCommand)); - + addLocalDaemonOptions( + daemon.command("set-password").description("Save a hashed daemon password (local operation)"), + ), + ).action(withOutput(runSetPasswordCommand)); return daemon; } diff --git a/packages/cli/src/commands/daemon/lifecycle.e2e.test.ts b/packages/cli/src/commands/daemon/lifecycle.e2e.test.ts new file mode 100644 index 0000000000..ea6cfc48f9 --- /dev/null +++ b/packages/cli/src/commands/daemon/lifecycle.e2e.test.ts @@ -0,0 +1,609 @@ +import { spawn } from "node:child_process"; +import { mkdtemp, mkdir, readFile, rm, writeFile, stat } from "node:fs/promises"; +import { existsSync } from "node:fs"; +import net from "node:net"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import { fileURLToPath, pathToFileURL } from "node:url"; +import { startDaemonInstance, readDaemonInstance } from "@getpaseo/server"; +import { expect, test } from "vitest"; +import { connectToDaemon } from "../../utils/client.js"; + +const repo = fileURLToPath(new URL("../../../../..", import.meta.url)); +const cli = path.join(repo, "packages/cli/dist/index.js"); + +async function port() { + const server = net.createServer(); + await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve)); + const value = (server.address() as net.AddressInfo).port; + await new Promise((resolve) => server.close(() => resolve())); + if (value === 6767 || value === 6768) throw new Error("Unsafe test port"); + return value; +} + +async function fixture() { + const root = await mkdtemp(path.join(tmpdir(), "paseo lifecycle ")); + const env = { + ...Object.fromEntries(Object.entries(process.env).filter(([key]) => !key.startsWith("PASEO_"))), + HOME: root, + USERPROFILE: root, + }; + const homes = [path.join(root, "a"), path.join(root, "b")]; + const owned = new Map(); + async function run(args: string[], overrides: NodeJS.ProcessEnv = {}) { + const child = spawn(process.execPath, [cli, ...args], { + cwd: root, + env: { ...env, ...overrides }, + stdio: ["ignore", "pipe", "pipe"], + }); + let stdout = "", + stderr = ""; + child.stdout.on("data", (data) => { + stdout += data; + }); + child.stderr.on("data", (data) => { + stderr += data; + }); + const code = await new Promise((resolve, reject) => { + const timer = setTimeout(() => { + child.kill("SIGTERM"); + reject(new Error(`CLI timed out: ${args.join(" ")}\n${stderr}`)); + }, 45_000); + child.once("error", reject); + child.once("close", (exitCode) => { + clearTimeout(timer); + resolve(exitCode); + }); + }); + for (const home of homes) { + try { + const lock = JSON.parse(await readFile(path.join(home, "paseo.pid"), "utf8")); + owned.set(home, lock); + } catch { + /* No acquired launch. */ + } + } + return { code, stdout, stderr, json: () => JSON.parse(stdout) }; + } + async function ok(args: string[], overrides?: NodeJS.ProcessEnv) { + const result = await run(["--json", ...args], overrides); + expect(result.code, `${args.join(" ")}\n${result.stdout}\n${result.stderr}`).toBe(0); + return result.json(); + } + async function liveStatus(home: string, overrides?: NodeJS.ProcessEnv) { + let status; + await expect + .poll( + async () => { + status = await ok(["status", "--home", home], overrides); + return status.connectedDaemon; + }, + { timeout: 30_000 }, + ) + .toBe("reachable"); + return status; + } + async function configure(home: string, listen: string) { + for (const [field, value] of [ + ["daemon.listen", listen], + ["daemon.relay.enabled", "false"], + ["features.webUi.enabled", "false"], + ["features.dictation.enabled", "false"], + ["features.voiceMode.enabled", "false"], + ]) { + await ok(["daemon", "config", "set", field!, value!, "--home", home]); + } + } + async function close() { + for (const [home, captured] of owned) { + try { + const lock = JSON.parse(await readFile(path.join(home, "paseo.pid"), "utf8")); + if (lock.pid === captured.pid && lock.startedAt === captured.startedAt) { + await run(["daemon", "stop", "--home", home, "--force", "--timeout", "2"]); + } + } catch { + /* Already exited. */ + } + } + await rm(root, { recursive: true, force: true }); + } + return { root, homes, env, run, ok, liveStatus, configure, close }; +} + +test("managed two-home restart retains its supervisor and never routes ordinary commands or stop to the other home", async () => { + const f = await fixture(); + const [a, b] = f.homes as [string, string]; + const portA = await port(), + portB = await port(), + movedPort = await port(); + try { + await f.configure(a, `127.0.0.1:${portA}`); + await f.configure(b, `127.0.0.1:${portB}`); + const launchA = await f.ok(["start", "--home", a, "--timeout", "30"]); + const poisoned = { + PASEO_HOME: a, + PASEO_HOST: `127.0.0.1:${portA}`, + PASEO_LISTEN: `127.0.0.1:${portA}`, + PORT: String(portA), + PASEO_WEB_UI_ENABLED: "true", + PASEO_RELAY_ENABLED: "true", + }; + const launchB = await f.ok(["daemon", "start", "--home", b, "--timeout", "30"], poisoned); + expect(launchA.listen).toBe(`127.0.0.1:${portA}`); + expect(launchB.listen).toBe(`127.0.0.1:${portB}`); + const beforeA = await f.liveStatus(a); + const beforeB = await f.ok(["--home", b, "daemon", "status"], poisoned); + if (process.platform !== "win32") { + for (const home of [a, b, path.join(f.root, ".paseo")]) + expect((await stat(home)).mode & 0o777).toBe(0o700); + } + + const repoB = path.join(f.root, "project-b"); + await mkdir(repoB); + await f.ok(["project", "create", repoB, "--home", b], poisoned); + expect(await f.ok(["project", "ls", "--home", a])).toEqual([]); + expect((await f.ok(["project", "ls", "--home", b], poisoned)).length).toBe(1); + const stream = await f.run(["logs", "missing-agent", "--follow", "--home", b], poisoned); + expect(stream.stderr).toContain("Agent not found: missing-agent"); + const restart = await f.ok(["restart", "--home", b, "--timeout", "30"], poisoned); + expect(restart.supervisorPid).toBe(launchB.pid); + expect(restart.workerPid).not.toBe(beforeB.workerPid); + const saved = await f.ok( + ["daemon", "config", "set", "daemon.listen", `127.0.0.1:${movedPort}`, "--home", b], + poisoned, + ); + expect(saved.restartRequiredPaths).toContain("daemon.listen"); + await f.ok(["daemon", "restart", "--home", b, "--timeout", "30"], poisoned); + const moved = await f.liveStatus(b); + expect(moved.listen).toBe(`127.0.0.1:${movedPort}`); + expect(moved.pid).toBe(launchB.pid); + await f.ok(["daemon", "stop", "--home", b], poisoned); + const stoppedQuery = await f.run(["--json", "project", "ls", "--home", b], poisoned); + expect(stoppedQuery.code).toBe(1); + expect(stoppedQuery.stderr).toContain("DAEMON_NOT_RUNNING"); + const afterA = await f.liveStatus(a); + expect(afterA.workerPid).toBe(beforeA.workerPid); + expect(afterA.pid).toBe(beforeA.pid); + expect(await f.ok(["project", "ls", "--home", a])).toEqual([]); + } finally { + await f.close(); + } +}, 120_000); + +test("removed flags and ambiguous targets fail before side effects; observation does not create a home", async () => { + const f = await fixture(); + const home = f.homes[0]!; + try { + await f.ok(["status", "--home", home]); + await f.ok(["daemon", "config", "get", "--home", home]); + expect(existsSync(home)).toBe(false); + for (const args of [ + ["start", "--port", "12345"], + ["start", "--foreground"], + ["daemon", "restart", "--no-relay"], + ]) { + const result = await f.run(["--json", ...args, "--home", home]); + expect(result.code).toBe(1); + expect(result.stderr).toContain("REMOVED_LAUNCH_OPTION"); + expect(existsSync(home)).toBe(false); + } + const ambiguous = await f.run(["--home", home, "project", "ls", "--host", "unused:12345"]); + expect(ambiguous.stderr).toContain("Choose either --home or --host"); + expect(existsSync(home)).toBe(false); + } finally { + await f.close(); + } +}, 30_000); + +test("an occupied initial or replacement address fails without false readiness or killing its owner", async () => { + const f = await fixture(); + const [a, b] = f.homes as [string, string]; + const portA = await port(), + portB = await port(); + try { + await f.configure(a, `127.0.0.1:${portA}`); + await f.configure(b, `127.0.0.1:${portA}`); + await f.ok(["start", "--home", a, "--timeout", "30"]); + const before = await f.liveStatus(a); + const failed = await f.run(["--json", "start", "--home", b, "--timeout", "30"]); + expect(failed.code).toBe(1); + expect(failed.stderr).toContain("DAEMON_START_FAILED"); + await f.ok(["daemon", "config", "set", "daemon.listen", `127.0.0.1:${portB}`, "--home", b]); + await f.ok(["start", "--home", b, "--timeout", "30"]); + await f.ok(["daemon", "config", "set", "daemon.listen", `127.0.0.1:${portA}`, "--home", b]); + const restart = await f.run(["--json", "restart", "--home", b, "--timeout", "30"]); + expect(restart.code).toBe(1); + expect(restart.stderr).toContain("RESTART_NOT_CONFIRMED"); + await f.ok(["daemon", "stop", "--home", b]); + expect((await f.liveStatus(a)).workerPid).toBe(before.workerPid); + } finally { + await f.close(); + } +}, 120_000); + +test("a slow worker outlives the caller's readiness deadline and remains stoppable", async () => { + const f = await fixture(); + const home = f.homes[0]!; + try { + await f.configure(home, `127.0.0.1:${await port()}`); + const preload = path.join(f.root, "slow-worker.mjs"); + await writeFile( + preload, + 'if (process.argv[1]?.endsWith("daemon-worker.js")) await new Promise(resolve => setTimeout(resolve, 5000));', + ); + const launch = await f.run(["--json", "start", "--home", home, "--timeout", "1"], { + NODE_OPTIONS: `--import=${pathToFileURL(preload).href}`, + }); + expect(launch.code).toBe(1); + expect(launch.stderr).toContain("DAEMON_NOT_READY"); + expect(launch.stderr).toContain("remains running"); + expect(launch.stderr).toContain("Logs:"); + expect(launch.stderr).toContain("Status:"); + expect(launch.stderr).toContain("Stop:"); + const pending = await f.ok(["status", "--home", home]); + expect(pending.localDaemon).toBe("not_ready"); + expect(pending.listen).toBeNull(); + process.kill(pending.pid, 0); + const already = await f.ok(["start", "--home", home]); + expect(already.action).toBe("already_running"); + expect(already.pid).toBe(pending.pid); + await f.ok(["project", "ls", "--home", home]); + expect((await f.ok(["status", "--home", home])).pid).toBe(pending.pid); + await f.ok(["daemon", "stop", "--home", home]); + } finally { + await f.close(); + } +}, 60_000); + +test("worker restart preserves an already-running legacy supervisor's launch flags", async () => { + const f = await fixture(); + const home = f.homes[0]!; + const filePort = await port(), + launchPort = await port(); + try { + await f.configure(home, `127.0.0.1:${filePort}`); + const launch = await startDaemonInstance({ + home, + command: process.execPath, + args: [ + path.join(repo, "packages/server/dist/scripts/supervisor-entrypoint.js"), + "--no-relay", + "--no-web-ui", + ], + // The legacy CLI translated --port into PASEO_LISTEN before spawning. + env: { ...f.env, PASEO_LISTEN: `127.0.0.1:${launchPort}` }, + mode: "deployment", + timeoutMs: 30_000, + }); + const before = await f.liveStatus(home); + expect(before.listen).toBe(`127.0.0.1:${launchPort}`); + const restarted = await f.ok(["restart", "--home", home, "--timeout", "30"], { + PASEO_LISTEN: `127.0.0.1:${filePort}`, + }); + expect(restarted.supervisorPid).toBe(launch.instance.pid); + expect(restarted.workerPid).not.toBe(before.workerPid); + expect((await f.ok(["status", "--home", home])).listen).toBe(before.listen); + } finally { + await f.close(); + } +}, 60_000); + +test.skipIf(process.platform === "win32")( + "POSIX stop ignores a wrong endpoint even with copied identities; supported live identity overrides still restart", + async () => { + const f = await fixture(); + const [a, b] = f.homes as [string, string]; + try { + await f.configure(a, `127.0.0.1:${await port()}`); + await f.configure(b, `127.0.0.1:${await port()}`); + await writeFile(path.join(b, "server-id"), "saved-b"); + await f.ok(["start", "--home", a, "--timeout", "30"]); + await f.ok(["start", "--home", b, "--timeout", "30"], { PASEO_SERVER_ID: "live-b" }); + const beforeA = await f.liveStatus(a); + expect((await f.liveStatus(b)).serverId).toBe("live-b"); + await f.ok(["restart", "--home", b, "--timeout", "30"]); + const bLock = JSON.parse(await readFile(path.join(b, "paseo.pid"), "utf8")); + await writeFile( + path.join(b, "paseo.pid"), + JSON.stringify({ ...bLock, listen: beforeA.listen }), + ); + await writeFile(path.join(b, "server-id"), beforeA.serverId); + await f.ok(["daemon", "stop", "--home", b]); + expect((await f.liveStatus(a)).workerPid).toBe(beforeA.workerPid); + } finally { + await f.close(); + } + }, + 60_000, +); + +test.skipIf(process.platform === "win32").each([["start"], ["daemon", "run"]])( + "explicit cancellation before readiness cleans up only this launch: %j", + async (...launchArgs) => { + const f = await fixture(); + const home = f.homes[0]!; + let child: ReturnType | undefined; + try { + await f.configure(home, `127.0.0.1:${await port()}`); + const preload = path.join(f.root, "slow-worker.mjs"); + await writeFile( + preload, + 'if (process.argv[1]?.endsWith("daemon-worker.js")) await new Promise(resolve => setTimeout(resolve, 30000));', + ); + child = spawn(process.execPath, [cli, ...launchArgs, "--home", home], { + cwd: repo, + env: { ...f.env, NODE_OPTIONS: `--import=${pathToFileURL(preload).href}` }, + stdio: "ignore", + }); + const exited = new Promise((resolve) => child!.once("exit", resolve)); + await expect + .poll(async () => existsSync(path.join(home, "paseo.pid")), { timeout: 10_000 }) + .toBe(true); + const lock = JSON.parse(await readFile(path.join(home, "paseo.pid"), "utf8")); + await f.ok(["status", "--home", home]); + child.kill("SIGINT"); + await exited; + expect(() => process.kill(lock.pid, 0)).toThrow(); + expect((await f.ok(["status", "--home", home])).localDaemon).toBe("stopped"); + } finally { + child?.kill("SIGTERM"); + await f.close(); + } + }, + 60_000, +); + +test.runIf(process.platform === "win32")( + "Windows unbound stop requires explicit force", + async () => { + const f = await fixture(); + const home = f.homes[0]!; + try { + await f.configure(home, `127.0.0.1:${await port()}`); + const preload = path.join(f.root, "slow-worker.mjs"); + await writeFile( + preload, + 'if (process.argv[1]?.endsWith("daemon-worker.js")) await new Promise(resolve => setTimeout(resolve, 30000));', + ); + const pending = await f.run(["--json", "start", "--home", home, "--timeout", "1"], { + NODE_OPTIONS: `--import=${pathToFileURL(preload).href}`, + }); + expect(pending.stderr).toContain("DAEMON_NOT_READY"); + const refused = await f.run(["--json", "daemon", "stop", "--home", home]); + expect(refused.code).toBe(1); + expect(refused.stderr).toContain("STOP_NO_GRACEFUL_CHANNEL"); + const stopped = await f.ok(["daemon", "stop", "--home", home, "--force"]); + expect(stopped.forced).toBe(true); + expect(stopped.usedLifecycleRpc).toBe(false); + } finally { + await f.close(); + } + }, + 60_000, +); + +test("empty explicit selectors never select the ambient daemon or create local state", async () => { + const f = await fixture(); + const home = f.homes[0]!; + try { + await f.configure(home, `127.0.0.1:${await port()}`); + await f.ok(["start", "--home", home]); + const before = await f.liveStatus(home); + for (const args of [ + ["daemon", "stop", "--home", ""], + ["daemon", "stop", "--host", ""], + ["start", "--home", ""], + ["daemon", "config", "set", "daemon.relay.enabled", "true", "--home", ""], + ]) { + const refused = await f.run(["--json", ...args], { PASEO_HOME: home }); + expect(refused.code).toBe(1); + expect(refused.stderr).toContain("TARGET_INVALID"); + expect((await f.liveStatus(home)).workerPid).toBe(before.workerPid); + expect(existsSync(path.join(f.root, "paseo.pid"))).toBe(false); + expect(existsSync(path.join(f.root, "config.json"))).toBe(false); + } + } finally { + await f.close(); + } +}, 60_000); + +test("IPv6 publication supports home-selected query and worker restart", async () => { + const f = await fixture(); + const home = f.homes[0]!; + const endpoint = `[::1]:${await port()}`; + try { + await f.configure(home, endpoint); + const launch = await f.ok(["start", "--home", home, "--timeout", "30"]); + expect(launch.listen).toBe(endpoint); + expect(await f.ok(["project", "ls", "--home", home])).toEqual([]); + const restarted = await f.ok(["restart", "--home", home, "--timeout", "30"]); + expect(restarted.supervisorPid).toBe(launch.pid); + expect(restarted.workerPid).not.toBe(restarted.previousWorkerPid); + } finally { + await f.close(); + } +}, 60_000); + +test("raw log following subscribes to a stored agent that exists only in B", async () => { + const f = await fixture(); + const [a, b] = f.homes as [string, string]; + let follower: ReturnType | undefined; + try { + await f.configure(a, `127.0.0.1:${await port()}`); + await f.configure(b, `127.0.0.1:${await port()}`); + const agentId = "4de8b157-7614-4c38-b189-e50e57fb9498"; + const now = new Date().toISOString(); + await mkdir(path.join(b, "agents")); + await writeFile( + path.join(b, "agents", `${agentId}.json`), + JSON.stringify({ + id: agentId, + provider: "codex", + cwd: f.root, + title: "B-only stored agent", + createdAt: now, + updatedAt: now, + lastStatus: "closed", + persistence: null, + }), + ); + const launchA = await f.ok(["start", "--home", a]); + await f.ok(["start", "--home", b]); + const absent = await f.run(["logs", agentId, "--home", a]); + expect(absent.stderr).toContain("Agent not found"); + follower = spawn( + process.execPath, + [cli, "logs", agentId, "--follow", "--tail", "0", "--home", b], + { + cwd: f.root, + env: { ...f.env, PASEO_HOME: a, PASEO_HOST: launchA.listen }, + stdio: ["ignore", "pipe", "pipe"], + }, + ); + let output = "", + errors = ""; + follower.stdout!.on("data", (data) => { + output += data; + }); + follower.stderr!.on("data", (data) => { + errors += data; + }); + await expect.poll(() => output, { timeout: 15_000 }).toContain("Following logs (no history;"); + expect(errors).not.toContain("Agent not found"); + } finally { + if (follower && follower.exitCode === null) { + const exited = new Promise((resolve) => follower!.once("exit", resolve)); + follower.kill("SIGINT"); + await exited; + } + await f.close(); + } +}, 60_000); + +test("a connection pinned to an earlier supervisor refuses its replacement", async () => { + const f = await fixture(); + const home = f.homes[0]!; + try { + await f.configure(home, `127.0.0.1:${await port()}`); + await f.ok(["start", "--home", home]); + const captured = (await readDaemonInstance(home))!; + await f.ok(["daemon", "stop", "--home", home]); + await f.ok(["start", "--home", home]); + const before = await f.liveStatus(home); + await expect( + connectToDaemon({ target: { kind: "instance", home }, instance: captured, timeout: 1000 }), + ).rejects.toMatchObject({ code: "DAEMON_REPLACED" }); + expect((await f.liveStatus(home)).workerPid).toBe(before.workerPid); + } finally { + await f.close(); + } +}, 60_000); + +test("foreground deployment retains environment until its owner ends the launch; endpoint stop reports acknowledgment", async () => { + const f = await fixture(); + const home = f.homes[0]!; + const fileEndpoint = `127.0.0.1:${await port()}`, + deploymentEndpoint = `127.0.0.1:${await port()}`, + changedEndpoint = `127.0.0.1:${await port()}`; + let deployment: ReturnType | undefined; + try { + await f.configure(home, fileEndpoint); + deployment = spawn(process.execPath, [cli, "daemon", "run", "--home", home], { + cwd: f.root, + env: { ...f.env, PASEO_LISTEN: deploymentEndpoint, PASEO_RELAY_ENABLED: "false" }, + stdio: "ignore", + }); + await expect + .poll(async () => (await readDaemonInstance(home))?.listen, { timeout: 30_000 }) + .toBe(deploymentEndpoint); + const before = await f.liveStatus(home); + expect(before.configuredListen).toBe(fileEndpoint); + expect(await f.ok(["daemon", "run", "--home", home])).toMatchObject({ + action: "already_running", + pid: before.pid, + }); + expect((await f.liveStatus(home)).workerPid).toBe(before.workerPid); + const changed = await f.ok([ + "daemon", + "config", + "set", + "daemon.listen", + changedEndpoint, + "--home", + home, + ]); + expect(changed.overrideControlledPaths).toContain("daemon.listen"); + const restarted = await f.ok(["restart", "--home", home], { PASEO_LISTEN: fileEndpoint }); + expect(restarted.supervisorPid).toBe(before.pid); + expect((await f.ok(["status", "--home", home])).listen).toBe(deploymentEndpoint); + const exited = new Promise((resolve) => deployment!.once("exit", resolve)); + const remote = await f.ok(["daemon", "stop", "--host", deploymentEndpoint]); + expect(remote.action).toBe("shutdown_requested"); + await exited; + const managed = await f.ok(["start", "--home", home]); + expect(managed.listen).toBe(changedEndpoint); + expect(managed.pid).not.toBe(before.pid); + } finally { + if (deployment?.exitCode === null) deployment.kill("SIGTERM"); + await f.close(); + } +}, 90_000); + +test("malformed configuration cannot turn a readiness timeout into launch cancellation", async () => { + const f = await fixture(); + const home = f.homes[0]!; + let starter: ReturnType | undefined; + try { + await f.configure(home, `127.0.0.1:${await port()}`); + const configPath = path.join(home, "config.json"); + const saved = await readFile(configPath, "utf8"); + const preload = path.join(f.root, "slow-worker.mjs"); + await writeFile( + preload, + 'if (process.argv[1]?.endsWith("daemon-worker.js")) await new Promise(resolve => setTimeout(resolve, 30000));', + ); + starter = spawn(process.execPath, [cli, "--json", "start", "--home", home, "--timeout", "2"], { + cwd: f.root, + env: { ...f.env, NODE_OPTIONS: `--import=${pathToFileURL(preload).href}` }, + stdio: ["ignore", "pipe", "pipe"], + }); + let errors = ""; + starter.stderr!.on("data", (data) => { + errors += data; + }); + const exited = new Promise((resolve) => starter!.once("exit", resolve)); + await expect + .poll(async () => (await readDaemonInstance(home))?.pid, { timeout: 10_000 }) + .toBeTruthy(); + const captured = (await readDaemonInstance(home))!; + await f.ok(["status", "--home", home]); + await writeFile(configPath, "{"); + await exited; + expect(errors).toContain("DAEMON_NOT_READY"); + expect(errors).toContain(path.join(home, "daemon.log")); + process.kill(captured.pid, 0); + await writeFile(configPath, saved); + await f.ok(["status", "--home", home]); + } finally { + if (starter?.exitCode === null) starter.kill("SIGTERM"); + await f.close(); + } +}, 60_000); + +test("onboarding preserves an existing MCP choice unless explicitly supplied", async () => { + const f = await fixture(); + const home = f.homes[0]!; + try { + await f.configure(home, `127.0.0.1:${await port()}`); + await f.ok(["daemon", "config", "set", "daemon.mcp.enabled", "false", "--home", home]); + for (const flags of [[], ["--no-mcp"]]) { + const onboard = await f.run(["onboard", "--home", home, "--voice", "disable", ...flags]); + expect(onboard.code, onboard.stderr).toBe(0); + expect( + (await f.ok(["daemon", "config", "get", "daemon.mcp.enabled", "--home", home])).value, + ).toBe(false); + } + } finally { + await f.close(); + } +}, 60_000); diff --git a/packages/cli/src/commands/daemon/local-daemon.supervision.test.ts b/packages/cli/src/commands/daemon/local-daemon.supervision.test.ts deleted file mode 100644 index 03449fa0cb..0000000000 --- a/packages/cli/src/commands/daemon/local-daemon.supervision.test.ts +++ /dev/null @@ -1,214 +0,0 @@ -import { EventEmitter } from "node:events"; -import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises"; -import os from "node:os"; -import path from "node:path"; -import { afterEach, beforeEach, describe, expect, test, vi } from "vitest"; - -import { - type DaemonLaunchRuntime, - type DetachedDaemonProcess, - resolveLocalDaemonState, - startLocalDaemonDetached, - startLocalDaemonForeground, -} from "./local-daemon.js"; - -type RecordedDaemonLaunch = - | { - mode: "detached"; - command: string; - args: string[]; - options: Parameters[2]; - } - | { - mode: "foreground"; - command: string; - args: string[]; - options: Parameters[2]; - }; - -class FakeDaemonProcess extends EventEmitter implements DetachedDaemonProcess { - pid = 4242; - wasUnreferenced = false; - - unref(): void { - this.wasUnreferenced = true; - } -} - -class FakeDaemonRuntime implements DaemonLaunchRuntime { - readonly recordedLaunches: RecordedDaemonLaunch[] = []; - readonly daemonProcess = new FakeDaemonProcess(); - foregroundStatus = 0; - runnerEntry = "/repo/packages/server/scripts/supervisor-entrypoint.ts"; - - resolveRunnerEntry(): string { - return this.runnerEntry; - } - - resolveHome(env: NodeJS.ProcessEnv): string { - return env.PASEO_HOME ?? "/tmp/paseo"; - } - - spawnDetached( - command: string, - args: string[], - options: Parameters[2], - ): DetachedDaemonProcess { - this.recordedLaunches.push({ mode: "detached", command, args, options }); - return this.daemonProcess; - } - - spawnForeground( - command: string, - args: string[], - options: Parameters[2], - ) { - this.recordedLaunches.push({ mode: "foreground", command, args, options }); - return { status: this.foregroundStatus, error: undefined }; - } -} - -const tempRoots: string[] = []; - -async function createPaseoHome(config: unknown): Promise { - const root = await mkdtemp(path.join(os.tmpdir(), "paseo-local-daemon-")); - tempRoots.push(root); - const paseoHome = path.join(root, ".paseo"); - await mkdir(paseoHome, { recursive: true }); - await writeFile(path.join(paseoHome, "config.json"), JSON.stringify(config, null, 2)); - return paseoHome; -} - -function expectSupervisorLaunch(argv: string[]): void { - const joined = argv.join(" "); - expect(joined).toContain("supervisor-entrypoint"); - expect(joined).not.toContain("src/server/index.ts"); - expect(joined).not.toContain("dist/server/server/index.js"); - expect(joined).not.toContain("src/server/daemon-worker.ts"); - expect(joined).not.toContain("dist/server/server/daemon-worker.js"); -} - -describe("local daemon launch supervision", () => { - beforeEach(() => { - vi.useRealTimers(); - }); - - afterEach(async () => { - await Promise.all( - tempRoots.splice(0).map((root) => rm(root, { recursive: true, force: true })), - ); - vi.restoreAllMocks(); - }); - - test("foreground start spawns supervisor-entrypoint instead of server/index", async () => { - const runtime = new FakeDaemonRuntime(); - - const status = startLocalDaemonForeground({ home: "/tmp/paseo-test", relay: false }, runtime); - - expect(status).toBe(0); - expect(runtime.recordedLaunches.map((launch) => launch.mode)).toEqual(["foreground"]); - const launch = runtime.recordedLaunches[0]; - expect(launch?.mode).toBe("foreground"); - expect(launch?.command).toBe(process.execPath); - expectSupervisorLaunch(launch?.args ?? []); - expect(launch?.args).toContain("--no-relay"); - }); - - test("detached start spawns supervisor-entrypoint instead of server/index", async () => { - vi.useFakeTimers(); - const runtime = new FakeDaemonRuntime(); - - const resultPromise = startLocalDaemonDetached( - { home: "/tmp/paseo-test", mcp: false }, - runtime, - ); - await vi.advanceTimersByTimeAsync(1200); - const result = await resultPromise; - - expect(result).toEqual({ pid: 4242, logPath: "/tmp/paseo-test/daemon.log" }); - expect(runtime.daemonProcess.wasUnreferenced).toBe(true); - expect(runtime.recordedLaunches.map((launch) => launch.mode)).toEqual(["detached"]); - const launch = runtime.recordedLaunches[0]; - expect(launch?.mode).toBe("detached"); - expect(launch?.command).toBe(process.execPath); - expectSupervisorLaunch(launch?.args ?? []); - expect(launch?.args).toContain("--no-mcp"); - }); - - test("relay TLS flag is passed to the supervised daemon", async () => { - const runtime = new FakeDaemonRuntime(); - - const status = startLocalDaemonForeground( - { - home: "/tmp/paseo-test", - relayUseTls: true, - }, - runtime, - ); - - expect(status).toBe(0); - expect(runtime.recordedLaunches.map((launch) => launch.mode)).toEqual(["foreground"]); - const launch = runtime.recordedLaunches[0]; - expect(launch?.mode).toBe("foreground"); - expect(launch?.args).toContain("--relay-use-tls"); - expect(launch?.options?.env?.PASEO_RELAY_USE_TLS).toBe("true"); - }); - - test("web UI flag is passed to the supervised daemon", async () => { - const runtime = new FakeDaemonRuntime(); - - const status = startLocalDaemonForeground( - { - home: "/tmp/paseo-test", - webUi: true, - }, - runtime, - ); - - expect(status).toBe(0); - expect(runtime.recordedLaunches.map((launch) => launch.mode)).toEqual(["foreground"]); - const launch = runtime.recordedLaunches[0]; - expect(launch?.mode).toBe("foreground"); - expect(launch?.args).toContain("--web-ui"); - expect(launch?.options?.env?.PASEO_WEB_UI_ENABLED).toBe("true"); - }); - - test("no-web UI flag is passed to the supervised daemon", async () => { - const runtime = new FakeDaemonRuntime(); - - const status = startLocalDaemonForeground( - { - home: "/tmp/paseo-test", - webUi: false, - }, - runtime, - ); - - expect(status).toBe(0); - expect(runtime.recordedLaunches.map((launch) => launch.mode)).toEqual(["foreground"]); - const launch = runtime.recordedLaunches[0]; - expect(launch?.mode).toBe("foreground"); - expect(launch?.args).toContain("--no-web-ui"); - expect(launch?.options?.env?.PASEO_WEB_UI_ENABLED).toBe("false"); - }); - - test("local daemon state keeps public relay TLS separate from daemon relay TLS", async () => { - const home = await createPaseoHome({ - version: 1, - daemon: { - relay: { - endpoint: "10.0.0.5:51185", - publicEndpoint: "paseo.example.com", - useTls: false, - publicUseTls: true, - }, - }, - }); - - const state = resolveLocalDaemonState({ home }); - - expect(state.relayEndpoint).toBe("paseo.example.com"); - expect(state.relayUseTls).toBe(false); - expect(state.relayPublicUseTls).toBe(true); - }); -}); diff --git a/packages/cli/src/commands/daemon/local-daemon.ts b/packages/cli/src/commands/daemon/local-daemon.ts index a7afb4e6b7..049cfd0bd9 100644 --- a/packages/cli/src/commands/daemon/local-daemon.ts +++ b/packages/cli/src/commands/daemon/local-daemon.ts @@ -1,184 +1,9 @@ -import { spawnSync, type ChildProcess } from "node:child_process"; -import { existsSync, readFileSync, unlinkSync } from "node:fs"; +import { Command, Option } from "commander"; +import { existsSync, readFileSync } from "node:fs"; import { createRequire } from "node:module"; import path from "node:path"; -import { loadConfig, resolvePaseoHome, spawnProcess } from "@getpaseo/server"; -import treeKill from "tree-kill"; -import { tryConnectToDaemon } from "../../utils/client.js"; - -export interface DaemonStartOptions { - port?: string; - listen?: string; - home?: string; - foreground?: boolean; - relay?: boolean; - relayUseTls?: boolean; - mcp?: boolean; - injectMcp?: boolean; - webUi?: boolean; - hostnames?: string; -} - -export interface LocalDaemonPidInfo { - pid: number; - startedAt?: string; - hostname?: string; - uid?: number; - listen?: string; - desktopManaged?: boolean; -} - -export interface LocalDaemonState { - home: string; - listen: string; - relayEnabled: boolean; - relayEndpoint: string; - relayUseTls: boolean; - relayPublicUseTls: boolean; - logPath: string; - pidPath: string; - pidInfo: LocalDaemonPidInfo | null; - running: boolean; - stalePidFile: boolean; -} - -export interface DetachedStartResult { - pid: number | null; - logPath: string; -} - -export interface StopLocalDaemonOptions { - home?: string; - timeoutMs?: number; - killTimeoutMs?: number; - force?: boolean; -} - -export interface StopLocalDaemonResult { - action: "stopped" | "not_running"; - home: string; - pid: number | null; - forced: boolean; - usedLifecycleRpc: boolean; - reason: "not_running" | "lifecycle_shutdown_rpc" | "owner_pid_signal" | "owner_pid_sigkill"; - message: string; -} - -interface ProcessExitDetails { - code: number | null; - signal: NodeJS.Signals | null; - error?: Error; -} - -type DetachedStartupResult = { exitedEarly: false } | ({ exitedEarly: true } & ProcessExitDetails); - -export interface DetachedDaemonProcess extends Pick {} - -export interface ForegroundDaemonProcessResult { - status: number | null; - error?: Error; -} - -export interface DaemonLaunchRuntime { - resolveRunnerEntry(): string; - resolveHome(env: NodeJS.ProcessEnv): string; - spawnDetached( - command: string, - args: string[], - options: Parameters[2], - ): DetachedDaemonProcess; - spawnForeground( - command: string, - args: string[], - options: Parameters[2], - ): ForegroundDaemonProcessResult; -} - -const DETACHED_STARTUP_GRACE_MS = 1200; -const PID_POLL_INTERVAL_MS = 100; -const DAEMON_LOG_FILENAME = "daemon.log"; -const DAEMON_PID_FILENAME = "paseo.pid"; - -export const DEFAULT_STOP_TIMEOUT_MS = 15_000; -export const DEFAULT_KILL_TIMEOUT_MS = 3_000; - +import { startDaemonInstance, resolvePaseoHome } from "@getpaseo/server"; const require = createRequire(import.meta.url); - -const defaultDaemonLaunchRuntime: DaemonLaunchRuntime = { - resolveRunnerEntry: resolveDaemonRunnerEntry, - resolveHome: resolvePaseoHome, - spawnDetached: spawnProcess, - spawnForeground: spawnSync, -}; - -const startupReady = (): DetachedStartupResult => ({ exitedEarly: false }); - -const startupExited = (details: ProcessExitDetails): DetachedStartupResult => ({ - exitedEarly: true, - ...details, -}); - -function envWithHome(home?: string): NodeJS.ProcessEnv { - if (!home) { - return process.env; - } - - return { ...process.env, PASEO_HOME: home }; -} - -function buildRunnerArgs(options: DaemonStartOptions): string[] { - const args: string[] = []; - if (options.relay === true) { - args.push("--relay"); - } - if (options.relay === false) { - args.push("--no-relay"); - } - if (options.relayUseTls === true) { - args.push("--relay-use-tls"); - } - - if (options.mcp === false) { - args.push("--no-mcp"); - } - if (options.injectMcp === false) { - args.push("--no-inject-mcp"); - } - if (options.webUi === true) { - args.push("--web-ui"); - } - if (options.webUi === false) { - args.push("--no-web-ui"); - } - - return args; -} - -function buildChildEnv(options: DaemonStartOptions): NodeJS.ProcessEnv { - const childEnv: NodeJS.ProcessEnv = { ...process.env }; - if (options.home) { - childEnv.PASEO_HOME = options.home; - } - if (options.listen) { - childEnv.PASEO_LISTEN = options.listen; - } else if (options.port) { - childEnv.PASEO_LISTEN = `127.0.0.1:${options.port}`; - } - if (options.hostnames) { - childEnv.PASEO_HOSTNAMES = options.hostnames; - } - if (options.relayUseTls === true) { - childEnv.PASEO_RELAY_USE_TLS = "true"; - } - if (options.webUi === true) { - childEnv.PASEO_WEB_UI_ENABLED = "true"; - } - if (options.webUi === false) { - childEnv.PASEO_WEB_UI_ENABLED = "false"; - } - return childEnv; -} - function resolveServerRunnerFromDir(currentDir: string): string | null { const packageJsonPath = path.join(currentDir, "package.json"); if (!existsSync(packageJsonPath)) return null; @@ -215,550 +40,72 @@ function resolveDaemonRunnerEntry(): string { throw new Error("Unable to resolve @getpaseo/server package root for daemon runner"); } -function pidFilePath(paseoHome: string): string { - return path.join(paseoHome, DAEMON_PID_FILENAME); -} - -function resolveListenField(listen: unknown, sockPath: unknown): string | undefined { - if (typeof listen === "string") return listen; - if (typeof sockPath === "string") return sockPath; - return undefined; -} - -function resolveStopMessage( - forced: boolean, - lifecycleRequested: boolean, - fallbackMessage: string | null | undefined, -): string { - if (forced) return "Daemon owner process was force-stopped"; - if (lifecycleRequested) return "Daemon stopped gracefully"; - return fallbackMessage ?? "Daemon stopped via owner PID signal"; -} - -function resolveStopReason( - forced: boolean, - lifecycleRequested: boolean, -): StopLocalDaemonResult["reason"] { - if (forced) return "owner_pid_sigkill"; - if (lifecycleRequested) return "lifecycle_shutdown_rpc"; - return "owner_pid_signal"; -} - -function readPidFile(pidPath: string): LocalDaemonPidInfo | null { +export async function launchLocalDaemon(options: { + home: string; + timeoutMs?: number; + foreground?: boolean; +}) { + const abort = new AbortController(); + const cancel = () => abort.abort(); + process.once("SIGINT", cancel); + process.once("SIGTERM", cancel); try { - const parsed = JSON.parse(readFileSync(pidPath, "utf-8")) as Record; - const pidValue = parsed.pid; - if (typeof pidValue !== "number" || !Number.isInteger(pidValue) || pidValue <= 0) { - return null; - } - - return { - pid: pidValue, - startedAt: typeof parsed.startedAt === "string" ? parsed.startedAt : undefined, - hostname: typeof parsed.hostname === "string" ? parsed.hostname : undefined, - uid: typeof parsed.uid === "number" ? parsed.uid : undefined, - listen: resolveListenField(parsed.listen, parsed.sockPath), - desktopManaged: parsed.desktopManaged === true ? true : undefined, - }; - } catch { - return null; - } -} - -function tailFile(filePath: string, lines = 30): string | null { - try { - const content = readFileSync(filePath, "utf-8"); - return content.split("\n").filter(Boolean).slice(-lines).join("\n"); - } catch { - return null; - } -} - -function sleep(ms: number): Promise { - return new Promise((resolve) => { - setTimeout(resolve, ms); - }); -} - -function readNodeErrnoCode(error: unknown): string | undefined { - if (typeof error !== "object" || error === null || !("code" in error)) { - return undefined; - } - - return typeof error.code === "string" ? error.code : undefined; -} - -function isProcessRunning(pid: number): boolean { - try { - process.kill(pid, 0); - return true; - } catch (err) { - const code = readNodeErrnoCode(err); - if (code === "EPERM") { - return true; - } - return false; - } -} - -function signalProcess(pid: number, signal: NodeJS.Signals): boolean { - try { - process.kill(pid, signal); - return true; - } catch (err) { - const code = readNodeErrnoCode(err); - if (code === "ESRCH") { - return false; - } - throw err; - } -} - -function signalProcessSafely(pid: number, signal: NodeJS.Signals): boolean { - if (!Number.isInteger(pid) || pid <= 1 || pid === process.pid) { - return false; - } - - try { - return signalProcess(pid, signal); - } catch (err) { - const code = readNodeErrnoCode(err); - if (code === "EPERM") { - return true; - } - throw err; - } -} - -async function signalProcessTreeSafely(pid: number, signal: NodeJS.Signals): Promise { - if (!Number.isInteger(pid) || pid <= 1 || pid === process.pid) { - return false; - } - - return new Promise((resolve, reject) => { - treeKill(pid, signal, (err) => { - if (!err) { - resolve(true); - return; - } - - const code = readNodeErrnoCode(err); - if (code === "ESRCH") { - resolve(false); - return; - } - if (code === "EPERM") { - resolve(true); - return; - } - reject(err); + const entry = resolveDaemonRunnerEntry(); + return await startDaemonInstance({ + home: resolvePaseoHome({ PASEO_HOME: options.home }), + command: process.execPath, + args: [...(entry.endsWith(".ts") ? ["--import", "tsx"] : []), entry], + env: process.env, + mode: options.foreground ? "deployment" : "managed", + foreground: options.foreground, + timeoutMs: options.timeoutMs, + signal: abort.signal, + onReady: options.foreground + ? (instance) => + process.stdout.write(`Listening on ${instance.listen} (PID ${instance.pid})\n`) + : undefined, }); - }); -} - -async function signalProcessTreeOrOwnerSafely( - pid: number, - signal: NodeJS.Signals, -): Promise { - try { - return await signalProcessTreeSafely(pid, signal); - } catch { - return signalProcessSafely(pid, signal); - } -} - -async function waitForPidExit(pid: number, timeoutMs: number): Promise { - const deadline = Date.now() + timeoutMs; - async function poll(): Promise { - if (!isProcessRunning(pid)) return true; - if (Date.now() >= deadline) return !isProcessRunning(pid); - await sleep(PID_POLL_INTERVAL_MS); - return poll(); - } - return poll(); -} - -async function waitForDaemonUnreachable( - state: LocalDaemonState, - timeoutMs: number, -): Promise { - const host = resolveTcpHostFromListen(state.listen); - if (!host) { - return true; - } - - const reachableHost = host; - const deadline = Date.now() + timeoutMs; - async function poll(): Promise { - const client = await tryConnectToDaemon({ host: reachableHost, timeout: 500 }); - if (!client) { - return true; - } - await client.close().catch(() => undefined); - if (Date.now() >= deadline) { - const finalClient = await tryConnectToDaemon({ - host: reachableHost, - timeout: PID_POLL_INTERVAL_MS, - }); - if (!finalClient) { - return true; - } - await finalClient.close().catch(() => undefined); - return false; - } - await sleep(PID_POLL_INTERVAL_MS); - return poll(); - } - - return poll(); -} - -function removeStalePidFile(state: LocalDaemonState): void { - if (!state.stalePidFile) { - return; - } - - try { - unlinkSync(state.pidPath); - } catch { - // Best-effort cleanup only. The successful lifecycle stop is authoritative. - } -} - -function createNotRunningStopResult( - state: LocalDaemonState, - pid: number | null, - message: string, -): StopLocalDaemonResult { - return { - action: "not_running", - home: state.home, - pid, - forced: false, - usedLifecycleRpc: false, - reason: "not_running", - message, - }; -} - -function createStopTimeoutError( - state: LocalDaemonState, - pid: number | null, - timeoutMs: number, -): Error { - if (!state.running) { - const host = resolveTcpHostFromListen(state.listen); - return new Error( - `Timed out waiting for daemon${host ? ` at ${host}` : ""} to stop after ${Math.ceil( - timeoutMs / 1000, - )}s`, - ); - } - return new Error( - `Timed out waiting for daemon PID ${pid} to stop after ${Math.ceil(timeoutMs / 1000)}s`, - ); -} - -async function signalDaemonOwnerForStop( - state: LocalDaemonState, - pid: number | null, -): Promise { - if (pid === null) { - return createNotRunningStopResult(state, null, "Daemon is not running"); - } - - const signaled = await signalProcessTreeOrOwnerSafely(pid, "SIGTERM"); - if (signaled) { - return null; - } - - return createNotRunningStopResult(state, pid, "Daemon process was already stopped"); -} - -async function waitForStopAfterRequest(args: { - state: LocalDaemonState; - pid: number | null; - timeoutMs: number; - killTimeoutMs: number; - force?: boolean; -}): Promise<{ stopped: boolean; forced: boolean }> { - const { state, pid, timeoutMs, killTimeoutMs, force } = args; - let stopped = - state.running && pid !== null - ? await waitForPidExit(pid, timeoutMs) - : await waitForDaemonUnreachable(state, timeoutMs); - - if (!stopped && force && state.running && pid !== null) { - await signalProcessTreeOrOwnerSafely(pid, "SIGKILL"); - stopped = await waitForPidExit(pid, killTimeoutMs); - return { stopped, forced: true }; - } - - return { stopped, forced: false }; -} - -type LifecycleShutdownAttempt = { requested: true } | { requested: false; reason: string }; - -function getErrorMessage(error: unknown): string { - return error instanceof Error ? error.message : String(error); -} - -export function resolveLocalPaseoHome(home?: string): string { - return resolvePaseoHome(envWithHome(home)); -} - -export function resolveTcpHostFromListen(listen: string): string | null { - const normalized = listen.trim(); - if (!normalized) { - return null; - } - - if ( - normalized.startsWith("/") || - normalized.startsWith("unix://") || - normalized.startsWith("pipe://") || - normalized.startsWith("\\\\.\\pipe\\") || - /^[A-Za-z]:[/\\]/.test(normalized) - ) { - return null; - } - - if (/^\d+$/.test(normalized)) { - return `127.0.0.1:${normalized}`; - } - - if (normalized.includes(":")) { - return normalized; - } - - return null; -} - -export function resolveLocalDaemonState(options: { home?: string } = {}): LocalDaemonState { - const env: NodeJS.ProcessEnv = { - ...envWithHome(options.home), - // Status should reflect local persisted config + pid file, not inherited daemon env overrides. - // This is CLI-side defensive scrubbing; the daemon RPC is authoritative when available. - PASEO_LISTEN: undefined, - PASEO_HOSTNAMES: undefined, - PASEO_ALLOWED_HOSTS: undefined, - PASEO_RELAY_ENABLED: undefined, - PASEO_RELAY_ENDPOINT: undefined, - PASEO_RELAY_PUBLIC_ENDPOINT: undefined, - PASEO_RELAY_USE_TLS: undefined, - PASEO_RELAY_PUBLIC_USE_TLS: undefined, - }; - const home = resolvePaseoHome(env); - const config = loadConfig(home, { env }); - const pidPath = pidFilePath(home); - const logPath = path.join(home, DAEMON_LOG_FILENAME); - const pidInfo = existsSync(pidPath) ? readPidFile(pidPath) : null; - const running = pidInfo ? isProcessRunning(pidInfo.pid) : false; - const listen = pidInfo?.listen ?? config.listen; - - return { - home, - listen, - relayEnabled: config.relayEnabled ?? true, - relayEndpoint: config.relayPublicEndpoint ?? config.relayEndpoint ?? "relay.paseo.sh:443", - relayUseTls: config.relayUseTls ?? false, - relayPublicUseTls: config.relayPublicUseTls ?? config.relayUseTls ?? false, - logPath, - pidPath, - pidInfo, - running, - stalePidFile: Boolean(pidInfo) && !running, - }; -} - -export function tailDaemonLog(home?: string, lines = 30): string | null { - const logPath = path.join(resolveLocalPaseoHome(home), DAEMON_LOG_FILENAME); - return tailFile(logPath, lines); -} - -export async function startLocalDaemonDetached( - options: DaemonStartOptions, - runtime: DaemonLaunchRuntime = defaultDaemonLaunchRuntime, -): Promise { - if (options.listen && options.port) { - throw new Error("Cannot use --listen and --port together"); - } - - const daemonRunnerEntry = runtime.resolveRunnerEntry(); - const childEnv = buildChildEnv(options); - - const paseoHome = runtime.resolveHome(childEnv); - const logPath = path.join(paseoHome, DAEMON_LOG_FILENAME); - const child = runtime.spawnDetached( - process.execPath, - [...process.execArgv, daemonRunnerEntry, ...buildRunnerArgs(options)], - { - detached: true, - envMode: "internal", - env: childEnv, - stdio: ["ignore", "ignore", "ignore"], - }, - ); - - child.unref(); - - const startup = await new Promise((resolve) => { - let settled = false; - - const finish = (value: DetachedStartupResult) => { - if (settled) return; - settled = true; - resolve(value); - }; - - const timer = setTimeout(() => finish(startupReady()), DETACHED_STARTUP_GRACE_MS); - - child.once("error", (error) => { - clearTimeout(timer); - finish(startupExited({ code: null, signal: null, error })); - }); - - child.once("exit", (code, signal) => { - clearTimeout(timer); - finish(startupExited({ code, signal })); - }); - }); - - if (startup.exitedEarly) { - const reason = startup.error - ? startup.error.message - : `exit code ${startup.code ?? "unknown"}${startup.signal ? ` (${startup.signal})` : ""}`; - const recentLogs = tailFile(logPath); - throw new Error( - [ - `Daemon failed to start in background (${reason}).`, - recentLogs ? `Recent daemon logs:\n${recentLogs}` : null, - ] - .filter(Boolean) - .join("\n\n"), - ); - } - - return { - pid: child.pid ?? null, - logPath, - }; -} - -export function startLocalDaemonForeground( - options: DaemonStartOptions, - runtime: DaemonLaunchRuntime = defaultDaemonLaunchRuntime, -): number { - if (options.listen && options.port) { - throw new Error("Cannot use --listen and --port together"); - } - - const daemonRunnerEntry = runtime.resolveRunnerEntry(); - const childEnv = buildChildEnv(options); - const result = runtime.spawnForeground( - process.execPath, - [...process.execArgv, daemonRunnerEntry, ...buildRunnerArgs(options)], - { - env: childEnv, - stdio: "inherit", - }, - ); - - if (result.error) { - throw result.error; - } - - return result.status ?? 1; -} - -async function requestLifecycleShutdown( - state: LocalDaemonState, - timeoutMs: number, -): Promise { - const host = resolveTcpHostFromListen(state.listen); - if (!host) { - return { - requested: false, - reason: "daemon listen target is not TCP, falling back to owner PID signal", - }; - } - - const deadline = Date.now() + timeoutMs; - const remainingTimeoutMs = () => Math.max(1, deadline - Date.now()); - const client = await tryConnectToDaemon({ host, timeout: Math.min(remainingTimeoutMs(), 5000) }); - if (!client) { - return { - requested: false, - reason: `daemon websocket at ${host} is not reachable, falling back to owner PID signal`, - }; - } - - try { - await client.shutdownServer({ timeout: Math.min(remainingTimeoutMs(), 5000) }); - return { requested: true }; - } catch (error) { - return { - requested: false, - reason: `daemon lifecycle shutdown request failed (${getErrorMessage( - error, - )}), falling back to owner PID signal`, - }; } finally { - await client.close().catch(() => undefined); + process.removeListener("SIGINT", cancel); + process.removeListener("SIGTERM", cancel); } } -export async function stopLocalDaemon( - options: StopLocalDaemonOptions = {}, -): Promise { - const timeoutMs = options.timeoutMs ?? DEFAULT_STOP_TIMEOUT_MS; - const killTimeoutMs = options.killTimeoutMs ?? DEFAULT_KILL_TIMEOUT_MS; - const state = resolveLocalDaemonState({ home: options.home }); - const deadline = Date.now() + timeoutMs; - const remainingTimeoutMs = () => Math.max(1, deadline - Date.now()); +export function parseTimeoutMs(raw: unknown, fallback = 600_000): number { + if (raw === undefined) return fallback; + const seconds = Number(raw); + if (!Number.isFinite(seconds) || seconds <= 0) + throw { code: "INVALID_TIMEOUT", message: "Timeout must be a positive number of seconds." }; + return Math.ceil(seconds * 1000); +} - const shutdownAttempt = await requestLifecycleShutdown(state, remainingTimeoutMs()); - const lifecycleRequested = shutdownAttempt.requested; +const REMOVED_LAUNCH_FLAGS: Record = { + "--port ": "daemon.listen", + "--listen ": "daemon.listen", + "--relay": "daemon.relay.enabled", + "--no-relay": "daemon.relay.enabled", + "--relay-use-tls": "daemon.relay.useTls", + "--no-mcp": "daemon.mcp.enabled", + "--no-inject-mcp": "daemon.mcp.injectIntoAgents", + "--web-ui": "features.webUi.enabled", + "--no-web-ui": "features.webUi.enabled", + "--hostnames ": "daemon.hostnames", + "--allowed-hosts ": "daemon.hostnames", + "--foreground": "", +}; - if (!state.pidInfo || (!state.running && !lifecycleRequested)) { - const staleSuffix = - state.stalePidFile && state.pidInfo ? ` (stale PID file for ${state.pidInfo.pid})` : ""; - return createNotRunningStopResult( - state, - state.pidInfo?.pid ?? null, - `Daemon is not running${staleSuffix}`, - ); - } - - const pid = state.pidInfo?.pid ?? null; - const fallbackMessage = shutdownAttempt.requested ? null : shutdownAttempt.reason; - if (!lifecycleRequested) { - const notRunningResult = await signalDaemonOwnerForStop(state, pid); - if (notRunningResult) return notRunningResult; - } - - const { stopped, forced } = await waitForStopAfterRequest({ - state, - pid, - timeoutMs: remainingTimeoutMs(), - killTimeoutMs, - force: options.force, +export function rejectRemovedLaunchFlags(command: Command): Command { + for (const flag of Object.keys(REMOVED_LAUNCH_FLAGS)) + command.addOption(new Option(flag).hideHelp()); + command.hook("preAction", () => { + for (const [flag, configPath] of Object.entries(REMOVED_LAUNCH_FLAGS)) { + const name = new Option(flag).attributeName(); + if (command.getOptionValueSource(name) !== "cli") continue; + throw { + code: "REMOVED_LAUNCH_OPTION", + message: `${flag.split(" ")[0]} was removed. ${configPath ? `Use paseo daemon config set ${configPath} --home , then start or restart.` : "Use paseo daemon run --home for foreground deployment."} Deployment environment overrides belong to paseo daemon run.`, + }; + } }); - if (!stopped) { - throw createStopTimeoutError(state, pid, timeoutMs); - } - - if (lifecycleRequested) { - removeStalePidFile(state); - } - - return { - action: "stopped", - home: state.home, - pid, - forced, - usedLifecycleRpc: lifecycleRequested, - reason: resolveStopReason(forced, lifecycleRequested), - message: resolveStopMessage(forced, lifecycleRequested, fallbackMessage), - }; + return command; } diff --git a/packages/cli/src/commands/daemon/pair.test.ts b/packages/cli/src/commands/daemon/pair.test.ts index 79117ee16a..42be1a972a 100644 --- a/packages/cli/src/commands/daemon/pair.test.ts +++ b/packages/cli/src/commands/daemon/pair.test.ts @@ -1,139 +1,28 @@ -import { describe, expect, test, vi } from "vitest"; +import { mkdtemp, rm, readFile } from "node:fs/promises"; +import { existsSync } from "node:fs"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import { test, expect } from "vitest"; +import { resolveLocalPairingOffer } from "./pair.js"; -import { runPairCommand, type PairCommandOutput, type PairingOffer } from "./pair.js"; - -const disabledOffer: PairingOffer = { relayEnabled: false, url: null, qr: null }; -const enabledOffer: PairingOffer = { - relayEnabled: true, - url: "https://app.paseo.sh/#offer=test", - qr: null, -}; - -interface RecordedPairCommandOutput extends PairCommandOutput { - stdout: string[]; - stderr: string[]; - successes: string[]; - exitCode: number | undefined; -} - -function createRecordedOutput(): RecordedPairCommandOutput { - return { - columns: 80, - stdout: [], - stderr: [], - successes: [], - exitCode: undefined, - writeStdout(message) { - this.stdout.push(message); - }, - writeStderr(message) { - this.stderr.push(message); - }, - setExitCode(code) { - this.exitCode = code; - }, - success(message) { - this.successes.push(message); - }, - }; -} - -describe("daemon pair workflow", () => { - test("interactive decline prints direct guidance and creates no pairing output", async () => { - const resolveOffer = vi.fn(async () => disabledOffer); - const confirmRelay = vi.fn(async () => false); - const printDirectGuidance = vi.fn(); - const output = createRecordedOutput(); - - await runPairCommand( - {}, - { resolveOffer, confirmRelay, printDirectGuidance, isInteractive: () => true, output }, - ); - - expect(confirmRelay).toHaveBeenCalledOnce(); - expect(printDirectGuidance).toHaveBeenCalledOnce(); - expect(output.stderr.join("")).toContain("No pairing QR was created"); - expect(output.exitCode).toBe(1); - }); - - test("interactive consent enables relay and prints the refreshed offer", async () => { - const resolveOffer = vi - .fn<(options: { paseoHome: string; enableRelay?: boolean }) => Promise>() - .mockResolvedValueOnce(disabledOffer) - .mockResolvedValueOnce(enabledOffer); - const output = createRecordedOutput(); - - await runPairCommand( - {}, - { - resolveOffer, - confirmRelay: async () => true, - printDirectGuidance: vi.fn(), - isInteractive: () => true, - output, - }, - ); - - expect(resolveOffer).toHaveBeenNthCalledWith(2, expect.objectContaining({ enableRelay: true })); - expect(output.stdout.join("")).toContain(enabledOffer.url ?? ""); - expect(output.successes).toEqual(["Relay enabled"]); - }); - - test("JSON mode never prompts and returns a structured relay-disabled error", async () => { - const confirmRelay = vi.fn(async () => true); - const output = createRecordedOutput(); - - await runPairCommand( - { json: true }, - { - resolveOffer: async () => disabledOffer, - confirmRelay, - printDirectGuidance: vi.fn(), - isInteractive: () => true, - output, - }, - ); - - expect(confirmRelay).not.toHaveBeenCalled(); - expect(output.stderr.join("")).toContain('"code":"RELAY_DISABLED"'); - expect(output.exitCode).toBe(1); - }); - - test("explicit relay opts in without prompting", async () => { - const resolveOffer = vi.fn(async () => enabledOffer); - const confirmRelay = vi.fn(async () => false); - const output = createRecordedOutput(); - - await runPairCommand( - { relay: true, json: true }, - { - resolveOffer, - confirmRelay, - printDirectGuidance: vi.fn(), - isInteractive: () => false, - output, - }, - ); - - expect(resolveOffer).toHaveBeenCalledWith(expect.objectContaining({ enableRelay: true })); - expect(confirmRelay).not.toHaveBeenCalled(); - expect(output.exitCode).toBeUndefined(); - }); - - test("surfaces launch-override rejection", async () => { - await expect( - runPairCommand( - { relay: true }, - { - resolveOffer: async () => { - throw new Error("Relay is controlled by a daemon launch override"); - }, - confirmRelay: vi.fn(), - printDirectGuidance: vi.fn(), - isInteractive: () => false, - output: createRecordedOutput(), - }, - ), - ).rejects.toThrow("launch override"); - }); +test("offline pairing requires relay consent and saves it in the selected home", async () => { + const root = await mkdtemp(path.join(tmpdir(), "paseo-offline-pair-")); + const home = path.join(root, "home"); + try { + expect(await resolveLocalPairingOffer({ paseoHome: home })).toMatchObject({ + relayEnabled: false, + url: null, + }); + expect(existsSync(home)).toBe(false); + const offer = await resolveLocalPairingOffer({ paseoHome: home, enableRelay: true }); + expect(offer.relayEnabled).toBe(true); + expect(offer.url).toContain("offer="); + expect( + JSON.parse(await readFile(path.join(home, "config.json"), "utf8")).daemon.relay.enabled, + ).toBe(true); + expect(existsSync(path.join(home, "server-id"))).toBe(true); + expect(existsSync(path.join(home, "daemon-keypair.json"))).toBe(true); + } finally { + await rm(root, { recursive: true, force: true }); + } }); diff --git a/packages/cli/src/commands/daemon/pair.ts b/packages/cli/src/commands/daemon/pair.ts index 366366b76c..1d9e090ee8 100644 --- a/packages/cli/src/commands/daemon/pair.ts +++ b/packages/cli/src/commands/daemon/pair.ts @@ -3,29 +3,23 @@ import { Command } from "commander"; import chalk from "chalk"; import { generateLocalPairingOffer, - getOrCreateServerId, - loadConfig, - resolvePaseoHome, + readDaemonInstance, + readPersistedConfig, + editPersistedConfig, + resolveConfigFromPersisted, } from "@getpaseo/server"; -import { tryConnectToDaemon } from "../../utils/client.js"; -import { resolveLocalDaemonState } from "./local-daemon.js"; -import { addJsonOption } from "../../utils/command-options.js"; +import { connectToDaemon } from "../../utils/client.js"; +import type { DaemonTarget } from "../../utils/daemon-target.js"; +import { addJsonAndDaemonHostOptions, withGlobalOptions } from "../../utils/command-options.js"; import { formatPairingInstructions } from "../../output/pairing.js"; interface PairOptions { + daemonTarget: DaemonTarget; home?: string; json?: boolean; relay?: boolean; } -export interface PairCommandDependencies { - resolveOffer: typeof resolveLocalPairingOffer; - confirmRelay: typeof confirmRelayPairing; - printDirectGuidance: typeof printDirectConnectionGuidance; - isInteractive: () => boolean; - output: PairCommandOutput; -} - export interface PairCommandOutput { columns: number | undefined; writeStdout(message: string): void; @@ -62,33 +56,32 @@ function createProcessOutput(): PairCommandOutput { } export function pairCommand(): Command { - return addJsonOption(new Command("pair").description("Print the daemon pairing QR code and link")) - .option("--home ", "Paseo home directory (default: ~/.paseo)") + return addJsonAndDaemonHostOptions( + new Command("pair").description("Print the daemon pairing QR code and link"), + ) .option("--relay", "Enable relay without prompting") - .action(async (_options: PairOptions, command: Command) => { - await runPairCommand(command.optsWithGlobals()); - }); + .action( + withGlobalOptions((options: PairOptions, _command: Command) => runPairCommand(options)), + ); } export async function resolveLocalPairingOffer(options: { paseoHome: string; enableRelay?: boolean; }): Promise { - const state = resolveLocalDaemonState({ home: options.paseoHome }); - const serverId = getOrCreateServerId(state.home); - const daemonOffer = await resolveDaemonPairingOffer(state.listen, serverId, options.enableRelay); - if (daemonOffer) return daemonOffer; - - if (state.running) { - throw new Error( - "The running daemon did not provide a pairing offer. Check daemon connectivity or update the daemon.", + const instance = await readDaemonInstance(options.paseoHome); + if (instance) + return resolveDaemonPairingOffer( + { kind: "instance", home: options.paseoHome }, + options.enableRelay, ); - } - - const config = loadConfig(options.paseoHome); - if (options.enableRelay && !config.relayEnabled) { - throw new Error("Start the daemon before enabling relay for pairing."); - } + if (options.enableRelay) + editPersistedConfig(options.paseoHome, "daemon.relay.enabled", { value: true }); + const config = resolveConfigFromPersisted( + options.paseoHome, + readPersistedConfig(options.paseoHome, { defaultsIfMissing: true }), + { env: {} }, + ); return generateLocalPairingOffer({ paseoHome: options.paseoHome, @@ -103,23 +96,16 @@ export async function resolveLocalPairingOffer(options: { } async function resolveDaemonPairingOffer( - listen: string, - expectedServerId: string, + target: DaemonTarget, enableRelay: boolean | undefined, -): Promise { - const client = await tryConnectToDaemon({ - host: listen, +): Promise { + const client = await connectToDaemon({ + target, timeout: PAIRING_DAEMON_RPC_TIMEOUT_MS, }); - if (!client) return null; try { const serverInfo = client.getLastServerInfoMessage(); - if (serverInfo?.serverId.trim() !== expectedServerId) { - throw new Error( - "The reachable daemon belongs to a different Paseo home. Check --home or the daemon listen configuration.", - ); - } if (serverInfo?.features?.daemonStatusRpc !== true) { throw new Error("Update the Paseo daemon before pairing from this command."); } @@ -132,9 +118,14 @@ async function resolveDaemonPairingOffer( throw new Error("Update the Paseo daemon before enabling relay from this command."); } await client.patchDaemonConfig({ relay: { enabled: true } }); - offer = await client.getDaemonPairingOffer({ - timeout: PAIRING_DAEMON_RPC_TIMEOUT_MS, - }); + try { + offer = await client.getDaemonPairingOffer({ timeout: PAIRING_DAEMON_RPC_TIMEOUT_MS }); + } catch (error) { + throw new Error( + `Relay configuration was saved, but fetching the pairing offer failed: ${String(error)}`, + { cause: error }, + ); + } } return { relayEnabled: offer.relayEnabled, @@ -164,40 +155,22 @@ export function printDirectConnectionGuidance(): void { console.log(`Learn more: ${RELAY_DOCS_URL}#direct-connections`); } -export async function runPairCommand( - options: PairOptions, - dependencyOverrides: Partial = {}, -): Promise { - if (options.home) process.env.PASEO_HOME = options.home; - const dependencies: PairCommandDependencies = { - resolveOffer: resolveLocalPairingOffer, - confirmRelay: confirmRelayPairing, - printDirectGuidance: printDirectConnectionGuidance, - isInteractive: () => Boolean(process.stdin.isTTY && process.stdout.isTTY), - output: createProcessOutput(), - ...dependencyOverrides, - }; +export async function runPairCommand(options: PairOptions): Promise { + const output = createProcessOutput(); + const target = options.daemonTarget; + const resolveOffer = (enableRelay: boolean) => + target.kind === "instance" + ? resolveLocalPairingOffer({ paseoHome: target.home, enableRelay }) + : resolveDaemonPairingOffer(target, enableRelay); + const offline = target.kind === "instance" && !(await readDaemonInstance(target.home)); + const pairing = await resolveOffer(options.relay === true); - const paseoHome = resolvePaseoHome(); - let pairing = await dependencies.resolveOffer({ - paseoHome, - enableRelay: options.relay === true, - }); + if (offline) + output.writeStderr( + `Offline pairing offer. Start with: paseo daemon start --home ${JSON.stringify(target.kind === "instance" ? target.home : "")}\n`, + ); - const canPrompt = dependencies.isInteractive() && options.json !== true; - if (!pairing.relayEnabled && canPrompt) { - const shouldEnable = await dependencies.confirmRelay(); - if (!shouldEnable) { - dependencies.printDirectGuidance(); - dependencies.output.writeStderr(`${chalk.yellow("No pairing QR was created.")}\n`); - dependencies.output.setExitCode(1); - return; - } - pairing = await dependencies.resolveOffer({ paseoHome, enableRelay: true }); - dependencies.output.success("Relay enabled"); - } - - outputPairingResult(pairing, options, dependencies.output); + outputPairingResult(pairing, options, output); } function outputPairingResult( diff --git a/packages/cli/src/commands/daemon/reload.test.ts b/packages/cli/src/commands/daemon/reload.test.ts index 518d02c799..a97400406e 100644 --- a/packages/cli/src/commands/daemon/reload.test.ts +++ b/packages/cli/src/commands/daemon/reload.test.ts @@ -2,6 +2,8 @@ import { describe, expect, test } from "vitest"; import { render } from "../../output/render.js"; import { daemonReloadSchema, type DaemonReloadResult } from "./reload.js"; +const restartCommand = 'paseo daemon restart --home "/selected home"'; + function result(data: DaemonReloadResult) { return { type: "single" as const, data, schema: daemonReloadSchema }; } @@ -11,6 +13,7 @@ describe("daemon reload output", () => { expect( render( result({ + restartCommand, appliedPaths: ["daemon.browserTools.enabled"], restartRequiredPaths: ["daemon.listen"], overrideControlledPaths: ["app.baseUrl"], @@ -23,7 +26,7 @@ describe("daemon reload output", () => { "Warning: These changes require a daemon restart:", " daemon.listen", "", - "Run: paseo daemon restart", + `Run: ${restartCommand}`, "", "Warning: These settings are controlled by daemon launch overrides:", " app.baseUrl", @@ -34,6 +37,7 @@ describe("daemon reload output", () => { test("human output omits restart guidance for a live-only reload", () => { const output = render( result({ + restartCommand, appliedPaths: ["daemon.browserTools.enabled"], restartRequiredPaths: [], overrideControlledPaths: [], @@ -45,6 +49,7 @@ describe("daemon reload output", () => { test("structured output preserves the daemon result", () => { const data = { + restartCommand, appliedPaths: ["daemon.browserTools.enabled"], restartRequiredPaths: ["daemon.listen"], overrideControlledPaths: [], diff --git a/packages/cli/src/commands/daemon/reload.ts b/packages/cli/src/commands/daemon/reload.ts index 4f523be26a..807bd26789 100644 --- a/packages/cli/src/commands/daemon/reload.ts +++ b/packages/cli/src/commands/daemon/reload.ts @@ -1,8 +1,12 @@ -import type { Command } from "commander"; +import { addJsonAndDaemonHostOptions } from "../../utils/command-options.js"; +import { withOutput } from "../../output/index.js"; +import { Command } from "commander"; +import { describeDaemonTarget } from "../../utils/daemon-target.js"; import { connectToDaemon } from "../../utils/client.js"; import type { CommandOptions, OutputSchema, SingleResult } from "../../output/index.js"; export interface DaemonReloadResult { + restartCommand: string; appliedPaths: string[]; restartRequiredPaths: string[]; overrideControlledPaths: string[]; @@ -20,7 +24,7 @@ export const daemonReloadSchema: OutputSchema = { "Warning: These changes require a daemon restart:", ...result.data.restartRequiredPaths.map((path) => ` ${path}`), "", - "Run: paseo daemon restart", + `Run: ${result.data.restartCommand}`, ); } if (result.data.overrideControlledPaths.length > 0) { @@ -38,12 +42,13 @@ export async function runDaemonReloadCommand( options: CommandOptions, _command: Command, ): Promise> { - const client = await connectToDaemon({ host: options.host }); + const client = await connectToDaemon({ target: options.daemonTarget }); try { const payload = await client.reloadDaemonConfig(); return { type: "single", data: { + restartCommand: `paseo daemon restart ${options.daemonTarget.kind === "instance" ? `--home ${JSON.stringify(options.daemonTarget.home)}` : `--host ${JSON.stringify(describeDaemonTarget(options.daemonTarget))}`}`, appliedPaths: payload.appliedPaths, restartRequiredPaths: payload.restartRequiredPaths, overrideControlledPaths: payload.overrideControlledPaths, @@ -54,3 +59,9 @@ export async function runDaemonReloadCommand( await client.close(); } } + +export function daemonReloadCommand(): Command { + return addJsonAndDaemonHostOptions( + new Command("reload").description("Reload config.json without restarting"), + ).action(withOutput(runDaemonReloadCommand)); +} diff --git a/packages/cli/src/commands/daemon/restart.ts b/packages/cli/src/commands/daemon/restart.ts index 94bf1e0989..5394fc4b53 100644 --- a/packages/cli/src/commands/daemon/restart.ts +++ b/packages/cli/src/commands/daemon/restart.ts @@ -1,131 +1,122 @@ -import type { Command } from "commander"; -import { - startLocalDaemonDetached, - stopLocalDaemon, - DEFAULT_STOP_TIMEOUT_MS, - type DaemonStartOptions, -} from "./local-daemon.js"; -import type { - CommandOptions, - SingleResult, - OutputSchema, - CommandError, -} from "../../output/index.js"; +import { Command } from "commander"; +import { readDaemonInstance, isSameDaemonInstance, DaemonInstanceError } from "@getpaseo/server"; +import { setTimeout as delay } from "node:timers/promises"; +import { connectToDaemon } from "../../utils/client.js"; +import { withOutput, type CommandOptions } from "../../output/index.js"; +import { addJsonAndDaemonHostOptions } from "../../utils/command-options.js"; +import { describeDaemonTarget } from "../../utils/daemon-target.js"; +import { parseTimeoutMs, rejectRemovedLaunchFlags } from "./local-daemon.js"; -interface RestartResult { - action: "restarted"; - home: string; - pid: string; - message: string; +export function daemonRestartCommand(): Command { + return rejectRemovedLaunchFlags( + addJsonAndDaemonHostOptions( + new Command("restart").description( + "Restart the selected daemon worker, retaining its supervisor launch", + ), + ), + ) + .option("--timeout ", "Replacement readiness deadline (default: 600)") + .action(withOutput(runRestartCommand)); } -const restartResultSchema: OutputSchema = { - idField: "action", - columns: [ - { - header: "STATUS", - field: "action", - color: () => "green", - }, - { header: "HOME", field: "home" }, - { header: "PID", field: "pid" }, - { header: "MESSAGE", field: "message" }, - ], -}; - -export type RestartCommandResult = SingleResult; - -function parseTimeoutMs(raw: unknown): number { - if (typeof raw !== "string" || raw.trim().length === 0) { - return DEFAULT_STOP_TIMEOUT_MS; +export async function runRestartCommand(options: CommandOptions, _command: Command) { + const target = options.daemonTarget; + const deadline = Date.now() + parseTimeoutMs(options.timeout); + const remaining = () => Math.max(1, deadline - Date.now()); + const instance = target.kind === "instance" ? await readDaemonInstance(target.home) : null; + async function checkSupervisor() { + if (target.kind !== "instance") return; + const current = await readDaemonInstance(target.home); + if (!current || !instance || !isSameDaemonInstance(instance, current)) + throw new DaemonInstanceError( + "DAEMON_REPLACED", + `Supervisor exited or was replaced for ${target.home}.`, + ); } - - const seconds = Number(raw); - if (!Number.isFinite(seconds) || seconds <= 0) { - const error: CommandError = { - code: "INVALID_TIMEOUT", - message: `Invalid timeout value: ${raw}`, - details: "Timeout must be a positive number of seconds", - }; - throw error; - } - - return Math.ceil(seconds * 1000); -} - -function toStartOptions(options: CommandOptions): DaemonStartOptions { - const startOptions: DaemonStartOptions = { - home: typeof options.home === "string" ? options.home : undefined, - listen: typeof options.listen === "string" ? options.listen : undefined, - port: typeof options.port === "string" ? options.port : undefined, - relay: typeof options.relay === "boolean" ? options.relay : undefined, - mcp: typeof options.mcp === "boolean" ? options.mcp : undefined, - injectMcp: typeof options.injectMcp === "boolean" ? options.injectMcp : undefined, - webUi: typeof options.webUi === "boolean" ? options.webUi : undefined, - hostnames: typeof options.hostnames === "string" ? options.hostnames : undefined, - }; - - if (startOptions.listen && startOptions.port) { - const error: CommandError = { - code: "INVALID_OPTIONS", - message: "Cannot use --listen and --port together", - }; - throw error; - } - - return startOptions; -} - -export async function runRestartCommand( - options: CommandOptions, - _command: Command, -): Promise { - const timeoutMs = parseTimeoutMs(options.timeout); - const force = options.force === true; - const startOptions = toStartOptions(options); - + if (target.kind === "instance" && !instance) + throw new DaemonInstanceError( + "DAEMON_NOT_RUNNING", + `Daemon is not running for ${target.home}.`, + ); + const client = await connectToDaemon({ + target, + instance: instance ?? undefined, + timeout: remaining(), + }); + let workerPid: number; + const serverId = client.getLastServerInfoMessage()?.serverId; + let acknowledged = false; try { - let stopResult: Awaited>; + workerPid = (await client.getDaemonStatus({ timeout: remaining() })).pid; + await checkSupervisor(); try { - stopResult = await stopLocalDaemon({ - home: startOptions.home, - timeoutMs, - force, - }); - } catch (err) { - const isTimeout = - err instanceof Error && err.message.includes("Timed out waiting for daemon PID"); - if (!force && isTimeout) { - stopResult = await stopLocalDaemon({ - home: startOptions.home, - timeoutMs, - force: true, - }); - } else { - throw err; - } + await client.restartServer("cli_restart", undefined, { timeout: remaining() }); + acknowledged = true; + } catch (error) { + if (!isReconnectFailure(error)) throw error; } - - const startup = await startLocalDaemonDetached(startOptions); - const before = stopResult.pid === null ? "not running" : `PID ${stopResult.pid}`; - const after = startup.pid === null ? "unknown PID" : `PID ${startup.pid}`; - - return { - type: "single", - data: { - action: "restarted", - home: stopResult.home, - pid: startup.pid === null ? "-" : String(startup.pid), - message: `Local daemon restarted (${before} -> ${after})`, - }, - schema: restartResultSchema, - }; - } catch (err) { - const message = err instanceof Error ? err.message : String(err); - const error: CommandError = { - code: "RESTART_FAILED", - message: `Failed to restart local daemon: ${message}`, - }; - throw error; + } finally { + await client.close(); } + let lastError: unknown = "No replacement worker observed"; + while (Date.now() < deadline) { + try { + const replacement = await connectToDaemon({ + target, + instance: instance ?? undefined, + timeout: Math.min(1_000, remaining()), + }); + try { + if (replacement.getLastServerInfoMessage()?.serverId !== serverId) + throw new Error("Connected peer identity changed"); + const status = await replacement.getDaemonStatus({ timeout: Math.min(1_000, remaining()) }); + if (status.pid !== workerPid) { + await checkSupervisor(); + return { + type: "single" as const, + data: { + action: "restarted", + target: describeDaemonTarget(target), + supervisorPid: instance?.pid ?? null, + previousWorkerPid: workerPid, + workerPid: status.pid, + acknowledged, + }, + schema: { + idField: "action" as const, + columns: [], + renderHuman: () => + `Restarted worker ${workerPid} → ${status.pid} at ${describeDaemonTarget(target)}. Supervisor launch retained.`, + }, + }; + } + } finally { + await replacement.close(); + } + } catch (error) { + lastError = error; + const code = (error as { code?: string } | null)?.code; + if (code === "DAEMON_REPLACED" || code === "DAEMON_NOT_RUNNING") break; + if (!isReconnectFailure(error)) throw error; + } + await delay(Math.min(100, remaining())); + } + throw { + code: "RESTART_NOT_CONFIRMED", + message: `Replacement was not confirmed for ${describeDaemonTarget(target)}. Restart acknowledged: ${acknowledged}. Last observation: ${String(lastError)}`, + }; +} + +function isReconnectFailure(error: unknown): boolean { + return Boolean( + error && + typeof error === "object" && + "code" in error && + [ + "DAEMON_CONNECTION_LOST", + "DAEMON_REQUEST_TIMEOUT", + "DAEMON_UNREACHABLE", + "DAEMON_NOT_READY", + ].includes(String(error.code)), + ); } diff --git a/packages/cli/src/commands/daemon/set-password.ts b/packages/cli/src/commands/daemon/set-password.ts index 3fd407f587..cf190327e0 100644 --- a/packages/cli/src/commands/daemon/set-password.ts +++ b/packages/cli/src/commands/daemon/set-password.ts @@ -3,7 +3,8 @@ import type { Command } from "commander"; import { isCancel, password as passwordPrompt } from "@clack/prompts"; import { hashDaemonPassword, - loadPersistedConfig, + readPersistedConfig, + resolvePaseoHome, savePersistedConfig, type PersistedConfig, } from "@getpaseo/server"; @@ -14,7 +15,6 @@ import type { OutputSchema, SingleResult, } from "../../output/index.js"; -import { resolveLocalPaseoHome } from "./local-daemon.js"; const CONFIG_FILENAME = "config.json"; @@ -81,9 +81,9 @@ export async function setDaemonPasswordInConfig( newPassword: string, options: SetPasswordOptions = {}, ): Promise { - const paseoHome = resolveLocalPaseoHome(options.home); + const paseoHome = resolvePaseoHome({ PASEO_HOME: options.home }); const configPath = path.join(paseoHome, CONFIG_FILENAME); - const persisted = loadPersistedConfig(paseoHome); + const persisted = readPersistedConfig(paseoHome); const nextConfig: PersistedConfig = { ...persisted, daemon: { @@ -100,8 +100,8 @@ export async function setDaemonPasswordInConfig( return { action: "password_set", configPath, - restartCommand: "paseo daemon restart", - message: `Password written to ${configPath}\nRestart the daemon for the change to take effect.\nRun: paseo daemon restart`, + restartCommand: `paseo daemon restart --home ${JSON.stringify(paseoHome)}`, + message: `Password written to ${configPath}\nRestart the daemon for the change to take effect.\nRun: paseo daemon restart --home ${JSON.stringify(paseoHome)}`, }; } @@ -115,7 +115,7 @@ export async function runSetPasswordCommand( : (message: string) => passwordPrompt({ message }); const newPassword = await promptForPassword(promptPassword); const result = await setDaemonPasswordInConfig(newPassword, { - home: typeof options.home === "string" ? options.home : undefined, + home: options.daemonTarget.kind === "instance" ? options.daemonTarget.home : undefined, }); return { diff --git a/packages/cli/src/commands/daemon/start.ts b/packages/cli/src/commands/daemon/start.ts index 3a4c3df44d..ae36e0eed0 100644 --- a/packages/cli/src/commands/daemon/start.ts +++ b/packages/cli/src/commands/daemon/start.ts @@ -1,71 +1,55 @@ -import { Command, Option } from "commander"; -import chalk from "chalk"; -import { - startLocalDaemonForeground, - startLocalDaemonDetached, - type DaemonStartOptions as StartOptions, -} from "./local-daemon.js"; -import { getErrorMessage } from "../../utils/errors.js"; - -export type { DaemonStartOptions as StartOptions } from "./local-daemon.js"; - -type RawStartCommandOptions = StartOptions & { - allowedHosts?: string; -}; +import { addLocalDaemonOptions } from "../../utils/command-options.js"; +import { Command } from "commander"; +import { daemonLogPath } from "@getpaseo/server"; +import { launchLocalDaemon, parseTimeoutMs, rejectRemovedLaunchFlags } from "./local-daemon.js"; +import { withOutput, type CommandOptions } from "../../output/index.js"; export function startCommand(): Command { - return new Command("start") - .description("Start the local Paseo daemon") - .option("--listen ", "Listen target (host:port, port, or unix socket path)") - .option("--port ", "Port to listen on (default: 6767)") - .option("--home ", "Paseo home directory (default: ~/.paseo)") - .option("--foreground", "Run in foreground (don't daemonize)") - .option("--relay", "Enable relay connection") - .option("--no-relay", "Disable relay connection") - .option("--relay-use-tls", "Use wss:// for the relay connection and pairing offers") - .option("--no-mcp", "Disable the Agent MCP HTTP endpoint") - .option("--no-inject-mcp", "Disable auto-injecting the Paseo MCP into created agents") - .option("--web-ui", "Enable the bundled daemon web UI") - .option("--no-web-ui", "Disable the bundled daemon web UI") - .option( - "--hostnames ", - 'Daemon hostnames (comma-separated, e.g. "myhost,.example.com" or "true" for any)', - ) - .addOption(new Option("--allowed-hosts ").hideHelp()) - .action(async (options: RawStartCommandOptions) => { - await runStart({ - ...options, - hostnames: options.hostnames ?? options.allowedHosts, - }); - }); + return rejectRemovedLaunchFlags(addLocalDaemonOptions(new Command("start"))) + .description("Start the local daemon from persistent configuration (local operation)") + .option("--timeout ", "Readiness deadline (default: 600)") + .action(withOutput(runStart)); } -export async function runStart(options: StartOptions): Promise { - if (options.listen && options.port) { - console.error(chalk.red("Cannot use --listen and --port together")); - process.exit(1); - } - - if (!options.foreground) { - try { - const startup = await startLocalDaemonDetached(options); - console.log(chalk.green(`Daemon starting in background (PID ${startup.pid ?? "unknown"}).`)); - console.log(chalk.dim(`Logs: ${startup.logPath}`)); - } catch (err) { - exitWithError(getErrorMessage(err)); - } - return; - } - try { - const status = startLocalDaemonForeground(options); - process.exit(status); - } catch (err) { - const message = getErrorMessage(err); - exitWithError(`Failed to start daemon: ${message}`); - } +export async function runStart(options: CommandOptions, _command: Command) { + if (options.daemonTarget.kind !== "instance") throw new Error("Start requires a local home"); + const home = options.daemonTarget.home; + const result = await launchLocalDaemon({ home, timeoutMs: parseTimeoutMs(options.timeout) }); + const data = { + action: result.spawned ? "started" : "already_running", + home, + pid: result.instance.pid, + listen: result.instance.listen, + logPath: daemonLogPath(home), + }; + return { + type: "single" as const, + data, + schema: { + idField: "pid" as const, + columns: [], + renderHuman: () => + `${result.spawned ? "Started" : "Already running"}: PID ${data.pid}${data.listen ? `, listening on ${data.listen}` : ", not ready"}\nLogs: ${data.logPath}`, + }, + }; } -function exitWithError(message: string): never { - console.error(chalk.red(message)); - process.exit(1); +export function daemonRunCommand(): Command { + return rejectRemovedLaunchFlags(addLocalDaemonOptions(new Command("run"))) + .description("Run a local daemon in the foreground with deployment environment overrides") + .action( + withOutput(async (options: CommandOptions, _command: Command) => { + if (options.daemonTarget.kind !== "instance") throw new Error("Run requires a local home"); + const result = await launchLocalDaemon({ + home: options.daemonTarget.home, + foreground: true, + }); + process.exitCode = result.exitCode ?? 0; + return { + type: "single" as const, + data: { pid: result.instance.pid, action: result.spawned ? "exited" : "already_running" }, + schema: { idField: "pid" as const, columns: [] }, + }; + }), + ); } diff --git a/packages/cli/src/commands/daemon/status.test.ts b/packages/cli/src/commands/daemon/status.test.ts deleted file mode 100644 index dd900f902f..0000000000 --- a/packages/cli/src/commands/daemon/status.test.ts +++ /dev/null @@ -1,31 +0,0 @@ -import { describe, expect, test } from "vitest"; -import { selectRelayStatus } from "./status.js"; - -describe("selectRelayStatus", () => { - const persisted = { - enabled: false, - endpoint: "persisted.internal:443", - publicEndpoint: "persisted.example.com:443", - useTls: true, - publicUseTls: true, - }; - - test("uses the running daemon relay state over persisted config", () => { - expect( - selectRelayStatus({ - persisted, - live: { - enabled: true, - endpoint: "live.internal:443", - publicEndpoint: "live.example.com:443", - useTls: true, - publicUseTls: true, - }, - }), - ).toBe("wss://live.example.com:443"); - }); - - test("falls back to persisted config when the daemon cannot report live state", () => { - expect(selectRelayStatus({ persisted })).toBe("disabled"); - }); -}); diff --git a/packages/cli/src/commands/daemon/status.ts b/packages/cli/src/commands/daemon/status.ts index 87e34f0bcf..c44e01524e 100644 --- a/packages/cli/src/commands/daemon/status.ts +++ b/packages/cli/src/commands/daemon/status.ts @@ -1,464 +1,134 @@ -import type { Command } from "commander"; -import { createRequire } from "node:module"; -import { getOrCreateServerId, findExecutable, execCommand } from "@getpaseo/server"; -import { connectToDaemon } from "../../utils/client.js"; -import type { CommandOptions, ListResult, OutputSchema } from "../../output/index.js"; -import { resolveLocalDaemonState } from "./local-daemon.js"; -import { resolveNodePathFromPid } from "./runtime-toolchain.js"; +import { Command } from "commander"; +import { DaemonConnectionError } from "@getpaseo/client/internal/daemon-client"; +import { + readDaemonInstance, + readPersistedConfig, + resolveConfigFromPersisted, + daemonLogPath, + isSameDaemonInstance, + DaemonInstanceError, +} from "@getpaseo/server"; +import { connectToDaemon, buildDaemonConnectionCommandError } from "../../utils/client.js"; +import { withOutput, toCommandError, type CommandOptions } from "../../output/index.js"; +import { addJsonAndDaemonHostOptions } from "../../utils/command-options.js"; +import { describeDaemonTarget, type DaemonTarget } from "../../utils/daemon-target.js"; -const DAEMON_STATUS_PROBE_TIMEOUT_MS = 1500; - -interface ProviderBinaryStatus { - label: string; - path: string | null; - version: string | null; - source?: "daemon" | "local"; +export function daemonStatusCommand(): Command { + return addJsonAndDaemonHostOptions( + new Command("status").description("Observe the selected daemon and its published endpoint"), + ).action(withOutput(runStatusCommand)); } -interface DaemonStatus { - serverId: string | null; - localDaemon: "running" | "stopped" | "stale_pid" | "unresponsive"; - connectedDaemon: "reachable" | "unreachable" | "auth_required" | "auth_failed" | "not_probed"; - home: string; - listen: string; - relay: string; - hostname: string | null; - pid: number | null; - startedAt: string | null; - owner: string | null; - logPath: string; - daemonNode: string; - cliNode: string; - cliVersion: string; - daemonVersion: string | null; - desktopManaged: boolean; - providers: ProviderBinaryStatus[]; - note?: string; -} - -interface StatusRow { - key: string; - value: string; -} - -interface CliPackageJson { - version?: unknown; -} - -const require = createRequire(import.meta.url); - -function normalizeError(error: unknown): string { - if (error instanceof Error) { - return error.message; - } - return String(error); -} - -function shortenMessage(message: string, max = 120): string { - const normalized = message.replace(/\s+/g, " ").trim(); - if (normalized.length <= max) { - return normalized; - } - return `${normalized.slice(0, max - 3)}...`; -} - -function appendNote(current: string | undefined, next: string | undefined): string | undefined { - if (!next) return current; - if (!current) return next; - return `${current}; ${next}`; -} - -function resolveCliVersion(): string { - try { - const packageJson = require("../../../package.json") as CliPackageJson; - if (typeof packageJson.version === "string" && packageJson.version.trim().length > 0) { - return packageJson.version.trim(); - } - } catch { - // Fall through. - } - return "unknown"; -} - -function createStatusSchema(status: DaemonStatus): OutputSchema { +export async function runStatusCommand(options: CommandOptions, _command: Command) { + const target = options.daemonTarget; + const instance = target.kind === "instance" ? await readDaemonInstance(target.home) : null; + const local = + target.kind === "instance" + ? localStatus(target.home, instance) + : { host: describeDaemonTarget(target) }; + const observed = + target.kind === "endpoint" || instance?.listen + ? await probeDaemonStatus(target, instance, local) + : { connectedDaemon: "not_probed" }; + const data: Record = { ...local, ...observed }; return { - idField: "key", - columns: [ - { header: "KEY", field: "key" }, - { - header: "VALUE", - field: "value", - color: (_, item) => { - if (item.key === "Local Daemon") { - if (item.value === "running") return "green"; - if (item.value === "unresponsive") return "yellow"; - return "red"; - } - if (item.key === "Connected Daemon") { - if (item.value === "reachable") return "green"; - if (item.value === "not_probed" || item.value === "auth_required") return "yellow"; - return "red"; - } - if (item.key.startsWith(" ")) { - if (item.value === "not found" || item.value === "not found (daemon)") return "red"; - if (item.value.endsWith("(--version failed)")) return "yellow"; - return "green"; - } - return undefined; - }, - }, - ], - serialize: () => status, + type: "single" as const, + data, + schema: { + idField: () => "daemon", + columns: [], + renderHuman: () => + Object.entries(data) + .filter(([, value]) => value !== undefined) + .map( + ([key, value]) => + `${key}: ${typeof value === "object" ? JSON.stringify(value) : value}`, + ) + .join("\n"), + }, }; } -function toStatusRows(status: DaemonStatus): StatusRow[] { - const rows: StatusRow[] = [ - { key: "Server ID", value: status.serverId ?? "-" }, - { key: "Local Daemon", value: status.localDaemon }, - { key: "Connected Daemon", value: status.connectedDaemon }, - { key: "Home", value: status.home }, - { key: "Listen", value: status.listen }, - { key: "Relay", value: status.relay }, - { key: "Hostname", value: status.hostname ?? "-" }, - { key: "PID", value: status.pid === null ? "-" : String(status.pid) }, - { key: "Started", value: status.startedAt ?? "-" }, - { key: "Owner", value: status.owner ?? "-" }, - { key: "Logs", value: status.logPath }, - { key: "Daemon Node", value: status.daemonNode }, - { key: "CLI Node", value: status.cliNode }, - { key: "CLI", value: status.cliVersion }, - { key: "Daemon Version", value: status.daemonVersion ?? "-" }, - ]; - - if (status.note) { - rows.push({ key: "Note", value: status.note }); - } - - rows.push({ key: "", value: "" }); - rows.push({ key: "Providers", value: "" }); - for (const provider of status.providers) { - if (provider.source === "daemon") { - if (!provider.path) { - rows.push({ key: ` ${provider.label}`, value: "not found (daemon)" }); - } else { - rows.push({ key: ` ${provider.label}`, value: `${provider.path} (daemon)` }); - } - } else if (!provider.path) { - rows.push({ key: ` ${provider.label}`, value: "not found" }); - } else if (!provider.version) { - rows.push({ key: ` ${provider.label}`, value: `${provider.path} (--version failed)` }); - } else { - rows.push({ key: ` ${provider.label}`, value: `${provider.path} (${provider.version})` }); - } - } - - return rows; -} - -const PROVIDER_BINARIES: { label: string; binary: string }[] = [ - { label: "Claude", binary: "claude" }, - { label: "Codex", binary: "codex" }, - { label: "OpenCode", binary: "opencode" }, -]; - -async function checkProviderBinary( - binary: string, -): Promise<{ path: string | null; version: string | null }> { - const binaryPath = await findExecutable(binary); - if (!binaryPath) { - return { path: null, version: null }; - } - try { - const { stdout } = await execCommand(binaryPath, ["--version"], { - timeout: 5000, - }); - return { path: binaryPath, version: stdout.trim() || null }; - } catch { - return { path: binaryPath, version: null }; - } -} - -async function checkProviderBinaries(): Promise { - const results = await Promise.all( - PROVIDER_BINARIES.map(async ({ label, binary }) => { - const result = await checkProviderBinary(binary); - return Object.assign({ label }, result); - }), - ); - return results; -} - -function resolveOwnerLabel(uid: number | undefined, hostname: string | undefined): string | null { - if (uid === undefined && !hostname) { - return null; - } - const uidPart = uid === undefined ? "?" : String(uid); - const hostPart = hostname ?? "unknown-host"; - return `${uidPart}@${hostPart}`; -} - -interface DaemonProbeResult { - connectedDaemon: DaemonStatus["connectedDaemon"]; - localDaemonOverride?: DaemonStatus["localDaemon"]; - daemonVersion?: string | null; - daemonNodeOverride?: string; - daemonProviders?: ProviderBinaryStatus[]; - relayStatus?: string; - note?: string; -} - -type DaemonAuthProbeFailure = "auth_required" | "auth_failed"; - -function classifyDaemonAuthProbeFailure(error: unknown): DaemonAuthProbeFailure | null { - if (!(error instanceof Error)) return null; - if (error.message === "Password required") return "auth_required"; - if (error.message === "Incorrect password") return "auth_failed"; - return null; -} - -function describeDaemonAuthProbeFailure(host: string, failure: DaemonAuthProbeFailure): string { - if (failure === "auth_required") { - return `Daemon is reachable at ${host} but requires a password. Set PASEO_PASSWORD and retry.`; - } - return `Daemon is reachable at ${host} but the supplied password was rejected. Check PASEO_PASSWORD and retry.`; -} - -async function probeDaemonOverWebsocket(args: { - host: string; - state: ReturnType; -}): Promise { - const { host, state } = args; - let client: Awaited>; - try { - client = await connectToDaemon({ host, timeout: 1500 }); - } catch (error) { - const authFailure = classifyDaemonAuthProbeFailure(error); - if (authFailure) { - return { - connectedDaemon: authFailure, - note: describeDaemonAuthProbeFailure(host, authFailure), - }; - } - - if (state.running) { - return { - connectedDaemon: "unreachable", - localDaemonOverride: "unresponsive", - note: `Local daemon PID is running but websocket at ${host} is not reachable`, - }; - } - return { connectedDaemon: "unreachable" }; - } - - const daemonVersion = client.getLastServerInfoMessage()?.version ?? null; - try { - const statusPayload = await client.getDaemonStatus({ - timeout: DAEMON_STATUS_PROBE_TIMEOUT_MS, - }); - const labelMap = new Map(PROVIDER_BINARIES.map((p) => [p.binary, p.label])); - const daemonProviders = statusPayload.providers.map((p) => ({ - label: labelMap.get(p.provider) ?? p.provider, - path: p.available ? "available" : null, - version: p.available ? null : (p.error ?? null), - source: "daemon" as const, - })); - const relayStatus = - statusPayload.relay == null - ? undefined - : selectRelayStatus({ - persisted: relayConfigFromLocalState(state), - live: statusPayload.relay, - }); - - if (!state.running) { - return { - connectedDaemon: "reachable", - daemonVersion: statusPayload.version ?? daemonVersion, - daemonNodeOverride: statusPayload.nodePath, - daemonProviders, - relayStatus, - note: state.pidInfo - ? `Connected daemon is reachable at ${host} even though local daemon PID ${state.pidInfo.pid} is stale` - : `Connected daemon is reachable at ${host} but no local daemon PID file was found`, - }; - } - - return { - connectedDaemon: "reachable", - daemonVersion: statusPayload.version ?? daemonVersion, - daemonNodeOverride: statusPayload.nodePath, - daemonProviders, - relayStatus, - }; - } catch { - return { - connectedDaemon: "reachable", - daemonVersion, - note: state.running - ? `Local daemon PID is running but daemon detail request to ${host} failed` - : `Connected daemon websocket is reachable at ${host} but daemon status request failed`, - }; - } finally { - await client.close().catch(() => {}); - } -} - -interface ProbeMergeState { - probe: DaemonProbeResult; - connectedDaemon: DaemonStatus["connectedDaemon"]; - localDaemon: DaemonStatus["localDaemon"]; - daemonNode: string; - daemonVersion: string | null; - daemonProviders: ProviderBinaryStatus[] | undefined; - relayStatus: string; - note: string | undefined; -} - -function applyProbeToStatus(input: ProbeMergeState): Omit { - const { probe } = input; - return { - connectedDaemon: probe.connectedDaemon, - localDaemon: probe.localDaemonOverride ?? input.localDaemon, - daemonNode: probe.daemonNodeOverride ?? input.daemonNode, - daemonVersion: probe.daemonVersion !== undefined ? probe.daemonVersion : input.daemonVersion, - daemonProviders: probe.daemonProviders ?? input.daemonProviders, - relayStatus: probe.relayStatus ?? input.relayStatus, - note: probe.note ? appendNote(input.note, probe.note) : input.note, - }; -} - -function resolveServerIdSafely(home: string): { serverId: string | null; error: string | null } { - try { - return { serverId: getOrCreateServerId(home), error: null }; - } catch (error) { - return { - serverId: null, - error: `serverId unavailable: ${shortenMessage(normalizeError(error))}`, - }; - } -} - -async function resolveDaemonNodeLabel( - state: ReturnType, -): Promise { - if (!state.running) return "-"; - if (!state.pidInfo?.pid) return "unknown (no PID available)"; - const fromPid = await resolveNodePathFromPid(state.pidInfo.pid); - return fromPid.nodePath ?? `unknown (${fromPid.error ?? "could not resolve from PID"})`; -} - -interface RelayStatusConfig { - enabled: boolean; - endpoint: string; - publicEndpoint: string; - useTls: boolean; - publicUseTls: boolean; -} - -function relayConfigFromLocalState( - state: ReturnType, -): RelayStatusConfig { - return { - enabled: state.relayEnabled, - endpoint: state.relayEndpoint, - publicEndpoint: state.relayEndpoint, - useTls: state.relayUseTls, - publicUseTls: state.relayPublicUseTls, - }; -} - -export function selectRelayStatus(input: { - persisted: RelayStatusConfig; - live?: RelayStatusConfig; -}): string { - const relay = input.live ?? input.persisted; - if (!relay.enabled) return "disabled"; - const scheme = relay.publicUseTls ? "wss" : "ws"; - return `${scheme}://${relay.publicEndpoint}`; -} - -export type StatusResult = ListResult; - -export async function runStatusCommand( - options: CommandOptions, - _command: Command, -): Promise { - const home = typeof options.home === "string" ? options.home : undefined; - const state = resolveLocalDaemonState({ home }); - const daemonTarget = state.listen.trim(); - - const owner = resolveOwnerLabel(state.pidInfo?.uid, state.pidInfo?.hostname); - let daemonNode = await resolveDaemonNodeLabel(state); - const cliNode = process.execPath; - let localDaemon: DaemonStatus["localDaemon"] = state.running ? "running" : "stopped"; - let connectedDaemon: DaemonStatus["connectedDaemon"] = "not_probed"; - let daemonVersion: string | null = null; - let daemonProviders: ProviderBinaryStatus[] | undefined; - let relayStatus = selectRelayStatus({ persisted: relayConfigFromLocalState(state) }); +async function probeDaemonStatus( + target: DaemonTarget, + instance: Awaited>, + local: Record, +) { + let connectedDaemon = "unreachable"; let note: string | undefined; - - if (!state.running && state.stalePidFile && state.pidInfo) { - localDaemon = "stale_pid"; - note = `Stale PID file found for PID ${state.pidInfo.pid}`; + let live: Record = {}; + let client: Awaited> | undefined; + try { + client = await connectToDaemon({ target, instance: instance ?? undefined, timeout: 1_500 }); + } catch (error) { + const failure = buildDaemonConnectionCommandError({ target, error }); + if (target.kind === "endpoint") throw failure; + connectedDaemon = "unreachable"; + if (failure.code === "AUTH_REQUIRED") connectedDaemon = "auth_required"; + if (failure.code === "AUTH_FAILED") connectedDaemon = "auth_failed"; + note = failure.message; } - - if (daemonTarget) { - const probe = await probeDaemonOverWebsocket({ host: daemonTarget, state }); - ({ - connectedDaemon, - localDaemon, - daemonNode, - daemonVersion, - daemonProviders, - relayStatus, - note, - } = applyProbeToStatus({ - probe, - connectedDaemon, - localDaemon, - daemonNode, - daemonVersion, - daemonProviders, - relayStatus, - note, - })); + if (!client) return { connectedDaemon, note }; + try { + let requestError: unknown; + const status = await client.getDaemonStatus({ timeout: 1_500 }).catch((error: unknown) => { + requestError = error; + return null; + }); + // Recheck even when the RPC times out before combining local and live observations. + const current = target.kind === "instance" ? await readDaemonInstance(target.home) : null; + if (instance && (!current || !isSameDaemonInstance(instance, current))) + throw new DaemonInstanceError( + "DAEMON_REPLACED", + "Supervisor exited or was replaced during status observation.", + ); + const info = client.getLastServerInfoMessage(); + live = { serverId: info?.serverId, daemonVersion: info?.version }; + connectedDaemon = client.isConnected ? "reachable" : "unreachable"; + if (!status) { + const failure = toCommandError(requestError); + note = `Status details unavailable (${failure.code}): ${failure.message}`; + if ( + target.kind === "endpoint" || + !client.isConnected || + !(requestError instanceof DaemonConnectionError) || + requestError.code !== "DAEMON_REQUEST_TIMEOUT" + ) { + throw { ...failure, message: note, details: { ...local, ...live, connectedDaemon } }; + } + } else { + live = { + ...live, + daemonVersion: status.version, + workerPid: status.pid, + daemonNode: status.nodePath, + providers: status.providers, + relay: status.relay, + }; + } + } finally { + await client.close(); } + return { ...live, connectedDaemon, note }; +} - const cliVersion = resolveCliVersion(); - - const serverIdResult = resolveServerIdSafely(state.home); - const serverId = serverIdResult.serverId; - if (serverIdResult.error) { - note = appendNote(note, serverIdResult.error); - } - - const providers = daemonProviders ?? (await checkProviderBinaries()); - - const daemonStatus: DaemonStatus = { - serverId, - localDaemon, - connectedDaemon, - home: state.home, - listen: state.listen, - relay: relayStatus, - hostname: state.pidInfo?.hostname ?? null, - pid: state.pidInfo?.pid ?? null, - startedAt: state.pidInfo?.startedAt ?? null, - owner, - logPath: state.logPath, - daemonNode, - cliNode, - cliVersion, - daemonVersion, - desktopManaged: state.pidInfo?.desktopManaged === true, - providers, - note, - }; - +function localStatus(home: string, instance: Awaited>) { + const config = resolveConfigFromPersisted( + home, + readPersistedConfig(home, { defaultsIfMissing: true }), + { env: {} }, + ); + let localDaemon = "stopped"; + if (instance) localDaemon = instance.listen ? "running" : "not_ready"; return { - type: "list", - data: toStatusRows(daemonStatus), - schema: createStatusSchema(daemonStatus), + home, + pid: instance?.pid ?? null, + startedAt: instance?.startedAt ?? null, + listen: instance?.listen ?? null, + hostname: instance?.hostname ?? null, + configuredListen: config.listen, + localDaemon, + desktopManaged: instance?.desktopManaged === true, + logPath: daemonLogPath(home), }; } diff --git a/packages/cli/src/commands/daemon/stop.ts b/packages/cli/src/commands/daemon/stop.ts index 76351bb07b..a94eab24d4 100644 --- a/packages/cli/src/commands/daemon/stop.ts +++ b/packages/cli/src/commands/daemon/stop.ts @@ -1,94 +1,67 @@ -import type { Command } from "commander"; -import { - stopLocalDaemon, - DEFAULT_STOP_TIMEOUT_MS, - DEFAULT_KILL_TIMEOUT_MS, -} from "./local-daemon.js"; -import type { - CommandOptions, - SingleResult, - OutputSchema, - CommandError, -} from "../../output/index.js"; +import { Command } from "commander"; +import { stopDaemonInstance, type DaemonInstance } from "@getpaseo/server"; +import { connectToDaemon } from "../../utils/client.js"; +import { withOutput, type CommandOptions } from "../../output/index.js"; +import { addJsonAndDaemonHostOptions } from "../../utils/command-options.js"; +import { describeDaemonTarget } from "../../utils/daemon-target.js"; +import { parseTimeoutMs } from "./local-daemon.js"; -interface StopResult { - action: "stopped" | "not_running"; - home: string; - pid: string; - forced: boolean; - usedLifecycleRpc: boolean; - reason: "not_running" | "lifecycle_shutdown_rpc" | "owner_pid_signal" | "owner_pid_sigkill"; - message: string; +export function daemonStopCommand(): Command { + return addJsonAndDaemonHostOptions(new Command("stop").description("Stop the selected daemon")) + .option("--timeout ", "Graceful exit deadline (default: 15)") + .option("--force", "Permit forced local process cleanup") + .option("--kill-timeout ", "Forced exit deadline (default: 3)") + .action(withOutput(runStopCommand)); } -const stopResultSchema: OutputSchema = { - idField: "action", - columns: [ - { - header: "STATUS", - field: "action", - color: (value) => (value === "stopped" ? "green" : "yellow"), +export async function runStopCommand(options: CommandOptions, _command: Command) { + const target = options.daemonTarget; + const timeoutMs = parseTimeoutMs(options.timeout, 15_000); + const deadline = Date.now() + timeoutMs; + const remaining = () => Math.max(1, deadline - Date.now()); + const requestShutdown = async (instance?: DaemonInstance) => { + const client = await connectToDaemon({ target, timeout: remaining(), instance }); + try { + await client.shutdownServer({ timeout: remaining() }); + } finally { + await client.close(); + } + }; + if (target.kind === "endpoint" && options.force) + throw { + code: "INVALID_OPTIONS", + message: "--force requires a local --home; an endpoint gives no remote process authority.", + }; + const result: { + action: string; + home?: string; + host?: string; + pid?: number | null; + forced?: boolean; + usedLifecycleRpc?: boolean; + } = + target.kind === "instance" + ? { + ...(await stopDaemonInstance(target.home, { + force: options.force === true, + timeoutMs, + killTimeoutMs: parseTimeoutMs(options.killTimeout, 3_000), + requestShutdown, + })), + home: target.home, + } + : (await requestShutdown(), + { action: "shutdown_requested", host: describeDaemonTarget(target) }); + return { + type: "single" as const, + data: result, + schema: { + idField: "action" as const, + columns: [], + renderHuman: () => + target.kind === "endpoint" + ? "Shutdown requested; remote process exit was not verified." + : `${result.action.replaceAll("_", " ")}: ${target.home}`, }, - { header: "HOME", field: "home" }, - { header: "PID", field: "pid" }, - { header: "MESSAGE", field: "message" }, - ], -}; - -export type StopCommandResult = SingleResult; - -function parseSecondsOption(raw: unknown, fallbackMs: number, label: string): number { - if (typeof raw !== "string" || raw.trim().length === 0) { - return fallbackMs; - } - - const seconds = Number(raw); - if (!Number.isFinite(seconds) || seconds <= 0) { - const error: CommandError = { - code: "INVALID_TIMEOUT", - message: `Invalid ${label} value: ${raw}`, - details: `${label} must be a positive number of seconds`, - }; - throw error; - } - - return Math.ceil(seconds * 1000); -} - -export async function runStopCommand( - options: CommandOptions, - _command: Command, -): Promise { - const home = typeof options.home === "string" ? options.home : undefined; - const force = options.force === true; - const timeoutMs = parseSecondsOption(options.timeout, DEFAULT_STOP_TIMEOUT_MS, "timeout"); - const killTimeoutMs = parseSecondsOption( - options.killTimeout, - DEFAULT_KILL_TIMEOUT_MS, - "kill-timeout", - ); - - try { - const result = await stopLocalDaemon({ home, force, timeoutMs, killTimeoutMs }); - return { - type: "single", - data: { - action: result.action, - home: result.home, - pid: result.pid === null ? "-" : String(result.pid), - forced: result.forced, - usedLifecycleRpc: result.usedLifecycleRpc, - reason: result.reason, - message: result.message, - }, - schema: stopResultSchema, - }; - } catch (err) { - const message = err instanceof Error ? err.message : String(err); - const error: CommandError = { - code: "STOP_FAILED", - message: `Failed to stop local daemon: ${message}`, - }; - throw error; - } + }; } diff --git a/packages/cli/src/commands/heartbeat/index.ts b/packages/cli/src/commands/heartbeat/index.ts index 7ded358e8f..812af3a847 100644 --- a/packages/cli/src/commands/heartbeat/index.ts +++ b/packages/cli/src/commands/heartbeat/index.ts @@ -64,7 +64,7 @@ async function runCreateHeartbeat( if (!cron) { throw new Error("--cron is required"); } - const { client } = await connectScheduleClient(options.host); + const { client } = await connectScheduleClient(options.daemonTarget); try { const maxRuns = options.maxRuns ? Number.parseInt(options.maxRuns, 10) : undefined; if (maxRuns !== undefined && (!Number.isSafeInteger(maxRuns) || maxRuns <= 0)) { @@ -105,7 +105,7 @@ async function runUpdateHeartbeat( if (!cron) { throw new Error("--cron is required"); } - const { client } = await connectScheduleClient(options.host); + const { client } = await connectScheduleClient(options.daemonTarget); try { await requireOwnedHeartbeat(client, id, agentId); const payload = await client.scheduleUpdate({ @@ -133,7 +133,7 @@ async function runDeleteHeartbeat( _command: Command, ): Promise> { const agentId = requireCallerAgentId(); - const { client } = await connectScheduleClient(options.host); + const { client } = await connectScheduleClient(options.daemonTarget); try { await requireOwnedHeartbeat(client, id, agentId); const payload = await client.scheduleDelete({ id }); diff --git a/packages/cli/src/commands/hub/connect.ts b/packages/cli/src/commands/hub/connect.ts index 8f3fcf9bfc..90a4edca36 100644 --- a/packages/cli/src/commands/hub/connect.ts +++ b/packages/cli/src/commands/hub/connect.ts @@ -13,6 +13,7 @@ import { addHubResolutionHelp } from "./help.js"; interface HubConnectOptions { apiKey?: string; host?: string; + daemonTarget: import("../../utils/daemon-target.js").DaemonTarget; json?: boolean; permission?: readonly string[]; permissions?: readonly string[]; @@ -41,7 +42,7 @@ export async function runHubConnect( const credential = resolveHubCredential({ ...resolution, origin }); const token = await dependencies.hub.issueEnrollmentToken(origin, credential); const permissions = options.permissions ?? options.permission ?? []; - return withHubDaemon(dependencies.daemon, options.host, async (daemon) => { + return withHubDaemon(dependencies.daemon, options.daemonTarget, async (daemon) => { const response = await daemon.connectHub(origin, token, permissions); if ( response.status.hubOrigin !== null && diff --git a/packages/cli/src/commands/hub/daemon-client.ts b/packages/cli/src/commands/hub/daemon-client.ts index 3f53360ccd..21a2e7bde1 100644 --- a/packages/cli/src/commands/hub/daemon-client.ts +++ b/packages/cli/src/commands/hub/daemon-client.ts @@ -1,3 +1,4 @@ +import type { DaemonTarget } from "../../utils/daemon-target.js"; import { connectToDaemon } from "../../utils/client.js"; import type { ProviderSnapshotEntry } from "@getpaseo/protocol/agent-types"; @@ -33,19 +34,19 @@ export interface HubDaemonClient { } export interface HubDaemonConnection { - connect(host: string | undefined): Promise; + connect(target: DaemonTarget): Promise; } export const productionHubDaemonConnection: HubDaemonConnection = { - connect: (host) => connectToDaemon({ host }), + connect: (target) => connectToDaemon({ target }), }; export async function withHubDaemon( connection: HubDaemonConnection, - host: string | undefined, + target: DaemonTarget, action: (client: HubDaemonClient) => Promise, ): Promise { - const client = await connection.connect(host); + const client = await connection.connect(target); try { return await action(client); } finally { diff --git a/packages/cli/src/commands/hub/disconnect.ts b/packages/cli/src/commands/hub/disconnect.ts index 867201376f..a7052d4e50 100644 --- a/packages/cli/src/commands/hub/disconnect.ts +++ b/packages/cli/src/commands/hub/disconnect.ts @@ -9,6 +9,7 @@ import { hubStatusResult } from "./status-output.js"; interface HubDisconnectOptions { force?: boolean; host?: string; + daemonTarget: import("../../utils/daemon-target.js").DaemonTarget; json?: boolean; } @@ -21,7 +22,7 @@ export function runHubDisconnect( options: HubDisconnectOptions, dependencies: HubDisconnectDependencies, ) { - return withHubDaemon(dependencies.daemon, options.host, async (client) => { + return withHubDaemon(dependencies.daemon, options.daemonTarget, async (client) => { const current = (await client.getHubStatus()).status; if (current.hubOrigin !== null) { reportHubProgress( diff --git a/packages/cli/src/commands/hub/index.ts b/packages/cli/src/commands/hub/index.ts index 6bddbd1e70..650c5a0d53 100644 --- a/packages/cli/src/commands/hub/index.ts +++ b/packages/cli/src/commands/hub/index.ts @@ -59,7 +59,8 @@ export function createHubCommand(overrides: Partial = {}) flow: environment.login, reporter: environment.reporter, isInteractive: environment.isInteractive, - continueGuidedSetup: (origin) => continueHubGuidedSetup(origin, environment), + continueGuidedSetup: (origin, daemonTarget) => + continueHubGuidedSetup(origin, { ...environment, daemonTarget }), }); addHubInitCommand(hub, environment); addHubConnectCommand(hub, { @@ -71,8 +72,11 @@ export function createHubCommand(overrides: Partial = {}) }); addJsonAndDaemonHostOptions(hub.command("status")).action( withOutput(async (...args) => { - const options = args.at(-2) as { host?: string }; - return withHubDaemon(environment.daemon, options.host, async (client) => + const options = args.at(-2) as { + host?: string; + daemonTarget: import("../../utils/daemon-target.js").DaemonTarget; + }; + return withHubDaemon(environment.daemon, options.daemonTarget, async (client) => hubStatusResult((await client.getHubStatus()).status), ); }), diff --git a/packages/cli/src/commands/hub/init.ts b/packages/cli/src/commands/hub/init.ts index 6a97558b34..acd6ef054b 100644 --- a/packages/cli/src/commands/hub/init.ts +++ b/packages/cli/src/commands/hub/init.ts @@ -1,3 +1,6 @@ +import type { DaemonTarget } from "../../utils/daemon-target.js"; +import { withGlobalOptions } from "../../utils/command-options.js"; +import type { CommandOptions } from "../../output/index.js"; import { cancel, confirm, @@ -49,6 +52,7 @@ const DAEMON_READY_POLL_MS = 250; const PROVIDER_READY_TIMEOUT_MS = 60_000; export interface HubGuidedSetupEnvironment { + daemonTarget: DaemonTarget; env: Readonly>; credentials: HubCredentialStore; hub: HubHttpClient; @@ -80,22 +84,27 @@ function initErrorMessage(error: unknown): string { return error instanceof Error ? error.message : String(error); } -export function addHubInitCommand(parent: Command, environment: HubGuidedSetupEnvironment): void { +export function addHubInitCommand( + parent: Command, + environment: Omit, +): void { parent .command("init") .description("Create and optionally deploy a safe starter Hub trigger") - .action(async () => { - try { - await runHubInit(environment); - } catch (error) { - if (error instanceof HubInitCancelledError) { - cancel(error.message); - return; + .action( + withGlobalOptions(async (options: CommandOptions) => { + try { + await runHubInit({ ...environment, daemonTarget: options.daemonTarget }); + } catch (error) { + if (error instanceof HubInitCancelledError) { + cancel(error.message); + return; + } + cancel(initErrorMessage(error)); + process.exitCode = 1; } - cancel(initErrorMessage(error)); - process.exitCode = 1; - } - }); + }), + ); } export async function runHubInit(environment: HubGuidedSetupEnvironment): Promise { @@ -175,8 +184,10 @@ export async function continueHubGuidedSetup( origin: string, environment: HubGuidedSetupEnvironment, ): Promise { - const currentStatus = await withHubDaemon(environment.daemon, undefined, async (daemon) => - daemon.getHubStatus().then((response) => response.status), + const currentStatus = await withHubDaemon( + environment.daemon, + environment.daemonTarget, + async (daemon) => daemon.getHubStatus().then((response) => response.status), ); const current = resolveHubInitConnection(currentStatus, origin); if (current.kind === "connected") { @@ -187,7 +198,7 @@ export async function continueHubGuidedSetup( }.`, ); } else if (current.kind === "pending") { - await waitForDaemonReady(origin, environment.daemon); + await waitForDaemonReady(origin, environment); } else if (current.kind === "conflict") { reportMessage( environment, @@ -273,7 +284,7 @@ async function ensureDaemonConnection( confirmed = false, permissions: readonly string[] = ["hub.execute"], ): Promise { - const status = await withHubDaemon(environment.daemon, undefined, async (daemon) => + const status = await withHubDaemon(environment.daemon, environment.daemonTarget, async (daemon) => daemon.getHubStatus().then((response) => response.status), ); const connection = resolveHubInitConnection(status, origin); @@ -287,7 +298,7 @@ async function ensureDaemonConnection( return connection.daemonId; } if (connection.kind === "pending") { - return waitForDaemonReady(origin, environment.daemon); + return waitForDaemonReady(origin, environment); } if (connection.kind === "conflict") { throw new HubCommandError( @@ -315,7 +326,7 @@ async function connectDaemon( ): Promise { await runHubConnect( origin, - { permissions }, + { permissions, daemonTarget: environment.daemonTarget }, { env: environment.env, credentials: environment.credentials, @@ -324,15 +335,15 @@ async function connectDaemon( reporter: environment.reporter, }, ); - return waitForDaemonReady(origin, environment.daemon); + return waitForDaemonReady(origin, environment); } async function waitForDaemonReady( origin: string, - connection: HubDaemonConnection, + environment: HubGuidedSetupEnvironment, ): Promise { return withSpinner("Waiting for the daemon to connect", async (reporter) => - withHubDaemon(connection, undefined, async (daemon) => { + withHubDaemon(environment.daemon, environment.daemonTarget, async (daemon) => { const deadline = Date.now() + DAEMON_READY_TIMEOUT_MS; while (true) { const status = (await daemon.getHubStatus()).status; @@ -436,7 +447,7 @@ async function waitForStarterAgentProviders( cwd: string, ): Promise { return withSpinner("Discovering agent runtimes", async (reporter) => - withHubDaemon(environment.daemon, undefined, async (daemon) => { + withHubDaemon(environment.daemon, environment.daemonTarget, async (daemon) => { const deadline = Date.now() + PROVIDER_READY_TIMEOUT_MS; while (true) { const snapshot = await daemon.getProvidersSnapshot({ cwd }); @@ -750,7 +761,9 @@ async function requiredSelect( return answer; } -function requireInteractiveTerminal(environment: HubGuidedSetupEnvironment): void { +function requireInteractiveTerminal( + environment: Omit, +): void { if (!(environment.isInteractive?.() ?? (process.stdin.isTTY && process.stdout.isTTY))) { throw new HubCommandError("HUB_INIT_INTERACTIVE_REQUIRED", "paseo hub init requires a TTY."); } diff --git a/packages/cli/src/commands/hub/login.ts b/packages/cli/src/commands/hub/login.ts index e77329094c..056561deb8 100644 --- a/packages/cli/src/commands/hub/login.ts +++ b/packages/cli/src/commands/hub/login.ts @@ -1,3 +1,5 @@ +import type { CommandOptions } from "../../output/index.js"; +import type { DaemonTarget } from "../../utils/daemon-target.js"; import type { Command } from "commander"; import { withOutput, type OutputSchema, type SingleResult } from "../../output/index.js"; import { addJsonOption } from "../../utils/command-options.js"; @@ -26,12 +28,12 @@ interface HubLoginDependencies { flow: Pick; reporter: HubReporter; isInteractive?(): boolean; - continueGuidedSetup?(origin: string): Promise; + continueGuidedSetup?(origin: string, daemonTarget: DaemonTarget): Promise; } export async function runHubLogin( originInput: string | undefined, - options: { json?: boolean }, + options: Pick, dependencies: HubLoginDependencies, ): Promise> { const origin = resolveHubOrigin({ @@ -48,7 +50,7 @@ export async function runHubLogin( dependencies.isInteractive?.() && dependencies.continueGuidedSetup !== undefined ) { - await dependencies.continueGuidedSetup(origin); + await dependencies.continueGuidedSetup(origin, options.daemonTarget); } return { type: "single", data: { origin, status: "logged_in" }, schema }; } @@ -64,7 +66,7 @@ export function addHubLoginCommand(parent: Command, dependencies: HubLoginDepend ).action( withOutput(async (...args) => { const origin = args[0] as string | undefined; - const options = args.at(-2) as { json?: boolean }; + const options = args.at(-2) as CommandOptions; return runHubLogin(origin, options, dependencies); }), ); diff --git a/packages/cli/src/commands/hub/logout.ts b/packages/cli/src/commands/hub/logout.ts index 28f0837021..8fd6f2bb7c 100644 --- a/packages/cli/src/commands/hub/logout.ts +++ b/packages/cli/src/commands/hub/logout.ts @@ -28,6 +28,7 @@ interface HubLogoutOptions { disconnectDaemon?: boolean; force?: boolean; host?: string; + daemonTarget: import("../../utils/daemon-target.js").DaemonTarget; json?: boolean; } @@ -53,7 +54,7 @@ export async function runHubLogout( let daemonStatus: HubStatus | null = null; const shouldInspectDaemon = options.disconnectDaemon === true || mayPrompt; if (shouldInspectDaemon) { - daemonStatus = await withHubDaemon(dependencies.daemon, options.host, async (daemon) => + daemonStatus = await withHubDaemon(dependencies.daemon, options.daemonTarget, async (daemon) => daemon.getHubStatus().then((response) => response.status), ); } @@ -72,9 +73,13 @@ export async function runHubLogout( options, `Disconnecting this daemon from ${active.origin}`, ); - const disconnect = await withHubDaemon(dependencies.daemon, options.host, async (daemon) => { - return daemon.disconnectHub(options.force ?? false); - }); + const disconnect = await withHubDaemon( + dependencies.daemon, + options.daemonTarget, + async (daemon) => { + return daemon.disconnectHub(options.force ?? false); + }, + ); dependencies.credentials.logoutActive(); return logoutResult({ origin: active.origin, diff --git a/packages/cli/src/commands/hub/permissions.ts b/packages/cli/src/commands/hub/permissions.ts index ca9214b3e3..86f0b4c307 100644 --- a/packages/cli/src/commands/hub/permissions.ts +++ b/packages/cli/src/commands/hub/permissions.ts @@ -8,6 +8,7 @@ import { hubStatusResult } from "./status-output.js"; interface HubPermissionsOptions { host?: string; + daemonTarget: import("../../utils/daemon-target.js").DaemonTarget; json?: boolean; } @@ -33,7 +34,7 @@ export function runHubPermissionsList( options: HubPermissionsOptions, dependencies: HubPermissionsDependencies, ): Promise> { - return withHubDaemon(dependencies.daemon, options.host, async (client) => { + return withHubDaemon(dependencies.daemon, options.daemonTarget, async (client) => { const status = (await client.getHubStatus()).status; requireConnectedHub(status); return { @@ -53,7 +54,7 @@ export function runHubPermissionChange( options: HubPermissionsOptions, dependencies: HubPermissionsDependencies, ) { - return withHubDaemon(dependencies.daemon, options.host, async (client) => { + return withHubDaemon(dependencies.daemon, options.daemonTarget, async (client) => { const current = (await client.getHubStatus()).status; requireConnectedHub(current); const response = await client.updateHubPermissions( diff --git a/packages/cli/src/commands/onboard.ts b/packages/cli/src/commands/onboard.ts index 1ce0ac4283..4719e2823f 100644 --- a/packages/cli/src/commands/onboard.ts +++ b/packages/cli/src/commands/onboard.ts @@ -1,17 +1,18 @@ -import { cancel, confirm, intro, isCancel, log, note, outro, spinner } from "@clack/prompts"; +import { addLocalDaemonOptions } from "../utils/command-options.js"; +import { cancel, confirm, intro, isCancel, log, note, outro } from "@clack/prompts"; import { Command, Option } from "commander"; -import { writeFileSync } from "node:fs"; import path from "node:path"; -import { loadPersistedConfig, type PersistedConfig } from "@getpaseo/server"; import { - resolveLocalPaseoHome, - resolveLocalDaemonState, - resolveTcpHostFromListen, - startLocalDaemonDetached, - tailDaemonLog, - type DaemonStartOptions, -} from "./daemon/local-daemon.js"; -import { tryConnectToDaemon } from "../utils/client.js"; + readPersistedConfig as loadPersistedConfig, + savePersistedConfig, + readDaemonInstance, + waitForDaemonReady, + type PersistedConfig, +} from "@getpaseo/server"; +import { withGlobalOptions } from "../utils/command-options.js"; +import type { CommandOptions } from "../output/index.js"; +import { launchLocalDaemon, parseTimeoutMs } from "./daemon/local-daemon.js"; +import { connectToDaemon } from "../utils/client.js"; import { formatPairingInstructions } from "../output/pairing.js"; import { confirmRelayPairing, @@ -19,7 +20,12 @@ import { resolveLocalPairingOffer, } from "./daemon/pair.js"; -interface OnboardOptions extends DaemonStartOptions { +interface OnboardOptions extends CommandOptions { + port?: string; + listen?: string; + relay?: boolean; + mcp?: boolean; + hostnames?: string; timeout?: string; voice?: "ask" | "enable" | "disable"; } @@ -39,41 +45,12 @@ type OnboardPersistedConfig = PersistedConfig & { }; }; -const DEFAULT_READY_TIMEOUT_MS = 10 * 60 * 1000; -const READY_PROBE_TIMEOUT_MS = 1200; - class OnboardCancelledError extends Error {} - const plainNoteFormat = (line: string): string => line; - function renderNote(message: string, title: string): void { note(message, title, { format: plainNoteFormat }); } -function sleep(ms: number): Promise { - return new Promise((resolve) => { - setTimeout(resolve, ms); - }); -} - -function parseTimeoutMs(raw: string | undefined): number { - if (!raw || raw.trim().length === 0) { - return DEFAULT_READY_TIMEOUT_MS; - } - - const seconds = Number(raw); - if (!Number.isFinite(seconds) || seconds <= 0) { - throw new Error(`Invalid timeout value: ${raw}`); - } - - return Math.ceil(seconds * 1000); -} - -function savePersistedConfig(paseoHome: string, config: OnboardPersistedConfig): void { - const configPath = path.join(paseoHome, "config.json"); - writeFileSync(configPath, `${JSON.stringify(config, null, 2)}\n`); -} - function applyVoiceSelection( config: OnboardPersistedConfig, enabled: boolean, @@ -135,134 +112,6 @@ async function resolveVoiceSelection(mode: OnboardOptions["voice"]): Promise= 0; index -= 1) { - const line = lines[index]; - if (!line || !line.includes("Downloading model artifact")) { - continue; - } - - const pctMatch = line.match(/"pct"\s*:\s*(\d{1,3})|\bpct[=:]\s*(\d{1,3})/); - const modelMatch = line.match(/"modelId"\s*:\s*"([^"]+)"|\bmodelId[=:]\s*"?([^\s",}]+)/); - - return { - modelId: modelMatch?.[1] ?? modelMatch?.[2] ?? null, - pct: pctMatch ? Number(pctMatch[1] ?? pctMatch[2]) : null, - }; - } - - return null; -} - -function renderProgressLine(progress: DownloadProgress): string { - const modelSuffix = progress.modelId ? ` (${progress.modelId})` : ""; - if (progress.pct === null) { - return `Downloading speech model${modelSuffix}...`; - } - return `Downloading speech model${modelSuffix}: ${progress.pct}%`; -} - -type ProbeResult = { kind: "ready"; listen: string; host: string | null } | { kind: "pending" }; - -async function probeDaemonReady(home: string, timeoutMs: number): Promise { - const state = resolveLocalDaemonState({ home }); - const host = resolveTcpHostFromListen(state.listen); - const deadline = Date.now() + timeoutMs; - const remainingTimeoutMs = () => Math.max(1, deadline - Date.now()); - - if (state.running && host) { - const client = await tryConnectToDaemon({ - host, - timeout: Math.min(remainingTimeoutMs(), READY_PROBE_TIMEOUT_MS), - }); - if (client) { - try { - await client.fetchAgents({ - timeout: Math.min(remainingTimeoutMs(), READY_PROBE_TIMEOUT_MS), - }); - return { kind: "ready", listen: state.listen, host }; - } catch { - // Daemon process is alive but not API-ready yet. - } finally { - await client.close().catch(() => {}); - } - } - } else if (state.running && !host) { - return { kind: "ready", listen: state.listen, host: null }; - } - - return { kind: "pending" }; -} - -interface ProgressState { - lastStatus: string; - lastPrintedAt: number; -} - -function announceProgress( - home: string, - state: ProgressState, - onStatus: ((message: string) => void) | undefined, -): ProgressState { - const progress = parseDownloadProgress(tailDaemonLog(home, 120) ?? ""); - const progressLine = progress ? renderProgressLine(progress) : null; - const statusMessage = progressLine ?? "Waiting for daemon to become ready..."; - - if (statusMessage !== state.lastStatus) { - onStatus?.(statusMessage); - return { lastStatus: statusMessage, lastPrintedAt: Date.now() }; - } - if (!onStatus && Date.now() - state.lastPrintedAt >= 3000) { - console.log(statusMessage); - return { lastStatus: state.lastStatus, lastPrintedAt: Date.now() }; - } - return state; -} - -async function waitForDaemonReady(args: { - home: string; - timeoutMs: number; - onStatus?: (message: string) => void; -}): Promise<{ listen: string; host: string | null }> { - const deadline = Date.now() + args.timeoutMs; - const createTimeoutError = () => { - const recentLogs = tailDaemonLog(args.home, 60); - return new Error( - [ - `Timed out after ${Math.ceil(args.timeoutMs / 1000)}s waiting for daemon readiness.`, - recentLogs ? `Recent daemon logs:\n${recentLogs}` : null, - ] - .filter(Boolean) - .join("\n\n"), - ); - }; - - async function poll(state: ProgressState): Promise<{ listen: string; host: string | null }> { - if (Date.now() >= deadline) { - throw createTimeoutError(); - } - const probe = await probeDaemonReady(args.home, Math.max(1, deadline - Date.now())); - if (probe.kind === "ready") { - return { listen: probe.listen, host: probe.host }; - } - const nextState = announceProgress(args.home, state, args.onStatus); - if (Date.now() >= deadline) { - throw createTimeoutError(); - } - await sleep(200); - return poll(nextState); - } - - return poll({ lastStatus: "", lastPrintedAt: 0 }); -} - function printNextSteps(pairingUrl: string | null, paseoHome: string, richUi: boolean): void { const daemonLogPath = path.join(paseoHome, "daemon.log"); const nextStepsLines = [ @@ -272,13 +121,13 @@ function printNextSteps(pairingUrl: string | null, paseoHome: string, richUi: bo "2. Web app: https://app.paseo.sh", "3. Desktop app: https://github.com/getpaseo/paseo/releases/latest", "4. Docs: https://paseo.sh/docs", - '5. Example: paseo run --output-schema schema.json "extract fields"', + `5. Example: paseo run --home ${JSON.stringify(paseoHome)} --output-schema schema.json "extract fields"`, ]; const quickReferenceLines = [ "1. paseo --help", - "2. paseo ls", - '3. paseo run "your prompt"', - "4. paseo status", + `2. paseo ls --home ${JSON.stringify(paseoHome)}`, + `3. paseo run --home ${JSON.stringify(paseoHome)} "your prompt"`, + `4. paseo status --home ${JSON.stringify(paseoHome)}`, `5. Daemon logs: ${daemonLogPath}`, ]; @@ -301,11 +150,10 @@ function printNextSteps(pairingUrl: string | null, paseoHome: string, richUi: bo } export function onboardCommand(): Command { - return new Command("onboard") + return addLocalDaemonOptions(new Command("onboard")) .description("Run first-time setup, start daemon, and print pairing instructions") .option("--listen ", "Listen target (host:port, port, or unix socket path)") .option("--port ", "Port to listen on (default: 6767)") - .option("--home ", "Paseo home directory (default: ~/.paseo)") .option("--relay", "Enable relay connection without prompting") .option("--no-relay", "Disable relay connection") .option("--no-mcp", "Disable the Agent MCP HTTP endpoint") @@ -316,12 +164,15 @@ export function onboardCommand(): Command { .addOption(new Option("--allowed-hosts ").hideHelp()) .option("--timeout ", "Max time to wait for daemon readiness (default: 600)") .option("--voice ", "Voice setup mode: ask, enable, disable", "ask") - .action(async (options: RawOnboardOptions) => { - await runOnboard({ - ...options, - hostnames: options.hostnames ?? options.allowedHosts, - }); - }); + .action( + withGlobalOptions(async (options: RawOnboardOptions, command: Command) => { + await runOnboard({ + ...options, + mcp: command.getOptionValueSource("mcp") === "cli" ? options.mcp : undefined, + hostnames: options.hostnames ?? options.allowedHosts, + }); + }), + ); } async function resolveAndPersistVoice( @@ -354,70 +205,30 @@ async function resolveAndPersistVoice( return voiceEnabled; } -async function ensureDaemonStarted(options: OnboardOptions, richUi: boolean): Promise { - const stateBeforeStart = resolveLocalDaemonState({ home: options.home }); - if (stateBeforeStart.running) { - log.message(`Daemon already running (PID ${stateBeforeStart.pidInfo?.pid ?? "unknown"}).`); - return; - } - - const startSpinner = richUi ? spinner() : null; - try { - if (startSpinner) { - startSpinner.start("Starting daemon..."); - } else { - log.message("Starting daemon..."); - } - const startup = await startLocalDaemonDetached(options); - if (startSpinner) { - startSpinner.stop(`Daemon started (PID ${startup.pid ?? "unknown"})`); - } else { - log.message(`Daemon started (PID ${startup.pid ?? "unknown"})`); - } - log.message(`Logs: ${startup.logPath}`); - } catch (error) { - const message = error instanceof Error ? error.message : String(error); - if (startSpinner) { - startSpinner.error(message); - } else { - log.error(message); - } - process.exit(1); - } -} - -async function waitForDaemonReadyWithUi(args: { - home: string; - timeoutMs: number; - richUi: boolean; -}): Promise<{ listen: string; host: string | null }> { - const readySpinner = args.richUi ? spinner() : null; - try { - if (readySpinner) { - readySpinner.start("Waiting for daemon to become ready..."); - } else { - log.message("Waiting for daemon to become ready..."); - } - const readyState = await waitForDaemonReady({ - home: args.home, - timeoutMs: args.timeoutMs, - onStatus: readySpinner ? (message) => readySpinner.message(message) : undefined, - }); - if (readySpinner) { - readySpinner.stop(`Daemon ready on ${readyState.listen}`); - } else { - log.message(`Daemon ready on ${readyState.listen}`); - } - return readyState; - } catch (error) { - const message = error instanceof Error ? error.message : String(error); - if (readySpinner) { - readySpinner.error(message); - } else { - log.error(message); - } - return process.exit(1); +function persistSetupChoices(paseoHome: string, options: OnboardOptions): void { + const persisted = loadPersistedConfig(paseoHome, { defaultsIfMissing: true }); + if (options.listen || options.port) { + persisted.daemon = { + ...persisted.daemon, + listen: options.listen ?? `127.0.0.1:${options.port}`, + }; } + if (options.relay !== undefined) + persisted.daemon = { + ...persisted.daemon, + relay: { ...persisted.daemon?.relay, enabled: options.relay }, + }; + if (options.mcp !== undefined) + persisted.daemon = { + ...persisted.daemon, + mcp: { ...persisted.daemon?.mcp, enabled: options.mcp }, + }; + if (options.hostnames) + persisted.daemon = { + ...persisted.daemon, + hostnames: options.hostnames === "true" ? true : options.hostnames.split(","), + }; + savePersistedConfig(paseoHome, persisted); } export async function runOnboard(options: OnboardOptions): Promise { @@ -431,16 +242,12 @@ export async function runOnboard(options: OnboardOptions): Promise { process.exit(1); } - let timeoutMs = DEFAULT_READY_TIMEOUT_MS; - try { - timeoutMs = parseTimeoutMs(options.timeout); - } catch (error) { - const message = error instanceof Error ? error.message : String(error); - cancel(message); - process.exit(1); - } + const timeoutMs = parseTimeoutMs(options.timeout); - const paseoHome = resolveLocalPaseoHome(options.home); + if (options.daemonTarget.kind !== "instance") throw new Error("Onboarding requires a local home"); + const paseoHome = options.daemonTarget.home; + const alreadyRunning = await readDaemonInstance(paseoHome); + persistSetupChoices(paseoHome, options); if (richUi) { renderNote(paseoHome, "Paseo home"); } @@ -452,12 +259,19 @@ export async function runOnboard(options: OnboardOptions): Promise { : "Voice features disabled. Local speech models will not be downloaded.", ); - await ensureDaemonStarted(options, richUi); - await waitForDaemonReadyWithUi({ - home: options.home ?? paseoHome, - timeoutMs, - richUi, - }); + if (alreadyRunning) { + log.message(`Daemon already running (PID ${alreadyRunning.pid}); retaining its supervisor.`); + const client = await connectToDaemon({ target: options.daemonTarget, timeout: timeoutMs }); + try { + log.message(JSON.stringify(await client.reloadDaemonConfig())); + } finally { + await client.close(); + } + } else { + await launchLocalDaemon({ home: paseoHome, timeoutMs }); + } + const ready = await waitForDaemonReady(paseoHome, { timeoutMs }); + log.message(`Daemon ready on ${ready.listen}`); if (options.relay === false) { log.message("Relay pairing skipped because --no-relay was provided."); diff --git a/packages/cli/src/commands/open.ts b/packages/cli/src/commands/open.ts index c815dd36f8..51656436e9 100644 --- a/packages/cli/src/commands/open.ts +++ b/packages/cli/src/commands/open.ts @@ -95,6 +95,7 @@ export async function openDesktopWithProject(projectPath: string): Promise try { launchDesktop([projectPath]); } catch (error) { + if (error && typeof error === "object" && "code" in error) throw error; const message = error instanceof Error ? error.message : String(error); process.stderr.write(`${message}\n`); process.exitCode = 1; diff --git a/packages/cli/src/commands/permit/allow.ts b/packages/cli/src/commands/permit/allow.ts index 15234c0d01..d2338cffca 100644 --- a/packages/cli/src/commands/permit/allow.ts +++ b/packages/cli/src/commands/permit/allow.ts @@ -1,6 +1,6 @@ import type { Command } from "commander"; import type { AgentPermissionRequest } from "@getpaseo/protocol/agent-types"; -import { connectToDaemon, getDaemonHost } from "../../utils/client.js"; +import { connectToDaemon } from "../../utils/client.js"; import type { CommandOptions, ListResult, OutputSchema, CommandError } from "../../output/index.js"; /** Permission response item for display */ @@ -46,8 +46,6 @@ export async function runAllowCommand( options: PermitAllowOptions, _command: Command, ): Promise { - const host = getDaemonHost({ host: options.host }); - // No validation needed - if no reqId provided, allow all by default // Parse input JSON if provided @@ -65,18 +63,7 @@ export async function runAllowCommand( } } - let client; - try { - client = await connectToDaemon({ host: options.host }); - } catch (err) { - const message = err instanceof Error ? err.message : String(err); - const error: CommandError = { - code: "DAEMON_NOT_RUNNING", - message: `Cannot connect to daemon at ${host}: ${message}`, - details: "Start the daemon with: paseo daemon start", - }; - throw error; - } + const client = await connectToDaemon({ target: options.daemonTarget }); try { const fetchResult = await client.fetchAgent({ agentId: agentIdOrPrefix }); diff --git a/packages/cli/src/commands/permit/deny.ts b/packages/cli/src/commands/permit/deny.ts index 94ffe9d571..c0d4707f35 100644 --- a/packages/cli/src/commands/permit/deny.ts +++ b/packages/cli/src/commands/permit/deny.ts @@ -1,6 +1,6 @@ import type { Command } from "commander"; import type { AgentPermissionRequest } from "@getpaseo/protocol/agent-types"; -import { connectToDaemon, getDaemonHost } from "../../utils/client.js"; +import { connectToDaemon } from "../../utils/client.js"; import type { CommandOptions, ListResult, CommandError } from "../../output/index.js"; import { permitResponseSchema, type PermissionResponseItem } from "./allow.js"; @@ -19,8 +19,6 @@ export async function runDenyCommand( options: PermitDenyOptions, _command: Command, ): Promise { - const host = getDaemonHost({ host: options.host }); - // Validate arguments if (!options.all && !reqId) { const error: CommandError = { @@ -31,18 +29,7 @@ export async function runDenyCommand( throw error; } - let client; - try { - client = await connectToDaemon({ host: options.host }); - } catch (err) { - const message = err instanceof Error ? err.message : String(err); - const error: CommandError = { - code: "DAEMON_NOT_RUNNING", - message: `Cannot connect to daemon at ${host}: ${message}`, - details: "Start the daemon with: paseo daemon start", - }; - throw error; - } + const client = await connectToDaemon({ target: options.daemonTarget }); try { const fetchResult = await client.fetchAgent({ agentId: agentIdOrPrefix }); diff --git a/packages/cli/src/commands/permit/ls.ts b/packages/cli/src/commands/permit/ls.ts index adeaa70ff1..b04c8d9f58 100644 --- a/packages/cli/src/commands/permit/ls.ts +++ b/packages/cli/src/commands/permit/ls.ts @@ -1,7 +1,7 @@ import type { Command } from "commander"; import type { AgentPermissionRequest } from "@getpaseo/protocol/agent-types"; import type { AgentSnapshotPayload } from "@getpaseo/protocol/messages"; -import { connectToDaemon, getDaemonHost } from "../../utils/client.js"; +import { connectToDaemon } from "../../utils/client.js"; import type { CommandOptions, ListResult, OutputSchema, CommandError } from "../../output/index.js"; /** Permission list item for display */ @@ -48,20 +48,7 @@ export async function runLsCommand( options: PermitLsOptions, _command: Command, ): Promise { - const host = getDaemonHost({ host: options.host }); - - let client; - try { - client = await connectToDaemon({ host: options.host }); - } catch (err) { - const message = err instanceof Error ? err.message : String(err); - const error: CommandError = { - code: "DAEMON_NOT_RUNNING", - message: `Cannot connect to daemon at ${host}: ${message}`, - details: "Start the daemon with: paseo daemon start", - }; - throw error; - } + const client = await connectToDaemon({ target: options.daemonTarget }); try { const agentsPayload = await client.fetchAgents({ filter: { includeArchived: true } }); diff --git a/packages/cli/src/commands/plugin/index.test.ts b/packages/cli/src/commands/plugin/index.test.ts index 02c6c846ce..957a0072ae 100644 --- a/packages/cli/src/commands/plugin/index.test.ts +++ b/packages/cli/src/commands/plugin/index.test.ts @@ -1,5 +1,7 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; +const daemonTarget = { kind: "endpoint" as const, host: "example.test:12345" }; + const listPlugins = vi.fn(async () => [ { id: "git-plugin", @@ -68,7 +70,9 @@ describe("plugin management commands", () => { }); it("requires host support before attempting a management RPC", async () => { - await expect(runPluginListCommand(undefined, {}, {} as never)).rejects.toMatchObject({ + await expect( + runPluginListCommand(undefined, { daemonTarget }, {} as never), + ).rejects.toMatchObject({ code: "DAEMON_UPDATE_REQUIRED", message: "Update the host to use plugin management.", }); @@ -89,7 +93,7 @@ describe("plugin management commands", () => { it("lists runtime state and the installed commit without an upstream commit", async () => { features.pluginManagement = true; - const result = await runPluginListCommand(undefined, {}, {} as never); + const result = await runPluginListCommand(undefined, { daemonTarget }, {} as never); const output = render(result, { noColor: true }); expect(output).toContain("SOURCE"); @@ -102,13 +106,15 @@ describe("plugin management commands", () => { it("filters the shared ls and status command by plugin ID", async () => { features.pluginManagement = true; - const result = await runPluginListCommand("legacy-plugin", {}, {} as never); + const result = await runPluginListCommand("legacy-plugin", { daemonTarget }, {} as never); expect(result.data.map((plugin) => plugin.id)).toEqual(["legacy-plugin"]); }); it("requires plugin log support before attempting the RPC", async () => { - await expect(runPluginLogsCommand("example", {}, {} as never)).rejects.toMatchObject({ + await expect( + runPluginLogsCommand("example", { daemonTarget }, {} as never), + ).rejects.toMatchObject({ code: "DAEMON_UPDATE_REQUIRED", message: "Update the host to view plugin logs.", }); @@ -117,7 +123,7 @@ describe("plugin management commands", () => { it("returns readable and JSON plugin log output", async () => { features.pluginLogs = true; - const result = await runPluginLogsCommand("example", {}, {} as never); + const result = await runPluginLogsCommand("example", { daemonTarget }, {} as never); expect(getPluginLogs).toHaveBeenCalledWith("example"); expect(render(result, { noColor: true })).toContain("ready"); diff --git a/packages/cli/src/commands/plugin/index.ts b/packages/cli/src/commands/plugin/index.ts index 608a57e5d1..e067d4aa39 100644 --- a/packages/cli/src/commands/plugin/index.ts +++ b/packages/cli/src/commands/plugin/index.ts @@ -90,7 +90,9 @@ export async function runPluginListCommand( options: PluginOptions, _command: Command, ): Promise> { - const plugins = await withPluginManagementClient(options.host, (client) => client.listPlugins()); + const plugins = await withPluginManagementClient(options.daemonTarget, (client) => + client.listPlugins(), + ); const data = pluginId ? plugins.filter((plugin) => plugin.id === pluginId) : plugins; if (pluginId && data.length === 0) throw new Error(`Plugin is not configured: ${pluginId}`); return { type: "list", data, schema: pluginSchema }; @@ -101,7 +103,9 @@ export async function runPluginLogsCommand( options: PluginOptions, _command: Command, ): Promise> { - const data = await withPluginLogsClient(options.host, (client) => client.getPluginLogs(pluginId)); + const data = await withPluginLogsClient(options.daemonTarget, (client) => + client.getPluginLogs(pluginId), + ); return { type: "list", data, schema: pluginLogsSchema }; } @@ -126,10 +130,10 @@ async function install( isExplicitPath && !hasPluginPathSuffix && !options.ref && !options.path; const sourceReference = formatPluginSourceReference(source, options.path); const data = canUseLegacyDirectoryInstall - ? await withPluginManagementClient(options.host, (client) => + ? await withPluginManagementClient(options.daemonTarget, (client) => client.installDirectoryPlugin(source, options.id), ) - : await withPluginSourceClient(options.host, (client) => + : await withPluginSourceClient(options.daemonTarget, (client) => client.installPluginSource({ source: sourceReference, ...(options.id ? { id: options.id } : {}), @@ -147,7 +151,7 @@ async function update( if ((pluginId === undefined) === (options.all !== true)) { throw new Error("Choose one plugin ID or pass --all"); } - const data = await withPluginSourceClient(options.host, (client) => + const data = await withPluginSourceClient(options.daemonTarget, (client) => client.updatePluginSources(pluginId), ); return { type: "list", data, schema: pluginUpdateSchema }; @@ -158,7 +162,7 @@ async function act( pluginId: string, options: PluginOptions, ): Promise> { - const data = await withPluginManagementClient(options.host, (client) => + const data = await withPluginManagementClient(options.daemonTarget, (client) => client[`${action}Plugin`](pluginId), ); return { type: "single", data, schema: pluginSchema }; @@ -169,7 +173,7 @@ async function remove( options: PluginOptions, _command: Command, ): Promise> { - const data = await withPluginManagementClient(options.host, async (client) => { + const data = await withPluginManagementClient(options.daemonTarget, async (client) => { const current = (await client.listPlugins()).find((plugin) => plugin.id === pluginId); if (!current) throw new Error(`Plugin is not configured: ${pluginId}`); await client.removePlugin(pluginId); diff --git a/packages/cli/src/commands/plugin/shared.ts b/packages/cli/src/commands/plugin/shared.ts index c85a8d7e32..80c3dcfafa 100644 --- a/packages/cli/src/commands/plugin/shared.ts +++ b/packages/cli/src/commands/plugin/shared.ts @@ -1,3 +1,4 @@ +import type { DaemonTarget } from "../../utils/daemon-target.js"; import type { DaemonClient } from "@getpaseo/client/internal/daemon-client"; import type { CommandError } from "../../output/index.js"; import { connectToDaemon } from "../../utils/client.js"; @@ -5,12 +6,12 @@ import { connectToDaemon } from "../../utils/client.js"; type PluginFeature = "pluginManagement" | "pluginLogs" | "pluginGitManagement"; async function withPluginClient( - host: string | undefined, + target: DaemonTarget, feature: PluginFeature, updateMessage: string, run: (client: DaemonClient) => Promise, ): Promise { - const client = await connectToDaemon({ host }); + const client = await connectToDaemon({ target }); if (client.getLastServerInfoMessage()?.features?.[feature] !== true) { await client.close().catch(() => undefined); throw { @@ -26,12 +27,12 @@ async function withPluginClient( } export async function withPluginManagementClient( - host: string | undefined, + target: DaemonTarget, run: (client: DaemonClient) => Promise, ): Promise { // COMPAT(pluginManagement): added in v0.3.1, remove gate after 2027-08-14. return withPluginClient( - host, + target, "pluginManagement", "Update the host to use plugin management.", run, @@ -39,20 +40,20 @@ export async function withPluginManagementClient( } export async function withPluginLogsClient( - host: string | undefined, + target: DaemonTarget, run: (client: DaemonClient) => Promise, ): Promise { // COMPAT(pluginLogs): added in v0.4.0, remove gate after 2027-08-16. - return withPluginClient(host, "pluginLogs", "Update the host to view plugin logs.", run); + return withPluginClient(target, "pluginLogs", "Update the host to view plugin logs.", run); } export async function withPluginSourceClient( - host: string | undefined, + target: DaemonTarget, run: (client: DaemonClient) => Promise, ): Promise { // COMPAT(pluginGitManagement): added in v0.7.0, remove gate after 2027-08-26. return withPluginClient( - host, + target, "pluginGitManagement", "Update the host to install and update Git plugins.", run, diff --git a/packages/cli/src/commands/project/create.ts b/packages/cli/src/commands/project/create.ts index 2210e6c214..b141ea7f18 100644 --- a/packages/cli/src/commands/project/create.ts +++ b/packages/cli/src/commands/project/create.ts @@ -31,10 +31,10 @@ export async function runCreateCommand( const projectPath = resolveProjectPath({ pathArg, cwd: process.cwd(), - daemonTarget: options.host ?? process.env.PASEO_HOST, + daemonTarget: options.daemonTarget.kind === "endpoint" ? options.daemonTarget.host : undefined, }); - const client = await connectToDaemon({ host: options.host }).catch((error: unknown) => { - throw buildDaemonConnectionCommandError({ host: options.host, error }); + const client = await connectToDaemon({ target: options.daemonTarget }).catch((error: unknown) => { + throw buildDaemonConnectionCommandError({ target: options.daemonTarget, error }); }); try { @@ -44,6 +44,7 @@ export async function runCreateCommand( } return { type: "single", data: toProjectRow(payload.project), schema: projectSchema }; } catch (error) { + if (error && typeof error === "object" && "code" in error) throw error; const message = error instanceof Error ? error.message : String(error); throw { code: "PROJECT_CREATE_FAILED", message } satisfies CommandError; } finally { diff --git a/packages/cli/src/commands/project/delete.ts b/packages/cli/src/commands/project/delete.ts index 05dd657ef1..2c4a20ddc2 100644 --- a/packages/cli/src/commands/project/delete.ts +++ b/packages/cli/src/commands/project/delete.ts @@ -24,8 +24,8 @@ export async function runDeleteCommand( options: CommandOptions, _command: Command, ): Promise> { - const client = await connectToDaemon({ host: options.host }).catch((error: unknown) => { - throw buildDaemonConnectionCommandError({ host: options.host, error }); + const client = await connectToDaemon({ target: options.daemonTarget }).catch((error: unknown) => { + throw buildDaemonConnectionCommandError({ target: options.daemonTarget, error }); }); try { @@ -36,6 +36,7 @@ export async function runDeleteCommand( schema: projectDeleteSchema, }; } catch (error) { + if (error && typeof error === "object" && "code" in error) throw error; const message = error instanceof Error ? error.message : String(error); throw { code: "PROJECT_DELETE_FAILED", message } satisfies CommandError; } finally { diff --git a/packages/cli/src/commands/project/ls.ts b/packages/cli/src/commands/project/ls.ts index 750428881f..acd9a21935 100644 --- a/packages/cli/src/commands/project/ls.ts +++ b/packages/cli/src/commands/project/ls.ts @@ -7,8 +7,8 @@ export async function runLsCommand( options: CommandOptions, _command: Command, ): Promise> { - const client = await connectToDaemon({ host: options.host }).catch((error: unknown) => { - throw buildDaemonConnectionCommandError({ host: options.host, error }); + const client = await connectToDaemon({ target: options.daemonTarget }).catch((error: unknown) => { + throw buildDaemonConnectionCommandError({ target: options.daemonTarget, error }); }); try { diff --git a/packages/cli/src/commands/project/project.test.ts b/packages/cli/src/commands/project/project.test.ts index 47ea1cecf5..301c7a8a65 100644 --- a/packages/cli/src/commands/project/project.test.ts +++ b/packages/cli/src/commands/project/project.test.ts @@ -47,7 +47,11 @@ describe("project commands", () => { }); it("creates a project from an absolute directory path", async () => { - const result = await runCreateCommand("relative/project", {}, {} as never); + const result = await runCreateCommand( + "relative/project", + { daemonTarget: { kind: "instance", home: "/tmp/project-test" } }, + {} as never, + ); expect(addProject).toHaveBeenCalledWith(path.resolve("relative/project")); expect(result.data).toEqual({ @@ -77,7 +81,10 @@ describe("project commands", () => { }); it("lists projects", async () => { - const result = await runLsCommand({}, {} as never); + const result = await runLsCommand( + { daemonTarget: { kind: "instance", home: "/tmp/project-test" } }, + {} as never, + ); expect(listProjects).toHaveBeenCalled(); expect(result.data).toHaveLength(1); @@ -88,7 +95,12 @@ describe("project commands", () => { expect(resolveProjectName({ name: " New name " })).toBe("New name"); expect(resolveProjectName({ reset: true })).toBeNull(); - const result = await runRenameCommand("project-1", " New name ", {}, {} as never); + const result = await runRenameCommand( + "project-1", + " New name ", + { daemonTarget: { kind: "instance", home: "/tmp/project-test" } }, + {} as never, + ); expect(renameProject).toHaveBeenCalledWith("project-1", "New name"); expect(result.data).toEqual({ projectId: "project-1", name: "New name" }); }); @@ -98,7 +110,11 @@ describe("project commands", () => { }); it("deletes a project", async () => { - const result = await runDeleteCommand("project-1", {}, {} as never); + const result = await runDeleteCommand( + "project-1", + { daemonTarget: { kind: "instance", home: "/tmp/project-test" } }, + {} as never, + ); expect(removeProject).toHaveBeenCalledWith("project-1"); expect(result.data).toEqual({ diff --git a/packages/cli/src/commands/project/rename.ts b/packages/cli/src/commands/project/rename.ts index b00fdb06be..42bf21bd90 100644 --- a/packages/cli/src/commands/project/rename.ts +++ b/packages/cli/src/commands/project/rename.ts @@ -44,8 +44,8 @@ export async function runRenameCommand( _command: Command, ): Promise> { const name = resolveProjectName({ name: nameArg, reset: options.reset }); - const client = await connectToDaemon({ host: options.host }).catch((error: unknown) => { - throw buildDaemonConnectionCommandError({ host: options.host, error }); + const client = await connectToDaemon({ target: options.daemonTarget }).catch((error: unknown) => { + throw buildDaemonConnectionCommandError({ target: options.daemonTarget, error }); }); try { @@ -56,6 +56,7 @@ export async function runRenameCommand( schema: projectRenameSchema, }; } catch (error) { + if (error && typeof error === "object" && "code" in error) throw error; const message = error instanceof Error ? error.message : String(error); throw { code: "PROJECT_RENAME_FAILED", message } satisfies CommandError; } finally { diff --git a/packages/cli/src/commands/provider/diagnostic.ts b/packages/cli/src/commands/provider/diagnostic.ts index 0ff05556a1..cbc13bd670 100644 --- a/packages/cli/src/commands/provider/diagnostic.ts +++ b/packages/cli/src/commands/provider/diagnostic.ts @@ -28,7 +28,7 @@ export async function runDiagnosticCommand( options: ProviderDiagnosticOptions, _command: Command, ): Promise> { - const client = await connectToDaemon({ host: options.host }); + const client = await connectToDaemon({ target: options.daemonTarget }); try { const result = await client.getProviderDiagnostic(provider.trim().toLowerCase()); return { diff --git a/packages/cli/src/commands/provider/ls.ts b/packages/cli/src/commands/provider/ls.ts index 6b902383db..1e56b60f73 100644 --- a/packages/cli/src/commands/provider/ls.ts +++ b/packages/cli/src/commands/provider/ls.ts @@ -1,8 +1,7 @@ import type { Command } from "commander"; import type { CommandOptions, ListResult, OutputSchema } from "../../output/index.js"; import type { ProviderSnapshotEntry } from "@getpaseo/protocol/agent-types"; -import { AGENT_PROVIDER_DEFINITIONS } from "@getpaseo/protocol/provider-manifest"; -import { tryConnectToDaemon } from "../../utils/client.js"; +import { connectToDaemon } from "../../utils/client.js"; export interface ProviderListItem { provider: ProviderSnapshotEntry["provider"]; @@ -13,20 +12,6 @@ export interface ProviderListItem { modes: string; } -/** Derive provider list from the manifest — single source of truth */ -const PROVIDERS: ProviderListItem[] = AGENT_PROVIDER_DEFINITIONS.map((def) => ({ - provider: def.id, - label: def.label, - status: "available", - enabled: def.enabledByDefault === false ? "Disabled" : "Enabled", - defaultMode: def.defaultModeId ?? "-", - modes: def.modes.length > 0 ? def.modes.map((m) => m.label).join(", ") : "-", -})); - -function getStaticProviders(): ProviderListItem[] { - return PROVIDERS; -} - /** Schema for provider ls output */ export const providerLsSchema: OutputSchema = { idField: "provider", @@ -59,15 +44,7 @@ export async function runLsCommand( options: ProviderLsOptions, _command: Command, ): Promise { - const client = await tryConnectToDaemon({ host: options.host }); - - if (!client) { - return { - type: "list", - data: getStaticProviders(), - schema: providerLsSchema, - }; - } + const client = await connectToDaemon({ target: options.daemonTarget }); try { const snapshot = await client.getProvidersSnapshot(); @@ -83,12 +60,6 @@ export async function runLsCommand( })), schema: providerLsSchema, }; - } catch { - return { - type: "list", - data: getStaticProviders(), - schema: providerLsSchema, - }; } finally { await client.close().catch(() => {}); } diff --git a/packages/cli/src/commands/provider/models.ts b/packages/cli/src/commands/provider/models.ts index e5dde2d136..1d8fd3f7bb 100644 --- a/packages/cli/src/commands/provider/models.ts +++ b/packages/cli/src/commands/provider/models.ts @@ -62,7 +62,7 @@ export async function runModelsCommand( ): Promise { const normalizedProvider = provider.toLowerCase(); - const client = await connectToDaemon({ host: options.host }); + const client = await connectToDaemon({ target: options.daemonTarget }); try { const result = await client.listProviderModels(normalizedProvider); diff --git a/packages/cli/src/commands/schedule/create.ts b/packages/cli/src/commands/schedule/create.ts index 471765876d..8b77ea392f 100644 --- a/packages/cli/src/commands/schedule/create.ts +++ b/packages/cli/src/commands/schedule/create.ts @@ -43,12 +43,12 @@ export async function runCreateCommand( mode: options.mode, thinking: options.thinking, cwd: options.cwd, - host: options.host, + daemonTarget: options.daemonTarget, maxRuns: options.maxRuns, expiresIn: options.expiresIn, runNow, }); - const { client } = await connectScheduleClient(options.host); + const { client } = await connectScheduleClient(options.daemonTarget); try { const payload = await client.scheduleCreate(input); if (payload.error || !payload.schedule) { diff --git a/packages/cli/src/commands/schedule/delete.ts b/packages/cli/src/commands/schedule/delete.ts index f1f4ba2cae..f46ecf5d1f 100644 --- a/packages/cli/src/commands/schedule/delete.ts +++ b/packages/cli/src/commands/schedule/delete.ts @@ -25,7 +25,7 @@ export async function runDeleteCommand( options: ScheduleCommandOptions, _command: Command, ): Promise> { - const { client } = await connectScheduleClient(options.host); + const { client } = await connectScheduleClient(options.daemonTarget); try { const payload = await client.scheduleDelete({ id }); if (payload.error) { diff --git a/packages/cli/src/commands/schedule/inspect.ts b/packages/cli/src/commands/schedule/inspect.ts index d020f24089..8a94a82e23 100644 --- a/packages/cli/src/commands/schedule/inspect.ts +++ b/packages/cli/src/commands/schedule/inspect.ts @@ -16,7 +16,7 @@ export async function runInspectCommand( options: ScheduleCommandOptions, _command: Command, ): Promise> { - const { client } = await connectScheduleClient(options.host); + const { client } = await connectScheduleClient(options.daemonTarget); try { const payload = await client.scheduleInspect({ id }); if (payload.error || !payload.schedule) { diff --git a/packages/cli/src/commands/schedule/logs.ts b/packages/cli/src/commands/schedule/logs.ts index c9565b5c0d..7609851766 100644 --- a/packages/cli/src/commands/schedule/logs.ts +++ b/packages/cli/src/commands/schedule/logs.ts @@ -13,7 +13,7 @@ export async function runLogsCommand( options: ScheduleCommandOptions, _command: Command, ): Promise> { - const { client } = await connectScheduleClient(options.host); + const { client } = await connectScheduleClient(options.daemonTarget); try { await requireNewAgentSchedule(client, id); const payload = await client.scheduleLogs({ id }); diff --git a/packages/cli/src/commands/schedule/ls.ts b/packages/cli/src/commands/schedule/ls.ts index 501c86653e..c3dfb6db28 100644 --- a/packages/cli/src/commands/schedule/ls.ts +++ b/packages/cli/src/commands/schedule/ls.ts @@ -13,7 +13,7 @@ export async function runLsCommand( options: ScheduleCommandOptions, _command: Command, ): Promise> { - const { client } = await connectScheduleClient(options.host); + const { client } = await connectScheduleClient(options.daemonTarget); try { const payload = await client.scheduleList(); if (payload.error) { diff --git a/packages/cli/src/commands/schedule/pause.ts b/packages/cli/src/commands/schedule/pause.ts index ed8e12108d..07929516fc 100644 --- a/packages/cli/src/commands/schedule/pause.ts +++ b/packages/cli/src/commands/schedule/pause.ts @@ -15,7 +15,7 @@ export async function runPauseCommand( options: ScheduleCommandOptions, _command: Command, ): Promise> { - const { client } = await connectScheduleClient(options.host); + const { client } = await connectScheduleClient(options.daemonTarget); try { await requireNewAgentSchedule(client, id); const payload = await client.schedulePause({ id }); diff --git a/packages/cli/src/commands/schedule/resume.ts b/packages/cli/src/commands/schedule/resume.ts index efac2d6340..8d2855b397 100644 --- a/packages/cli/src/commands/schedule/resume.ts +++ b/packages/cli/src/commands/schedule/resume.ts @@ -15,7 +15,7 @@ export async function runResumeCommand( options: ScheduleCommandOptions, _command: Command, ): Promise> { - const { client } = await connectScheduleClient(options.host); + const { client } = await connectScheduleClient(options.daemonTarget); try { await requireNewAgentSchedule(client, id); const payload = await client.scheduleResume({ id }); diff --git a/packages/cli/src/commands/schedule/run-once.ts b/packages/cli/src/commands/schedule/run-once.ts index 2818afc5db..beb8f65b29 100644 --- a/packages/cli/src/commands/schedule/run-once.ts +++ b/packages/cli/src/commands/schedule/run-once.ts @@ -15,7 +15,7 @@ export async function runRunOnceCommand( options: ScheduleCommandOptions, _command: Command, ): Promise> { - const { client } = await connectScheduleClient(options.host); + const { client } = await connectScheduleClient(options.daemonTarget); try { await requireNewAgentSchedule(client, id); const payload = await client.scheduleRunOnce({ id }); diff --git a/packages/cli/src/commands/schedule/shared.test.ts b/packages/cli/src/commands/schedule/shared.test.ts index 0777134b0e..bac537e4fe 100644 --- a/packages/cli/src/commands/schedule/shared.test.ts +++ b/packages/cli/src/commands/schedule/shared.test.ts @@ -1,4 +1,6 @@ -import { afterEach, beforeEach, describe, expect, test, vi } from "vitest"; +import { describe, expect, test, vi } from "vitest"; + +import { selectDaemonTarget } from "../../utils/daemon-target.js"; import { compileEveryPresetToCron, @@ -7,32 +9,25 @@ import { } from "./shared.js"; const baseOptions = { + daemonTarget: selectDaemonTarget({ home: process.cwd() }, {}), prompt: "do the thing", every: "5m", provider: "claude", }; const baseCron = { + daemonTarget: selectDaemonTarget({ home: process.cwd() }, {}), prompt: "do the thing", cron: "0 9 * * *", provider: "claude", }; describe("parseScheduleCreateInput cwd/host validation", () => { - beforeEach(() => { - vi.spyOn(process, "cwd").mockReturnValue("/local/project"); - }); - - afterEach(() => { - vi.restoreAllMocks(); - vi.unstubAllEnvs(); - }); - test("no host, no cwd → defaults to process.cwd()", () => { const input = parseScheduleCreateInput(baseOptions); expect(input.target).toEqual({ type: "new-agent", - config: { provider: "claude", cwd: "/local/project" }, + config: { provider: "claude", cwd: process.cwd() }, }); }); @@ -47,7 +42,7 @@ describe("parseScheduleCreateInput cwd/host validation", () => { test("host with cwd → uses provided cwd", () => { const input = parseScheduleCreateInput({ ...baseOptions, - host: "dev:6767", + daemonTarget: selectDaemonTarget({ host: "dev:12345" }, {}), cwd: "/remote/project", }); expect(input.target).toEqual({ @@ -57,7 +52,12 @@ describe("parseScheduleCreateInput cwd/host validation", () => { }); test("host without cwd → throws MISSING_CWD", () => { - expect(() => parseScheduleCreateInput({ ...baseOptions, host: "dev:6767" })).toThrow( + expect(() => + parseScheduleCreateInput({ + ...baseOptions, + daemonTarget: selectDaemonTarget({ host: "dev:12345" }, {}), + }), + ).toThrow( expect.objectContaining({ code: "MISSING_CWD", message: expect.stringContaining("--cwd is required when --host is specified"), @@ -66,9 +66,9 @@ describe("parseScheduleCreateInput cwd/host validation", () => { }); test("PASEO_HOST without cwd → throws MISSING_CWD", () => { - vi.stubEnv("PASEO_HOST", "dev:6767"); + const daemonTarget = selectDaemonTarget({}, { PASEO_HOST: "dev:12345" }); - expect(() => parseScheduleCreateInput(baseOptions)).toThrow( + expect(() => parseScheduleCreateInput({ ...baseOptions, daemonTarget })).toThrow( expect.objectContaining({ code: "MISSING_CWD", message: expect.stringContaining("--cwd is required"), @@ -76,9 +76,21 @@ describe("parseScheduleCreateInput cwd/host validation", () => { ); }); + test("explicit home beats PASEO_HOST and defaults cwd locally", () => { + const daemonTarget = selectDaemonTarget({ home: process.cwd() }, { PASEO_HOST: "dev:12345" }); + expect(parseScheduleCreateInput({ ...baseOptions, daemonTarget }).target).toEqual({ + type: "new-agent", + config: { provider: "claude", cwd: process.cwd() }, + }); + }); + test("host with whitespace-only cwd → throws MISSING_CWD", () => { expect(() => - parseScheduleCreateInput({ ...baseOptions, host: "dev:6767", cwd: " " }), + parseScheduleCreateInput({ + ...baseOptions, + daemonTarget: selectDaemonTarget({ host: "dev:12345" }, {}), + cwd: " ", + }), ).toThrow(expect.objectContaining({ code: "MISSING_CWD" })); }); }); diff --git a/packages/cli/src/commands/schedule/shared.ts b/packages/cli/src/commands/schedule/shared.ts index beae80acc0..7506596092 100644 --- a/packages/cli/src/commands/schedule/shared.ts +++ b/packages/cli/src/commands/schedule/shared.ts @@ -1,4 +1,5 @@ -import { connectToDaemon, getDaemonHost, getExplicitDaemonHost } from "../../utils/client.js"; +import type { DaemonTarget } from "../../utils/daemon-target.js"; +import { connectToDaemon, getDaemonHost } from "../../utils/client.js"; import type { CommandError, CommandOptions } from "../../output/index.js"; import type { CreateScheduleInput, @@ -19,15 +20,16 @@ export interface ScheduleCommandOptions extends CommandOptions { } export async function connectScheduleClient( - host: string | undefined, + target: DaemonTarget, ): Promise<{ client: ScheduleDaemonClient; host: string }> { - const resolvedHost = getDaemonHost({ host }); + const resolvedHost = getDaemonHost({ target }); try { const client = (await connectToDaemon({ - host, + target, })) as unknown as ScheduleDaemonClient; return { client, host: resolvedHost }; } catch (error) { + if (error && typeof error === "object" && "code" in error) throw error; const message = error instanceof Error ? error.message : String(error); throw { code: "DAEMON_NOT_RUNNING", @@ -147,6 +149,7 @@ export function parseScheduleCreateInput(options: { thinking?: string; cwd?: string; host?: string; + daemonTarget: import("../../utils/daemon-target.js").DaemonTarget; maxRuns?: string; expiresIn?: string; runNow?: boolean; @@ -168,7 +171,7 @@ export function parseScheduleCreateInput(options: { } const cwdInput = options.cwd?.trim(); - if (getExplicitDaemonHost(options.host) !== undefined && !cwdInput) { + if (options.daemonTarget.kind === "endpoint" && !cwdInput) { throw { code: "MISSING_CWD", message: diff --git a/packages/cli/src/commands/schedule/update.ts b/packages/cli/src/commands/schedule/update.ts index 30873853c1..81f3762962 100644 --- a/packages/cli/src/commands/schedule/update.ts +++ b/packages/cli/src/commands/schedule/update.ts @@ -50,7 +50,7 @@ export async function runUpdateCommand( clearMaxRuns: options.noMaxRuns, clearExpires: options.noExpiresIn, }); - const { client } = await connectScheduleClient(options.host); + const { client } = await connectScheduleClient(options.daemonTarget); try { await requireNewAgentSchedule(client, id); const payload = await client.scheduleUpdate(input); diff --git a/packages/cli/src/commands/script/ls.ts b/packages/cli/src/commands/script/ls.ts index cd7d98e9f8..e8271d0bef 100644 --- a/packages/cli/src/commands/script/ls.ts +++ b/packages/cli/src/commands/script/ls.ts @@ -12,7 +12,7 @@ export async function runLsCommand( options: WorkspaceScriptCommandOptions, _command: Command, ): Promise> { - const client = await connectWorkspaceScriptClient(options.host); + const client = await connectWorkspaceScriptClient(options.daemonTarget); try { const workspaceId = await resolveWorkspaceScriptWorkspaceId(client, options); const payload = await client.listWorkspaceScripts(workspaceId); diff --git a/packages/cli/src/commands/script/shared.ts b/packages/cli/src/commands/script/shared.ts index 4e3308bdfb..b5b2326b6c 100644 --- a/packages/cli/src/commands/script/shared.ts +++ b/packages/cli/src/commands/script/shared.ts @@ -1,3 +1,4 @@ +import type { DaemonTarget } from "../../utils/daemon-target.js"; import { resolve } from "node:path"; import type { DaemonClient } from "@getpaseo/client/internal/daemon-client"; import { connectToDaemon, getDaemonHost } from "../../utils/client.js"; @@ -9,10 +10,10 @@ export interface WorkspaceScriptCommandOptions extends CommandOptions { workspace?: string; } -export async function connectWorkspaceScriptClient(host?: string): Promise { - const daemonHost = getDaemonHost({ host }); +export async function connectWorkspaceScriptClient(target: DaemonTarget): Promise { + const daemonHost = getDaemonHost({ target }); try { - const client = await connectToDaemon({ host }); + const client = await connectToDaemon({ target }); // COMPAT(workspaceScriptManagement): added in v0.1.105, remove gate after 2027-01-10. if (!client.getLastServerInfoMessage()?.features?.workspaceScriptManagement) { await client.close().catch(() => {}); diff --git a/packages/cli/src/commands/script/start.ts b/packages/cli/src/commands/script/start.ts index 1b377267fa..0883f52720 100644 --- a/packages/cli/src/commands/script/start.ts +++ b/packages/cli/src/commands/script/start.ts @@ -13,7 +13,7 @@ export async function runStartCommand( options: WorkspaceScriptCommandOptions, _command: Command, ): Promise> { - const client = await connectWorkspaceScriptClient(options.host); + const client = await connectWorkspaceScriptClient(options.daemonTarget); try { const workspaceId = await resolveWorkspaceScriptWorkspaceId(client, options); const payload = await client.startWorkspaceScriptWithStatus(workspaceId, scriptName); diff --git a/packages/cli/src/commands/script/stop.ts b/packages/cli/src/commands/script/stop.ts index 6855656057..21dbf21371 100644 --- a/packages/cli/src/commands/script/stop.ts +++ b/packages/cli/src/commands/script/stop.ts @@ -13,7 +13,7 @@ export async function runStopCommand( options: WorkspaceScriptCommandOptions, _command: Command, ): Promise> { - const client = await connectWorkspaceScriptClient(options.host); + const client = await connectWorkspaceScriptClient(options.daemonTarget); try { const workspaceId = await resolveWorkspaceScriptWorkspaceId(client, options); const payload = await client.stopWorkspaceScript(workspaceId, scriptName); diff --git a/packages/cli/src/commands/terminal/capture.ts b/packages/cli/src/commands/terminal/capture.ts index 98b9f2ca2e..94ff1726ee 100644 --- a/packages/cli/src/commands/terminal/capture.ts +++ b/packages/cli/src/commands/terminal/capture.ts @@ -16,11 +16,9 @@ export interface TerminalCaptureOptions extends TerminalCommandOptions { export async function runCaptureCommand( terminalId: string, - _options: TerminalCaptureOptions, - command: Command, + options: TerminalCaptureOptions, + _command: Command, ): Promise { - const options = command.optsWithGlobals() as TerminalCaptureOptions; - try { const payload = await executeCaptureCommand(terminalId, options); if (options.json) { @@ -55,7 +53,7 @@ async function executeCaptureCommand( terminalId: string, options: TerminalCaptureOptions, ): Promise<{ terminalId: string; lines: string[]; totalLines: number }> { - const { client, close } = await connectTerminalClient(options.host); + const { client, close } = await connectTerminalClient(options.daemonTarget); try { const resolvedId = await resolveTerminalId(client, terminalId); diff --git a/packages/cli/src/commands/terminal/create.ts b/packages/cli/src/commands/terminal/create.ts index 9524be0223..be41d877e0 100644 --- a/packages/cli/src/commands/terminal/create.ts +++ b/packages/cli/src/commands/terminal/create.ts @@ -17,7 +17,7 @@ export async function runCreateCommand( options: TerminalCreateOptions, _command: Command, ): Promise> { - const { client, close } = await connectTerminalClient(options.host); + const { client, close } = await connectTerminalClient(options.daemonTarget); try { const cwd = options.cwd ?? (options.workspace ? undefined : process.cwd()); const workspaceId = diff --git a/packages/cli/src/commands/terminal/kill.ts b/packages/cli/src/commands/terminal/kill.ts index 30990d9c11..685c8ecaf3 100644 --- a/packages/cli/src/commands/terminal/kill.ts +++ b/packages/cli/src/commands/terminal/kill.ts @@ -13,7 +13,7 @@ export async function runKillCommand( options: TerminalCommandOptions, _command: Command, ): Promise> { - const { client, close } = await connectTerminalClient(options.host); + const { client, close } = await connectTerminalClient(options.daemonTarget); try { const resolvedId = await requireTerminalId(client, terminalId); diff --git a/packages/cli/src/commands/terminal/ls.ts b/packages/cli/src/commands/terminal/ls.ts index bec0b0047b..23b1a182a6 100644 --- a/packages/cli/src/commands/terminal/ls.ts +++ b/packages/cli/src/commands/terminal/ls.ts @@ -17,7 +17,7 @@ export async function runLsCommand( options: TerminalLsOptions, _command: Command, ): Promise> { - const { client, close } = await connectTerminalClient(options.host); + const { client, close } = await connectTerminalClient(options.daemonTarget); const cwd = options.all || options.workspace ? undefined : (options.cwd ?? process.cwd()); try { diff --git a/packages/cli/src/commands/terminal/send-keys.ts b/packages/cli/src/commands/terminal/send-keys.ts index 5097ea3908..8b1a274204 100644 --- a/packages/cli/src/commands/terminal/send-keys.ts +++ b/packages/cli/src/commands/terminal/send-keys.ts @@ -14,11 +14,9 @@ export interface TerminalSendKeysOptions extends TerminalCommandOptions { export async function runSendKeysCommand( terminalId: string, keys: string[], - _options: TerminalSendKeysOptions, - command: Command, + options: TerminalSendKeysOptions, + _command: Command, ): Promise { - const options = command.optsWithGlobals() as TerminalSendKeysOptions; - try { const payload = await executeSendKeysCommand(terminalId, keys, options); if (options.json) { @@ -39,7 +37,7 @@ async function executeSendKeysCommand( keys: string[], options: TerminalSendKeysOptions, ): Promise<{ terminalId: string; keysSent: number }> { - const { client, close } = await connectTerminalClient(options.host); + const { client, close } = await connectTerminalClient(options.daemonTarget); try { const resolvedId = await resolveTerminalId(client, terminalId); diff --git a/packages/cli/src/commands/terminal/shared.ts b/packages/cli/src/commands/terminal/shared.ts index 101caf0879..0fcb9ef64d 100644 --- a/packages/cli/src/commands/terminal/shared.ts +++ b/packages/cli/src/commands/terminal/shared.ts @@ -1,3 +1,4 @@ +import type { DaemonTarget } from "../../utils/daemon-target.js"; import { createPaseoApi } from "@getpaseo/client"; import { connectToDaemon, getDaemonHost } from "../../utils/client.js"; import type { CommandError, CommandOptions } from "../../output/index.js"; @@ -11,12 +12,13 @@ interface TerminalLike { name?: string | null; } -export async function connectTerminalClient(host?: string) { - const daemonHost = getDaemonHost({ host }); +export async function connectTerminalClient(target: DaemonTarget) { + const daemonHost = getDaemonHost({ target }); try { - const client = await connectToDaemon({ host }); + const client = await connectToDaemon({ target }); return { client: createPaseoApi(client), daemonHost, close: () => client.close() }; } catch (err) { + if (err && typeof err === "object" && "code" in err) throw err; const message = err instanceof Error ? err.message : String(err); const error: CommandError = { code: "DAEMON_NOT_RUNNING", diff --git a/packages/cli/src/commands/workspace/archive.ts b/packages/cli/src/commands/workspace/archive.ts index 958d0451d0..a9dad90e91 100644 --- a/packages/cli/src/commands/workspace/archive.ts +++ b/packages/cli/src/commands/workspace/archive.ts @@ -19,11 +19,11 @@ const workspaceArchiveSchema: OutputSchema = { export async function runArchiveCommand( workspaceId: string, - options: { host?: string }, + options: { host?: string; daemonTarget: import("../../utils/daemon-target.js").DaemonTarget }, _command: Command, ): Promise> { - const host = getDaemonHost({ host: options.host }); - const client = await connectToDaemon({ host: options.host }).catch((error: unknown) => { + const host = getDaemonHost({ target: options.daemonTarget }); + const client = await connectToDaemon({ target: options.daemonTarget }).catch((error: unknown) => { const message = error instanceof Error ? error.message : String(error); throw { code: "DAEMON_NOT_RUNNING", @@ -44,6 +44,7 @@ export async function runArchiveCommand( schema: workspaceArchiveSchema, }; } catch (error) { + if (error && typeof error === "object" && "code" in error) throw error; const message = error instanceof Error ? error.message : String(error); throw { code: "WORKSPACE_ARCHIVE_FAILED", message } satisfies CommandError; } finally { diff --git a/packages/cli/src/commands/workspace/create.ts b/packages/cli/src/commands/workspace/create.ts index 078eefb06e..5ce49278ef 100644 --- a/packages/cli/src/commands/workspace/create.ts +++ b/packages/cli/src/commands/workspace/create.ts @@ -134,8 +134,8 @@ export async function runCreateCommand( options: WorkspaceCreateOptions, _command: Command, ): Promise> { - const host = getDaemonHost({ host: options.host }); - const client = await connectToDaemon({ host: options.host }).catch((error: unknown) => { + const host = getDaemonHost({ target: options.daemonTarget }); + const client = await connectToDaemon({ target: options.daemonTarget }).catch((error: unknown) => { const message = error instanceof Error ? error.message : String(error); throw { code: "DAEMON_NOT_RUNNING", @@ -153,6 +153,7 @@ export async function runCreateCommand( } return { type: "single", data: toWorkspaceRow(payload.workspace), schema: workspaceSchema }; } catch (error) { + if (error && typeof error === "object" && "code" in error) throw error; const message = error instanceof Error ? error.message : String(error); throw { code: "WORKSPACE_CREATE_FAILED", message } satisfies CommandError; } finally { diff --git a/packages/cli/src/commands/workspace/ls.ts b/packages/cli/src/commands/workspace/ls.ts index 7ea9e8f56d..b9821e061a 100644 --- a/packages/cli/src/commands/workspace/ls.ts +++ b/packages/cli/src/commands/workspace/ls.ts @@ -4,11 +4,11 @@ import type { CommandError, ListResult } from "../../output/index.js"; import { toWorkspaceRow, workspaceSchema, type WorkspaceRow } from "./shared.js"; export async function runLsCommand( - options: { host?: string }, + options: { host?: string; daemonTarget: import("../../utils/daemon-target.js").DaemonTarget }, _command: Command, ): Promise> { - const host = getDaemonHost({ host: options.host }); - const client = await connectToDaemon({ host: options.host }).catch((error: unknown) => { + const host = getDaemonHost({ target: options.daemonTarget }); + const client = await connectToDaemon({ target: options.daemonTarget }).catch((error: unknown) => { const message = error instanceof Error ? error.message : String(error); throw { code: "DAEMON_NOT_RUNNING", diff --git a/packages/cli/src/commands/workspace/rename.ts b/packages/cli/src/commands/workspace/rename.ts index fd5857682b..85fa5f5ff4 100644 --- a/packages/cli/src/commands/workspace/rename.ts +++ b/packages/cli/src/commands/workspace/rename.ts @@ -18,6 +18,7 @@ const workspaceRenameSchema: OutputSchema = { export interface WorkspaceRenameOptions { host?: string; + daemonTarget: import("../../utils/daemon-target.js").DaemonTarget; reset?: boolean; } @@ -55,8 +56,8 @@ export async function runRenameCommand( ): Promise> { const title = resolveWorkspaceTitle({ title: titleArg, reset: options.reset }); - const host = getDaemonHost({ host: options.host }); - const client = await connectToDaemon({ host: options.host }).catch((error: unknown) => { + const host = getDaemonHost({ target: options.daemonTarget }); + const client = await connectToDaemon({ target: options.daemonTarget }).catch((error: unknown) => { const message = error instanceof Error ? error.message : String(error); throw { code: "DAEMON_NOT_RUNNING", @@ -72,6 +73,7 @@ export async function runRenameCommand( schema: workspaceRenameSchema, }; } catch (error) { + if (error && typeof error === "object" && "code" in error) throw error; const message = error instanceof Error ? error.message : String(error); throw { code: "WORKSPACE_RENAME_FAILED", message } satisfies CommandError; } finally { diff --git a/packages/cli/src/commands/workspace/setup.ts b/packages/cli/src/commands/workspace/setup.ts index ce15d10f26..0d9e99821a 100644 --- a/packages/cli/src/commands/workspace/setup.ts +++ b/packages/cli/src/commands/workspace/setup.ts @@ -17,11 +17,11 @@ const workspaceSetupSchema: OutputSchema = { export async function runSetupCommand( workspaceId: string, - options: { host?: string }, + options: { host?: string; daemonTarget: import("../../utils/daemon-target.js").DaemonTarget }, _command: Command, ): Promise> { - const host = getDaemonHost({ host: options.host }); - const client = await connectToDaemon({ host: options.host }).catch((error: unknown) => { + const host = getDaemonHost({ target: options.daemonTarget }); + const client = await connectToDaemon({ target: options.daemonTarget }).catch((error: unknown) => { throw { code: "DAEMON_NOT_RUNNING", message: `Cannot connect to daemon at ${host}: ${error instanceof Error ? error.message : String(error)}`, diff --git a/packages/cli/src/commands/worktree/archive.test.ts b/packages/cli/src/commands/worktree/archive.test.ts index 5314be144d..f591aaa6a7 100644 --- a/packages/cli/src/commands/worktree/archive.test.ts +++ b/packages/cli/src/commands/worktree/archive.test.ts @@ -2,6 +2,8 @@ import { describe, expect, it } from "vitest"; import type { DaemonClient } from "@getpaseo/client/internal/daemon-client"; import { runArchiveCommandWithDeps } from "./archive.js"; +const daemonTarget = { kind: "endpoint" as const, host: "example.test:12345" }; + function createFakeDaemonClient( overrides: Partial< Pick @@ -60,9 +62,12 @@ describe("runArchiveCommand", () => { const result = await runArchiveCommandWithDeps( "feature", - {}, + { daemonTarget }, { - connectToDaemon: async () => fakeClient, + connectToDaemon: async (options) => { + expect(options.target).toEqual(daemonTarget); + return fakeClient; + }, }, ); @@ -111,9 +116,12 @@ describe("runArchiveCommand", () => { await runArchiveCommandWithDeps( "feature-x", - {}, + { daemonTarget }, { - connectToDaemon: async () => fakeClient, + connectToDaemon: async (options) => { + expect(options.target).toEqual(daemonTarget); + return fakeClient; + }, }, ); @@ -134,9 +142,12 @@ describe("runArchiveCommand", () => { await expect( runArchiveCommandWithDeps( "missing", - {}, + { daemonTarget }, { - connectToDaemon: async () => fakeClient, + connectToDaemon: async (options) => { + expect(options.target).toEqual(daemonTarget); + return fakeClient; + }, }, ), ).rejects.toMatchObject({ diff --git a/packages/cli/src/commands/worktree/archive.ts b/packages/cli/src/commands/worktree/archive.ts index 711711dced..eaf3857322 100644 --- a/packages/cli/src/commands/worktree/archive.ts +++ b/packages/cli/src/commands/worktree/archive.ts @@ -48,7 +48,7 @@ export async function runArchiveCommandWithDeps( options: WorktreeArchiveOptions, deps: { connectToDaemon: typeof connectToDaemon }, ): Promise { - const host = getDaemonHost({ host: options.host }); + const host = getDaemonHost({ target: options.daemonTarget }); // Validate arguments if (!nameArg || nameArg.trim().length === 0) { @@ -62,8 +62,9 @@ export async function runArchiveCommandWithDeps( let client: DaemonClient; try { - client = await deps.connectToDaemon({ host: options.host }); + client = await deps.connectToDaemon({ target: options.daemonTarget }); } catch (err) { + if (err && typeof err === "object" && "code" in err) throw err; const message = err instanceof Error ? err.message : String(err); const error: CommandError = { code: "DAEMON_NOT_RUNNING", diff --git a/packages/cli/src/commands/worktree/create.ts b/packages/cli/src/commands/worktree/create.ts index 257fadc679..da7d873b5a 100644 --- a/packages/cli/src/commands/worktree/create.ts +++ b/packages/cli/src/commands/worktree/create.ts @@ -1,7 +1,6 @@ import path from "node:path"; import type { Command } from "commander"; -import type { DaemonClient } from "@getpaseo/client/internal/daemon-client"; -import { connectToDaemon, getDaemonHost } from "../../utils/client.js"; +import { connectToDaemon } from "../../utils/client.js"; import type { CommandError, OutputSchema, SingleResult } from "../../output/index.js"; import { buildCreateWorktreeRequest, type WorktreeCreateOptions } from "./create-input.js"; @@ -31,18 +30,7 @@ export async function runCreateCommand( const cwd = options.cwd ?? process.cwd(); const request = buildCreateWorktreeRequest(options, cwd); - const host = getDaemonHost({ host: options.host }); - let client: DaemonClient; - try { - client = await connectToDaemon({ host: options.host }); - } catch (err) { - const message = err instanceof Error ? err.message : String(err); - throw cmdError( - "DAEMON_NOT_RUNNING", - `Cannot connect to daemon at ${host}: ${message}`, - "Start the daemon with: paseo daemon start", - ); - } + const client = await connectToDaemon({ target: options.daemonTarget }); try { const response = await client.createPaseoWorktree(request); diff --git a/packages/cli/src/commands/worktree/ls.ts b/packages/cli/src/commands/worktree/ls.ts index 10f1344e17..cd388b2b08 100644 --- a/packages/cli/src/commands/worktree/ls.ts +++ b/packages/cli/src/commands/worktree/ls.ts @@ -1,8 +1,6 @@ import type { Command } from "commander"; -import { homedir } from "node:os"; -import { basename, join, sep } from "node:path"; -import type { DaemonClient } from "@getpaseo/client/internal/daemon-client"; -import { connectToDaemon, getDaemonHost } from "../../utils/client.js"; +import { basename } from "node:path"; +import { connectToDaemon } from "../../utils/client.js"; import type { CommandOptions, ListResult, OutputSchema, CommandError } from "../../output/index.js"; /** Worktree list item for display */ @@ -27,19 +25,6 @@ function extractWorktreeName(path: string): string { return basename(path); } -export function resolvePaseoHomePath(): string { - return process.env.PASEO_HOME ?? join(homedir(), ".paseo"); -} - -export function resolvePaseoWorktreesDir(): string { - return join(resolvePaseoHomePath(), "worktrees"); -} - -function isAgentInManagedWorktree(agentCwd: string): boolean { - const worktreesDir = resolvePaseoWorktreesDir(); - return agentCwd === worktreesDir || agentCwd.startsWith(worktreesDir + sep); -} - /** Schema for worktree ls output */ export const worktreeLsSchema: OutputSchema = { idField: "name", @@ -61,20 +46,7 @@ export async function runLsCommand( options: WorktreeLsOptions, _command: Command, ): Promise { - const host = getDaemonHost({ host: options.host }); - - let client: DaemonClient; - try { - client = await connectToDaemon({ host: options.host }); - } catch (err) { - const message = err instanceof Error ? err.message : String(err); - const error: CommandError = { - code: "DAEMON_NOT_RUNNING", - message: `Cannot connect to daemon at ${host}: ${message}`, - details: "Start the daemon with: paseo daemon start", - }; - throw error; - } + const client = await connectToDaemon({ target: options.daemonTarget }); try { const agentsPayload = await client.fetchAgents({ filter: { includeArchived: true } }); @@ -96,9 +68,7 @@ export async function runLsCommand( // Build a map of worktree paths to agent IDs const worktreeAgentMap = new Map(); for (const agent of agents) { - if (isAgentInManagedWorktree(agent.cwd)) { - worktreeAgentMap.set(agent.cwd, agent.id.slice(0, 7)); - } + worktreeAgentMap.set(agent.cwd, agent.id.slice(0, 7)); } const items: WorktreeListItem[] = response.worktrees.map((wt) => ({ diff --git a/packages/cli/src/output/with-output.ts b/packages/cli/src/output/with-output.ts index ad0f631988..723b6c15d5 100644 --- a/packages/cli/src/output/with-output.ts +++ b/packages/cli/src/output/with-output.ts @@ -11,6 +11,8 @@ import { withGlobalOptions } from "../utils/command-options.js"; /** Options that include output settings from global options */ export interface CommandOptions extends Partial { + daemonTarget: import("../utils/daemon-target.js").DaemonTarget; + home?: string; /** Daemon host target from --host option */ host?: string; /** JSON output flag from --json option */ diff --git a/packages/cli/src/run.ts b/packages/cli/src/run.ts index 4b679c5298..b95dd9b419 100644 --- a/packages/cli/src/run.ts +++ b/packages/cli/src/run.ts @@ -1,3 +1,4 @@ +import { renderError, toCommandError, defaultOutputOptions } from "./output/render.js"; import { createCli } from "./cli.js"; import { classifyInvocation } from "./classify.js"; import { openDesktopWithProject } from "./commands/open.js"; @@ -48,6 +49,16 @@ export async function runCli(argv: string[], options: RunCliOptions = {}): Promi } const program = createCli(); - await program.parseAsync(parseArgv, { from: "node" }); + try { + await program.parseAsync(parseArgv, { from: "node" }); + } catch (error) { + process.stderr.write( + renderError(toCommandError(error), { + ...defaultOutputOptions, + format: argv.includes("--json") ? "json" : "table", + }) + "\n", + ); + return 1; + } return typeof process.exitCode === "number" ? process.exitCode : 0; } diff --git a/packages/cli/src/utils/client-id.ts b/packages/cli/src/utils/client-id.ts index 13863e0945..145b698884 100644 --- a/packages/cli/src/utils/client-id.ts +++ b/packages/cli/src/utils/client-id.ts @@ -1,14 +1,7 @@ -import { mkdir, readFile, writeFile } from "node:fs/promises"; +import { ensurePrivateDirectory } from "@getpaseo/server"; +import { readFile, writeFile } from "node:fs/promises"; import { randomUUID } from "node:crypto"; -import { dirname, join } from "node:path"; -import { homedir } from "node:os"; - -const CLIENT_SESSION_KEY_FILE = join( - process.env.PASEO_HOME ?? join(homedir(), ".paseo"), - "cli-client-id", -); - -let cachedClientId: string | null = null; +import { join } from "node:path"; function normalizeClientId(value: string): string | null { const trimmed = value.trim(); @@ -19,15 +12,12 @@ function generateClientId(): string { return `cid_${randomUUID().replace(/-/g, "")}`; } -export async function getOrCreateCliClientId(): Promise { - if (cachedClientId) { - return cachedClientId; - } +export async function getOrCreateCliClientId(home: string): Promise { + const clientSessionKeyFile = join(home, "cli-client-id"); try { - const existing = normalizeClientId(await readFile(CLIENT_SESSION_KEY_FILE, "utf8")); + const existing = normalizeClientId(await readFile(clientSessionKeyFile, "utf8")); if (existing) { - cachedClientId = existing; return existing; } } catch (error) { @@ -38,8 +28,7 @@ export async function getOrCreateCliClientId(): Promise { } const nextValue = generateClientId(); - await mkdir(dirname(CLIENT_SESSION_KEY_FILE), { recursive: true }); - await writeFile(CLIENT_SESSION_KEY_FILE, nextValue, { mode: 0o600 }); - cachedClientId = nextValue; + ensurePrivateDirectory(home); + await writeFile(clientSessionKeyFile, nextValue, { mode: 0o600 }); return nextValue; } diff --git a/packages/cli/src/utils/client.ssh.test.ts b/packages/cli/src/utils/client.ssh.test.ts index 4311db71b5..19b720a893 100644 --- a/packages/cli/src/utils/client.ssh.test.ts +++ b/packages/cli/src/utils/client.ssh.test.ts @@ -47,7 +47,9 @@ describe("CLI SSH transport", () => { }); it("routes an SSH host through a local tunnel", async () => { - await connectToDaemon({ host: "ssh://deploy@build-box:2222?daemonPort=7777" }); + await connectToDaemon({ + target: { kind: "endpoint", host: "ssh://deploy@build-box:2222?daemonPort=7777" }, + }); expect(mocks.createSshTunnel).toHaveBeenCalledWith({ host: "deploy@build-box", diff --git a/packages/cli/src/utils/client.ts b/packages/cli/src/utils/client.ts index bd774ddaff..f2eae4b6a2 100644 --- a/packages/cli/src/utils/client.ts +++ b/packages/cli/src/utils/client.ts @@ -1,5 +1,6 @@ -import { existsSync, readFileSync } from "node:fs"; -import { loadConfig, resolvePaseoHome } from "@getpaseo/server"; +import { waitForDaemonReady, resolvePaseoHome, type DaemonInstance } from "@getpaseo/server"; +import { describeDaemonTarget, type DaemonTarget } from "./daemon-target.js"; +export type { DaemonTarget } from "./daemon-target.js"; import { buildDaemonWebSocketUrl, buildRelayWebSocketUrl, @@ -13,57 +14,44 @@ import { } from "@getpaseo/protocol/connection-offer"; import { parseSshTransportUri } from "@getpaseo/protocol/ssh-transport"; import { DaemonClient, type WebSocketLike } from "@getpaseo/client/internal/daemon-client"; -import path from "node:path"; import { WebSocket } from "ws"; import { getOrCreateCliClientId } from "./client-id.js"; import { resolveCliVersion } from "../version.js"; import { createSshTunnel } from "../ssh/ssh-tunnel.js"; export interface ConnectOptions { - host?: string; + target: DaemonTarget; timeout?: number; + instance?: DaemonInstance; } - -export interface DaemonConnectionCommandError { - code: "DAEMON_NOT_RUNNING"; - message: string; - details: string; -} - -const DEFAULT_HOST = "localhost:6767"; const DEFAULT_TIMEOUT = 15000; -const PID_FILENAME = "paseo.pid"; +type TransportTarget = + | { type: "tcp"; url: string } + | { type: "ipc"; url: string; socketPath: string }; -type DaemonTarget = - | { - type: "tcp"; - url: string; - } - | { - type: "ipc"; - url: string; - socketPath: string; - }; - -/** - * Get the daemon host from environment or options - */ -export function getDaemonHost(options?: ConnectOptions): string { - return resolveDaemonHostCandidates(options)[0] ?? DEFAULT_HOST; +export function getDaemonHost(options: ConnectOptions): string { + return describeDaemonTarget(options.target); } -export function buildDaemonConnectionCommandError(options: { - host?: string; - error: unknown; -}): DaemonConnectionCommandError { - const host = getDaemonHost({ host: options.host }); - const message = options.error instanceof Error ? options.error.message : String(options.error); +export function buildDaemonConnectionCommandError(options: ConnectOptions & { error: unknown }) { + const error = options.error; + let message = error instanceof Error ? error.message : String(error); + if (error && typeof error === "object" && "message" in error) message = String(error.message); + if (options.target.kind === "endpoint") + message = message.replaceAll(options.target.host, describeDaemonTarget(options.target)); + if (message.startsWith("Cannot connect to daemon at ")) + return error as { code: string; message: string; details: string }; + let code = "DAEMON_UNREACHABLE"; + if (typeof error === "object" && error !== null && "code" in error) code = String(error.code); + else if (message === "Password required") code = "AUTH_REQUIRED"; + else if (message === "Incorrect password") code = "AUTH_FAILED"; return { - code: "DAEMON_NOT_RUNNING", - message: `Cannot connect to daemon at ${host}: ${message}`, - details: host.trim().startsWith("ssh://") - ? "Start the Paseo daemon on the SSH host; SSH transport does not install or start it." - : "Start the daemon with: paseo daemon start", + code, + message: `Cannot connect to daemon at ${describeDaemonTarget(options.target)}: ${message}`, + details: + options.target.kind === "instance" + ? `Start with: paseo daemon start --home ${JSON.stringify(options.target.home)}` + : "Check the selected endpoint and credentials. SSH transport does not install or start the daemon.", }; } @@ -118,100 +106,13 @@ export function normalizeDaemonHost(raw: string): string | null { return trimmed.includes(":") ? trimmed : null; } -export function resolveDefaultDaemonHost(env: NodeJS.ProcessEnv = process.env): string { - return resolveDefaultDaemonHosts(env)[0] ?? DEFAULT_HOST; -} - -function isIpcDaemonHost(host: string | null): host is string { - return host !== null && (host.startsWith("unix://") || host.startsWith("pipe://")); -} - -function isTcpDaemonHost(host: string | null): host is string { - return host !== null && !isIpcDaemonHost(host); -} - -function readPidSocketTarget(paseoHome: string): string | null { - const pidPath = path.join(paseoHome, PID_FILENAME); - if (!existsSync(pidPath)) { - return null; - } - - try { - const parsed = JSON.parse(readFileSync(pidPath, "utf-8")) as { - listen?: unknown; - sockPath?: unknown; - }; - if (typeof parsed.listen === "string") return parsed.listen; - if (typeof parsed.sockPath === "string") return parsed.sockPath; - return null; - } catch { - return null; - } -} - -function resolveConfiguredIpcDaemonHost(env: NodeJS.ProcessEnv, paseoHome: string): string | null { - const directEnvHost = normalizeDaemonHost(env.PASEO_LISTEN ?? ""); - if (isIpcDaemonHost(directEnvHost)) { - return directEnvHost; - } - - const pidHost = normalizeDaemonHost(readPidSocketTarget(paseoHome) ?? ""); - if (isIpcDaemonHost(pidHost)) { - return pidHost; - } - - const config = loadConfig(paseoHome, { env }); - const configuredHost = normalizeDaemonHost(config.listen); - return isIpcDaemonHost(configuredHost) ? configuredHost : null; -} - -function resolveConfiguredTcpDaemonHost(env: NodeJS.ProcessEnv, paseoHome: string): string | null { - const configuredHost = normalizeDaemonHost(loadConfig(paseoHome, { env }).listen); - if (!isTcpDaemonHost(configuredHost)) { - return null; - } - return configuredHost === "127.0.0.1:6767" ? null : configuredHost; -} - -export function resolveDefaultDaemonHosts(env: NodeJS.ProcessEnv = process.env): string[] { - const paseoHome = resolvePaseoHome(env); - const candidates: string[] = []; - const configuredIpcHost = resolveConfiguredIpcDaemonHost(env, paseoHome); - if (configuredIpcHost) { - candidates.push(configuredIpcHost); - } - const configuredTcpHost = resolveConfiguredTcpDaemonHost(env, paseoHome); - if (configuredTcpHost) { - candidates.push(configuredTcpHost); - } - candidates.push(DEFAULT_HOST); - return Array.from(new Set(candidates)); -} - -function resolveDaemonHostCandidates(options?: ConnectOptions): string[] { - const explicitHost = getExplicitDaemonHost(options?.host); - if (explicitHost) { - return [explicitHost]; - } - - return resolveDefaultDaemonHosts(); -} - -export function getExplicitDaemonHost( - host: string | undefined, - env: NodeJS.ProcessEnv = process.env, -): string | undefined { - const explicitHost = host ?? env.PASEO_HOST; - return explicitHost?.trim() ? explicitHost : undefined; -} - function stripIpcPrefix(trimmed: string): string { if (trimmed.startsWith("unix://")) return trimmed.slice("unix://".length).trim(); if (trimmed.startsWith("pipe://")) return trimmed.slice("pipe://".length).trim(); return trimmed; } -export function resolveDaemonTarget(host: string): DaemonTarget { +export function resolveDaemonTarget(host: string): TransportTarget { const trimmed = normalizeDaemonHost(host); if (!trimmed) { throw new Error(`Invalid daemon target: ${host}`); @@ -363,12 +264,21 @@ function parseHostOfferOrNull(host: string | undefined): ConnectionOffer | null } } -export async function connectToDaemon(options?: ConnectOptions): Promise { - const timeout = options?.timeout ?? DEFAULT_TIMEOUT; - const clientId = await getOrCreateCliClientId(); +async function connectSelectedDaemon(options: ConnectOptions): Promise { + const timeout = options.timeout ?? DEFAULT_TIMEOUT; + const deadline = Date.now() + timeout; + const explicitHost = + options.target.kind === "endpoint" + ? options.target.host + : ( + await waitForDaemonReady(options.target.home, { + timeoutMs: timeout, + instance: options.instance, + }) + ).listen; + const clientId = await getOrCreateCliClientId(resolvePaseoHome({})); const nodeWebSocketFactory = createNodeWebSocketFactory(); - const explicitHost = getExplicitDaemonHost(options?.host); if (explicitHost?.trim().startsWith("ssh://")) { const target = parseSshTransportUri(explicitHost.trim()); const tunnel = await createSshTunnel(target); @@ -377,10 +287,20 @@ export async function connectToDaemon(options?: ConnectOptions): Promise { + try { + await close(); + } finally { + tunnel.close(); + } + }; + return result.client; + } const failure = tunnel.failureDetail(); tunnel.close(); @@ -389,32 +309,37 @@ export async function connectToDaemon(options?: ConnectOptions): Promise { - if (index >= hosts.length) { - if (lastError instanceof Error) throw lastError; - throw new Error(`Unable to connect to Paseo daemon via ${hosts.join(", ")}`); - } - const host = hosts[index]; - const password = resolveDaemonPassword(host); - const result = await tryConnectHost(host, password, clientId, timeout, nodeWebSocketFactory); - if ("client" in result) { - return result.client; - } - return tryNext(index + 1, result.error); +export async function connectToDaemon(options: ConnectOptions): Promise { + try { + return await connectSelectedDaemon(options); + } catch (error) { + throw buildDaemonConnectionCommandError({ ...options, error }); } - - return tryNext(0, null); } /** * Try to connect to the daemon, returns null if connection fails */ -export async function tryConnectToDaemon(options?: ConnectOptions): Promise { +export async function tryConnectToDaemon(options: ConnectOptions): Promise { try { return await connectToDaemon(options); } catch { diff --git a/packages/cli/src/utils/command-options.test.ts b/packages/cli/src/utils/command-options.test.ts index 652fe9356b..f491c6db03 100644 --- a/packages/cli/src/utils/command-options.test.ts +++ b/packages/cli/src/utils/command-options.test.ts @@ -1,16 +1,19 @@ import { Command } from "commander"; import { describe, expect, it } from "vitest"; -import { addDaemonHostOption, withGlobalOptions } from "./command-options.js"; +import { createCli } from "../cli.js"; +import { withGlobalOptions } from "./command-options.js"; async function parseHost(argv: string[]): Promise { - const program = new Command().exitOverride().option("--host "); + const program = createCli().exitOverride(); let receivedHost: string | undefined; - addDaemonHostOption(program.command("ls")).action( - withGlobalOptions((options: { host?: string }) => { - receivedHost = options.host; - }), - ); + program.commands + .find((command) => command.name() === "ls")! + .action( + withGlobalOptions((options: { host?: string }) => { + receivedHost = options.host; + }), + ); await program.parseAsync(argv, { from: "user" }); return receivedHost; @@ -25,10 +28,10 @@ describe("global command options", () => { await expect(parseHost(["ls", "--host", "local:6767"])).resolves.toBe("local:6767"); }); - it("uses the last explicit host", async () => { - await expect(parseHost(["--host", "first:6767", "ls", "--host", "last:6767"])).resolves.toBe( - "last:6767", - ); + it("rejects conflicting parent and command selectors", async () => { + await expect( + parseHost(["--host", "first:6767", "ls", "--host", "last:6767"]), + ).rejects.toMatchObject({ code: "TARGET_AMBIGUOUS" }); }); it("lets local-only commands ignore a global host", async () => { diff --git a/packages/cli/src/utils/command-options.ts b/packages/cli/src/utils/command-options.ts index b02b4abdb1..02fcd924a0 100644 --- a/packages/cli/src/utils/command-options.ts +++ b/packages/cli/src/utils/command-options.ts @@ -1,8 +1,17 @@ import type { Command } from "commander"; +import { selectDaemonTarget } from "./daemon-target.js"; const JSON_OPTION_DESCRIPTION = "Output in JSON format"; const DAEMON_HOST_OPTION_DESCRIPTION = - "Daemon host target: host:port, tcp://host:port, or ssh://user@host (default: local socket/pipe, then localhost:6767)"; + "Explicit daemon endpoint: host:port, TCP, socket, SSH, or pairing URL (default: local home)"; + +const localCommands = new WeakSet(); + +export function addLocalDaemonOptions(command: T): T { + localCommands.add(command); + command.option("--home ", "Local daemon home (default: ~/.paseo)"); + return command; +} export function collectMultiple(value: string, previous: string[]): string[] { return previous.concat([value]); @@ -15,6 +24,8 @@ export function addJsonOption(command: T): T { export function addDaemonHostOption(command: T): T { command.option("--host ", DAEMON_HOST_OPTION_DESCRIPTION); + if (!command.options.some((option) => option.long === "--home")) + command.option("--home ", "Select a local daemon home (default: ~/.paseo)"); return command; } @@ -28,7 +39,19 @@ export function withGlobalOptions( return (...args) => { const command = args.at(-1) as Command; const mergedArgs = [...args]; - mergedArgs[mergedArgs.length - 2] = command.optsWithGlobals(); + const options = command.optsWithGlobals(); + const selectors = { home: new Set(), host: new Set() }; + for (let current: Command | null = command; current; current = current.parent) { + for (const key of ["home", "host"] as const) { + const value = current.opts()[key]; + if (typeof value === "string") selectors[key].add(value); + } + } + if (selectors.home.size > 1 || selectors.host.size > 1) + throw { code: "TARGET_AMBIGUOUS", message: "Conflicting duplicate daemon selectors." }; + const localOnly = localCommands.has(command); + options.daemonTarget = selectDaemonTarget(options, process.env, localOnly); + mergedArgs[mergedArgs.length - 2] = options; return handler(...(mergedArgs as Args)); }; } diff --git a/packages/cli/src/utils/daemon-target.test.ts b/packages/cli/src/utils/daemon-target.test.ts new file mode 100644 index 0000000000..f42b9e8704 --- /dev/null +++ b/packages/cli/src/utils/daemon-target.test.ts @@ -0,0 +1,32 @@ +import { test, expect } from "vitest"; +import { selectDaemonTarget, describeDaemonTarget } from "./daemon-target.js"; + +test("explicit selectors win over both environment selectors", () => { + const env = { PASEO_HOME: "/tmp/a", PASEO_HOST: "unused:12345" }; + expect(selectDaemonTarget({ home: "/tmp/b" }, env)).toEqual({ kind: "instance", home: "/tmp/b" }); + expect(selectDaemonTarget({ host: "chosen:23456" }, env)).toEqual({ + kind: "endpoint", + host: "chosen:23456", + }); + expect(() => selectDaemonTarget({}, env)).toThrow(); + expect(() => selectDaemonTarget({ home: "/tmp/b", host: "chosen:23456" }, {})).toThrow(); +}); + +test("local operations ignore routing environment but reject an explicit endpoint", () => { + expect( + selectDaemonTarget({}, { PASEO_HOME: "/tmp/b", PASEO_HOST: "unused:12345" }, true), + ).toEqual({ kind: "instance", home: "/tmp/b" }); + expect(() => selectDaemonTarget({ host: "chosen:23456" }, {}, true)).toThrow(); +}); + +test("endpoint descriptions redact pairing material and credentials", () => { + expect( + describeDaemonTarget({ + kind: "endpoint", + host: "tcp://user:private@example.test:23456?password=secret", + }), + ).not.toMatch(/private|secret/); + expect( + describeDaemonTarget({ kind: "endpoint", host: "https://app.paseo.sh/#offer=private" }), + ).not.toContain("private"); +}); diff --git a/packages/cli/src/utils/daemon-target.ts b/packages/cli/src/utils/daemon-target.ts new file mode 100644 index 0000000000..2705168159 --- /dev/null +++ b/packages/cli/src/utils/daemon-target.ts @@ -0,0 +1,51 @@ +import { resolvePaseoHome } from "@getpaseo/server"; + +export type DaemonTarget = { kind: "instance"; home: string } | { kind: "endpoint"; host: string }; + +export function selectDaemonTarget( + options: { home?: string; host?: string }, + env: NodeJS.ProcessEnv = process.env, + localOnly = false, +): DaemonTarget { + for (const key of ["home", "host"] as const) { + if (options[key] !== undefined && !options[key]!.trim()) + throw { code: "TARGET_INVALID", message: `--${key} requires a non-empty value.` }; + } + if (options.home !== undefined && options.host !== undefined) + throw { code: "TARGET_AMBIGUOUS", message: "Choose either --home or --host, not both." }; + if (localOnly) { + if (options.host !== undefined) + throw { + code: "LOCAL_OPERATION", + message: "This is a local operation; use --home. --host is not supported.", + }; + return { + kind: "instance", + home: resolvePaseoHome({ PASEO_HOME: options.home ?? env.PASEO_HOME }), + }; + } + if (options.home !== undefined) + return { kind: "instance", home: resolvePaseoHome({ PASEO_HOME: options.home }) }; + if (options.host !== undefined) return { kind: "endpoint", host: options.host }; + if (env.PASEO_HOME && env.PASEO_HOST) + throw { + code: "TARGET_AMBIGUOUS", + message: "PASEO_HOME and PASEO_HOST are both set. Choose --home or --host explicitly.", + }; + if (env.PASEO_HOST) return { kind: "endpoint", host: env.PASEO_HOST }; + return { kind: "instance", home: resolvePaseoHome({ PASEO_HOME: env.PASEO_HOME }) }; +} + +export function describeDaemonTarget(target: DaemonTarget): string { + if (target.kind === "instance") return `home ${target.home}`; + try { + const url = new URL(target.host); + if (url.password) url.password = "REDACTED"; + for (const key of url.searchParams.keys()) + if (/password|token|secret/i.test(key)) url.searchParams.set(key, "REDACTED"); + if (url.hash) url.hash = "REDACTED"; + return url.toString(); + } catch { + return target.host.replace(/([?&](?:password|token|secret)=)[^&]*/gi, "$1REDACTED"); + } +} diff --git a/packages/cli/tests/03-daemon.test.ts b/packages/cli/tests/03-daemon.test.ts index aa84c4caae..d395bdc6fe 100644 --- a/packages/cli/tests/03-daemon.test.ts +++ b/packages/cli/tests/03-daemon.test.ts @@ -11,7 +11,7 @@ * - daemon status reports stopped when daemon not running * - daemon status --json outputs valid JSON * - daemon stop handles daemon not running gracefully - * - daemon restart starts the daemon and can be cleaned up + * - daemon restart refuses to start a stopped daemon * - daemon status probes the live relay state over local IPC */ @@ -140,7 +140,7 @@ try { const result = await daemonCommand(["status"]); assert.strictEqual(result.exitCode, 0, "status should succeed when daemon is stopped"); const output = result.stdout.toLowerCase(); - assert(output.includes("local daemon"), "status table should include Local Daemon row"); + assert(output.includes("localdaemon"), "status table should include Local Daemon row"); assert(output.includes("stopped"), "status should report stopped"); console.log("✓ daemon status reports stopped when not running\n"); } @@ -164,7 +164,7 @@ try { const result = await daemonCommand(["status", "--json"]); assert.strictEqual(result.exitCode, 0, "--json status should succeed"); const status = JSON.parse(result.stdout); - assert.strictEqual(typeof status.serverId, "string", "json status should include serverId"); + assert.strictEqual(status.serverId, undefined, "stopped observation must not invent identity"); assert.strictEqual(status.localDaemon, "stopped", "json status should report stopped"); assert.strictEqual(status.home, paseoHome, "json status should reflect the isolated home"); assert.strictEqual( @@ -175,20 +175,12 @@ try { console.log("✓ daemon status --json outputs valid JSON\n"); } - // Global hosts do not change local daemon lifecycle commands. { - console.log("Test 5b: daemon status ignores a global --host"); - const result = await runLocalPaseo(["--host", "localhost:1", "daemon", "status", "--json"], { + const result = await runLocalPaseo(["--host", "127.0.0.1:1", "daemon", "status", "--json"], { PASEO_HOME: paseoHome, }); - assert.strictEqual(result.exitCode, 0, `daemon status should stay local: ${result.stderr}`); - const parsed = JSON.parse(result.stdout); - assert.strictEqual( - parsed.localDaemon, - "stopped", - "daemon should report the local stopped state", - ); - console.log("✓ daemon status ignores a global --host\n"); + assert.strictEqual(result.exitCode, 1, "an explicit endpoint must not report local status"); + assert.match(result.stderr, /127.0.0.1:1/); } // Test 6: daemon stop handles daemon not running gracefully @@ -205,16 +197,12 @@ try { console.log("✓ daemon stop succeeds gracefully when daemon not running\n"); } - // Test 7: daemon restart starts daemon and can be stopped { - console.log("Test 7: daemon restart starts daemon and can be stopped"); - const result = await daemonCommand(["restart", "--port", String(port)]); - assert.strictEqual(result.exitCode, 0, "restart should succeed even when previously stopped"); - assert(result.stdout.toLowerCase().includes("restarted"), "output should report restart"); - - const cleanup = await daemonCommand(["stop", "--force"]); - assert.strictEqual(cleanup.exitCode, 0, "cleanup stop should succeed after restart"); - console.log("✓ daemon restart starts and stop cleanup succeeds\n"); + const result = await daemonCommand(["restart", "--json"]); + assert.strictEqual(result.exitCode, 1, "restart must not start a stopped daemon"); + assert.match(result.stderr, /DAEMON_NOT_RUNNING/); + const configured = await daemonCommand(["config", "set", "daemon.listen", `127.0.0.1:${port}`]); + assert.strictEqual(configured.exitCode, 0, configured.stderr); } // Test 8: status uses the running daemon as relay authority over local IPC @@ -256,10 +244,10 @@ try { const relayProbe = await retryWhileWorkerRuns( worker, async () => { - const statusResult = await daemonCommand(["status", "--json"]); + const statusResult = await daemonCommand(["status", "--host", listen, "--json"]); const statusPayload = statusResult.exitCode === 0 ? JSON.parse(statusResult.stdout) : null; - const pairingResult = await daemonCommand(["pair", "--json"]); + const pairingResult = await daemonCommand(["pair", "--host", listen, "--json"]); return { statusResult, statusPayload, pairingResult }; }, (result) => @@ -280,8 +268,8 @@ try { assert.strictEqual(payload.connectedDaemon, "reachable", "IPC daemon should be reachable"); assert.strictEqual( payload.localDaemon, - "stopped", - "missing PID should report stopped locally", + undefined, + "endpoint status must not invent local PID state", ); assert.notStrictEqual(payload.relay, "disabled", "status should use live relay state"); assert.strictEqual( @@ -303,6 +291,7 @@ try { const nestedReload = await daemonCommand(["reload", "--host", listen, "--json"]); assert.strictEqual(nestedReload.exitCode, 0, nestedReload.stderr); assert.deepStrictEqual(JSON.parse(nestedReload.stdout), { + restartCommand: `paseo daemon restart --host ${JSON.stringify(listen)}`, appliedPaths: ["daemon.browserTools.enabled"], restartRequiredPaths: [], overrideControlledPaths: ["daemon.listen"], @@ -315,6 +304,7 @@ try { }); assert.strictEqual(aliasReload.exitCode, 0, aliasReload.stderr); assert.deepStrictEqual(JSON.parse(aliasReload.stdout), { + restartCommand: `paseo daemon restart --host ${JSON.stringify(listen)}`, appliedPaths: ["daemon.browserTools.enabled"], restartRequiredPaths: [], overrideControlledPaths: [], @@ -323,6 +313,7 @@ try { const yamlReload = await daemonCommand(["reload", "--host", listen, "--format", "yaml"]); assert.strictEqual(yamlReload.exitCode, 0, yamlReload.stderr); assert.deepStrictEqual(YAML.parse(yamlReload.stdout), { + restartCommand: `paseo daemon restart --host ${JSON.stringify(listen)}`, appliedPaths: [], restartRequiredPaths: [], overrideControlledPaths: [], @@ -335,31 +326,16 @@ try { try { await writeFile( join(foreignHome, "config.json"), - `${JSON.stringify({ daemon: { listen } }, null, 2)}\n`, + `${JSON.stringify({ daemon: { listen, relay: { enabled: false } } }, null, 2)}\n`, "utf-8", ); - const foreignPairing = await retryWhileWorkerRuns( - worker, - () => - runLocalPaseo(["daemon", "pair", "--home", foreignHome, "--json"], { - PASEO_HOME: foreignHome, - }), - (result) => result.stderr.includes("different Paseo home"), - (result) => `Pairing did not report the daemon identity mismatch: ${result.stderr}`, - ); - assert.notStrictEqual( - foreignPairing.exitCode, - 0, - "pairing should reject a daemon owned by another home", - ); - assert( - foreignPairing.stderr.includes("different Paseo home"), - "pairing should explain the daemon identity mismatch", - ); - assert( - !foreignPairing.stdout.includes("#offer="), - "pairing should not expose another offer", + const foreignPairing = await runLocalPaseo( + ["daemon", "pair", "--home", foreignHome, "--json"], + { PASEO_HOME: foreignHome }, ); + assert.strictEqual(foreignPairing.exitCode, 1); + assert.match(foreignPairing.stderr, /RELAY_DISABLED/); + assert(!foreignPairing.stdout.includes("#offer=")); } finally { await rm(foreignHome, { recursive: true, force: true }); } @@ -369,14 +345,14 @@ try { console.log("✓ daemon status probes live relay state over local IPC\n"); } - // Test 9: --relay accepts an already-enabled persisted relay while stopped + // Test 9: explicit offline relay consent persists for subsequent pairing { - console.log("Test 9: daemon pair --relay accepts persisted relay while stopped"); + console.log("Test 9: daemon pair --relay persists offline relay consent"); const configPath = join(paseoHome, "config.json"); const config = JSON.parse(await readFile(configPath, "utf-8")); config.daemon = { ...config.daemon, - relay: { ...config.daemon?.relay, enabled: true }, + relay: { ...config.daemon?.relay, enabled: false }, }; await writeFile(configPath, `${JSON.stringify(config, null, 2)}\n`, "utf-8"); @@ -390,7 +366,13 @@ try { const payload = JSON.parse(pairing.stdout); assert.strictEqual(payload.relayEnabled, true, "pairing should preserve persisted relay state"); assert.match(payload.url, /#offer=/, "pairing should include the offline offer"); - console.log("✓ daemon pair --relay accepts persisted relay while stopped\n"); + const persistedRelay = await daemonCommand(["config", "get", "daemon.relay.enabled", "--json"]); + assert.strictEqual(persistedRelay.exitCode, 0, persistedRelay.stderr); + assert.strictEqual(JSON.parse(persistedRelay.stdout).value, true); + const subsequentPairing = await daemonCommand(["pair", "--json"]); + assert.strictEqual(subsequentPairing.exitCode, 0, subsequentPairing.stderr); + assert.match(JSON.parse(subsequentPairing.stdout).url, /#offer=/); + console.log("✓ daemon pair --relay persists offline consent for subsequent pairing\n"); } } finally { // Best-effort daemon cleanup in case assertions fail before explicit stop. diff --git a/packages/cli/tests/04-agent-ls.test.ts b/packages/cli/tests/04-agent-ls.test.ts index 363eba5694..65b3bec0bc 100644 --- a/packages/cli/tests/04-agent-ls.test.ts +++ b/packages/cli/tests/04-agent-ls.test.ts @@ -23,12 +23,13 @@ import assert from "node:assert"; import { mkdtemp, rm } from "fs/promises"; import { tmpdir } from "os"; import { join } from "path"; +import { getAvailablePort } from "./helpers/network.ts"; import { runLocalPaseo } from "./helpers/local-cli.ts"; console.log("=== LS Command Tests ===\n"); -// Get random port that's definitely not in use (never 6767) -const port = 10000 + Math.floor(Math.random() * 50000); +// Allocate an unused endpoint for connection-error and argument-validation checks. +const port = await getAvailablePort(); const paseoHome = await mkdtemp(join(tmpdir(), "paseo-test-home-")); try { @@ -62,7 +63,6 @@ try { console.log("Test 3: paseo ls handles daemon not running"); const result = await runLocalPaseo(["ls"], { PASEO_HOST: `localhost:${port}`, - PASEO_HOME: paseoHome, }); // Should fail because daemon not running assert.notStrictEqual(result.exitCode, 0, "should fail when daemon not running"); @@ -74,8 +74,8 @@ try { assert(hasError, "error message should mention connection issue"); assert.match( output, - /--host .*PASEO_HOST/s, - "the recovery message should explain both remote connection inputs", + /Check the selected endpoint and credentials/, + "the recovery message should explain how to check the selected endpoint", ); console.log("✓ paseo ls handles daemon not running\n"); } @@ -85,7 +85,6 @@ try { console.log("Test 4: paseo ls --json handles errors"); const result = await runLocalPaseo(["ls", "--json"], { PASEO_HOST: `localhost:${port}`, - PASEO_HOME: paseoHome, }); // Should still fail (daemon not running) assert.notStrictEqual(result.exitCode, 0, "should fail when daemon not running"); @@ -109,7 +108,6 @@ try { console.log("Test 5: paseo ls -a flag is accepted"); const result = await runLocalPaseo(["ls", "-a"], { PASEO_HOST: `localhost:${port}`, - PASEO_HOME: paseoHome, }); // Will fail due to no daemon, but flag should be parsed without error // (no "unknown option" error) @@ -124,7 +122,6 @@ try { console.log("Test 6: paseo ls -g flag is accepted"); const result = await runLocalPaseo(["ls", "-g"], { PASEO_HOST: `localhost:${port}`, - PASEO_HOME: paseoHome, }); const output = result.stdout + result.stderr; assert(!output.includes("unknown option"), "should accept -g flag"); @@ -137,7 +134,6 @@ try { console.log("Test 7: paseo ls -ag combined flags are accepted"); const result = await runLocalPaseo(["ls", "-ag"], { PASEO_HOST: `localhost:${port}`, - PASEO_HOME: paseoHome, }); const output = result.stdout + result.stderr; assert(!output.includes("unknown option"), "should accept -ag flags"); @@ -150,7 +146,6 @@ try { console.log("Test 8: -q (quiet) flag is accepted"); const result = await runLocalPaseo(["-q", "ls"], { PASEO_HOST: `localhost:${port}`, - PASEO_HOME: paseoHome, }); const output = result.stdout + result.stderr; assert(!output.includes("unknown option"), "should accept -q flag"); @@ -163,7 +158,6 @@ try { console.log("Test 9: paseo ls --ui is rejected"); const result = await runLocalPaseo(["ls", "--ui"], { PASEO_HOST: `localhost:${port}`, - PASEO_HOME: paseoHome, }); assert.notStrictEqual(result.exitCode, 0, "should fail for removed --ui flag"); const output = result.stdout + result.stderr; @@ -185,17 +179,23 @@ try { console.log("✓ global --host targets the requested daemon\n"); } - // Test 11: the last explicit --host wins + // Test 11: conflicting explicit --host selectors are rejected { - console.log("Test 11: the last explicit --host wins"); + console.log("Test 11: conflicting explicit --host selectors are rejected"); const firstHost = `localhost:${port}`; - const lastHost = `localhost:${port + 1}`; + const lastHost = `localhost:${await getAvailablePort()}`; const result = await runLocalPaseo(["--host", firstHost, "ls", "--host", lastHost]); const output = result.stdout + result.stderr; - assert.notStrictEqual(result.exitCode, 0, "should fail when the selected daemon is absent"); - assert(output.includes(lastHost), "connection error should name the last explicit host"); - assert(!output.includes(firstHost), "the earlier host should be fully overridden"); - console.log("✓ the last explicit --host wins\n"); + assert.notStrictEqual(result.exitCode, 0, "should reject conflicting explicit selectors"); + assert( + output.includes("Conflicting duplicate --host selectors."), + "should report the selector conflict", + ); + assert( + !output.includes("Cannot connect"), + "must reject the selectors before attempting a connection", + ); + console.log("✓ conflicting explicit --host selectors are rejected\n"); } } finally { // Clean up temp directory diff --git a/packages/cli/tests/05-agent-run.test.ts b/packages/cli/tests/05-agent-run.test.ts index 0af2a6ec7c..5c56cadfbf 100644 --- a/packages/cli/tests/05-agent-run.test.ts +++ b/packages/cli/tests/05-agent-run.test.ts @@ -21,6 +21,7 @@ */ import assert from "node:assert"; +import { getAvailablePort } from "./helpers/network.ts"; import { $ } from "zx"; import { mkdtemp, rm, writeFile } from "fs/promises"; import { tmpdir } from "os"; @@ -30,8 +31,8 @@ $.verbose = false; console.log("=== Run Command Tests ===\n"); -// Get random port that's definitely not in use (never 6767) -const port = 10000 + Math.floor(Math.random() * 50000); +// Allocate an unused endpoint for connection-error and argument-validation checks. +const port = await getAvailablePort(); const paseoHome = await mkdtemp(join(tmpdir(), "paseo-test-home-")); const schemaPath = join(paseoHome, "run-output-schema.json"); await writeFile( @@ -72,7 +73,7 @@ try { { console.log("Test 2: run requires prompt argument"); const result = - await $`PASEO_HOST=localhost:${port} PASEO_HOME=${paseoHome} npx paseo run`.nothrow(); + await $`PASEO_HOME=${paseoHome} npx paseo --host localhost:${port} run`.nothrow(); assert.notStrictEqual(result.exitCode, 0, "should fail without prompt"); const output = result.stdout + result.stderr; // Commander should complain about missing argument @@ -88,7 +89,7 @@ try { { console.log("Test 3: run handles daemon not running"); const result = - await $`PASEO_HOST=localhost:${port} PASEO_HOME=${paseoHome} npx paseo run --provider claude "test prompt"`.nothrow(); + await $`PASEO_HOME=${paseoHome} npx paseo --host localhost:${port} run --provider claude "test prompt"`.nothrow(); // Should fail because daemon not running assert.notStrictEqual(result.exitCode, 0, "should fail when daemon not running"); const output = result.stdout + result.stderr; @@ -104,7 +105,7 @@ try { { console.log("Test 4: run -d flag is accepted"); const result = - await $`PASEO_HOST=localhost:${port} PASEO_HOME=${paseoHome} npx paseo run -d "test prompt"`.nothrow(); + await $`PASEO_HOME=${paseoHome} npx paseo --host localhost:${port} run -d "test prompt"`.nothrow(); const output = result.stdout + result.stderr; assert(!output.includes("unknown option"), "should accept -d flag"); assert(!output.includes("error: option"), "should not have option parsing error"); @@ -115,7 +116,7 @@ try { { console.log("Test 5: run --name flag is accepted"); const result = - await $`PASEO_HOST=localhost:${port} PASEO_HOME=${paseoHome} npx paseo run --name "test-agent" "test prompt"`.nothrow(); + await $`PASEO_HOME=${paseoHome} npx paseo --host localhost:${port} run --name "test-agent" "test prompt"`.nothrow(); const output = result.stdout + result.stderr; assert(!output.includes("unknown option"), "should accept --name flag"); assert(!output.includes("error: option"), "should not have option parsing error"); @@ -126,7 +127,7 @@ try { { console.log("Test 6: run --provider flag is accepted"); const result = - await $`PASEO_HOST=localhost:${port} PASEO_HOME=${paseoHome} npx paseo run --provider codex "test prompt"`.nothrow(); + await $`PASEO_HOME=${paseoHome} npx paseo --host localhost:${port} run --provider codex "test prompt"`.nothrow(); const output = result.stdout + result.stderr; assert(!output.includes("unknown option"), "should accept --provider flag"); assert(!output.includes("error: option"), "should not have option parsing error"); @@ -137,7 +138,7 @@ try { { console.log("Test 6b: run --provider provider/model syntax is accepted"); const result = - await $`PASEO_HOST=localhost:${port} PASEO_HOME=${paseoHome} npx paseo run --provider codex/gpt-5.4 "test prompt"`.nothrow(); + await $`PASEO_HOME=${paseoHome} npx paseo --host localhost:${port} run --provider codex/gpt-5.4 "test prompt"`.nothrow(); const output = result.stdout + result.stderr; assert(!output.includes("unknown option"), "should accept provider/model syntax"); assert(!output.includes("error: option"), "should not have option parsing error"); @@ -148,7 +149,7 @@ try { { console.log("Test 7: run --mode flag is accepted"); const result = - await $`PASEO_HOST=localhost:${port} PASEO_HOME=${paseoHome} npx paseo run --mode bypass "test prompt"`.nothrow(); + await $`PASEO_HOME=${paseoHome} npx paseo --host localhost:${port} run --mode bypass "test prompt"`.nothrow(); const output = result.stdout + result.stderr; assert(!output.includes("unknown option"), "should accept --mode flag"); assert(!output.includes("error: option"), "should not have option parsing error"); @@ -159,7 +160,7 @@ try { { console.log("Test 8: run --cwd flag is accepted"); const result = - await $`PASEO_HOST=localhost:${port} PASEO_HOME=${paseoHome} npx paseo run --cwd /tmp "test prompt"`.nothrow(); + await $`PASEO_HOME=${paseoHome} npx paseo --host localhost:${port} run --cwd /tmp "test prompt"`.nothrow(); const output = result.stdout + result.stderr; assert(!output.includes("unknown option"), "should accept --cwd flag"); assert(!output.includes("error: option"), "should not have option parsing error"); @@ -170,7 +171,7 @@ try { { console.log("Test 9: run --output-schema flag is accepted"); const result = - await $`PASEO_HOST=localhost:${port} PASEO_HOME=${paseoHome} npx paseo run --output-schema ${schemaPath} "test prompt"`.nothrow(); + await $`PASEO_HOME=${paseoHome} npx paseo --host localhost:${port} run --output-schema ${schemaPath} "test prompt"`.nothrow(); const output = result.stdout + result.stderr; assert(!output.includes("unknown option"), "should accept --output-schema flag"); assert(!output.includes("error: option"), "should not have option parsing error"); @@ -181,7 +182,7 @@ try { { console.log("Test 10: run --output-schema cannot be used with --background"); const result = - await $`PASEO_HOST=localhost:${port} PASEO_HOME=${paseoHome} npx paseo run --background --output-schema ${schemaPath} "test prompt"`.nothrow(); + await $`PASEO_HOME=${paseoHome} npx paseo --host localhost:${port} run --background --output-schema ${schemaPath} "test prompt"`.nothrow(); assert.notStrictEqual(result.exitCode, 0, "should fail with --background and --output-schema"); const output = result.stdout + result.stderr; assert( @@ -195,7 +196,7 @@ try { { console.log("Test 11: -q (quiet) flag is accepted with run"); const result = - await $`PASEO_HOST=localhost:${port} PASEO_HOME=${paseoHome} npx paseo -q run -d "test prompt"`.nothrow(); + await $`PASEO_HOME=${paseoHome} npx paseo --host localhost:${port} -q run -d "test prompt"`.nothrow(); const output = result.stdout + result.stderr; assert(!output.includes("unknown option"), "should accept -q flag"); assert(!output.includes("error: option"), "should not have option parsing error"); @@ -206,7 +207,7 @@ try { { console.log("Test 12: Combined flags work together"); const result = - await $`PASEO_HOST=localhost:${port} PASEO_HOME=${paseoHome} npx paseo -q run -d --name "test-fixer" --provider claude --mode bypass --cwd /tmp "Fix the tests"`.nothrow(); + await $`PASEO_HOME=${paseoHome} npx paseo --host localhost:${port} -q run -d --name "test-fixer" --provider claude --mode bypass --cwd /tmp "Fix the tests"`.nothrow(); const output = result.stdout + result.stderr; assert(!output.includes("unknown option"), "should accept all combined flags"); assert(!output.includes("error: option"), "should not have option parsing error"); @@ -217,7 +218,7 @@ try { { console.log("Test 12b: conflicting provider/model syntax is rejected"); const result = - await $`PASEO_HOST=localhost:${port} PASEO_HOME=${paseoHome} npx paseo run --provider codex/gpt-5.4 --model gpt-5.5 "test prompt"`.nothrow(); + await $`PASEO_HOME=${paseoHome} npx paseo --host localhost:${port} run --provider codex/gpt-5.4 --model gpt-5.5 "test prompt"`.nothrow(); assert.notStrictEqual(result.exitCode, 0, "should fail for conflicting model inputs"); const output = result.stdout + result.stderr; assert( @@ -240,7 +241,7 @@ try { { console.log("Test 14: run --ui is rejected"); const result = - await $`PASEO_HOST=localhost:${port} PASEO_HOME=${paseoHome} npx paseo run --ui "test prompt"`.nothrow(); + await $`PASEO_HOME=${paseoHome} npx paseo --host localhost:${port} run --ui "test prompt"`.nothrow(); assert.notStrictEqual(result.exitCode, 0, "should fail for removed --ui flag"); const output = result.stdout + result.stderr; assert(output.includes("unknown option"), "should report unknown option for --ui"); @@ -251,7 +252,7 @@ try { { console.log("Test 15: run --new-workspace is accepted"); const result = - await $`PASEO_HOST=localhost:${port} PASEO_HOME=${paseoHome} npx paseo run --new-workspace local "test prompt"`.nothrow(); + await $`PASEO_HOME=${paseoHome} npx paseo --host localhost:${port} run --new-workspace local "test prompt"`.nothrow(); const output = result.stdout + result.stderr; assert(!output.includes("unknown option"), "should accept --new-workspace"); assert(!output.includes("error: option"), "should not have option parsing error"); @@ -262,7 +263,7 @@ try { { console.log("Test 16: run --isolation is rejected"); const result = - await $`PASEO_HOST=localhost:${port} PASEO_HOME=${paseoHome} npx paseo run --isolation local "test prompt"`.nothrow(); + await $`PASEO_HOME=${paseoHome} npx paseo --host localhost:${port} run --isolation local "test prompt"`.nothrow(); assert.notStrictEqual(result.exitCode, 0, "should fail for removed --isolation flag"); const output = result.stdout + result.stderr; assert(output.includes("unknown option"), "should report unknown option for --isolation"); diff --git a/packages/cli/tests/06-agent-send.test.ts b/packages/cli/tests/06-agent-send.test.ts index 60fd9aac72..ca28e26f56 100644 --- a/packages/cli/tests/06-agent-send.test.ts +++ b/packages/cli/tests/06-agent-send.test.ts @@ -18,6 +18,7 @@ */ import assert from "node:assert"; +import { getAvailablePort } from "./helpers/network.ts"; import { $ } from "zx"; import { mkdtemp, rm, writeFile } from "fs/promises"; import { tmpdir } from "os"; @@ -27,8 +28,8 @@ $.verbose = false; console.log("=== Send Command Tests ===\n"); -// Get random port that's definitely not in use (never 6767) -const port = 10000 + Math.floor(Math.random() * 50000); +// Allocate an unused endpoint for connection-error and argument-validation checks. +const port = await getAvailablePort(); const paseoHome = await mkdtemp(join(tmpdir(), "paseo-test-home-")); const promptFilePath = join(paseoHome, "send-prompt.txt"); await writeFile(promptFilePath, "prompt from file"); @@ -58,7 +59,7 @@ try { { console.log("Test 2: send requires id argument"); const result = - await $`PASEO_HOST=localhost:${port} PASEO_HOME=${paseoHome} npx paseo send`.nothrow(); + await $`PASEO_HOME=${paseoHome} npx paseo --host localhost:${port} send`.nothrow(); assert.notStrictEqual(result.exitCode, 0, "should fail without id"); const output = result.stdout + result.stderr; // Commander should complain about missing argument @@ -74,7 +75,7 @@ try { { console.log("Test 3: send requires prompt argument"); const result = - await $`PASEO_HOST=localhost:${port} PASEO_HOME=${paseoHome} npx paseo send abc123`.nothrow(); + await $`PASEO_HOME=${paseoHome} npx paseo --host localhost:${port} send abc123`.nothrow(); assert.notStrictEqual(result.exitCode, 0, "should fail without prompt"); const output = result.stdout + result.stderr; // Commander should complain about missing argument @@ -90,7 +91,7 @@ try { { console.log("Test 4: send handles daemon not running"); const result = - await $`PASEO_HOST=localhost:${port} PASEO_HOME=${paseoHome} npx paseo send abc123 "test prompt"`.nothrow(); + await $`PASEO_HOME=${paseoHome} npx paseo --host localhost:${port} send abc123 "test prompt"`.nothrow(); // Should fail because daemon not running assert.notStrictEqual(result.exitCode, 0, "should fail when daemon not running"); const output = result.stdout + result.stderr; @@ -106,7 +107,7 @@ try { { console.log("Test 5: send --no-wait flag is accepted"); const result = - await $`PASEO_HOST=localhost:${port} PASEO_HOME=${paseoHome} npx paseo send --no-wait abc123 "test prompt"`.nothrow(); + await $`PASEO_HOME=${paseoHome} npx paseo --host localhost:${port} send --no-wait abc123 "test prompt"`.nothrow(); const output = result.stdout + result.stderr; assert(!output.includes("unknown option"), "should accept --no-wait flag"); assert(!output.includes("error: option"), "should not have option parsing error"); @@ -117,7 +118,7 @@ try { { console.log("Test 5b: send --prompt flag is accepted"); const result = - await $`PASEO_HOST=localhost:${port} PASEO_HOME=${paseoHome} npx paseo send --prompt "test prompt" abc123`.nothrow(); + await $`PASEO_HOME=${paseoHome} npx paseo --host localhost:${port} send --prompt "test prompt" abc123`.nothrow(); const output = result.stdout + result.stderr; assert(!output.includes("unknown option"), "should accept --prompt flag"); assert(!output.includes("error: option"), "should not have option parsing error"); @@ -128,7 +129,7 @@ try { { console.log("Test 5c: send --prompt-file flag is accepted"); const result = - await $`PASEO_HOST=localhost:${port} PASEO_HOME=${paseoHome} npx paseo send --prompt-file ${promptFilePath} abc123`.nothrow(); + await $`PASEO_HOME=${paseoHome} npx paseo --host localhost:${port} send --prompt-file ${promptFilePath} abc123`.nothrow(); const output = result.stdout + result.stderr; assert(!output.includes("unknown option"), "should accept --prompt-file flag"); assert(!output.includes("error: option"), "should not have option parsing error"); @@ -150,7 +151,7 @@ try { { console.log("Test 7: -q (quiet) flag is accepted with send"); const result = - await $`PASEO_HOST=localhost:${port} PASEO_HOME=${paseoHome} npx paseo -q send --no-wait abc123 "test prompt"`.nothrow(); + await $`PASEO_HOME=${paseoHome} npx paseo --host localhost:${port} -q send --no-wait abc123 "test prompt"`.nothrow(); const output = result.stdout + result.stderr; assert(!output.includes("unknown option"), "should accept -q flag"); assert(!output.includes("error: option"), "should not have option parsing error"); @@ -161,7 +162,7 @@ try { { console.log("Test 8: Combined flags work together"); const result = - await $`PASEO_HOST=localhost:${port} PASEO_HOME=${paseoHome} npx paseo -q send --no-wait abc123 "Run the linter"`.nothrow(); + await $`PASEO_HOME=${paseoHome} npx paseo --host localhost:${port} -q send --no-wait abc123 "Run the linter"`.nothrow(); const output = result.stdout + result.stderr; assert(!output.includes("unknown option"), "should accept all combined flags"); assert(!output.includes("error: option"), "should not have option parsing error"); @@ -172,7 +173,7 @@ try { { console.log("Test 8b: conflicting prompt sources are rejected"); const result = - await $`PASEO_HOST=localhost:${port} PASEO_HOME=${paseoHome} npx paseo send abc123 "positional prompt" --prompt "flag prompt"`.nothrow(); + await $`PASEO_HOME=${paseoHome} npx paseo --host localhost:${port} send abc123 "positional prompt" --prompt "flag prompt"`.nothrow(); assert.notStrictEqual(result.exitCode, 0, "should fail for conflicting prompt sources"); const output = result.stdout + result.stderr; assert( diff --git a/packages/cli/tests/07-agent-stop.test.ts b/packages/cli/tests/07-agent-stop.test.ts index 0b20028ec0..81b0cba66c 100644 --- a/packages/cli/tests/07-agent-stop.test.ts +++ b/packages/cli/tests/07-agent-stop.test.ts @@ -18,6 +18,7 @@ */ import assert from "node:assert"; +import { getAvailablePort } from "./helpers/network.ts"; import { $ } from "zx"; import { mkdtemp, rm } from "fs/promises"; import { tmpdir } from "os"; @@ -27,8 +28,8 @@ $.verbose = false; console.log("=== Stop Command Tests ===\n"); -// Get random port that's definitely not in use (never 6767) -const port = 10000 + Math.floor(Math.random() * 50000); +// Allocate an unused endpoint for connection-error and argument-validation checks. +const port = await getAvailablePort(); const paseoHome = await mkdtemp(join(tmpdir(), "paseo-test-home-")); try { @@ -48,7 +49,7 @@ try { { console.log("Test 2: stop requires ID, --all, or --cwd"); const result = - await $`PASEO_HOST=localhost:${port} PASEO_HOME=${paseoHome} npx paseo stop`.nothrow(); + await $`PASEO_HOME=${paseoHome} npx paseo --host localhost:${port} stop`.nothrow(); assert.notStrictEqual(result.exitCode, 0, "should fail without id, --all, or --cwd"); const output = result.stdout + result.stderr; const hasError = @@ -64,7 +65,7 @@ try { { console.log("Test 3: stop handles daemon not running"); const result = - await $`PASEO_HOST=localhost:${port} PASEO_HOME=${paseoHome} npx paseo stop abc123`.nothrow(); + await $`PASEO_HOME=${paseoHome} npx paseo --host localhost:${port} stop abc123`.nothrow(); // Should fail because daemon not running assert.notStrictEqual(result.exitCode, 0, "should fail when daemon not running"); const output = result.stdout + result.stderr; @@ -80,7 +81,7 @@ try { { console.log("Test 4: stop --all flag is accepted"); const result = - await $`PASEO_HOST=localhost:${port} PASEO_HOME=${paseoHome} npx paseo stop --all`.nothrow(); + await $`PASEO_HOME=${paseoHome} npx paseo --host localhost:${port} stop --all`.nothrow(); const output = result.stdout + result.stderr; assert(!output.includes("unknown option"), "should accept --all flag"); assert(!output.includes("error: option"), "should not have option parsing error"); @@ -91,7 +92,7 @@ try { { console.log("Test 5: stop --cwd flag is accepted"); const result = - await $`PASEO_HOST=localhost:${port} PASEO_HOME=${paseoHome} npx paseo stop --cwd /tmp`.nothrow(); + await $`PASEO_HOME=${paseoHome} npx paseo --host localhost:${port} stop --cwd /tmp`.nothrow(); const output = result.stdout + result.stderr; assert(!output.includes("unknown option"), "should accept --cwd flag"); assert(!output.includes("error: option"), "should not have option parsing error"); @@ -102,7 +103,7 @@ try { { console.log("Test 6: stop with ID and --host flag is accepted"); const result = - await $`PASEO_HOST=localhost:${port} PASEO_HOME=${paseoHome} npx paseo stop abc123 --host localhost:${port}`.nothrow(); + await $`PASEO_HOME=${paseoHome} npx paseo --host localhost:${port} stop abc123 --host localhost:${port}`.nothrow(); const output = result.stdout + result.stderr; assert(!output.includes("unknown option"), "should accept --host flag"); assert(!output.includes("error: option"), "should not have option parsing error"); @@ -122,7 +123,7 @@ try { { console.log("Test 8: -q (quiet) flag is accepted with stop"); const result = - await $`PASEO_HOST=localhost:${port} PASEO_HOME=${paseoHome} npx paseo -q stop abc123`.nothrow(); + await $`PASEO_HOME=${paseoHome} npx paseo --host localhost:${port} -q stop abc123`.nothrow(); const output = result.stdout + result.stderr; assert(!output.includes("unknown option"), "should accept -q flag"); assert(!output.includes("error: option"), "should not have option parsing error"); diff --git a/packages/cli/tests/08-agent-logs.test.ts b/packages/cli/tests/08-agent-logs.test.ts index f857bffdff..672f4a2dd0 100644 --- a/packages/cli/tests/08-agent-logs.test.ts +++ b/packages/cli/tests/08-agent-logs.test.ts @@ -18,6 +18,7 @@ */ import assert from "node:assert"; +import { getAvailablePort } from "./helpers/network.ts"; import { $ } from "zx"; import { mkdtemp, rm } from "fs/promises"; import { tmpdir } from "os"; @@ -27,8 +28,8 @@ $.verbose = false; console.log("=== Logs Command Tests ===\n"); -// Get random port that's definitely not in use (never 6767) -const port = 10000 + Math.floor(Math.random() * 50000); +// Allocate an unused endpoint for connection-error and argument-validation checks. +const port = await getAvailablePort(); const paseoHome = await mkdtemp(join(tmpdir(), "paseo-test-home-")); try { @@ -51,7 +52,7 @@ try { { console.log("Test 2: logs requires ID argument"); const result = - await $`PASEO_HOST=localhost:${port} PASEO_HOME=${paseoHome} npx paseo logs`.nothrow(); + await $`PASEO_HOME=${paseoHome} npx paseo --host localhost:${port} logs`.nothrow(); assert.notStrictEqual(result.exitCode, 0, "should fail without id"); const output = result.stdout + result.stderr; const hasError = @@ -67,7 +68,7 @@ try { { console.log("Test 3: logs handles daemon not running"); const result = - await $`PASEO_HOST=localhost:${port} PASEO_HOME=${paseoHome} npx paseo logs abc123`.nothrow(); + await $`PASEO_HOME=${paseoHome} npx paseo --host localhost:${port} logs abc123`.nothrow(); // Should fail because daemon not running assert.notStrictEqual(result.exitCode, 0, "should fail when daemon not running"); const output = result.stdout + result.stderr; @@ -84,7 +85,7 @@ try { console.log("Test 4: logs -f (follow) flag is accepted"); // Use timeout to avoid hanging on follow mode const result = - await $`timeout 1 bash -c 'PASEO_HOST=localhost:${port} PASEO_HOME=${paseoHome} npx paseo logs -f abc123' || true`.nothrow(); + await $`timeout 1 bash -c 'PASEO_HOME=${paseoHome} npx paseo --host localhost:${port} logs -f abc123' || true`.nothrow(); const output = result.stdout + result.stderr; assert(!output.includes("unknown option"), "should accept -f flag"); assert(!output.includes("error: option"), "should not have option parsing error"); @@ -95,7 +96,7 @@ try { { console.log("Test 5: logs --follow flag is accepted"); const result = - await $`timeout 1 bash -c 'PASEO_HOST=localhost:${port} PASEO_HOME=${paseoHome} npx paseo logs --follow abc123' || true`.nothrow(); + await $`timeout 1 bash -c 'PASEO_HOME=${paseoHome} npx paseo --host localhost:${port} logs --follow abc123' || true`.nothrow(); const output = result.stdout + result.stderr; assert(!output.includes("unknown option"), "should accept --follow flag"); assert(!output.includes("error: option"), "should not have option parsing error"); @@ -106,7 +107,7 @@ try { { console.log("Test 6: logs --tail flag is accepted"); const result = - await $`PASEO_HOST=localhost:${port} PASEO_HOME=${paseoHome} npx paseo logs --tail 50 abc123`.nothrow(); + await $`PASEO_HOME=${paseoHome} npx paseo --host localhost:${port} logs --tail 50 abc123`.nothrow(); const output = result.stdout + result.stderr; assert(!output.includes("unknown option"), "should accept --tail flag"); assert(!output.includes("error: option"), "should not have option parsing error"); @@ -117,7 +118,7 @@ try { { console.log("Test 7: logs with ID and --host flag is accepted"); const result = - await $`PASEO_HOST=localhost:${port} PASEO_HOME=${paseoHome} npx paseo logs abc123 --host localhost:${port}`.nothrow(); + await $`PASEO_HOME=${paseoHome} npx paseo --host localhost:${port} logs abc123 --host localhost:${port}`.nothrow(); const output = result.stdout + result.stderr; assert(!output.includes("unknown option"), "should accept --host flag"); assert(!output.includes("error: option"), "should not have option parsing error"); @@ -137,7 +138,7 @@ try { { console.log("Test 9: -q (quiet) flag is accepted with logs"); const result = - await $`PASEO_HOST=localhost:${port} PASEO_HOME=${paseoHome} npx paseo -q logs abc123`.nothrow(); + await $`PASEO_HOME=${paseoHome} npx paseo --host localhost:${port} -q logs abc123`.nothrow(); const output = result.stdout + result.stderr; assert(!output.includes("unknown option"), "should accept -q flag"); assert(!output.includes("error: option"), "should not have option parsing error"); diff --git a/packages/cli/tests/09-agent-inspect.test.ts b/packages/cli/tests/09-agent-inspect.test.ts index 0ae33343d7..9c449971bd 100644 --- a/packages/cli/tests/09-agent-inspect.test.ts +++ b/packages/cli/tests/09-agent-inspect.test.ts @@ -18,6 +18,7 @@ */ import assert from "node:assert"; +import { getAvailablePort } from "./helpers/network.ts"; import { $ } from "zx"; import { mkdtemp, rm } from "fs/promises"; import { tmpdir } from "os"; @@ -27,8 +28,8 @@ $.verbose = false; console.log("=== Inspect Command Tests ===\n"); -// Get random port that's definitely not in use (never 6767) -const port = 10000 + Math.floor(Math.random() * 50000); +// Allocate an unused endpoint for connection-error and argument-validation checks. +const port = await getAvailablePort(); const paseoHome = await mkdtemp(join(tmpdir(), "paseo-test-home-")); try { @@ -48,7 +49,7 @@ try { { console.log("Test 2: inspect requires id argument"); const result = - await $`PASEO_HOST=localhost:${port} PASEO_HOME=${paseoHome} npx paseo inspect`.nothrow(); + await $`PASEO_HOME=${paseoHome} npx paseo --host localhost:${port} inspect`.nothrow(); assert.notStrictEqual(result.exitCode, 0, "should fail without id"); const output = result.stdout + result.stderr; // Commander should complain about missing argument @@ -64,7 +65,7 @@ try { { console.log("Test 3: inspect handles daemon not running"); const result = - await $`PASEO_HOST=localhost:${port} PASEO_HOME=${paseoHome} npx paseo inspect abc123`.nothrow(); + await $`PASEO_HOME=${paseoHome} npx paseo --host localhost:${port} inspect abc123`.nothrow(); // Should fail because daemon not running assert.notStrictEqual(result.exitCode, 0, "should fail when daemon not running"); const output = result.stdout + result.stderr; @@ -91,7 +92,7 @@ try { { console.log("Test 5: -q (quiet) flag is accepted with inspect"); const result = - await $`PASEO_HOST=localhost:${port} PASEO_HOME=${paseoHome} npx paseo -q inspect abc123`.nothrow(); + await $`PASEO_HOME=${paseoHome} npx paseo --host localhost:${port} -q inspect abc123`.nothrow(); const output = result.stdout + result.stderr; assert(!output.includes("unknown option"), "should accept -q flag"); assert(!output.includes("error: option"), "should not have option parsing error"); @@ -102,7 +103,7 @@ try { { console.log("Test 6: --json flag is accepted with inspect"); const result = - await $`PASEO_HOST=localhost:${port} PASEO_HOME=${paseoHome} npx paseo inspect abc123 --json`.nothrow(); + await $`PASEO_HOME=${paseoHome} npx paseo --host localhost:${port} inspect abc123 --json`.nothrow(); const output = result.stdout + result.stderr; assert(!output.includes("unknown option"), "should accept --json flag"); assert(!output.includes("error: option"), "should not have option parsing error"); @@ -113,7 +114,7 @@ try { { console.log("Test 7: --format yaml flag is accepted with inspect"); const result = - await $`PASEO_HOST=localhost:${port} PASEO_HOME=${paseoHome} npx paseo --format yaml inspect abc123`.nothrow(); + await $`PASEO_HOME=${paseoHome} npx paseo --host localhost:${port} --format yaml inspect abc123`.nothrow(); const output = result.stdout + result.stderr; assert(!output.includes("unknown option"), "should accept --format yaml flag"); assert(!output.includes("error: option"), "should not have option parsing error"); diff --git a/packages/cli/tests/10-agent-mode.test.ts b/packages/cli/tests/10-agent-mode.test.ts index 0aa492ea4f..e44898c03e 100644 --- a/packages/cli/tests/10-agent-mode.test.ts +++ b/packages/cli/tests/10-agent-mode.test.ts @@ -17,6 +17,7 @@ */ import assert from "node:assert"; +import { getAvailablePort } from "./helpers/network.ts"; import { $ } from "zx"; import { mkdtemp, rm } from "fs/promises"; import { tmpdir } from "os"; @@ -26,8 +27,8 @@ $.verbose = false; console.log("=== Agent Mode Command Tests ===\n"); -// Get random port that's definitely not in use (never 6767) -const port = 10000 + Math.floor(Math.random() * 50000); +// Allocate an unused endpoint for connection-error and argument-validation checks. +const port = await getAvailablePort(); const paseoHome = await mkdtemp(join(tmpdir(), "paseo-test-home-")); try { @@ -47,7 +48,7 @@ try { { console.log("Test 2: agent mode requires id argument"); const result = - await $`PASEO_HOST=localhost:${port} PASEO_HOME=${paseoHome} npx paseo agent mode`.nothrow(); + await $`PASEO_HOME=${paseoHome} npx paseo --host localhost:${port} agent mode`.nothrow(); assert.notStrictEqual(result.exitCode, 0, "should fail without id"); const output = result.stdout + result.stderr; const hasError = @@ -63,7 +64,7 @@ try { { console.log("Test 3: agent mode handles daemon not running"); const result = - await $`PASEO_HOST=localhost:${port} PASEO_HOME=${paseoHome} npx paseo agent mode abc123 bypass`.nothrow(); + await $`PASEO_HOME=${paseoHome} npx paseo --host localhost:${port} agent mode abc123 bypass`.nothrow(); // Should fail because daemon not running assert.notStrictEqual(result.exitCode, 0, "should fail when daemon not running"); const output = result.stdout + result.stderr; @@ -79,7 +80,7 @@ try { { console.log("Test 4: agent mode --list flag is accepted"); const result = - await $`PASEO_HOST=localhost:${port} PASEO_HOME=${paseoHome} npx paseo agent mode --list abc123`.nothrow(); + await $`PASEO_HOME=${paseoHome} npx paseo --host localhost:${port} agent mode --list abc123`.nothrow(); const output = result.stdout + result.stderr; assert(!output.includes("unknown option"), "should accept --list flag"); assert(!output.includes("error: option"), "should not have option parsing error"); @@ -90,7 +91,7 @@ try { { console.log("Test 5: agent mode with ID and --host flag is accepted"); const result = - await $`PASEO_HOST=localhost:${port} PASEO_HOME=${paseoHome} npx paseo agent mode abc123 plan --host localhost:${port}`.nothrow(); + await $`PASEO_HOME=${paseoHome} npx paseo --host localhost:${port} agent mode abc123 plan --host localhost:${port}`.nothrow(); const output = result.stdout + result.stderr; assert(!output.includes("unknown option"), "should accept --host flag"); assert(!output.includes("error: option"), "should not have option parsing error"); @@ -110,7 +111,7 @@ try { { console.log("Test 7: -q (quiet) flag is accepted with agent mode"); const result = - await $`PASEO_HOST=localhost:${port} PASEO_HOME=${paseoHome} npx paseo -q agent mode abc123 bypass`.nothrow(); + await $`PASEO_HOME=${paseoHome} npx paseo --host localhost:${port} -q agent mode abc123 bypass`.nothrow(); const output = result.stdout + result.stderr; assert(!output.includes("unknown option"), "should accept -q flag"); assert(!output.includes("error: option"), "should not have option parsing error"); @@ -121,7 +122,7 @@ try { { console.log("Test 8: agent mode requires mode argument when not using --list"); const result = - await $`PASEO_HOST=localhost:${port} PASEO_HOME=${paseoHome} npx paseo agent mode abc123`.nothrow(); + await $`PASEO_HOME=${paseoHome} npx paseo --host localhost:${port} agent mode abc123`.nothrow(); // Should fail because mode is required unless --list is specified assert.notStrictEqual(result.exitCode, 0, "should fail without mode argument"); const output = result.stdout + result.stderr; diff --git a/packages/cli/tests/11-agent-archive.test.ts b/packages/cli/tests/11-agent-archive.test.ts index 7493395a2b..0685e2948b 100644 --- a/packages/cli/tests/11-agent-archive.test.ts +++ b/packages/cli/tests/11-agent-archive.test.ts @@ -17,6 +17,7 @@ */ import assert from "node:assert"; +import { getAvailablePort } from "./helpers/network.ts"; import { $ } from "zx"; import { mkdtemp, rm } from "fs/promises"; import { tmpdir } from "os"; @@ -26,8 +27,8 @@ $.verbose = false; console.log("=== Agent Archive Command Tests ===\n"); -// Get random port that's definitely not in use (never 6767) -const port = 10000 + Math.floor(Math.random() * 50000); +// Allocate an unused endpoint for connection-error and argument-validation checks. +const port = await getAvailablePort(); const paseoHome = await mkdtemp(join(tmpdir(), "paseo-test-home-")); try { @@ -46,7 +47,7 @@ try { { console.log("Test 2: agent archive requires ID argument"); const result = - await $`PASEO_HOST=localhost:${port} PASEO_HOME=${paseoHome} npx paseo agent archive`.nothrow(); + await $`PASEO_HOME=${paseoHome} npx paseo --host localhost:${port} agent archive`.nothrow(); assert.notStrictEqual(result.exitCode, 0, "should fail without id"); const output = result.stdout + result.stderr; const hasError = @@ -61,7 +62,7 @@ try { { console.log("Test 3: agent archive handles daemon not running"); const result = - await $`PASEO_HOST=localhost:${port} PASEO_HOME=${paseoHome} npx paseo agent archive abc123`.nothrow(); + await $`PASEO_HOME=${paseoHome} npx paseo --host localhost:${port} agent archive abc123`.nothrow(); // Should fail because daemon not running assert.notStrictEqual(result.exitCode, 0, "should fail when daemon not running"); const output = result.stdout + result.stderr; @@ -77,7 +78,7 @@ try { { console.log("Test 4: agent archive --force flag is accepted"); const result = - await $`PASEO_HOST=localhost:${port} PASEO_HOME=${paseoHome} npx paseo agent archive abc123 --force`.nothrow(); + await $`PASEO_HOME=${paseoHome} npx paseo --host localhost:${port} agent archive abc123 --force`.nothrow(); const output = result.stdout + result.stderr; assert(!output.includes("unknown option"), "should accept --force flag"); assert(!output.includes("error: option"), "should not have option parsing error"); @@ -88,7 +89,7 @@ try { { console.log("Test 5: agent archive with ID and --host flag is accepted"); const result = - await $`PASEO_HOST=localhost:${port} PASEO_HOME=${paseoHome} npx paseo agent archive abc123 --host localhost:${port}`.nothrow(); + await $`PASEO_HOME=${paseoHome} npx paseo --host localhost:${port} agent archive abc123 --host localhost:${port}`.nothrow(); const output = result.stdout + result.stderr; assert(!output.includes("unknown option"), "should accept --host flag"); assert(!output.includes("error: option"), "should not have option parsing error"); @@ -108,7 +109,7 @@ try { { console.log("Test 7: -q (quiet) flag is accepted with agent archive"); const result = - await $`PASEO_HOST=localhost:${port} PASEO_HOME=${paseoHome} npx paseo -q agent archive abc123`.nothrow(); + await $`PASEO_HOME=${paseoHome} npx paseo --host localhost:${port} -q agent archive abc123`.nothrow(); const output = result.stdout + result.stderr; assert(!output.includes("unknown option"), "should accept -q flag"); assert(!output.includes("error: option"), "should not have option parsing error"); diff --git a/packages/cli/tests/11-agent-wait.test.ts b/packages/cli/tests/11-agent-wait.test.ts index 8984bc0c4e..198fa08910 100644 --- a/packages/cli/tests/11-agent-wait.test.ts +++ b/packages/cli/tests/11-agent-wait.test.ts @@ -19,6 +19,7 @@ */ import assert from "node:assert"; +import { getAvailablePort } from "./helpers/network.ts"; import { $ } from "zx"; import { mkdtemp, rm } from "fs/promises"; import { tmpdir } from "os"; @@ -28,8 +29,8 @@ $.verbose = false; console.log("=== Wait Command Tests ===\n"); -// Get random port that's definitely not in use (never 6767) -const port = 10000 + Math.floor(Math.random() * 50000); +// Allocate an unused endpoint for connection-error and argument-validation checks. +const port = await getAvailablePort(); const paseoHome = await mkdtemp(join(tmpdir(), "paseo-test-home-")); try { @@ -51,7 +52,7 @@ try { { console.log("Test 2: wait requires id argument"); const result = - await $`PASEO_HOST=localhost:${port} PASEO_HOME=${paseoHome} npx paseo wait`.nothrow(); + await $`PASEO_HOME=${paseoHome} npx paseo --host localhost:${port} wait`.nothrow(); assert.notStrictEqual(result.exitCode, 0, "should fail without id"); const output = result.stdout + result.stderr; // Commander should complain about missing argument @@ -67,7 +68,7 @@ try { { console.log("Test 3: wait handles daemon not running"); const result = - await $`PASEO_HOST=localhost:${port} PASEO_HOME=${paseoHome} npx paseo wait abc123`.nothrow(); + await $`PASEO_HOME=${paseoHome} npx paseo --host localhost:${port} wait abc123`.nothrow(); // Should fail because daemon not running assert.notStrictEqual(result.exitCode, 0, "should fail when daemon not running"); const output = result.stdout + result.stderr; @@ -105,7 +106,7 @@ try { { console.log("Test 6: -q (quiet) flag is accepted with wait"); const result = - await $`PASEO_HOST=localhost:${port} PASEO_HOME=${paseoHome} npx paseo -q wait abc123`.nothrow(); + await $`PASEO_HOME=${paseoHome} npx paseo --host localhost:${port} -q wait abc123`.nothrow(); const output = result.stdout + result.stderr; assert(!output.includes("unknown option"), "should accept -q flag"); assert(!output.includes("error: option"), "should not have option parsing error"); @@ -116,7 +117,7 @@ try { { console.log("Test 7: --json flag is accepted with wait"); const result = - await $`PASEO_HOST=localhost:${port} PASEO_HOME=${paseoHome} npx paseo wait abc123 --json`.nothrow(); + await $`PASEO_HOME=${paseoHome} npx paseo --host localhost:${port} wait abc123 --json`.nothrow(); const output = result.stdout + result.stderr; assert(!output.includes("unknown option"), "should accept --json flag"); assert(!output.includes("error: option"), "should not have option parsing error"); @@ -127,7 +128,7 @@ try { { console.log("Test 8: --format yaml flag is accepted with wait"); const result = - await $`PASEO_HOST=localhost:${port} PASEO_HOME=${paseoHome} npx paseo --format yaml wait abc123`.nothrow(); + await $`PASEO_HOME=${paseoHome} npx paseo --host localhost:${port} --format yaml wait abc123`.nothrow(); const output = result.stdout + result.stderr; assert(!output.includes("unknown option"), "should accept --format yaml flag"); assert(!output.includes("error: option"), "should not have option parsing error"); diff --git a/packages/cli/tests/12-permit-ls.test.ts b/packages/cli/tests/12-permit-ls.test.ts index c4549ae6a7..44b9a720af 100644 --- a/packages/cli/tests/12-permit-ls.test.ts +++ b/packages/cli/tests/12-permit-ls.test.ts @@ -17,6 +17,7 @@ */ import assert from "node:assert"; +import { getAvailablePort } from "./helpers/network.ts"; import { $ } from "zx"; import { mkdtemp, rm } from "fs/promises"; import { tmpdir } from "os"; @@ -26,8 +27,8 @@ $.verbose = false; console.log("=== Permit LS Command Tests ===\n"); -// Get random port that's definitely not in use (never 6767) -const port = 10000 + Math.floor(Math.random() * 50000); +// Allocate an unused endpoint for connection-error and argument-validation checks. +const port = await getAvailablePort(); const paseoHome = await mkdtemp(join(tmpdir(), "paseo-test-home-")); try { @@ -55,7 +56,7 @@ try { { console.log("Test 3: permit ls handles daemon not running"); const result = - await $`PASEO_HOST=localhost:${port} PASEO_HOME=${paseoHome} npx paseo permit ls`.nothrow(); + await $`PASEO_HOME=${paseoHome} npx paseo --host localhost:${port} permit ls`.nothrow(); // Should fail because daemon not running assert.notStrictEqual(result.exitCode, 0, "should fail when daemon not running"); const output = result.stdout + result.stderr; @@ -71,7 +72,7 @@ try { { console.log("Test 4: permit ls --json handles errors"); const result = - await $`PASEO_HOST=localhost:${port} PASEO_HOME=${paseoHome} npx paseo permit ls --json`.nothrow(); + await $`PASEO_HOME=${paseoHome} npx paseo --host localhost:${port} permit ls --json`.nothrow(); // Should still fail (daemon not running) assert.notStrictEqual(result.exitCode, 0, "should fail when daemon not running"); // But output should be valid JSON if present @@ -93,7 +94,7 @@ try { { console.log("Test 5: -q (quiet) flag is accepted"); const result = - await $`PASEO_HOST=localhost:${port} PASEO_HOME=${paseoHome} npx paseo -q permit ls`.nothrow(); + await $`PASEO_HOME=${paseoHome} npx paseo --host localhost:${port} -q permit ls`.nothrow(); const output = result.stdout + result.stderr; assert(!output.includes("unknown option"), "should accept -q flag"); assert(!output.includes("error: option"), "should not have option parsing error"); diff --git a/packages/cli/tests/13-permit-allow-deny.test.ts b/packages/cli/tests/13-permit-allow-deny.test.ts index 51773da185..a44113726f 100644 --- a/packages/cli/tests/13-permit-allow-deny.test.ts +++ b/packages/cli/tests/13-permit-allow-deny.test.ts @@ -22,6 +22,7 @@ */ import assert from "node:assert"; +import { getAvailablePort } from "./helpers/network.ts"; import { $ } from "zx"; import { mkdtemp, rm } from "fs/promises"; import { tmpdir } from "os"; @@ -31,8 +32,8 @@ $.verbose = false; console.log("=== Permit Allow/Deny Command Tests ===\n"); -// Get random port that's definitely not in use (never 6767) -const port = 10000 + Math.floor(Math.random() * 50000); +// Allocate an unused endpoint for connection-error and argument-validation checks. +const port = await getAvailablePort(); const paseoHome = await mkdtemp(join(tmpdir(), "paseo-test-home-")); try { @@ -65,7 +66,7 @@ try { { console.log("Test 3: permit allow handles daemon not running"); const result = - await $`PASEO_HOST=localhost:${port} PASEO_HOME=${paseoHome} npx paseo permit allow abc123 req456`.nothrow(); + await $`PASEO_HOME=${paseoHome} npx paseo --host localhost:${port} permit allow abc123 req456`.nothrow(); // Should fail because daemon not running assert.notStrictEqual(result.exitCode, 0, "should fail when daemon not running"); const output = result.stdout + result.stderr; @@ -81,7 +82,7 @@ try { { console.log("Test 4: permit deny handles daemon not running"); const result = - await $`PASEO_HOST=localhost:${port} PASEO_HOME=${paseoHome} npx paseo permit deny abc123 req456`.nothrow(); + await $`PASEO_HOME=${paseoHome} npx paseo --host localhost:${port} permit deny abc123 req456`.nothrow(); // Should fail because daemon not running assert.notStrictEqual(result.exitCode, 0, "should fail when daemon not running"); const output = result.stdout + result.stderr; @@ -97,7 +98,7 @@ try { { console.log("Test 5: permit allow --all flag is accepted"); const result = - await $`PASEO_HOST=localhost:${port} PASEO_HOME=${paseoHome} npx paseo permit allow abc123 --all`.nothrow(); + await $`PASEO_HOME=${paseoHome} npx paseo --host localhost:${port} permit allow abc123 --all`.nothrow(); const output = result.stdout + result.stderr; assert(!output.includes("unknown option"), "should accept --all flag"); assert(!output.includes("error: option"), "should not have option parsing error"); @@ -108,7 +109,7 @@ try { { console.log("Test 6: permit deny --all flag is accepted"); const result = - await $`PASEO_HOST=localhost:${port} PASEO_HOME=${paseoHome} npx paseo permit deny abc123 --all`.nothrow(); + await $`PASEO_HOME=${paseoHome} npx paseo --host localhost:${port} permit deny abc123 --all`.nothrow(); const output = result.stdout + result.stderr; assert(!output.includes("unknown option"), "should accept --all flag"); assert(!output.includes("error: option"), "should not have option parsing error"); @@ -119,7 +120,7 @@ try { { console.log("Test 7: permit deny --message flag is accepted"); const result = - await $`PASEO_HOST=localhost:${port} PASEO_HOME=${paseoHome} npx paseo permit deny abc123 req456 --message "Not allowed"`.nothrow(); + await $`PASEO_HOME=${paseoHome} npx paseo --host localhost:${port} permit deny abc123 req456 --message "Not allowed"`.nothrow(); const output = result.stdout + result.stderr; assert(!output.includes("unknown option"), "should accept --message flag"); assert(!output.includes("error: option"), "should not have option parsing error"); @@ -130,7 +131,7 @@ try { { console.log("Test 8: permit deny --interrupt flag is accepted"); const result = - await $`PASEO_HOST=localhost:${port} PASEO_HOME=${paseoHome} npx paseo permit deny abc123 req456 --interrupt`.nothrow(); + await $`PASEO_HOME=${paseoHome} npx paseo --host localhost:${port} permit deny abc123 req456 --interrupt`.nothrow(); const output = result.stdout + result.stderr; assert(!output.includes("unknown option"), "should accept --interrupt flag"); assert(!output.includes("error: option"), "should not have option parsing error"); @@ -141,7 +142,7 @@ try { { console.log("Test 9: permit allow --input flag is accepted"); const result = - await $`PASEO_HOST=localhost:${port} PASEO_HOME=${paseoHome} npx paseo permit allow abc123 req456 --input '{"key":"value"}'`.nothrow(); + await $`PASEO_HOME=${paseoHome} npx paseo --host localhost:${port} permit allow abc123 req456 --input '{"key":"value"}'`.nothrow(); const output = result.stdout + result.stderr; assert(!output.includes("unknown option"), "should accept --input flag"); assert(!output.includes("error: option"), "should not have option parsing error"); @@ -152,7 +153,7 @@ try { { console.log("Test 10: permit allow requires req_id or --all"); const result = - await $`PASEO_HOST=localhost:${port} PASEO_HOME=${paseoHome} npx paseo permit allow abc123`.nothrow(); + await $`PASEO_HOME=${paseoHome} npx paseo --host localhost:${port} permit allow abc123`.nothrow(); // This might fail due to daemon not running first, or due to missing argument // The important thing is it doesn't crash with an unhandled error assert.notStrictEqual(result.exitCode, 0, "should fail without req_id or --all"); @@ -163,7 +164,7 @@ try { { console.log("Test 11: permit deny requires req_id or --all"); const result = - await $`PASEO_HOST=localhost:${port} PASEO_HOME=${paseoHome} npx paseo permit deny abc123`.nothrow(); + await $`PASEO_HOME=${paseoHome} npx paseo --host localhost:${port} permit deny abc123`.nothrow(); assert.notStrictEqual(result.exitCode, 0, "should fail without req_id or --all"); console.log("✓ permit deny requires req_id or --all\n"); } diff --git a/packages/cli/tests/14-worktree.test.ts b/packages/cli/tests/14-worktree.test.ts index d1b30a541d..e0c4d7f9b5 100644 --- a/packages/cli/tests/14-worktree.test.ts +++ b/packages/cli/tests/14-worktree.test.ts @@ -19,6 +19,7 @@ */ import assert from "node:assert"; +import { getAvailablePort } from "./helpers/network.ts"; import { $ } from "zx"; import { mkdtemp, rm } from "fs/promises"; import { tmpdir } from "os"; @@ -28,8 +29,8 @@ $.verbose = false; console.log("=== Worktree Command Tests ===\n"); -// Get random port that's definitely not in use (never 6767) -const port = 10000 + Math.floor(Math.random() * 50000); +// Allocate an unused endpoint for connection-error and argument-validation checks. +const port = await getAvailablePort(); const paseoHome = await mkdtemp(join(tmpdir(), "paseo-test-home-")); try { @@ -56,7 +57,7 @@ try { { console.log("Test 3: worktree ls handles daemon not running"); const result = - await $`PASEO_HOST=localhost:${port} PASEO_HOME=${paseoHome} npx paseo worktree ls`.nothrow(); + await $`PASEO_HOME=${paseoHome} npx paseo --host localhost:${port} worktree ls`.nothrow(); // Should fail because daemon not running assert.notStrictEqual(result.exitCode, 0, "should fail when daemon not running"); const output = result.stdout + result.stderr; @@ -72,7 +73,7 @@ try { { console.log("Test 4: worktree ls with --host flag is accepted"); const result = - await $`PASEO_HOST=localhost:${port} PASEO_HOME=${paseoHome} npx paseo worktree ls --host localhost:${port}`.nothrow(); + await $`PASEO_HOME=${paseoHome} npx paseo --host localhost:${port} worktree ls --host localhost:${port}`.nothrow(); const output = result.stdout + result.stderr; assert(!output.includes("unknown option"), "should accept --host flag"); assert(!output.includes("error: option"), "should not have option parsing error"); @@ -93,7 +94,7 @@ try { { console.log("Test 6: worktree archive requires name argument"); const result = - await $`PASEO_HOST=localhost:${port} PASEO_HOME=${paseoHome} npx paseo worktree archive`.nothrow(); + await $`PASEO_HOME=${paseoHome} npx paseo --host localhost:${port} worktree archive`.nothrow(); assert.notStrictEqual(result.exitCode, 0, "should fail without name"); const output = result.stdout + result.stderr; const hasError = @@ -108,7 +109,7 @@ try { { console.log("Test 7: worktree archive handles daemon not running"); const result = - await $`PASEO_HOST=localhost:${port} PASEO_HOME=${paseoHome} npx paseo worktree archive test-worktree`.nothrow(); + await $`PASEO_HOME=${paseoHome} npx paseo --host localhost:${port} worktree archive test-worktree`.nothrow(); // Should fail because daemon not running assert.notStrictEqual(result.exitCode, 0, "should fail when daemon not running"); const output = result.stdout + result.stderr; @@ -124,7 +125,7 @@ try { { console.log("Test 8: worktree archive with name and --host flag is accepted"); const result = - await $`PASEO_HOST=localhost:${port} PASEO_HOME=${paseoHome} npx paseo worktree archive test-worktree --host localhost:${port}`.nothrow(); + await $`PASEO_HOME=${paseoHome} npx paseo --host localhost:${port} worktree archive test-worktree --host localhost:${port}`.nothrow(); const output = result.stdout + result.stderr; assert(!output.includes("unknown option"), "should accept --host flag"); assert(!output.includes("error: option"), "should not have option parsing error"); @@ -135,7 +136,7 @@ try { { console.log("Test 9: -q (quiet) flag is accepted with worktree ls"); const result = - await $`PASEO_HOST=localhost:${port} PASEO_HOME=${paseoHome} npx paseo -q worktree ls`.nothrow(); + await $`PASEO_HOME=${paseoHome} npx paseo --host localhost:${port} -q worktree ls`.nothrow(); const output = result.stdout + result.stderr; assert(!output.includes("unknown option"), "should accept -q flag"); assert(!output.includes("error: option"), "should not have option parsing error"); @@ -146,7 +147,7 @@ try { { console.log("Test 10: --json flag is accepted with worktree ls"); const result = - await $`PASEO_HOST=localhost:${port} PASEO_HOME=${paseoHome} npx paseo worktree ls --json`.nothrow(); + await $`PASEO_HOME=${paseoHome} npx paseo --host localhost:${port} worktree ls --json`.nothrow(); const output = result.stdout + result.stderr; assert(!output.includes("unknown option"), "should accept --json flag"); assert(!output.includes("error: option"), "should not have option parsing error"); diff --git a/packages/cli/tests/16-agent-update.test.ts b/packages/cli/tests/16-agent-update.test.ts index 8e84ebf7b8..8ae54323bc 100644 --- a/packages/cli/tests/16-agent-update.test.ts +++ b/packages/cli/tests/16-agent-update.test.ts @@ -11,11 +11,11 @@ */ import assert from "node:assert"; +import { getAvailablePort } from "./helpers/network.ts"; import { $ } from "zx"; import { mkdtemp, rm } from "fs/promises"; import { tmpdir } from "os"; import { join } from "path"; -import { getAvailablePort } from "./helpers/network.ts"; $.verbose = false; @@ -43,7 +43,7 @@ try { { console.log("Test 2: agent update requires ID argument"); const result = - await $`PASEO_HOST=localhost:${port} PASEO_HOME=${paseoHome} npx paseo agent update --name "New Name"`.nothrow(); + await $`PASEO_HOME=${paseoHome} npx paseo --host localhost:${port} agent update --name "New Name"`.nothrow(); assert.notStrictEqual(result.exitCode, 0, "should fail without id"); const output = result.stdout + result.stderr; const hasError = @@ -59,7 +59,7 @@ try { { console.log("Test 3: agent update requires update field"); const result = - await $`PASEO_HOST=localhost:${port} PASEO_HOME=${paseoHome} npx paseo agent update abc123`.nothrow(); + await $`PASEO_HOME=${paseoHome} npx paseo --host localhost:${port} agent update abc123`.nothrow(); assert.notStrictEqual(result.exitCode, 0, "should fail without --name/--label"); const output = result.stdout + result.stderr; const hasError = @@ -74,7 +74,7 @@ try { { console.log("Test 4: agent update handles daemon not running"); const result = - await $`PASEO_HOST=localhost:${port} PASEO_HOME=${paseoHome} npx paseo agent update abc123 --name "Renamed Agent"`.nothrow(); + await $`PASEO_HOME=${paseoHome} npx paseo --host localhost:${port} agent update abc123 --name "Renamed Agent"`.nothrow(); assert.notStrictEqual(result.exitCode, 0, "should fail when daemon not running"); const output = result.stdout + result.stderr; const hasError = @@ -89,7 +89,7 @@ try { { console.log("Test 5: agent update accepts multi-label syntax"); const result = - await $`PASEO_HOST=localhost:${port} PASEO_HOME=${paseoHome} npx paseo agent update abc123 --label surface=workspace,area=frontend --label priority=high`.nothrow(); + await $`PASEO_HOME=${paseoHome} npx paseo --host localhost:${port} agent update abc123 --label surface=workspace,area=frontend --label priority=high`.nothrow(); const output = result.stdout + result.stderr; assert(!output.includes("unknown option"), "should accept --label flag"); assert(!output.includes("error: option"), "should not have option parsing error"); @@ -109,7 +109,7 @@ try { { console.log("Test 7: agent update accepts thinking as an update field"); const result = - await $`PASEO_HOST=localhost:${port} PASEO_HOME=${paseoHome} npx paseo agent update abc123 --thinking high`.nothrow(); + await $`PASEO_HOME=${paseoHome} npx paseo --host localhost:${port} agent update abc123 --thinking high`.nothrow(); const output = result.stdout + result.stderr; assert(!output.includes("Nothing to update"), "should treat --thinking as an update field"); console.log("✓ agent update accepts thinking as an update field\n"); @@ -119,7 +119,7 @@ try { { console.log("Test 8: thinking cannot be combined with metadata updates"); const result = - await $`PASEO_HOST=localhost:${port} PASEO_HOME=${paseoHome} npx paseo agent update abc123 --name renamed --thinking high`.nothrow(); + await $`PASEO_HOME=${paseoHome} npx paseo --host localhost:${port} agent update abc123 --name renamed --thinking high`.nothrow(); assert.notStrictEqual(result.exitCode, 0, "should reject a combined update"); const output = result.stdout + result.stderr; assert( diff --git a/packages/cli/tests/17-onboard.test.ts b/packages/cli/tests/17-onboard.test.ts index 3d8e641a26..a8b25148a2 100644 --- a/packages/cli/tests/17-onboard.test.ts +++ b/packages/cli/tests/17-onboard.test.ts @@ -5,6 +5,7 @@ import { readFile, mkdtemp, rm } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { $ } from "zx"; +import { runLocalPaseo } from "./helpers/local-cli.ts"; import { getAvailablePort } from "./helpers/network.ts"; $.verbose = false; @@ -15,9 +16,19 @@ const paseoHome = await mkdtemp(join(tmpdir(), "paseo-onboard-home-")); const port = await getAvailablePort(); try { + const configured = await runLocalPaseo([ + "daemon", + "config", + "set", + "daemon.listen", + `127.0.0.1:${port}`, + "--home", + paseoHome, + ]); + assert.strictEqual(configured.exitCode, 0, configured.stderr); + console.log("Test 1: `paseo` runs blocking onboarding without implicit relay pairing"); - const onboard = - await $`PASEO_HOME=${paseoHome} PASEO_LISTEN=127.0.0.1:${port} PASEO_PAIRING_QR=0 npx paseo`.nothrow(); + const onboard = await $`PASEO_HOME=${paseoHome} PASEO_PAIRING_QR=0 npx paseo`.nothrow(); assert.strictEqual( onboard.exitCode, @@ -37,8 +48,8 @@ try { assert(onboard.stdout.includes("paseo --help"), "onboard output should include --help shortcut"); assert(onboard.stdout.includes("paseo ls"), "onboard output should include ls shortcut"); assert( - onboard.stdout.includes('paseo run "your prompt"'), - "onboard output should include run shortcut", + onboard.stdout.includes(`paseo run --home ${JSON.stringify(paseoHome)} "your prompt"`), + "onboard output should include a run shortcut for the selected home", ); assert(onboard.stdout.includes("paseo status"), "onboard output should include status shortcut"); assert( @@ -58,8 +69,7 @@ try { assert.strictEqual(enableRelay.exitCode, 0, `relay enable should succeed: ${enableRelay.stderr}`); assert(enableRelay.stdout.includes("#offer="), "relay enable should produce a pairing offer"); - const noRelayOnboard = - await $`PASEO_HOME=${paseoHome} PASEO_LISTEN=127.0.0.1:${port} npx paseo --no-relay`.nothrow(); + const noRelayOnboard = await $`PASEO_HOME=${paseoHome} npx paseo --no-relay`.nothrow(); assert.strictEqual( noRelayOnboard.exitCode, 0, diff --git a/packages/cli/tests/18-local-daemon-utils.test.ts b/packages/cli/tests/18-local-daemon-utils.test.ts deleted file mode 100644 index 89780a6731..0000000000 --- a/packages/cli/tests/18-local-daemon-utils.test.ts +++ /dev/null @@ -1,53 +0,0 @@ -#!/usr/bin/env npx tsx - -/** - * Phase 18: Local daemon utility tests. - * - * Tests pure helpers that do not require a running daemon. - */ - -import assert from "node:assert"; -import { resolveTcpHostFromListen } from "../src/commands/daemon/local-daemon.js"; - -console.log("=== Local Daemon Utility Helpers ===\n"); - -{ - console.log("Test 1: resolves numeric listen values to localhost host:port"); - assert.strictEqual(resolveTcpHostFromListen("6767"), "127.0.0.1:6767"); - assert.strictEqual(resolveTcpHostFromListen(" 7777 "), "127.0.0.1:7777"); - console.log("✓ resolves numeric listen values\n"); -} - -{ - console.log("Test 2: preserves explicit host:port listen values"); - assert.strictEqual(resolveTcpHostFromListen("localhost:6767"), "localhost:6767"); - assert.strictEqual(resolveTcpHostFromListen("0.0.0.0:8080"), "0.0.0.0:8080"); - console.log("✓ preserves explicit host:port values\n"); -} - -{ - console.log("Test 3: rejects unix socket listen values"); - assert.strictEqual(resolveTcpHostFromListen("/tmp/paseo.sock"), null); - assert.strictEqual(resolveTcpHostFromListen("unix:///tmp/paseo.sock"), null); - assert.strictEqual(resolveTcpHostFromListen("pipe://\\\\.\\pipe\\paseo-managed-test"), null); - assert.strictEqual(resolveTcpHostFromListen("\\\\.\\pipe\\paseo-managed-test"), null); - console.log("✓ rejects unix socket listen values\n"); -} - -{ - console.log("Test 4: rejects empty and non-host listen values"); - assert.strictEqual(resolveTcpHostFromListen(""), null); - assert.strictEqual(resolveTcpHostFromListen(" "), null); - assert.strictEqual(resolveTcpHostFromListen("localhost"), null); - console.log("✓ rejects empty and non-host listen values\n"); -} - -{ - console.log("Test 5: rejects Windows absolute paths (not TCP endpoints)"); - assert.strictEqual(resolveTcpHostFromListen("C:\\Users\\foo\\.paseo\\paseo.sock"), null); - assert.strictEqual(resolveTcpHostFromListen("D:\\project\\socket"), null); - assert.strictEqual(resolveTcpHostFromListen("C:\\paseo.sock"), null); - console.log("✓ rejects Windows absolute paths\n"); -} - -console.log("=== All local daemon utility tests passed ==="); diff --git a/packages/cli/tests/20-worktree-ls-paths.test.ts b/packages/cli/tests/20-worktree-ls-paths.test.ts deleted file mode 100644 index d73cb5ca5e..0000000000 --- a/packages/cli/tests/20-worktree-ls-paths.test.ts +++ /dev/null @@ -1,38 +0,0 @@ -#!/usr/bin/env npx tsx - -import assert from "node:assert"; -import { homedir } from "node:os"; -import { join } from "node:path"; -import { resolvePaseoHomePath, resolvePaseoWorktreesDir } from "../src/commands/worktree/ls.js"; - -console.log("=== Worktree LS Path Helper Tests ===\n"); - -const originalPaseoHome = process.env.PASEO_HOME; - -try { - { - console.log("Test 1: resolves explicit PASEO_HOME when set"); - process.env.PASEO_HOME = "/tmp/paseo-explicit-home"; - - assert.strictEqual(resolvePaseoHomePath(), "/tmp/paseo-explicit-home"); - assert.strictEqual(resolvePaseoWorktreesDir(), "/tmp/paseo-explicit-home/worktrees"); - console.log("\u2713 explicit PASEO_HOME is respected\n"); - } - - { - console.log("Test 2: falls back to homedir/.paseo when PASEO_HOME is unset"); - delete process.env.PASEO_HOME; - - assert.strictEqual(resolvePaseoHomePath(), join(homedir(), ".paseo")); - assert.strictEqual(resolvePaseoWorktreesDir(), join(homedir(), ".paseo", "worktrees")); - console.log("\u2713 fallback home path is derived from os.homedir()\n"); - } -} finally { - if (originalPaseoHome === undefined) { - delete process.env.PASEO_HOME; - } else { - process.env.PASEO_HOME = originalPaseoHome; - } -} - -console.log("=== All worktree ls path helper tests passed ==="); diff --git a/packages/cli/tests/22-daemon-stop-supervisor.test.ts b/packages/cli/tests/22-daemon-stop-supervisor.test.ts index f6687703fa..979e866003 100644 --- a/packages/cli/tests/22-daemon-stop-supervisor.test.ts +++ b/packages/cli/tests/22-daemon-stop-supervisor.test.ts @@ -213,8 +213,9 @@ try { ); const capturedSupervisorLogs = await readCapturedSupervisorLogs(paseoHome, recentSupervisorLogs); assert( - capturedSupervisorLogs.includes('"msg":"Worker requested shutdown"') && - capturedSupervisorLogs.includes('"reason":"client_shutdown_rpc"'), + process.platform === "win32" + ? capturedSupervisorLogs.includes('"reason":"client_shutdown_rpc"') + : capturedSupervisorLogs.includes("supervisor_received_SIGTERM"), `stop should log lifecycle shutdown reason from daemon worker, logs:\n${capturedSupervisorLogs}`, ); assert( diff --git a/packages/cli/tests/25-daemon-restart-supervisor.test.ts b/packages/cli/tests/25-daemon-restart-supervisor.test.ts index 040fba7f2d..d02371f722 100644 --- a/packages/cli/tests/25-daemon-restart-supervisor.test.ts +++ b/packages/cli/tests/25-daemon-restart-supervisor.test.ts @@ -6,16 +6,20 @@ */ import assert from "node:assert"; -import { spawn, spawnSync, type ChildProcess } from "node:child_process"; -import { mkdtemp, readFile, rm } from "node:fs/promises"; +import { spawn, type ChildProcess } from "node:child_process"; +import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join } from "node:path"; -import { $ } from "zx"; -import { tryConnectToDaemon } from "../src/utils/client.ts"; +import { WebSocket } from "ws"; +import { + DaemonClient, + DaemonConnectionError, + type WebSocketLike, +} from "@getpaseo/client/internal/daemon-client"; +import { readDaemonInstance, isSameDaemonInstance } from "@getpaseo/server"; +import { runLocalPaseo } from "./helpers/local-cli.ts"; import { getAvailablePort } from "./helpers/network.ts"; -$.verbose = false; - const pollIntervalMs = 100; const testEnv = { PASEO_LOCAL_SPEECH_AUTO_DOWNLOAD: process.env.PASEO_LOCAL_SPEECH_AUTO_DOWNLOAD ?? "0", @@ -40,60 +44,6 @@ function isProcessRunning(pid: number): boolean { } } -function readWorkerPid(supervisorPid: number): number | null { - if (!Number.isInteger(supervisorPid) || supervisorPid <= 0) { - return null; - } - - const result = spawnSync("ps", ["ax", "-o", "pid=,ppid="], { encoding: "utf8" }); - if (result.status !== 0 || result.error) { - return null; - } - - for (const line of result.stdout.split("\n")) { - const trimmed = line.trim(); - if (!trimmed) { - continue; - } - const [pidToken, ppidToken] = trimmed.split(/\s+/); - const pid = Number.parseInt(pidToken ?? "", 10); - const ppid = Number.parseInt(ppidToken ?? "", 10); - if (!Number.isInteger(pid) || !Number.isInteger(ppid)) { - continue; - } - if (ppid === supervisorPid && pid > 0) { - return pid; - } - } - - return null; -} - -interface DaemonStatus { - localDaemon: string | null; - pid: number | null; -} - -async function readDaemonStatus(paseoHome: string): Promise { - const result = - await $`PASEO_HOME=${paseoHome} PASEO_LOCAL_SPEECH_AUTO_DOWNLOAD=${testEnv.PASEO_LOCAL_SPEECH_AUTO_DOWNLOAD} PASEO_DICTATION_ENABLED=${testEnv.PASEO_DICTATION_ENABLED} PASEO_VOICE_MODE_ENABLED=${testEnv.PASEO_VOICE_MODE_ENABLED} npx paseo daemon status --home ${paseoHome} --json`.nothrow(); - if (result.exitCode !== 0) { - return { localDaemon: null, pid: null }; - } - - try { - const parsed = JSON.parse(result.stdout) as { localDaemon?: unknown; pid?: unknown }; - const localDaemon = typeof parsed.localDaemon === "string" ? parsed.localDaemon : null; - const pid = - typeof parsed.pid === "number" && Number.isInteger(parsed.pid) && parsed.pid > 0 - ? parsed.pid - : null; - return { localDaemon, pid }; - } catch { - return { localDaemon: null, pid: null }; - } -} - async function readCapturedSupervisorLogs(paseoHome: string, recentLogs: string): Promise { const durableLogs = await readFile(join(paseoHome, "daemon.log"), "utf8").catch(() => ""); return `${recentLogs}\n${durableLogs}`; @@ -125,8 +75,30 @@ const host = `127.0.0.1:${port}`; let supervisorProcess: ChildProcess | null = null; let recentSupervisorLogs = ""; +let client: DaemonClient | undefined; +const availabilityLog = join(paseoHome, "availability.log"); try { + if (process.platform !== "win32") { + const provider = join(paseoHome, "slow-provider"); + await writeFile( + provider, + `#!${process.execPath} +import('node:fs').then(({appendFileSync}) => { + appendFileSync(${JSON.stringify(availabilityLog)}, JSON.stringify(process.argv.slice(2)) + "\\n"); + setTimeout(() => console.log('provider 1.0.0'), 1800); +}); +`, + { mode: 0o700 }, + ); + await writeFile( + join(paseoHome, "config.json"), + JSON.stringify({ + version: 1, + agents: { providers: { claude: { command: { mode: "replace", argv: [provider] } } } }, + }), + ); + } console.log("Test 1: start supervisor-entrypoint in dev mode with isolated PASEO_HOME"); supervisorProcess = spawn( @@ -135,7 +107,11 @@ try { { cwd: cliRoot, env: { - ...process.env, + ...Object.fromEntries( + Object.entries(process.env).filter(([key]) => !key.startsWith("PASEO_")), + ), + HOME: paseoHome, + USERPROFILE: paseoHome, ...testEnv, PASEO_HOME: paseoHome, PASEO_LISTEN: host, @@ -153,80 +129,90 @@ try { recentSupervisorLogs = (recentSupervisorLogs + chunk.toString()).slice(-8000); }); + let supervisor = await readDaemonInstance(paseoHome); await waitFor( async () => { - const status = await readDaemonStatus(paseoHome); - return ( - status.localDaemon === "running" && status.pid !== null && isProcessRunning(status.pid) - ); + supervisor = await readDaemonInstance(paseoHome); + return supervisor?.pid === supervisorProcess?.pid && Boolean(supervisor?.listen); }, 120000, - "daemon did not become running in time", + "daemon did not publish its bound endpoint in time", ); - - const statusBeforeRestart = await readDaemonStatus(paseoHome); - const supervisorPid = statusBeforeRestart.pid; - assert.strictEqual( - statusBeforeRestart.localDaemon, - "running", - "daemon should be running before restart", - ); - assert(supervisorPid !== null, "supervisor pid should exist once daemon starts"); + assert(supervisor?.listen, "owned supervisor should publish its bound endpoint"); + const supervisorPid = supervisor.pid; + assert.strictEqual(supervisorPid, supervisorProcess.pid, "PID lock should identify this launch"); assert(isProcessRunning(supervisorPid), "supervisor process should be running"); - const workerPidBeforeRestart = readWorkerPid(supervisorPid); - assert(workerPidBeforeRestart !== null, "supervisor should have a worker process before restart"); + + client = new DaemonClient({ + url: `ws://${supervisor.listen}/ws`, + clientId: "supervisor-restart-test", + connectTimeoutMs: 5000, + webSocketFactory: (url) => new WebSocket(url) as unknown as WebSocketLike, + }); + await client.connect(); + // The app reads worker identity through the full RPC, not the bounded CLI observation. + const statusBeforeRestart = await client.getDaemonStatus(); + const workerPidBeforeRestart = statusBeforeRestart.pid; assert( isProcessRunning(workerPidBeforeRestart), "worker process should be running before restart", ); + if (process.platform !== "win32") { + assert.match( + await readFile(availabilityLog, "utf8"), + /\["--version"\]/, + "the configured slow provider was actually probed", + ); + } console.log( `✓ daemon running with supervisor ${supervisorPid} and worker ${workerPidBeforeRestart}\n`, ); console.log("Test 2: app-style restart request should restart worker and keep daemon healthy"); - const client = await tryConnectToDaemon({ host, timeout: 5000 }); - assert(client, "daemon client should connect"); - try { - const restartAck = await client.restartServer("settings_update"); - assert.strictEqual( - restartAck.status, - "restart_requested", - "restart request should be acknowledged", - ); - } finally { - await client?.close().catch(() => undefined); - } + const restartAck = await client.restartServer("settings_update"); + assert.strictEqual( + restartAck.status, + "restart_requested", + "restart request should be acknowledged", + ); + const deadline = Date.now() + 20000; + let statusAfterRestart = statusBeforeRestart; await waitFor( - () => { - const workerPid = readWorkerPid(supervisorPid); - return ( - workerPid !== null && workerPid !== workerPidBeforeRestart && isProcessRunning(workerPid) - ); + async () => { + try { + const observed = await client!.getDaemonStatus({ + timeout: Math.max(1, deadline - Date.now()), + }); + if (observed.pid === workerPidBeforeRestart || !isProcessRunning(observed.pid)) + return false; + statusAfterRestart = observed; + return true; + } catch (error) { + if (error instanceof DaemonConnectionError) return false; + throw error; + } }, 20000, "worker pid did not change after restart request", ); - - const workerPidAfterRestart = readWorkerPid(supervisorPid); - assert(workerPidAfterRestart !== null, "worker process should exist after restart"); assert.notStrictEqual( - workerPidAfterRestart, + statusAfterRestart.pid, workerPidBeforeRestart, "worker pid should change after restart", ); - - const statusAfterRestart = await readDaemonStatus(paseoHome); + assert(isProcessRunning(statusAfterRestart.pid), "replacement worker should remain running"); + const current = await readDaemonInstance(paseoHome); + assert(current?.listen, "daemon should remain bound after restart"); assert.strictEqual( - statusAfterRestart.localDaemon, - "running", - "daemon should stay running after restart", - ); - assert.strictEqual( - statusAfterRestart.pid, + current.pid, supervisorPid, "supervisor pid should remain stable across restart", ); + assert( + isSameDaemonInstance(supervisor, current), + "supervisor start time should remain stable across restart", + ); const capturedSupervisorLogs = await readCapturedSupervisorLogs(paseoHome, recentSupervisorLogs); assert( capturedSupervisorLogs.includes('"msg":"Worker requested restart"') && @@ -243,6 +229,7 @@ try { ); console.log("✓ app-style restart keeps daemon healthy and restarts worker\n"); } finally { + await client?.close(); if (supervisorProcess?.pid && isProcessRunning(supervisorProcess.pid)) { supervisorProcess.kill("SIGTERM"); await waitFor( @@ -254,7 +241,7 @@ try { }); } - await $`PASEO_HOME=${paseoHome} PASEO_LOCAL_SPEECH_AUTO_DOWNLOAD=${testEnv.PASEO_LOCAL_SPEECH_AUTO_DOWNLOAD} PASEO_DICTATION_ENABLED=${testEnv.PASEO_DICTATION_ENABLED} PASEO_VOICE_MODE_ENABLED=${testEnv.PASEO_VOICE_MODE_ENABLED} npx paseo daemon stop --home ${paseoHome} --force`.nothrow(); + await runLocalPaseo(["daemon", "stop", "--home", paseoHome, "--force"]); await rm(paseoHome, { recursive: true, force: true }); } diff --git a/packages/cli/tests/27-agent-delete.test.ts b/packages/cli/tests/27-agent-delete.test.ts index 64ab4007bf..bec523f9ca 100644 --- a/packages/cli/tests/27-agent-delete.test.ts +++ b/packages/cli/tests/27-agent-delete.test.ts @@ -11,27 +11,23 @@ */ import assert from "node:assert"; -import { $ } from "zx"; +import { runLocalPaseo } from "./helpers/local-cli.ts"; +import { getAvailablePort } from "./helpers/network.ts"; import { mkdtemp, rm } from "fs/promises"; import { tmpdir } from "os"; -import { dirname, join } from "path"; -import { fileURLToPath } from "url"; - -$.verbose = false; +import { join } from "path"; console.log("=== Delete Command Tests ===\n"); -const cliRoot = dirname(fileURLToPath(import.meta.url)); -const repoRoot = join(cliRoot, "..", "..", ".."); -const port = 10000 + Math.floor(Math.random() * 50000); +const port = await getAvailablePort(); const paseoHome = await mkdtemp(join(tmpdir(), "paseo-delete-test-home-")); async function runCli(args: string[]) { - return $`npm --prefix ${repoRoot} run cli -- ${args}`.nothrow(); + return runLocalPaseo(["--host", `localhost:${port}`, ...args], { PASEO_HOME: paseoHome }); } async function runDelete(args: string[]) { - return $`PASEO_HOST=localhost:${port} PASEO_HOME=${paseoHome} npm --prefix ${repoRoot} run cli -- delete ${args}`.nothrow(); + return runCli(["delete", ...args]); } try { @@ -110,8 +106,7 @@ try { { console.log("Test 8: -q (quiet) flag is accepted with delete"); - const result = - await $`PASEO_HOST=localhost:${port} PASEO_HOME=${paseoHome} npm --prefix ${repoRoot} run cli -- -q delete abc123`.nothrow(); + const result = await runCli(["-q", "delete", "abc123"]); const output = result.stdout + result.stderr; assert(!output.includes("unknown option"), "should accept -q flag"); assert(!output.includes("error: option"), "should not have option parsing error"); diff --git a/packages/cli/tests/28-client-ipc-targets.test.ts b/packages/cli/tests/28-client-ipc-targets.test.ts index 24ceb9a632..3072927df0 100644 --- a/packages/cli/tests/28-client-ipc-targets.test.ts +++ b/packages/cli/tests/28-client-ipc-targets.test.ts @@ -1,16 +1,13 @@ #!/usr/bin/env npx tsx import assert from "node:assert"; -import { mkdtempSync, mkdirSync, rmSync, writeFileSync } from "node:fs"; -import os from "node:os"; -import path from "node:path"; import { getDaemonHost, normalizeDaemonHost, resolveDaemonPassword, resolveDaemonTarget, - resolveDefaultDaemonHosts, } from "../src/utils/client.js"; +import { selectDaemonTarget } from "../src/utils/daemon-target.js"; import { resolveCliVersion } from "../src/version.js"; console.log("=== CLI IPC Target Helpers ===\n"); @@ -81,48 +78,13 @@ console.log("=== CLI IPC Target Helpers ===\n"); } { - console.log("Test 6: default host resolution tries local IPC first, then localhost fallback"); - const paseoHome = mkdtempSync(path.join(os.tmpdir(), "paseo-client-targets-")); - try { - mkdirSync(paseoHome, { recursive: true }); - writeFileSync( - path.join(paseoHome, "paseo.pid"), - JSON.stringify({ pid: process.pid, listen: "/tmp/paseo-from-pid.sock" }), - ); - assert.deepStrictEqual(resolveDefaultDaemonHosts({ PASEO_HOME: paseoHome }), [ - "unix:///tmp/paseo-from-pid.sock", - "localhost:6767", - ]); - const previousHome = process.env.PASEO_HOME; - const previousHost = process.env.PASEO_HOST; - process.env.PASEO_HOME = paseoHome; - delete process.env.PASEO_HOST; - assert.strictEqual(getDaemonHost(), "unix:///tmp/paseo-from-pid.sock"); - if (previousHome === undefined) delete process.env.PASEO_HOME; - else process.env.PASEO_HOME = previousHome; - if (previousHost === undefined) delete process.env.PASEO_HOST; - else process.env.PASEO_HOST = previousHost; - } finally { - rmSync(paseoHome, { recursive: true, force: true }); - } - console.log("✓ default host resolution tries local IPC first, then localhost fallback\n"); -} - -{ - console.log("Test 7: configured TCP host is preserved before the localhost fallback"); - const paseoHome = mkdtempSync(path.join(os.tmpdir(), "paseo-client-targets-tcp-")); - try { - assert.deepStrictEqual( - resolveDefaultDaemonHosts({ - PASEO_HOME: paseoHome, - PASEO_LISTEN: "127.0.0.1:7777", - }), - ["127.0.0.1:7777", "localhost:6767"], - ); - } finally { - rmSync(paseoHome, { recursive: true, force: true }); - } - console.log("✓ configured TCP host is preserved before the localhost fallback\n"); + const target = selectDaemonTarget( + { home: "/tmp/selected-home" }, + { PASEO_HOST: "ignored:12345", PASEO_LISTEN: "ignored:23456" }, + ); + assert.deepStrictEqual(target, { kind: "instance", home: "/tmp/selected-home" }); + assert.strictEqual(getDaemonHost({ target }), "home /tmp/selected-home"); + assert.throws(() => selectDaemonTarget({}, { PASEO_HOME: "/tmp/a", PASEO_HOST: "unused:12345" })); } { @@ -131,28 +93,6 @@ console.log("=== CLI IPC Target Helpers ===\n"); console.log("✓ CLI app version resolves for daemon hello compatibility\n"); } -{ - console.log("Test 9: local IPC still takes priority over configured TCP hosts"); - const paseoHome = mkdtempSync(path.join(os.tmpdir(), "paseo-client-targets-order-")); - try { - mkdirSync(paseoHome, { recursive: true }); - writeFileSync( - path.join(paseoHome, "paseo.pid"), - JSON.stringify({ pid: process.pid, listen: "/tmp/paseo-priority.sock" }), - ); - assert.deepStrictEqual( - resolveDefaultDaemonHosts({ - PASEO_HOME: paseoHome, - PASEO_LISTEN: "127.0.0.1:7777", - }), - ["unix:///tmp/paseo-priority.sock", "127.0.0.1:7777", "localhost:6767"], - ); - } finally { - rmSync(paseoHome, { recursive: true, force: true }); - } - console.log("✓ local IPC still takes priority over configured TCP hosts\n"); -} - { console.log("Test 10: daemon password resolution prefers TCP URI query, falls back to env"); const previousEnv = process.env.PASEO_PASSWORD; diff --git a/packages/cli/tests/32-daemon-set-password.test.ts b/packages/cli/tests/32-daemon-set-password.test.ts index 2dab1d9561..5756cfe0a0 100644 --- a/packages/cli/tests/32-daemon-set-password.test.ts +++ b/packages/cli/tests/32-daemon-set-password.test.ts @@ -51,7 +51,10 @@ try { const config = JSON.parse(await readFile(join(paseoHome, "config.json"), "utf-8")); assert.strictEqual(result.configPath, join(paseoHome, "config.json")); - assert.strictEqual(result.restartCommand, "paseo daemon restart"); + assert.strictEqual( + result.restartCommand, + `paseo daemon restart --home ${JSON.stringify(paseoHome)}`, + ); assert.strictEqual(config.daemon.listen, "127.0.0.1:9999"); assert.strictEqual(config.daemon.relay.enabled, false); assert.notStrictEqual(config.daemon.auth.password, "shared-secret"); @@ -68,6 +71,7 @@ try { const result = await runSetPasswordCommand( { home: paseoHome, + daemonTarget: { kind: "instance", home: paseoHome }, promptPassword: promptSequence(["new-secret", "new-secret"]), }, {} as Command, @@ -88,6 +92,7 @@ try { runSetPasswordCommand( { home: paseoHome, + daemonTarget: { kind: "instance", home: paseoHome }, promptPassword: promptSequence(["first-secret", "second-secret"]), }, {} as Command, diff --git a/packages/cli/tests/33-daemon-stop-tree-kill.test.ts b/packages/cli/tests/33-daemon-stop-tree-kill.test.ts index 5ee8af7b61..3a290d91b2 100644 --- a/packages/cli/tests/33-daemon-stop-tree-kill.test.ts +++ b/packages/cli/tests/33-daemon-stop-tree-kill.test.ts @@ -118,6 +118,8 @@ try { join(paseoHome, "paseo.pid"), JSON.stringify({ pid: ownerProcess.pid, + hostname: "test", + uid: process.getuid?.() ?? -1, listen: "127.0.0.1:1", startedAt: new Date().toISOString(), }), @@ -153,15 +155,11 @@ try { action: parsed.action, forced: parsed.forced, usedLifecycleRpc: parsed.usedLifecycleRpc, - reason: parsed.reason, - message: parsed.message, }, { action: "stopped", forced: true, usedLifecycleRpc: false, - reason: "owner_pid_sigkill", - message: "Daemon owner process was force-stopped", }, `stop should report forced tree cleanup: ${stopResult.stdout}`, ); diff --git a/packages/cli/tests/34-daemon-status-auth.test.ts b/packages/cli/tests/34-daemon-status-auth.test.ts index bb683ed027..ebbad314a2 100644 --- a/packages/cli/tests/34-daemon-status-auth.test.ts +++ b/packages/cli/tests/34-daemon-status-auth.test.ts @@ -1,6 +1,9 @@ #!/usr/bin/env npx tsx import assert from "node:assert"; +import { mkdtemp, mkdir, writeFile, readFile, rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; import { runLocalPaseo } from "./helpers/local-cli.ts"; import { startTestDaemon } from "./helpers/test-daemon.ts"; @@ -26,7 +29,7 @@ try { assert.strictEqual(status.connectedDaemon, "auth_required"); assert(!("runningAgents" in status), "status should not fetch agent counts"); assert(!("idleAgents" in status), "status should not fetch agent counts"); - assert.match(status.note, /requires a password/i); + assert.match(status.note, /Password required/i); assert.doesNotMatch(status.note, /not reachable/i); console.log("✓ missing password reports auth_required\n"); } @@ -44,7 +47,7 @@ try { assert.strictEqual(status.localDaemon, "running"); assert.strictEqual(status.connectedDaemon, "auth_failed"); - assert.match(status.note, /password was rejected/i); + assert.match(status.note, /Incorrect password/i); assert.doesNotMatch(status.note, /not reachable/i); console.log("✓ wrong password reports auth_failed\n"); } @@ -70,4 +73,78 @@ try { await daemon.stop(); } +// POSIX executable probing executes --version; Windows resolves executables differently. +if (process.platform !== "win32") { + const root = await mkdtemp(join(tmpdir(), "paseo status slow provider ")); + const home = join(root, "daemon"); + const workDir = join(root, "work"); + const provider = join(root, "slow-provider"); + const marker = join(root, "availability.log"); + let slowDaemon: Awaited> | undefined; + try { + await mkdir(home); + await mkdir(workDir); + await writeFile( + provider, + `#!${process.execPath} +import('node:fs').then(({appendFileSync}) => { + appendFileSync(${JSON.stringify(marker)}, JSON.stringify(process.argv.slice(2)) + "\\n"); + setTimeout(() => console.log('provider 1.0.0'), 1800); +}); +`, + { mode: 0o700 }, + ); + await writeFile( + join(home, "config.json"), + JSON.stringify({ + version: 1, + agents: { providers: { claude: { command: { mode: "replace", argv: [provider] } } } }, + }), + ); + slowDaemon = await startTestDaemon({ + paseoHome: home, + workDir, + env: { PASEO_PASSWORD: "shared-secret" }, + }); + console.log("Test 4: local status separates authenticated reachability from slow details"); + const local = await runLocalPaseo(["daemon", "status", "--home", home, "--json"], { + PASEO_PASSWORD: "shared-secret", + }); + assert.strictEqual(local.exitCode, 0, local.stderr); + const status = JSON.parse(local.stdout); + assert.strictEqual(status.localDaemon, "running"); + assert.strictEqual(status.connectedDaemon, "reachable", JSON.stringify(status)); + assert.strictEqual(typeof status.serverId, "string"); + assert.match(status.note, /DAEMON_REQUEST_TIMEOUT/); + assert.match(status.note, /Status details unavailable/); + assert(!("workerPid" in status), "must not invent worker facts"); + assert(!("providers" in status), "must not invent provider facts"); + assert.match( + await readFile(marker, "utf8"), + /\["--version"\]/, + "the public provider executable was actually probed", + ); + console.log("✓ local authenticated connection remains reachable when details time out\n"); + + console.log("Test 5: explicit endpoint status remains an error when details time out"); + const remote = await runLocalPaseo( + ["daemon", "status", "--host", `127.0.0.1:${slowDaemon.port}`, "--json"], + { PASEO_PASSWORD: "shared-secret" }, + ); + assert.notStrictEqual(remote.exitCode, 0); + const { error } = JSON.parse(remote.stderr); + assert.strictEqual(error.code, "DAEMON_REQUEST_TIMEOUT"); + assert.match(error.message, /Status details unavailable/); + assert.strictEqual(error.details.connectedDaemon, "reachable"); + assert.strictEqual(error.details.serverId, status.serverId); + assert(!("home" in error.details), "endpoint observation must not invent local ownership"); + assert(!("workerPid" in error.details)); + assert(!("providers" in error.details)); + console.log("✓ failed remote query retains its error and authenticated connection fact\n"); + } finally { + await slowDaemon?.stop(); + await rm(root, { recursive: true, force: true }); + } +} + console.log("=== Daemon Status Auth Tests Passed ==="); diff --git a/packages/cli/tests/34-daemon-stop-stale-reachable.test.ts b/packages/cli/tests/34-daemon-stop-stale-reachable.test.ts index 91d789d1da..aaba355f67 100644 --- a/packages/cli/tests/34-daemon-stop-stale-reachable.test.ts +++ b/packages/cli/tests/34-daemon-stop-stale-reachable.test.ts @@ -1,8 +1,8 @@ #!/usr/bin/env npx tsx /** - * Regression: `paseo daemon stop` must stop a reachable daemon even when the - * local pid file points at a dead supervisor owner. + * Regression: `paseo daemon stop` must leave a reachable daemon alone when the + * selected home points at a dead supervisor owner. */ import assert from "node:assert"; @@ -12,6 +12,7 @@ import { mkdtemp, rm, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { $ } from "zx"; +import { connectToDaemon } from "../src/utils/client.js"; import { getAvailablePort } from "./helpers/network.ts"; $.verbose = false; @@ -146,21 +147,24 @@ try { await waitFor( async () => { - const status = await readDaemonStatus(paseoHome); - return status.localDaemon === "stale_pid" && status.connectedDaemon === "reachable"; + try { + const client = await connectToDaemon({ target: { kind: "endpoint", host }, timeout: 500 }); + await client.close(); + return true; + } catch { + return false; + } }, 120000, - "daemon did not enter stale_pid + reachable state in time", + "unowned worker did not become reachable in time", ); const statusBeforeStop = await readDaemonStatus(paseoHome); - assert.strictEqual(statusBeforeStop.pid, stalePid, "status should report the stale owner pid"); + assert.strictEqual(statusBeforeStop.pid, null, "status should not claim a dead owner is running"); assert(workerProcess.pid && isProcessRunning(workerProcess.pid), "worker should be running"); console.log(`✓ fixture has stale pid ${stalePid} and live worker ${workerProcess.pid}\n`); - console.log( - "Test 2: `paseo daemon stop` should stop reachable worker instead of saying not_running", - ); + console.log("Test 2: home-selected stop leaves the unowned reachable worker running"); const stopResult = await $`PASEO_HOME=${paseoHome} PASEO_LOCAL_SPEECH_AUTO_DOWNLOAD=${testEnv.PASEO_LOCAL_SPEECH_AUTO_DOWNLOAD} PASEO_DICTATION_ENABLED=${testEnv.PASEO_DICTATION_ENABLED} PASEO_VOICE_MODE_ENABLED=${testEnv.PASEO_VOICE_MODE_ENABLED} npx paseo daemon stop --home ${paseoHome} --json`.nothrow(); assert.strictEqual(stopResult.exitCode, 0, `stop should succeed: ${stopResult.stderr}`); @@ -169,22 +173,11 @@ try { pid?: unknown; message?: unknown; }; - assert.strictEqual(stopJson.action, "stopped", "stop should report stopped action"); - assert.strictEqual( - stopJson.pid, - String(stalePid), - "stop should report the stale pid it recovered from", - ); - assert.strictEqual( - stopJson.message, - "Daemon stopped gracefully", - "stop should route through lifecycle shutdown", - ); - - await waitFor( - () => !isProcessRunning(workerProcess?.pid ?? -1), - 15000, - "worker remained running after stop", + assert.strictEqual(stopJson.action, "not_running"); + assert.strictEqual(stopJson.pid, stalePid); + assert( + workerProcess.pid && isProcessRunning(workerProcess.pid), + "stop must not contact the stale endpoint", ); assert.strictEqual(existsSync(pidPath), false, "stale pid file should be removed after stop"); console.log("✓ stop recovered stale supervisor pid state\n"); diff --git a/packages/cli/tests/36-daemon-status-windows-process-lookup.test.ts b/packages/cli/tests/36-daemon-status-windows-process-lookup.test.ts index 313ec5c4c6..f7872b310b 100644 --- a/packages/cli/tests/36-daemon-status-windows-process-lookup.test.ts +++ b/packages/cli/tests/36-daemon-status-windows-process-lookup.test.ts @@ -1,43 +1,12 @@ #!/usr/bin/env npx tsx import assert from "node:assert"; -import { spawn } from "node:child_process"; +import { runLocalPaseo } from "./helpers/local-cli.ts"; import { mkdtemp, rm } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { getAvailablePort } from "./helpers/network.ts"; -const CLI_ENTRY = join(import.meta.dirname, "..", "dist", "index.js"); - -interface CommandResult { - exitCode: number | null; - stdout: string; - stderr: string; -} - -function runLocalPaseo(args: string[], env: NodeJS.ProcessEnv): Promise { - return new Promise((resolve, reject) => { - const child = spawn(process.execPath, [CLI_ENTRY, ...args], { - env: { ...process.env, ...env }, - stdio: ["ignore", "pipe", "pipe"], - windowsHide: true, - }); - - let stdout = ""; - let stderr = ""; - child.stdout.on("data", (chunk: Buffer) => { - stdout += chunk.toString(); - }); - child.stderr.on("data", (chunk: Buffer) => { - stderr += chunk.toString(); - }); - child.on("error", reject); - child.on("close", (exitCode) => { - resolve({ exitCode, stdout, stderr }); - }); - }); -} - if (process.platform !== "win32") { console.log("Skipping Windows daemon status process lookup regression on non-Windows."); process.exit(0); @@ -55,7 +24,16 @@ const env = { }; try { - const start = await runLocalPaseo(["daemon", "restart", "--port", String(port)], env); + for (const [field, value] of [ + ["daemon.listen", `127.0.0.1:${port}`], + ["daemon.relay.enabled", "false"], + ["features.dictation.enabled", "false"], + ["features.voiceMode.enabled", "false"], + ]) { + const saved = await runLocalPaseo(["daemon", "config", "set", field!, value!], env); + assert.equal(saved.exitCode, 0, saved.stderr); + } + const start = await runLocalPaseo(["daemon", "start"], env); assert.strictEqual( start.exitCode, 0, diff --git a/packages/cli/tests/e2e/plugin-lifecycle.test.ts b/packages/cli/tests/e2e/plugin-lifecycle.test.ts index 4cb407a787..02c5fab136 100644 --- a/packages/cli/tests/e2e/plugin-lifecycle.test.ts +++ b/packages/cli/tests/e2e/plugin-lifecycle.test.ts @@ -44,7 +44,9 @@ async function main(): Promise { assert.equal(install.exitCode, 0, install.stderr); assert.equal(JSON.parse(install.stdout).id, "cli-e2e"); - const client = await connectToDaemon({ host: `127.0.0.1:${context.port}` }); + const client = await connectToDaemon({ + target: { kind: "endpoint", host: `127.0.0.1:${context.port}` }, + }); await client.patchDaemonConfig({ pluginsEnabled: true }); await client.close(); diff --git a/packages/cli/tests/helpers/local-cli.ts b/packages/cli/tests/helpers/local-cli.ts index ad7f3a4524..e6d22bb0ff 100644 --- a/packages/cli/tests/helpers/local-cli.ts +++ b/packages/cli/tests/helpers/local-cli.ts @@ -1,5 +1,10 @@ import { $, ProcessPromise } from "zx"; import { join } from "node:path"; +import { mkdtempSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; + +const cliHome = mkdtempSync(join(tmpdir(), "paseo-test-cli-os-home-")); +process.once("exit", () => rmSync(cliHome, { recursive: true, force: true })); const CLI_ENTRY = join(import.meta.dirname, "..", "..", "dist", "index.js"); @@ -11,6 +16,13 @@ export function runLocalPaseo( $.verbose = false; return $({ cwd, - env: { ...process.env, ...env }, + env: { + ...Object.fromEntries( + Object.entries(process.env).filter(([key]) => !key.startsWith("PASEO_")), + ), + ...env, + HOME: cliHome, + USERPROFILE: cliHome, + }, })`${process.execPath} ${CLI_ENTRY} ${args}`.nothrow(); } diff --git a/packages/cli/tests/helpers/test-daemon.ts b/packages/cli/tests/helpers/test-daemon.ts index 8607b1cfb5..f8be7e6ead 100644 --- a/packages/cli/tests/helpers/test-daemon.ts +++ b/packages/cli/tests/helpers/test-daemon.ts @@ -169,19 +169,23 @@ export async function createTempDirs(): Promise<{ paseoHome: string; workDir: st * Wait for daemon to be ready by running `paseo agent ls` * This connects via WebSocket and ensures the daemon is responsive */ -async function probeDaemonReady(port: number, env?: NodeJS.ProcessEnv): Promise { +async function probeDaemonReady( + port: number, + paseoHome: string, + env?: NodeJS.ProcessEnv, +): Promise { try { const { exitCode } = await runPaseoCli( { port, wsUrl: `ws://${TEST_DAEMON_HOST}:${port}`, - paseoHome: "", + paseoHome, workDir: "", process: null, isReady: false, stop: async () => {}, }, - ["agent", "ls"], + ["agent", "ls", "--host", `${TEST_DAEMON_HOST}:${port}`], { env }, ); return exitCode === 0; @@ -192,13 +196,14 @@ async function probeDaemonReady(port: number, env?: NodeJS.ProcessEnv): Promise< async function waitForDaemonReady( port: number, + paseoHome: string, timeout = 30000, env?: NodeJS.ProcessEnv, ): Promise { const deadline = Date.now() + timeout; async function poll(): Promise { - if (await probeDaemonReady(port, env)) return; + if (await probeDaemonReady(port, paseoHome, env)) return; if (Date.now() >= deadline) { throw new Error(`Daemon failed to become ready on port ${port} within ${timeout}ms`); } @@ -240,23 +245,23 @@ export async function startTestDaemon(options?: { const cliSrcPath = join(cliDir, "src", "index.ts"); // Start daemon process using tsx to run TypeScript directly - const daemonProcess = spawn( - process.execPath, - [TSX_ENTRY, cliSrcPath, "daemon", "start", "--foreground"], - { - env: { - ...process.env, - ...TEST_DAEMON_ENV_DEFAULTS, - PASEO_HOME: paseoHome, - PASEO_LISTEN: `${TEST_DAEMON_HOST}:${port}`, - // Force no TTY to prevent QR code output - CI: "true", - ...options?.env, - }, - stdio: ["ignore", "pipe", "pipe"], - detached: process.platform !== "win32", + const daemonProcess = spawn(process.execPath, [TSX_ENTRY, cliSrcPath, "daemon", "run"], { + env: { + ...Object.fromEntries( + Object.entries(process.env).filter(([key]) => !key.startsWith("PASEO_")), + ), + ...TEST_DAEMON_ENV_DEFAULTS, + PASEO_HOME: paseoHome, + PASEO_LISTEN: `${TEST_DAEMON_HOST}:${port}`, + // Force no TTY to prevent QR code output + CI: "true", + ...options?.env, + HOME: paseoHome, + USERPROFILE: paseoHome, }, - ); + stdio: ["ignore", "pipe", "pipe"], + detached: process.platform !== "win32", + }); const stdout = createOutputCapture(); const stderr = createOutputCapture(); @@ -319,7 +324,7 @@ export async function startTestDaemon(options?: { // Wait for daemon to be ready try { - await waitForDaemonReady(port, timeout, options?.env); + await waitForDaemonReady(port, paseoHome, timeout, options?.env); ctx.isReady = true; } catch (err) { // Daemon failed to start - clean up and rethrow @@ -358,11 +363,14 @@ export async function runPaseoCli( return new Promise((resolve, reject) => { const proc = spawn(process.execPath, [TSX_ENTRY, cliSrcPath, ...args], { env: { - ...process.env, + ...Object.fromEntries( + Object.entries(process.env).filter(([key]) => !key.startsWith("PASEO_")), + ), ...TEST_DAEMON_ENV_DEFAULTS, - PASEO_HOST: `${TEST_DAEMON_HOST}:${ctx.port}`, PASEO_HOME: ctx.paseoHome, ...options?.env, + HOME: ctx.paseoHome, + USERPROFILE: ctx.paseoHome, }, cwd, stdio: ["ignore", "pipe", "pipe"], diff --git a/packages/cli/tests/run-all.ts b/packages/cli/tests/run-all.ts index d3d4cc8b2e..7303f5408e 100644 --- a/packages/cli/tests/run-all.ts +++ b/packages/cli/tests/run-all.ts @@ -196,12 +196,17 @@ async function runSingleTest(testFile: string): Promise { const testName = testFile.replace(/\.test\.ts$/, ""); const startedAt = Date.now(); const npmCache = await mkdtemp(join(tmpdir(), "paseo-cli-test-npm-cache-")); + const osHome = await mkdtemp(join(tmpdir(), "paseo-cli-test-os-home-")); try { return await new Promise((resolve) => { const proc = spawn("npx", ["tsx", testPath], { env: { - ...process.env, + ...Object.fromEntries( + Object.entries(process.env).filter(([key]) => !key.startsWith("PASEO_")), + ), + HOME: osHome, + USERPROFILE: osHome, PATH: [rootNodeModulesBin, process.env.PATH].filter(Boolean).join(delimiter), npm_config_cache: npmCache, PASEO_LOCAL_SPEECH_AUTO_DOWNLOAD: testEnvDefaults.PASEO_LOCAL_SPEECH_AUTO_DOWNLOAD, @@ -249,6 +254,7 @@ async function runSingleTest(testFile: string): Promise { }); } finally { await rm(npmCache, { recursive: true, force: true }); + await rm(osHome, { recursive: true, force: true }); } } diff --git a/packages/cli/tests/setup.ts b/packages/cli/tests/setup.ts index 94f872300f..5b860cf6c2 100644 --- a/packages/cli/tests/setup.ts +++ b/packages/cli/tests/setup.ts @@ -20,6 +20,16 @@ const TEST_ENV_DEFAULTS = { PASEO_VOICE_MODE_ENABLED: process.env.PASEO_VOICE_MODE_ENABLED ?? "0", }; +function testEnvironment(paseoHome: string): NodeJS.ProcessEnv { + return { + ...Object.fromEntries(Object.entries(process.env).filter(([key]) => !key.startsWith("PASEO_"))), + ...TEST_ENV_DEFAULTS, + PASEO_HOME: paseoHome, + HOME: paseoHome, + USERPROFILE: paseoHome, + }; +} + function killPidTree(pid: number, signal: NodeJS.Signals): void { if (!Number.isInteger(pid) || pid <= 0) { return; @@ -83,19 +93,25 @@ export async function createTempDirs(): Promise<{ paseoHome: string; workDir: st * Wait for daemon to be ready by testing WebSocket connection * Uses `paseo agent ls` which connects via WebSocket */ -async function probeDaemon(port: number): Promise { +async function probeDaemon(port: number, paseoHome: string): Promise { try { - const result = await $`PASEO_HOST=localhost:${port} paseo agent ls`.nothrow(); + const result = await $({ + env: testEnvironment(paseoHome), + })`paseo agent ls --host localhost:${port}`.nothrow(); return result.exitCode === 0; } catch { return false; } } -export async function waitForDaemon(port: number, timeout = 30000): Promise { +export async function waitForDaemon( + port: number, + paseoHome: string, + timeout = 30000, +): Promise { const deadline = Date.now() + timeout; async function poll(): Promise { - if (await probeDaemon(port)) return; + if (await probeDaemon(port, paseoHome)) return; if (Date.now() >= deadline) { throw new Error(`Daemon failed to start on port ${port} within ${timeout}ms`); } @@ -110,8 +126,14 @@ export async function waitForDaemon(port: number, timeout = 30000): Promise { $.verbose = false; - const daemon = - $`PASEO_HOME=${paseoHome} PASEO_LISTEN=127.0.0.1:${port} PASEO_RELAY_ENABLED=false PASEO_LOCAL_SPEECH_AUTO_DOWNLOAD=${TEST_ENV_DEFAULTS.PASEO_LOCAL_SPEECH_AUTO_DOWNLOAD} PASEO_DICTATION_ENABLED=${TEST_ENV_DEFAULTS.PASEO_DICTATION_ENABLED} PASEO_VOICE_MODE_ENABLED=${TEST_ENV_DEFAULTS.PASEO_VOICE_MODE_ENABLED} CI=true paseo daemon start --foreground`.nothrow(); + const daemon = $({ + env: { + ...testEnvironment(paseoHome), + PASEO_LISTEN: `127.0.0.1:${port}`, + PASEO_RELAY_ENABLED: "false", + CI: "true", + }, + })`paseo daemon run`.nothrow(); return daemon; } @@ -125,7 +147,7 @@ export async function createTestContext(): Promise { // Helper to run CLI commands against test daemon const paseo = (args: string[]): ProcessPromise => { $.verbose = false; - return $`PASEO_HOST=localhost:${port} PASEO_LOCAL_SPEECH_AUTO_DOWNLOAD=${TEST_ENV_DEFAULTS.PASEO_LOCAL_SPEECH_AUTO_DOWNLOAD} PASEO_DICTATION_ENABLED=${TEST_ENV_DEFAULTS.PASEO_DICTATION_ENABLED} PASEO_VOICE_MODE_ENABLED=${TEST_ENV_DEFAULTS.PASEO_VOICE_MODE_ENABLED} paseo ${args}`.nothrow(); + return $({ env: testEnvironment(paseoHome) })`paseo --home ${paseoHome} ${args}`.nothrow(); }; // Cleanup function @@ -162,7 +184,7 @@ export async function createTestContext(): Promise { export async function createTestContextWithDaemon(): Promise { const ctx = await createTestContext(); ctx.daemon = await startDaemon(ctx.port, ctx.paseoHome); - await waitForDaemon(ctx.port); + await waitForDaemon(ctx.port, ctx.paseoHome); return ctx; } diff --git a/packages/client/src/daemon-client.test.ts b/packages/client/src/daemon-client.test.ts index 72d78b4fe0..bf41e06961 100644 --- a/packages/client/src/daemon-client.test.ts +++ b/packages/client/src/daemon-client.test.ts @@ -1431,7 +1431,7 @@ test("honors explicit getDaemonStatus timeout below the session RPC default", as clients.push(client); const connectPromise = client.connect(); - mock.triggerOpen(); + mock.triggerOpen({ features: { daemonStatusRpc: true } }); await connectPromise; const responsePromise = client.getDaemonStatus({ diff --git a/packages/client/src/daemon-client.ts b/packages/client/src/daemon-client.ts index 9529b3179e..042541f5ca 100644 --- a/packages/client/src/daemon-client.ts +++ b/packages/client/src/daemon-client.ts @@ -884,6 +884,16 @@ type CorrelatedResponsePayloads = { type CorrelatedResponsePayload = CorrelatedResponsePayloads[TType]; +export class DaemonConnectionError extends Error { + constructor( + message: string, + readonly code: "DAEMON_CONNECTION_LOST" | "DAEMON_REQUEST_TIMEOUT" = "DAEMON_CONNECTION_LOST", + ) { + super(message); + this.name = "DaemonConnectionError"; + } +} + class DaemonRpcError extends Error { readonly requestId: string; readonly requestType?: string; @@ -1580,7 +1590,7 @@ export class DaemonClient { private sendTransportFrame(frame: string | Uint8Array | ArrayBuffer): void { if (!this.transport) { - throw new Error("Transport not connected"); + throw new DaemonConnectionError("Transport not connected"); } const isOpen = this.beginTraceSection("paseo.ws.frame.outbound", { kind: typeof frame === "string" ? "text" : "binary", @@ -1662,7 +1672,12 @@ export class DaemonClient { if (idx !== -1) { this.pendingSendQueue.splice(idx, 1); } - reject(new Error(`Timed out waiting for connection to send message`)); + reject( + new DaemonConnectionError( + "Timed out waiting for connection to send message", + "DAEMON_REQUEST_TIMEOUT", + ), + ); }, DEFAULT_SEND_QUEUE_TIMEOUT_MS); this.pendingSendQueue.push({ message, resolve, reject, timeoutHandle }); @@ -1670,7 +1685,7 @@ export class DaemonClient { } // Not connected and not connecting - fail immediately - return Promise.reject(new Error(`Transport not connected (status: ${status})`)); + return Promise.reject(new DaemonConnectionError(`Transport not connected (status: ${status})`)); } /** @@ -1688,7 +1703,7 @@ export class DaemonClient { this.sendJsonMessage("session", payload.type, { type: "session", message: payload }); pending.resolve(); } else { - pending.reject(new Error("Connection lost before message could be sent")); + pending.reject(new DaemonConnectionError("Connection lost before message could be sent")); } } catch (error) { pending.reject(error instanceof Error ? error : new Error(String(error))); @@ -1835,7 +1850,7 @@ export class DaemonClient { private sendSessionMessageStrict(message: SessionInboundMessage): void { if (!this.transport || this.connectionState.status !== "connected") { - throw new Error("Transport not connected"); + throw new DaemonConnectionError("Transport not connected"); } const payload = SessionInboundMessageSchema.parse(message); try { @@ -3467,7 +3482,11 @@ export class DaemonClient { return payload.notice ?? null; } - async restartServer(reason?: string, requestId?: string): Promise { + async restartServer( + reason?: string, + requestId?: string, + options?: { timeout?: number }, + ): Promise { const resolvedRequestId = this.createRequestId(requestId); const message = SessionInboundMessageSchema.parse({ type: "restart_server_request", @@ -3477,6 +3496,7 @@ export class DaemonClient { return this.sendRequest({ requestId: resolvedRequestId, message, + timeout: options?.timeout, options: { skipQueue: true }, select: (msg) => { if (msg.type !== "status") { @@ -4801,6 +4821,10 @@ export class DaemonClient { } async getDaemonStatus(options?: DaemonStatusOptions): Promise { + if (!this.lastServerInfoMessage) throw new DaemonConnectionError("Transport not connected"); + if (this.lastServerInfoMessage?.features?.daemonStatusRpc !== true) { + throw new Error("Update the host to read daemon status."); + } return this.sendCorrelatedSessionRequest({ requestId: options?.requestId, message: { @@ -6122,9 +6146,9 @@ export class DaemonClient { // Clear all pending waiters and queued sends since the connection was lost // and responses from the previous connection will never arrive. - this.clearWaiters(new Error(reason ?? "Connection lost")); - this.rejectPendingSendQueue(new Error(reason ?? "Connection lost")); - this.rejectPingProbe(new Error(reason ?? "Connection lost")); + this.clearWaiters(new DaemonConnectionError(reason ?? "Connection lost")); + this.rejectPendingSendQueue(new DaemonConnectionError(reason ?? "Connection lost")); + this.rejectPingProbe(new DaemonConnectionError(reason ?? "Connection lost")); this.terminalStreams.clearSlots(); this.lastServerInfoMessage = null; @@ -6387,7 +6411,10 @@ export class DaemonClient { options?: WaitOptions, ): WaitHandle { // Capture stack trace at call site, not inside setTimeout - const timeoutError = new Error(`Timeout waiting for message (${timeout}ms)`); + const timeoutError = new DaemonConnectionError( + `Timeout waiting for message (${timeout}ms)`, + "DAEMON_REQUEST_TIMEOUT", + ); let waiter: Waiter | null = null; let settled = false; diff --git a/packages/desktop/bin/paseo b/packages/desktop/bin/paseo index 05797ae6d1..957280d684 100755 --- a/packages/desktop/bin/paseo +++ b/packages/desktop/bin/paseo @@ -44,6 +44,4 @@ fi RUNNER_PATH="${RESOURCES_DIR}/app.asar.unpacked/dist/daemon/node-entrypoint-runner.js" CLI_ENTRYPOINT="${RESOURCES_DIR}/app.asar/node_modules/@getpaseo/cli/dist/index.js" -# PASEO_DESKTOP_MANAGED marks daemons started through this bundled CLI as -# desktop-managed, so the desktop app restarts them when it upgrades. -exec env ELECTRON_RUN_AS_NODE=1 PASEO_NODE_ENV=production PASEO_DESKTOP_MANAGED=1 PASEO_CLI="${SCRIPT_PATH}" "${APP_EXECUTABLE}" --disable-warning=DEP0040 "${RUNNER_PATH}" node-script "${CLI_ENTRYPOINT}" "$@" +exec env ELECTRON_RUN_AS_NODE=1 PASEO_NODE_ENV=production PASEO_CLI="${SCRIPT_PATH}" "${APP_EXECUTABLE}" --disable-warning=DEP0040 "${RUNNER_PATH}" node-script "${CLI_ENTRYPOINT}" "$@" diff --git a/packages/desktop/e2e/daemon-lifecycle-renderer.electron.mjs b/packages/desktop/e2e/daemon-lifecycle-renderer.electron.mjs new file mode 100644 index 0000000000..d514481992 --- /dev/null +++ b/packages/desktop/e2e/daemon-lifecycle-renderer.electron.mjs @@ -0,0 +1,164 @@ +import { spawn, execFileSync } from "node:child_process"; +import { once } from "node:events"; +import { writeFile } from "node:fs/promises"; +import path from "node:path"; +import net from "node:net"; +import { _electron as electron, expect } from "playwright/test"; +import { readDaemonInstance } from "@getpaseo/server"; + +// Run under the test's private Xvfb display; native dialog input never targets +// a user's desktop. All daemon operations still cross the real preload/IPC. +export async function verifyAttachedDaemonControls({ repo, root, env, home, port, instance }) { + if (!process.env.DISPLAY) + throw new Error("Renderer lifecycle QA requires a private Xvfb display"); + const listener = net.createServer(); + await new Promise((resolve) => listener.listen(0, "127.0.0.1", resolve)); + const metroPort = listener.address().port; + await new Promise((resolve) => listener.close(resolve)); + const metro = spawn( + process.execPath, + [ + path.join(repo, "node_modules/expo/bin/cli"), + "start", + "--web", + "--port", + String(metroPort), + "--offline", + ], + { + cwd: path.join(repo, "packages/app"), + detached: true, + stdio: "ignore", + env: { + ...env, + EXPO_NO_DOTENV: "1", + CI: "1", + PASEO_WEB_PLATFORM: "electron", + EXPO_PUBLIC_LOCAL_DAEMON: `127.0.0.1:${port}`, + }, + }, + ); + let desktop; + let page; + try { + await expect + .poll( + async () => { + try { + return (await fetch(`http://127.0.0.1:${metroPort}/status`)).ok; + } catch { + return false; + } + }, + { timeout: 60_000 }, + ) + .toBe(true); + desktop = await electron.launch({ + args: [path.join(repo, "packages/desktop/dist/main.js"), "--no-sandbox"], + env: { + ...env, + EXPO_DEV_URL: `http://127.0.0.1:${metroPort}`, + PASEO_DISABLE_SINGLE_INSTANCE_LOCK: "1", + PASEO_ELECTRON_USER_DATA_DIR: path.join(root, "renderer-user-data"), + }, + }); + page = await desktop.firstWindow(); + page.on("pageerror", (error) => console.log("Renderer error:", error.message)); + await page.route(/:(6767|6768)\b/, (route) => route.abort()); + await page.getByRole("button", { name: "Settings", exact: true }).click({ timeout: 90_000 }); + await page.getByRole("button", { name: "Enable built-in daemon", exact: true }).click(); + await expect(page.getByText("Attached to an existing daemon", { exact: true })).toBeVisible(); + await expect( + page.getByText("Removes localhost from this device and stops the built-in daemon", { + exact: true, + }), + ).toBeHidden(); + await page + .getByText("Attached to an existing daemon", { exact: true }) + .scrollIntoViewIfNeeded(); + await page.screenshot({ path: path.join(root, "attached-daemon.png") }); + + async function respondToNativeDialog(title, artifact, key) { + let windowId; + await expect + .poll( + () => { + try { + windowId = execFileSync("xdotool", ["search", "--onlyvisible", "--name", title], { + encoding: "utf8", + }) + .trim() + .split("\n") + .at(-1); + return Boolean(windowId); + } catch { + return false; + } + }, + { timeout: 10_000 }, + ) + .toBe(true); + const screenshot = await desktop.evaluate(async ({ desktopCapturer }) => { + const sources = await desktopCapturer.getSources({ + types: ["screen"], + thumbnailSize: { width: 1280, height: 800 }, + }); + return sources[0].thumbnail.toPNG().toString("base64"); + }); + await writeFile(path.join(root, artifact), Buffer.from(screenshot, "base64")); + // Escape destroys the dialog on key-down. Send through the private display + // focus so key-up does not target an already-destroyed X11 window. + execFileSync("xdotool", ["windowfocus", "--sync", windowId, "key", key]); + } + + const management = page.getByRole("switch", { name: "Manage built-in daemon" }); + await management.click(); + await respondToNativeDialog( + "Pause built-in daemon", + "pause-attached-confirmation.png", + "Return", + ); + await expect(management).not.toBeChecked(); + expect((await readDaemonInstance(home)).pid).toBe(instance.pid); + + await page.getByRole("button", { name: "Stop daemon", exact: true }).click(); + await respondToNativeDialog("Stop local daemon", "stop-attached-cancelled.png", "Escape"); + await expect(page.getByRole("button", { name: "Stop daemon", exact: true })).toBeEnabled(); + expect(await readDaemonInstance(home)).toMatchObject({ + pid: instance.pid, + startedAt: instance.startedAt, + }); + + await page.getByRole("button", { name: "Stop daemon", exact: true }).click(); + await respondToNativeDialog("Stop local daemon", "stop-attached-confirmation.png", "Return"); + await expect.poll(() => readDaemonInstance(home), { timeout: 20_000 }).toBeNull(); + await expect(page.getByRole("button", { name: "Stop daemon", exact: true })).toBeHidden(); + await page.screenshot({ path: path.join(root, "attached-daemon-stopped.png") }); + console.log( + "PASS: real Desktop renderer pauses management without stopping an attached daemon, then confirms and stops that instance.", + ); + } catch (error) { + if (page) { + console.log( + "Renderer state:", + await page + .locator("body") + .innerText() + .catch(() => "closed"), + ); + await page.screenshot({ path: path.join(root, "renderer-failure.png") }).catch(() => {}); + } + throw error; + } finally { + if (desktop) { + const exited = once(desktop.process(), "exit"); + await desktop.evaluate(({ app }) => app.exit(0)).catch(() => {}); + await exited; + } + if (metro.exitCode === null) { + const exited = once(metro, "exit"); + process.kill(-metro.pid, "SIGTERM"); + await exited; + } + } +} diff --git a/packages/desktop/e2e/daemon-lifecycle.e2e.mjs b/packages/desktop/e2e/daemon-lifecycle.e2e.mjs new file mode 100644 index 0000000000..2ef29e1589 --- /dev/null +++ b/packages/desktop/e2e/daemon-lifecycle.e2e.mjs @@ -0,0 +1,212 @@ +import { verifyAttachedDaemonControls } from "./daemon-lifecycle-renderer.electron.mjs"; +import { once } from "node:events"; +import assert from "node:assert/strict"; +import { mkdtemp, mkdir, writeFile, readFile, rm, readdir, copyFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import net from "node:net"; +import { fileURLToPath, pathToFileURL } from "node:url"; +import { _electron as electron, expect } from "playwright/test"; +import { + savePersistedConfig, + startDaemonInstance, + stopDaemonInstance, + readDaemonInstance, +} from "@getpaseo/server"; + +const repo = fileURLToPath(new URL("../../..", import.meta.url)); +const root = await mkdtemp(path.join(tmpdir(), "paseo desktop lifecycle ")); +const home = path.join(root, "daemon"); +const env = Object.fromEntries( + Object.entries(process.env).filter( + ([key]) => !key.startsWith("PASEO_") && key !== "ELECTRON_RUN_AS_NODE", + ), +); +env.HOME = root; +env.USERPROFILE = root; +env.PASEO_HOME = home; +const server = net.createServer(); +await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve)); +const port = server.address().port; +await new Promise((resolve) => server.close(resolve)); +assert.ok(port !== 6767 && port !== 6768); +await mkdir(path.join(root, "web")); +await writeFile(path.join(root, "web/index.html"), "Lifecycle web UI"); +savePersistedConfig(home, { + daemon: { listen: `127.0.0.1:${port}`, relay: { enabled: false } }, + features: { + dictation: { enabled: false }, + voiceMode: { enabled: false }, + webUi: { enabled: true, distDir: path.join(root, "web") }, + }, +}); +const main = path.join(root, "main.cjs"); +const managerPath = path.join(repo, "packages/desktop/dist/daemon/daemon-manager.js"); +await writeFile( + main, + `const { app } = require("electron"); app.setPath("userData", ${JSON.stringify(path.join(root, "user-data"))}); app.whenReady().then(() => { global.lifecycle = require(${JSON.stringify(managerPath)}); });`, +); +let desktop; +let captured; +async function openDesktop() { + desktop = await electron.launch({ + args: ["--no-sandbox", "--ozone-platform=headless", main], + env: { + ...env, + PASEO_LISTEN: "127.0.0.1:1", + PASEO_WEB_UI_ENABLED: "false", + PASEO_HOST: "unused:1", + }, + }); + await expect + .poll(() => desktop.evaluate(() => Boolean(global.lifecycle)), { timeout: 10_000 }) + .toBe(true); +} +async function closeDesktop() { + if (!desktop) return; + const child = desktop.process(); + const exited = once(child, "exit"); + await desktop.evaluate(({ app }) => app.exit(0)).catch(() => {}); + await exited; + desktop = null; +} +async function command(name, args) { + console.log(`Desktop command: ${name}${args?.reason ? ` (${args.reason})` : ""}`); + return desktop.evaluate( + async (_, input) => global.lifecycle.createDaemonCommandHandlers()[input.name](input.args), + { name, args }, + ); +} +try { + // Simulate a legacy independent launch that carries the old Desktop flag. + const launch = await startDaemonInstance({ + home, + command: process.execPath, + args: [path.join(repo, "packages/server/dist/scripts/supervisor-entrypoint.js")], + env, + mode: "deployment", + desktopManaged: true, + timeoutMs: 30_000, + }); + captured = launch.instance; + await openDesktop(); + const attached = await command("start_desktop_daemon"); + assert.equal(attached.pid, captured.pid); + assert.equal(attached.desktopManaged, true); + assert.equal(attached.ownedByDesktop, false); + for (const reason of ["quit", "settings", "app_update", "host_remove", "version_mismatch"]) { + const status = await command("stop_desktop_daemon", { reason }); + assert.equal(status.pid, captured.pid, `${reason} stopped an attached daemon`); + process.kill(captured.pid, 0); + } + const restarted = await command("restart_desktop_daemon"); + assert.equal(restarted.pid, captured.pid); + await assert.rejects( + command("stop_desktop_daemon", { + reason: "manual_ipc", + pid: captured.pid, + startedAt: "outdated-confirmation", + }), + /changed since confirmation/, + ); + const stopped = await command("stop_desktop_daemon", { + reason: "manual_ipc", + pid: captured.pid, + startedAt: captured.startedAt, + }); + assert.equal(stopped.status, "stopped"); + captured = null; + + const owned = await command("start_desktop_daemon"); + captured = await readDaemonInstance(home); + assert.equal(owned.pid, captured.pid); + assert.equal(owned.ownedByDesktop, true); + assert.equal(owned.listen, `127.0.0.1:${port}`); + assert.equal( + JSON.parse(await readFile(path.join(home, "config.json"), "utf8")).features.webUi.enabled, + true, + ); + const liveWebUi = await fetch(`http://127.0.0.1:${port}/`); + assert.equal(liveWebUi.status, 200); + assert.match(await liveWebUi.text(), /Lifecycle web UI/); + await command("patch_desktop_settings", { daemon: { keepRunningAfterQuit: true } }); + await closeDesktop(); + desktop = null; + process.kill(captured.pid, 0); + await openDesktop(); + assert.equal((await command("start_desktop_daemon")).ownedByDesktop, false); + await command("stop_desktop_daemon", { reason: "quit" }); + process.kill(captured.pid, 0); + await command("stop_desktop_daemon", { + reason: "manual_ipc", + pid: captured.pid, + startedAt: captured.startedAt, + }); + captured = null; + const next = await command("start_desktop_daemon"); + captured = await readDaemonInstance(home); + assert.equal(next.ownedByDesktop, true); + await command("stop_desktop_daemon", { reason: "quit" }); + assert.equal(await readDaemonInstance(home), null); + captured = null; + await closeDesktop(); + if (process.platform === "linux") { + const rendererLaunch = await startDaemonInstance({ + home, + command: process.execPath, + args: [path.join(repo, "packages/server/dist/scripts/supervisor-entrypoint.js")], + env, + mode: "deployment", + desktopManaged: true, + timeoutMs: 30_000, + }); + captured = rendererLaunch.instance; + await verifyAttachedDaemonControls({ repo, root, env, home, port, instance: captured }); + captured = null; + } + if (process.platform !== "win32") { + const preload = path.join(root, "slow-worker.mjs"); + await writeFile( + preload, + 'if (process.argv[1]?.endsWith("daemon-worker.js")) await new Promise(resolve => setTimeout(resolve, 45000));', + ); + await openDesktop(); + // Playwright removes NODE_OPTIONS from electron.launch's environment. + // Restore this runtime input inside the isolated fixture before it launches the daemon. + await desktop.evaluate( + (_, value) => { + process.env.NODE_OPTIONS = value; + }, + `--import=${pathToFileURL(preload).href}`, + ); + const began = Date.now(); + const starting = await command("start_desktop_daemon"); + captured = await readDaemonInstance(home); + assert.equal(starting.status, "starting"); + assert.equal(starting.ownedByDesktop, true); + assert.equal(starting.pid, captured.pid); + assert.ok(Date.now() - began < 40_000, "Desktop did not return its bounded starting state"); + process.kill(captured.pid, 0); + await command("stop_desktop_daemon", { reason: "quit" }); + captured = null; + await closeDesktop(); + console.log( + "PASS: Desktop readiness deadline returns starting and preserves its owned slow launch.", + ); + } + console.log( + "PASS: attached legacy daemon survives automatic actions; explicit captured stop, owned launch, managed settings, worker restart, and next-session attachment.", + ); +} finally { + await closeDesktop(); + if (captured) + await stopDaemonInstance(home, { instance: captured, force: true, timeoutMs: 2_000 }); + const artifacts = + process.env.PASEO_DESKTOP_LIFECYCLE_ARTIFACT_DIR ?? + (await mkdtemp(path.join(tmpdir(), "paseo-desktop-lifecycle-artifacts-"))); + await mkdir(artifacts, { recursive: true }); + for (const name of await readdir(root)) + if (name.endsWith(".png")) await copyFile(path.join(root, name), path.join(artifacts, name)); + await rm(root, { recursive: true, force: true }); + console.log(`Lifecycle artifacts: ${artifacts}`); +} diff --git a/packages/desktop/e2e/packaged-app-smoke.js b/packages/desktop/e2e/packaged-app-smoke.js index 6a77538952..8b121a1e7f 100644 --- a/packages/desktop/e2e/packaged-app-smoke.js +++ b/packages/desktop/e2e/packaged-app-smoke.js @@ -158,19 +158,15 @@ function getShellCommand(script) { } function createDefaultDaemonEnv(extraEnv) { - const env = { - ...process.env, + return { + ...Object.fromEntries(Object.entries(process.env).filter(([key]) => !key.startsWith("PASEO_"))), ...extraEnv, }; - - delete env.PASEO_HOME; - delete env.PASEO_LISTEN; - return env; } function createIsolatedDesktopEnv({ home, listen, userData, cdpPort }) { return { - ...process.env, + ...createDefaultDaemonEnv({ HOME: home, USERPROFILE: home }), PASEO_HOME: home, PASEO_LISTEN: listen, PASEO_ELECTRON_USER_DATA_DIR: userData, @@ -657,24 +653,15 @@ async function smokeColdCliDaemonStart({ appPath }) { const pidPath = path.join(home, "paseo.pid"); const port = await reserveLocalTcpPort(); const listen = `127.0.0.1:${port}`; - const env = createDefaultDaemonEnv(); + const env = createDefaultDaemonEnv({ HOME: home, USERPROFILE: home }); + configureIsolatedDaemonHome(home, listen); try { console.log("Packaged desktop smoke: cold-starting daemon through bundled CLI shim"); await runCliShimCommand({ appPath, env, - args: [ - "daemon", - "start", - "--home", - home, - "--listen", - listen, - "--no-relay", - "--no-mcp", - "--no-inject-mcp", - ], + args: ["daemon", "start", "--home", home], label: "Bundled CLI shim cold daemon start", }); diff --git a/packages/desktop/e2e/support/runtime.ts b/packages/desktop/e2e/support/runtime.ts index 14b770b2fd..9032e5c56e 100644 --- a/packages/desktop/e2e/support/runtime.ts +++ b/packages/desktop/e2e/support/runtime.ts @@ -62,6 +62,9 @@ export interface DesktopRuntimeConfig { slowInstall?: boolean; /** Initial PID reported by desktop_daemon_status. Defaults to null. */ daemonPid?: number | null; + daemonHome?: string; + /** Current-session ownership, independent of the legacy management flag. */ + ownedByDesktop?: boolean; daemonVersion?: string | null; daemonLogPath?: string; /** Initial manageBuiltInDaemon setting. Defaults to false. */ @@ -139,7 +142,7 @@ export async function installDesktopRuntime( let manageDaemon = cfg.manageBuiltInDaemon ?? false; let daemonRunning = true; let currentPid: number | null = cfg.daemonPid ?? null; - let startCount = 0; + let ownedByDesktop = cfg.ownedByDesktop ?? false; let manualUpdateAdmitted = false; window.__desktopDaemonStartRequested = false; @@ -150,7 +153,9 @@ export async function installDesktopRuntime( listen: cfg.daemonListen ?? "127.0.0.1:6767", hostname: null, pid: currentPid, - home: "", + home: cfg.daemonHome ?? "", + startedAt: currentPid === null ? null : "2026-09-01T00:00:00.000Z", + ownedByDesktop, version: cfg.daemonVersion ?? null, desktopManaged: manageDaemon, error: null, @@ -162,11 +167,11 @@ export async function installDesktopRuntime( if (cfg.hangDaemonStart) { return new Promise(() => undefined); } - startCount += 1; + if (!daemonRunning) { + currentPid = (cfg.daemonPid ?? 10000) + 1000; + ownedByDesktop = true; + } daemonRunning = true; - // First start (bootstrap) returns the configured PID; subsequent starts - // (after a stop) get a fresh PID so tests can observe the change. - currentPid = (cfg.daemonPid ?? 10000) + (startCount - 1) * 1000; return buildDaemonStatus(); } @@ -268,6 +273,7 @@ export async function installDesktopRuntime( } if (command === "stop_desktop_daemon") { + ownedByDesktop = false; daemonRunning = false; currentPid = null; return buildDaemonStatus(); @@ -396,6 +402,12 @@ export async function interceptDaemonManagementConfirmDialog( return page.evaluate(() => window.__capturedDialogCall!); } +export async function interceptDaemonStopConfirmDialog(page: Page): Promise { + await page.getByRole("button", { name: "Stop daemon", exact: true }).click(); + await page.waitForFunction(() => !!window.__capturedDialogCall); + return page.evaluate(() => window.__capturedDialogCall!); +} + export async function toggleDaemonManagement( page: Page, _action: "enable" | "disable", @@ -405,7 +417,9 @@ export async function toggleDaemonManagement( export function expectDaemonManagementConfirmDialog(args: ConfirmDialogCall): void { expect(args.title).toBe("Pause built-in daemon"); - expect(args.message).toContain("stop the built-in daemon immediately"); + expect(args.message).toBe( + "This will stop the built-in daemon immediately. Running agents and terminals connected to the built-in daemon will be stopped.", + ); } export async function expectDaemonManagementEnabled(page: Page): Promise { diff --git a/packages/desktop/e2e/updates.spec.ts b/packages/desktop/e2e/updates.spec.ts index 9a2153163e..361002edac 100644 --- a/packages/desktop/e2e/updates.spec.ts +++ b/packages/desktop/e2e/updates.spec.ts @@ -18,6 +18,7 @@ import { clickInstallUpdate, expectInstallInProgress, interceptDaemonManagementConfirmDialog, + interceptDaemonStopConfirmDialog, toggleDaemonManagement, expectDaemonManagementConfirmDialog, expectDaemonManagementEnabled, @@ -27,8 +28,8 @@ import { expectDaemonStatusVersion, } from "./support/runtime"; -// No Playwright Electron runner exists; we simulate the desktop bridge via -// addInitScript so Electron-gated UI activates without a real Electron process. +// These renderer cases use the Desktop bridge fixture. Actual Electron ownership +// and native confirmation journeys live in daemon-lifecycle.e2e.mjs. test.describe("Desktop updates", () => { test("a desktop-managed daemon explains why its update action is disabled", async ({ page, @@ -111,6 +112,7 @@ test.describe("Desktop daemon management", () => { await installDesktopRuntime(page, { serverId, manageBuiltInDaemon: true, + ownedByDesktop: true, confirmShouldAccept: false, }); await gotoAppShell(page); @@ -127,6 +129,7 @@ test.describe("Desktop daemon management", () => { await installDesktopRuntime(page, { serverId, manageBuiltInDaemon: true, + ownedByDesktop: true, confirmShouldAccept: false, }); await gotoAppShell(page); @@ -142,6 +145,7 @@ test.describe("Desktop daemon management", () => { await installDesktopRuntime(page, { serverId, manageBuiltInDaemon: true, + ownedByDesktop: true, confirmShouldAccept: true, }); await gotoAppShell(page); @@ -178,6 +182,7 @@ test.describe("Desktop daemon management", () => { await installDesktopRuntime(page, { serverId, manageBuiltInDaemon: true, + ownedByDesktop: true, daemonPid: realState.pid, daemonVersion: realState.version, daemonLogPath: realState.logPath, @@ -197,4 +202,67 @@ test.describe("Desktop daemon management", () => { const newPid = realState.pid !== null ? realState.pid + 1000 : 11000; await expectDaemonStatusPid(page, newPid); }); + + test("pausing management preserves an attached legacy desktop-managed daemon", async ({ + page, + }) => { + const serverId = getServerId(); + const realState = await loadRealDaemonState(); + await installDesktopRuntime(page, { + serverId, + manageBuiltInDaemon: true, + ownedByDesktop: false, + daemonPid: realState.pid, + confirmShouldAccept: true, + }); + await gotoAppShell(page); + await openDesktopSettings(page, serverId); + + const dialog = await interceptDaemonManagementConfirmDialog(page); + expect(dialog).toEqual({ + title: "Pause built-in daemon", + message: "Pause automatic daemon management? The attached daemon will keep running.", + }); + await expectDaemonManagementDisabled(page); + await expectDaemonStatusPid(page, realState.pid); + + await toggleDaemonManagement(page, "enable"); + await expectDaemonManagementEnabled(page); + await expectDaemonStatusPid(page, realState.pid); + }); + + for (const ownedByDesktop of [false, true]) { + for (const confirmShouldAccept of [false, true]) { + test(`${confirmShouldAccept ? "confirming" : "cancelling"} Stop identifies the ${ownedByDesktop ? "owned" : "attached"} daemon`, async ({ + page, + }) => { + const serverId = getServerId(); + const realState = await loadRealDaemonState(); + const daemonHome = process.env.E2E_PASEO_HOME!; + await installDesktopRuntime(page, { + serverId, + daemonPid: realState.pid, + daemonHome, + ownedByDesktop, + confirmShouldAccept, + }); + await gotoAppShell(page); + await openDesktopSettings(page, serverId); + + const dialog = await interceptDaemonStopConfirmDialog(page); + expect(dialog).toEqual({ + title: "Stop local daemon?", + message: [ + ownedByDesktop + ? "This daemon was launched by this Desktop session." + : "This daemon was not launched by this Desktop session.", + `Home: ${daemonHome}`, + `Supervisor PID: ${realState.pid}`, + "Running agent work will be interrupted.", + ].join("\n"), + }); + await expectDaemonStatusPid(page, confirmShouldAccept ? null : realState.pid); + }); + } + } }); diff --git a/packages/desktop/package.json b/packages/desktop/package.json index cae4c2b777..1346430760 100644 --- a/packages/desktop/package.json +++ b/packages/desktop/package.json @@ -24,7 +24,8 @@ "test:e2e:browser-tabs": "npm run build:main && node ./e2e/browser-tabs.e2e.mjs", "verify:electron-cdp": "node ./scripts/verify-electron-cdp.mjs", "test": "vitest run --exclude \"e2e/**\"", - "typecheck": "tsgo --noEmit -p tsconfig.json" + "typecheck": "tsgo --noEmit -p tsconfig.json", + "test:e2e:lifecycle": "npm run build:main && node ./e2e/daemon-lifecycle.e2e.mjs" }, "dependencies": { "@getpaseo/cli": "*", diff --git a/packages/desktop/src/daemon/daemon-manager.test.ts b/packages/desktop/src/daemon/daemon-manager.test.ts index 8ccd2d1062..1bd3beed33 100644 --- a/packages/desktop/src/daemon/daemon-manager.test.ts +++ b/packages/desktop/src/daemon/daemon-manager.test.ts @@ -1,13 +1,13 @@ -import { EventEmitter } from "node:events"; -import { mkdirSync, rmSync, writeFileSync } from "node:fs"; +import { mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import path from "node:path"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { DEFAULT_DESKTOP_SETTINGS } from "../settings/desktop-settings"; -import { getBundledCliShimPath } from "../integrations/cli-install"; import { createDaemonCommandHandlers } from "./daemon-manager"; const mocks = vi.hoisted(() => ({ - paseoHome: "/tmp/paseo-desktop-daemon-manager-test-home", + paseoHome: "", settings: { releaseChannel: "stable", daemon: { @@ -25,13 +25,13 @@ const mocks = vi.hoisted(() => ({ spawnProcess: vi.fn(), logInfo: vi.fn(), logError: vi.fn(), - appLogPath: "/tmp/paseo-desktop-daemon-manager-test-main.log", + appLogPath: "", getElectronLogFile: vi.fn(), })); vi.mock("electron", () => ({ app: { - getPath: vi.fn(() => "/tmp/paseo-user-data"), + getPath: vi.fn(() => mocks.paseoHome), getVersion: vi.fn(() => "1.2.3"), isPackaged: true, }, @@ -67,7 +67,7 @@ vi.mock("../settings/desktop-settings-electron.js", () => ({ vi.mock("./runtime-paths.js", () => ({ createNodeEntrypointInvocation: mocks.createNodeEntrypointInvocation, resolveDaemonRunnerEntrypoint: vi.fn(() => ({ - entryPath: "/tmp/daemon.js", + entryPath: path.join(mocks.paseoHome, "daemon.js"), execArgv: [], })), })); @@ -77,40 +77,13 @@ vi.mock("./cli/external.js", () => ({ runExternalCliTextCommand: mocks.runExternalCliTextCommand, })); -function desktopSettingsWithManagement(enabled: boolean) { - return { - ...DEFAULT_DESKTOP_SETTINGS, - daemon: { - ...DEFAULT_DESKTOP_SETTINGS.daemon, - manageBuiltInDaemon: enabled, - }, - }; -} - -type MockChildProcess = EventEmitter & { - pid: number; - spawnfile: string; - spawnargs: string[]; - unref: ReturnType; -}; - -function createMockChildProcess(): MockChildProcess { - const child = new EventEmitter() as MockChildProcess; - child.pid = 1234; - child.spawnfile = "node"; - child.spawnargs = ["node", "daemon.js"]; - child.unref = vi.fn(); - return child; -} - -function scheduleFailedStartup(child: MockChildProcess): void { - setImmediate(() => { - child.emit("exit", 1, null); - }); -} - describe("daemon-manager commands", () => { + let fixtureRoot: string; + beforeEach(() => { + fixtureRoot = mkdtempSync(path.join(tmpdir(), "paseo daemon manager ")); + mocks.paseoHome = path.join(fixtureRoot, "home"); + mocks.appLogPath = path.join(fixtureRoot, "main.log"); mocks.settings = DEFAULT_DESKTOP_SETTINGS; mocks.runExternalCliJsonCommand.mockReset(); mocks.runExternalCliTextCommand.mockReset(); @@ -121,384 +94,10 @@ describe("daemon-manager commands", () => { mocks.logError.mockReset(); mocks.getElectronLogFile.mockReset(); mocks.getElectronLogFile.mockReturnValue({ path: mocks.appLogPath }); - rmSync(mocks.paseoHome, { recursive: true, force: true }); - rmSync(mocks.appLogPath, { force: true }); }); afterEach(() => { - rmSync(mocks.paseoHome, { recursive: true, force: true }); - rmSync(mocks.appLogPath, { force: true }); - }); - - it("refuses start and restart while built-in daemon management is disabled", async () => { - mocks.settings = desktopSettingsWithManagement(false); - const handlers = createDaemonCommandHandlers(); - - await expect(handlers.start_desktop_daemon()).rejects.toThrow( - "Built-in daemon management is disabled.", - ); - await expect(handlers.restart_desktop_daemon()).rejects.toThrow( - "Built-in daemon management is disabled.", - ); - - expect(mocks.runExternalCliJsonCommand).not.toHaveBeenCalled(); - expect(mocks.spawnProcess).not.toHaveBeenCalled(); - }); - - it("keeps stop callable while built-in daemon management is disabled", async () => { - mocks.settings = desktopSettingsWithManagement(false); - mocks.runExternalCliJsonCommand.mockResolvedValue({ - localDaemon: "stopped", - serverId: "", - }); - const handlers = createDaemonCommandHandlers(); - - await expect(handlers.stop_desktop_daemon()).resolves.toEqual({ - serverId: "", - status: "stopped", - listen: null, - hostname: null, - pid: null, - home: mocks.paseoHome, - version: null, - desktopManaged: false, - error: null, - }); - - expect(mocks.runExternalCliJsonCommand).toHaveBeenCalledWith(["daemon", "status", "--json"]); - }); - - it("routes running desktop daemon stops through external CLI daemon stop", async () => { - mocks.runExternalCliJsonCommand - .mockResolvedValueOnce({ - localDaemon: "running", - serverId: "server-1", - pid: 4242, - listen: "127.0.0.1:6767", - desktopManaged: true, - }) - .mockResolvedValueOnce({ action: "stopped" }) - .mockResolvedValueOnce({ - localDaemon: "stopped", - serverId: "", - }); - const handlers = createDaemonCommandHandlers(); - - await expect(handlers.stop_desktop_daemon()).resolves.toEqual({ - serverId: "", - status: "stopped", - listen: null, - hostname: null, - pid: null, - home: mocks.paseoHome, - version: null, - desktopManaged: false, - error: null, - }); - - expect(mocks.runExternalCliJsonCommand).toHaveBeenNthCalledWith(1, [ - "daemon", - "status", - "--json", - ]); - expect(mocks.runExternalCliJsonCommand).toHaveBeenNthCalledWith(2, [ - "daemon", - "stop", - "--json", - "--timeout", - "5", - "--force", - "--kill-timeout", - "5", - ]); - expect(mocks.runExternalCliJsonCommand).toHaveBeenNthCalledWith(3, [ - "daemon", - "status", - "--json", - ]); - expect(mocks.logInfo).toHaveBeenCalledWith( - "[desktop daemon]", - "desktop daemon stop requested", - expect.objectContaining({ - reason: "manual_ipc", - statusBefore: expect.objectContaining({ - status: "running", - pid: 4242, - serverId: "server-1", - desktopManaged: true, - }), - }), - ); - expect(mocks.logInfo).toHaveBeenCalledWith( - "[desktop daemon]", - "desktop daemon stop completed", - expect.objectContaining({ - reason: "manual_ipc", - cliResult: { action: "stopped" }, - statusAfter: expect.objectContaining({ - status: "stopped", - serverId: null, - }), - }), - ); - }); - - it("routes stale reachable desktop daemon stops through external CLI daemon stop", async () => { - mocks.runExternalCliJsonCommand - .mockResolvedValueOnce({ - localDaemon: "stale_pid", - connectedDaemon: "reachable", - serverId: "server-1", - pid: 7675, - listen: "127.0.0.1:6767", - daemonVersion: "1.2.2", - desktopManaged: true, - }) - .mockResolvedValueOnce({ action: "stopped" }) - .mockResolvedValueOnce({ - localDaemon: "stopped", - connectedDaemon: "unreachable", - serverId: "", - }); - const handlers = createDaemonCommandHandlers(); - - await expect(handlers.stop_desktop_daemon()).resolves.toEqual({ - serverId: "", - status: "stopped", - listen: null, - hostname: null, - pid: null, - home: mocks.paseoHome, - version: null, - desktopManaged: false, - error: null, - }); - - expect(mocks.runExternalCliJsonCommand).toHaveBeenNthCalledWith(2, [ - "daemon", - "stop", - "--json", - "--timeout", - "5", - "--force", - "--kill-timeout", - "5", - ]); - }); - - it("records the renderer stop reason when stopping the desktop daemon", async () => { - mocks.runExternalCliJsonCommand - .mockResolvedValueOnce({ - localDaemon: "running", - serverId: "server-1", - pid: 4242, - listen: "127.0.0.1:6767", - desktopManaged: true, - }) - .mockResolvedValueOnce({ action: "stopped", reason: "lifecycle_shutdown_rpc" }) - .mockResolvedValueOnce({ - localDaemon: "stopped", - serverId: "", - }); - const handlers = createDaemonCommandHandlers(); - - await handlers.stop_desktop_daemon({ reason: "host_remove" }); - - expect(mocks.logInfo).toHaveBeenCalledWith( - "[desktop daemon]", - "desktop daemon stop requested", - expect.objectContaining({ reason: "host_remove" }), - ); - expect(mocks.logInfo).toHaveBeenCalledWith( - "[desktop daemon]", - "desktop daemon stop completed", - expect.objectContaining({ - reason: "host_remove", - cliResult: { action: "stopped", reason: "lifecycle_shutdown_rpc" }, - }), - ); - }); - - it("uses a stale reachable desktop daemon when the version matches", async () => { - mocks.runExternalCliJsonCommand.mockResolvedValue({ - localDaemon: "stale_pid", - connectedDaemon: "reachable", - serverId: "server-1", - pid: 7675, - listen: "127.0.0.1:6767", - hostname: "dev-host", - daemonVersion: "1.2.3", - desktopManaged: true, - }); - const handlers = createDaemonCommandHandlers(); - - await expect(handlers.start_desktop_daemon()).resolves.toEqual({ - serverId: "server-1", - status: "running", - listen: "127.0.0.1:6767", - hostname: "dev-host", - pid: null, - home: mocks.paseoHome, - version: "1.2.3", - desktopManaged: true, - error: null, - }); - - expect(mocks.spawnProcess).not.toHaveBeenCalled(); - }); - - it("restarts a stale reachable desktop daemon when the version differs", async () => { - mocks.runExternalCliJsonCommand - .mockResolvedValueOnce({ - localDaemon: "stale_pid", - connectedDaemon: "reachable", - serverId: "server-1", - pid: 7675, - listen: "127.0.0.1:6767", - hostname: "dev-host", - daemonVersion: "1.2.2", - desktopManaged: true, - }) - .mockResolvedValueOnce({ - localDaemon: "stale_pid", - connectedDaemon: "reachable", - serverId: "server-1", - pid: 7675, - listen: "127.0.0.1:6767", - daemonVersion: "1.2.2", - desktopManaged: true, - }) - .mockResolvedValueOnce({ action: "stopped" }) - .mockResolvedValueOnce({ - localDaemon: "stopped", - connectedDaemon: "unreachable", - serverId: "", - }) - .mockResolvedValueOnce({ - localDaemon: "running", - connectedDaemon: "reachable", - serverId: "server-2", - pid: 8888, - listen: "127.0.0.1:6767", - hostname: "dev-host", - daemonVersion: "1.2.3", - desktopManaged: true, - }); - mocks.spawnProcess.mockReturnValue(createMockChildProcess()); - const handlers = createDaemonCommandHandlers(); - - await expect(handlers.start_desktop_daemon()).resolves.toEqual({ - serverId: "server-2", - status: "running", - listen: "127.0.0.1:6767", - hostname: "dev-host", - pid: 8888, - home: mocks.paseoHome, - version: "1.2.3", - desktopManaged: true, - error: null, - }); - - expect(mocks.runExternalCliJsonCommand).toHaveBeenNthCalledWith(3, [ - "daemon", - "stop", - "--json", - "--timeout", - "5", - "--force", - "--kill-timeout", - "5", - ]); - expect(mocks.spawnProcess).toHaveBeenCalled(); - }); - - it("starts the managed daemon detached from desktop stdio and reports daemon log failures", async () => { - mkdirSync(mocks.paseoHome, { recursive: true }); - writeFileSync( - `${mocks.paseoHome}/daemon.log`, - ["old log line", "recent daemon failure"].join("\n"), - ); - mocks.runExternalCliJsonCommand.mockResolvedValue({ - localDaemon: "stopped", - connectedDaemon: "unreachable", - serverId: "", - }); - mocks.spawnProcess.mockImplementation(() => { - const child = createMockChildProcess(); - scheduleFailedStartup(child); - return child; - }); - const handlers = createDaemonCommandHandlers(); - - let thrown: Error | null = null; - try { - await handlers.start_desktop_daemon(); - } catch (error) { - thrown = error instanceof Error ? error : new Error(String(error)); - } - - expect(thrown).toBeInstanceOf(Error); - const message = thrown?.message ?? ""; - const recentLogsLabel = message.match(/Recent logs \(([^)]*)\):/)?.[1]; - expect(message).toContain("Daemon failed to start: exit code 1"); - expect(recentLogsLabel?.split(/[\\/]/).at(-1)).toBe("daemon.log"); - expect(message).toContain("recent daemon failure"); - expect(mocks.createNodeEntrypointInvocation).toHaveBeenCalledWith( - expect.objectContaining({ args: [] }), - ); - expect(mocks.spawnProcess).toHaveBeenCalledWith( - "node", - [], - expect.objectContaining({ - detached: true, - stdio: ["ignore", "ignore", "ignore"], - envOverlay: expect.objectContaining({ - PASEO_CLI: getBundledCliShimPath(), - PASEO_WEB_UI_ENABLED: "false", - }), - }), - ); - }); - - it("passes stale lock reclaim only after a live desktop daemon is confirmed unresponsive", async () => { - mocks.runExternalCliJsonCommand.mockResolvedValue({ - localDaemon: "unresponsive", - connectedDaemon: "unreachable", - serverId: "", - pid: 7675, - listen: "127.0.0.1:6767", - desktopManaged: true, - }); - mocks.spawnProcess.mockImplementation(() => { - const child = createMockChildProcess(); - scheduleFailedStartup(child); - return child; - }); - - await expect(createDaemonCommandHandlers().start_desktop_daemon()).rejects.toThrow( - "Daemon failed to start: exit code 1", - ); - - expect(mocks.createNodeEntrypointInvocation).toHaveBeenCalledWith( - expect.objectContaining({ args: ["--reclaim-stale-pid-lock"] }), - ); - }); - - it("does not pass stale lock reclaim when the status command fails", async () => { - mocks.runExternalCliJsonCommand.mockRejectedValue(new Error("status command failed")); - mocks.spawnProcess.mockImplementation(() => { - const child = createMockChildProcess(); - scheduleFailedStartup(child); - return child; - }); - - await expect(createDaemonCommandHandlers().start_desktop_daemon()).rejects.toThrow( - "Daemon failed to start: exit code 1", - ); - - expect(mocks.createNodeEntrypointInvocation).toHaveBeenCalledWith( - expect.objectContaining({ args: [] }), - ); + rmSync(fixtureRoot, { recursive: true, force: true }); }); it("returns the Electron main-process log tail from electron-log", () => { diff --git a/packages/desktop/src/daemon/daemon-manager.ts b/packages/desktop/src/daemon/daemon-manager.ts index 4d3e5531eb..b2e19798c8 100644 --- a/packages/desktop/src/daemon/daemon-manager.ts +++ b/packages/desktop/src/daemon/daemon-manager.ts @@ -1,9 +1,16 @@ -import { type ChildProcess } from "node:child_process"; import { readFileSync } from "node:fs"; import path from "node:path"; import { app, ipcMain, powerMonitor } from "electron"; import log from "electron-log/main"; -import { resolvePaseoHome, spawnProcess } from "@getpaseo/server"; +import { + resolvePaseoHome, + startDaemonInstance, + DaemonInstanceError, + stopDaemonInstance, + readDaemonInstance, + isSameDaemonInstance, + type DaemonInstance, +} from "@getpaseo/server"; import { copyAttachmentFileToManagedStorage, deleteManagedAttachmentFile, @@ -46,9 +53,7 @@ import { import { tailFile } from "../diagnostics/tail-file.js"; const DAEMON_LOG_FILENAME = "daemon.log"; -const STARTUP_POLL_INTERVAL_MS = 200; -const STARTUP_POLL_MAX_ATTEMPTS = 150; -const DETACHED_STARTUP_GRACE_MS = 1200; +let ownedLaunch: { home: string; instance: DaemonInstance } | null = null; type DesktopDaemonState = "starting" | "running" | "stopped" | "errored"; const DESKTOP_DAEMON_STOP_REASON_VALUES = [ @@ -74,6 +79,8 @@ export interface DesktopDaemonStatus { home: string; version: string | null; desktopManaged: boolean; + ownedByDesktop: boolean; + startedAt: string | null; error: string | null; } @@ -123,73 +130,19 @@ function logFilePath(): string { } export function isDesktopManagedDaemonRunningSync(): boolean { + if (!ownedLaunch) return false; try { - const raw = readFileSync(path.join(getPaseoHome(), "paseo.pid"), "utf-8"); - const lock = JSON.parse(raw) as { pid?: unknown; desktopManaged?: unknown }; - if (lock.desktopManaged !== true) return false; - if (typeof lock.pid !== "number" || !Number.isInteger(lock.pid)) return false; - return isProcessRunning(lock.pid); + const lock = JSON.parse(readFileSync(path.join(ownedLaunch.home, "paseo.pid"), "utf8")); + return isSameDaemonInstance(lock, ownedLaunch.instance) && isProcessRunning(lock.pid); } catch { return false; } } -function summarizeDesktopDaemonStatus(status: DesktopDaemonStatus): Record { - return { - status: status.status, - pid: status.pid, - listen: status.listen, - serverId: status.serverId || null, - version: status.version, - desktopManaged: status.desktopManaged, - error: status.error, - }; -} - -const DESKTOP_DAEMON_STOP_CLI_ARGS = [ - "daemon", - "stop", - "--json", - "--timeout", - "5", - "--force", - "--kill-timeout", - "5", -]; - -async function runDesktopDaemonStopViaCli({ - reason, - statusBefore, - resolveStatusAfter = false, -}: { - reason: DesktopDaemonStopReason; - statusBefore?: DesktopDaemonStatus | null; - resolveStatusAfter?: boolean; -}): Promise<{ - cliResult: unknown; - statusAfter: DesktopDaemonStatus | null; -}> { - logDesktopDaemonLifecycle("desktop daemon stop requested", { - reason, - statusBefore: statusBefore ? summarizeDesktopDaemonStatus(statusBefore) : null, - }); - - const cliResult = await runExternalCliJsonCommand(DESKTOP_DAEMON_STOP_CLI_ARGS); - const statusAfter = resolveStatusAfter ? await resolveDesktopDaemonStatus() : null; - - logDesktopDaemonLifecycle("desktop daemon stop completed", { - reason, - cliResult, - statusAfter: statusAfter ? summarizeDesktopDaemonStatus(statusAfter) : null, - }); - - return { cliResult, statusAfter }; -} - export async function stopDesktopDaemonViaCli( reason: DesktopDaemonStopReason = DEFAULT_DESKTOP_DAEMON_STOP_REASON, ): Promise { - await runDesktopDaemonStopViaCli({ reason }); + await stopDesktopDaemon(reason); } function isProcessRunning(pid: number): boolean { @@ -204,12 +157,6 @@ function isProcessRunning(pid: number): boolean { } } -function sleep(ms: number): Promise { - return new Promise((resolve) => { - setTimeout(resolve, ms); - }); -} - function logDesktopDaemonLifecycle(message: string, details?: Record): void { log.info("[desktop daemon]", message, { pid: process.pid, @@ -222,25 +169,27 @@ function statusFromDaemonProbe( home: string, ): DesktopDaemonStatus { const local = typeof payload.localDaemon === "string" ? payload.localDaemon : "stopped"; - const reachable = payload.connectedDaemon === "reachable"; - const processAlive = local === "running"; - const stalledProcess = local === "unresponsive"; + const processAlive = local === "running" || local === "not_ready"; let status: DesktopDaemonState = "stopped"; - if (reachable || processAlive) { - status = "running"; - } else if (stalledProcess) { - status = "errored"; - } + if (local === "not_ready") status = "starting"; + if (local === "running") status = "running"; return { serverId: typeof payload.serverId === "string" ? payload.serverId : "", status, listen: typeof payload.listen === "string" ? payload.listen : null, hostname: status === "running" && typeof payload.hostname === "string" ? payload.hostname : null, - pid: (processAlive || stalledProcess) && typeof payload.pid === "number" ? payload.pid : null, + pid: processAlive && typeof payload.pid === "number" ? payload.pid : null, home, version: typeof payload.daemonVersion === "string" ? payload.daemonVersion : null, desktopManaged: payload.desktopManaged === true, + startedAt: typeof payload.startedAt === "string" ? payload.startedAt : null, + ownedByDesktop: Boolean( + ownedLaunch && + ownedLaunch.home === home && + payload.pid === ownedLaunch.instance.pid && + payload.startedAt === ownedLaunch.instance.startedAt, + ), error: null, }; } @@ -273,23 +222,28 @@ export async function resolveDesktopDaemonStatus(): Promise const home = getPaseoHome(); try { - const payload = (await runExternalCliJsonCommand(["daemon", "status", "--json"])) as Record< - string, - unknown - >; + const payload = (await runExternalCliJsonCommand([ + "daemon", + "status", + "--home", + home, + "--json", + ])) as Record; return statusFromDaemonProbe(payload, home); } catch (error) { const errorMessage = error instanceof Error ? error.message : String(error); logDesktopDaemonLifecycle("resolveStatus CLI command failed", { error: errorMessage }); return { serverId: "", - status: "stopped", + status: "errored", listen: null, hostname: null, pid: null, home, version: null, desktopManaged: false, + ownedByDesktop: false, + startedAt: null, error: errorMessage, }; } @@ -302,7 +256,7 @@ function normalizeVersion(version: string | null): string | null { } function shouldRestartForVersion(current: DesktopDaemonStatus): boolean { - if (!current.desktopManaged) return false; + if (!current.ownedByDesktop) return false; const appVersion = normalizeVersion(resolveDesktopAppVersion()); const daemonVersion = normalizeVersion(current.version); return Boolean(appVersion && daemonVersion && appVersion !== daemonVersion); @@ -314,40 +268,6 @@ function assertBuiltInDaemonManagementEnabled(settings: DesktopSettings): void { } } -function buildStartupFailureError(result: { - code: number | null; - signal: string | null; - error?: Error; -}): Error { - const reason = result.error - ? result.error.message - : `exit code ${result.code ?? "unknown"}${result.signal ? ` (${result.signal})` : ""}`; - const parts = [`Daemon failed to start: ${reason}`]; - const logs = tailFile(logFilePath(), 15); - if (logs) parts.push(`Recent logs (${logFilePath()}):\n${logs}`); - return new Error(parts.join("\n\n")); -} - -async function pollForRunningDaemon(): Promise { - async function poll(attempt: number): Promise { - if (attempt >= STARTUP_POLL_MAX_ATTEMPTS) return resolveDesktopDaemonStatus(); - const status = await resolveDesktopDaemonStatus(); - if (attempt === 0 || attempt === STARTUP_POLL_MAX_ATTEMPTS - 1 || attempt % 10 === 9) { - logDesktopDaemonLifecycle("polling daemon status after detached start", { - attempt: attempt + 1, - status: status.status, - pid: status.pid, - listen: status.listen, - serverId: status.serverId || null, - }); - } - if (status.status === "running" && status.serverId && status.listen) return status; - await sleep(STARTUP_POLL_INTERVAL_MS); - return poll(attempt + 1); - } - return poll(0); -} - async function startDaemon(): Promise { assertBuiltInDaemonManagementEnabled(await getDesktopSettingsStore().get()); @@ -360,7 +280,7 @@ async function startDaemon(): Promise { error: current.error, desktopManaged: current.desktopManaged, }); - if (current.status === "running") { + if (current.status === "running" || current.status === "starting") { if (shouldRestartForVersion(current)) { logDesktopDaemonLifecycle("daemon version mismatch, restarting", { appVersion: normalizeVersion(resolveDesktopAppVersion()), @@ -372,118 +292,69 @@ async function startDaemon(): Promise { } } - const daemonRunner = resolveDaemonRunnerEntrypoint(); - const reclaimStalePidLock = - current.status === "errored" && current.desktopManaged && current.error === null; + const home = getPaseoHome(); const invocation = createNodeEntrypointInvocation({ - entrypoint: daemonRunner, + entrypoint: resolveDaemonRunnerEntrypoint(), argvMode: "node-script", - args: reclaimStalePidLock ? ["--reclaim-stale-pid-lock"] : [], + args: [], baseEnv: process.env, }); - - logDesktopDaemonLifecycle("starting detached daemon", { - appIsPackaged: app.isPackaged, - daemonRunnerEntry: daemonRunner.entryPath, - daemonRunnerExecArgv: daemonRunner.execArgv, - command: invocation.command, - args: invocation.args, - electronRunAsNode: invocation.env.ELECTRON_RUN_AS_NODE ?? null, - parentExecPath: process.execPath, - parentElectronRunAsNode: process.env.ELECTRON_RUN_AS_NODE ?? null, - electronVersion: process.versions.electron ?? null, - nodeVersion: process.versions.node, - platform: process.platform, - arch: process.arch, - }); - - const child: ChildProcess = spawnProcess(invocation.command, invocation.args, { - detached: true, - envMode: "internal", - env: invocation.env, - envOverlay: { - PASEO_DESKTOP_MANAGED: "1", - PASEO_CLI: getBundledCliShimPath(), - PASEO_WEB_UI_ENABLED: "false", - }, - stdio: ["ignore", "ignore", "ignore"], - }); - - logDesktopDaemonLifecycle("detached spawn returned", { - childPid: child.pid ?? null, - spawnfile: child.spawnfile, - spawnargs: child.spawnargs, - }); - - child.unref(); - - type GraceResult = - | { exitedEarly: false } - | { exitedEarly: true; code: number | null; signal: string | null; error?: Error }; - - const result = await new Promise((resolve) => { - let settled = false; - const finish = (value: GraceResult) => { - if (settled) return; - settled = true; - resolve(value); - }; - - const timer = setTimeout(() => finish({ exitedEarly: false }), DETACHED_STARTUP_GRACE_MS); - - child.once("error", (error) => { - clearTimeout(timer); - finish({ exitedEarly: true, code: null, signal: null, error }); + try { + await startDaemonInstance({ + home, + timeoutMs: 30_000, + ...invocation, + env: { ...invocation.env, PASEO_CLI: getBundledCliShimPath() }, + mode: "managed", + desktopManaged: true, + onAcquired: (instance) => { + ownedLaunch = { home, instance }; + }, }); - child.once("exit", (code, signal) => { - clearTimeout(timer); - finish({ exitedEarly: true, code, signal }); - }); - }); - - logDesktopDaemonLifecycle("detached startup grace period completed", { - childPid: child.pid ?? null, - exitedEarly: result.exitedEarly, - ...(result.exitedEarly - ? { - exitCode: result.code, - signal: result.signal, - error: result.error?.message ?? null, - } - : {}), - }); - - if (result.exitedEarly) { - throw buildStartupFailureError(result); + } catch (error) { + if (!(error instanceof DaemonInstanceError && error.code === "DAEMON_NOT_READY")) throw error; } - - return pollForRunningDaemon(); + return resolveDesktopDaemonStatus(); } export async function stopDesktopDaemon( reason: DesktopDaemonStopReason = DEFAULT_DESKTOP_DAEMON_STOP_REASON, + confirmedInstance?: { pid: number; startedAt: string }, ): Promise { - const status = await resolveDesktopDaemonStatus(); - if (status.status !== "running") { - logDesktopDaemonLifecycle("desktop daemon stop skipped", { - reason, - statusBefore: summarizeDesktopDaemonStatus(status), - }); - return status; - } - - const { statusAfter } = await runDesktopDaemonStopViaCli({ - reason, - statusBefore: status, - resolveStatusAfter: true, + const home = getPaseoHome(); + const instance = await readDaemonInstance(home); + const owned = Boolean( + instance && + ownedLaunch && + ownedLaunch.home === home && + isSameDaemonInstance(instance, ownedLaunch.instance), + ); + const explicit = + reason === "manual_ipc" && + confirmedInstance && + instance && + instance.pid === confirmedInstance.pid && + instance.startedAt === confirmedInstance.startedAt; + if (confirmedInstance && !explicit) + throw new Error( + "Daemon changed since confirmation; inspect its current home and PID before stopping it.", + ); + if (!instance || (!owned && !explicit)) return resolveDesktopDaemonStatus(); + logDesktopDaemonLifecycle("stopping captured supervisor", { reason, pid: instance.pid, owned }); + await stopDaemonInstance(home, { + instance, + timeoutMs: 15_000, + requestShutdown: async (ready) => { + await runExternalCliJsonCommand(["daemon", "stop", "--host", ready.listen, "--json"]); + }, }); - return statusAfter ?? (await resolveDesktopDaemonStatus()); + if (owned) ownedLaunch = null; + return resolveDesktopDaemonStatus(); } async function restartDaemon(): Promise { - assertBuiltInDaemonManagementEnabled(await getDesktopSettingsStore().get()); - await stopDesktopDaemon("restart"); - return startDaemon(); + await runExternalCliJsonCommand(["daemon", "restart", "--home", getPaseoHome(), "--json"]); + return resolveDesktopDaemonStatus(); } function getDaemonLogs(): DesktopDaemonLogs { @@ -495,7 +366,7 @@ function getDaemonLogs(): DesktopDaemonLogs { } async function getCliDaemonStatus(): Promise { - return await runExternalCliTextCommand(["daemon", "status"]); + return await runExternalCliTextCommand(["daemon", "status", "--home", getPaseoHome()]); } async function getLocalDaemonVersion(): Promise<{ version: string | null; error: string | null }> { @@ -528,7 +399,13 @@ export function createDaemonCommandHandlers(): Record resolveDesktopDaemonStatus(), start_desktop_daemon: () => startDaemon(), - stop_desktop_daemon: (args) => stopDesktopDaemon(parseDesktopDaemonStopReason(args)), + stop_desktop_daemon: (args) => + stopDesktopDaemon( + parseDesktopDaemonStopReason(args), + typeof args?.pid === "number" && typeof args.startedAt === "string" + ? { pid: args.pid, startedAt: args.startedAt } + : undefined, + ), restart_desktop_daemon: () => restartDaemon(), desktop_daemon_logs: () => getDaemonLogs(), desktop_app_logs: () => getDesktopAppLogs(), diff --git a/packages/server/scripts/supervisor-entrypoint.ts b/packages/server/scripts/supervisor-entrypoint.ts index b379e6179f..c929281525 100644 --- a/packages/server/scripts/supervisor-entrypoint.ts +++ b/packages/server/scripts/supervisor-entrypoint.ts @@ -18,13 +18,11 @@ process.title = "Paseo Supervisor"; interface DaemonRunnerConfig { devMode: boolean; - reclaimStalePidLock: boolean; workerArgs: string[]; } function parseConfig(argv: string[]): DaemonRunnerConfig { let devMode = false; - let reclaimStalePidLock = false; const workerArgs: string[] = []; for (const arg of argv) { @@ -33,13 +31,14 @@ function parseConfig(argv: string[]): DaemonRunnerConfig { continue; } if (arg === "--reclaim-stale-pid-lock") { - reclaimStalePidLock = true; - continue; + throw new Error( + "--reclaim-stale-pid-lock was removed: stop the existing supervisor before starting another.", + ); } workerArgs.push(arg); } - return { devMode, reclaimStalePidLock, workerArgs }; + return { devMode, workerArgs }; } function resolveWorkerEntry(): string { @@ -116,7 +115,6 @@ async function main(): Promise { try { await acquirePidLock(paseoHome, null, { ownerPid: process.pid, - reclaimStaleDesktopLock: config.reclaimStalePidLock, }); } catch (error) { if (error instanceof PidLockError) { @@ -177,6 +175,7 @@ async function main(): Promise { onWorkerReady: async ({ listen }) => { await updatePidLock(paseoHome, { listen }, { ownerPid: process.pid }); }, + onWorkerExit: () => updatePidLock(paseoHome, { listen: null }, { ownerPid: process.pid }), onSupervisorExit: releaseLock, }); requestSupervisorShutdown = supervisor.requestShutdown; diff --git a/packages/server/scripts/supervisor.logging.test.ts b/packages/server/scripts/supervisor.logging.test.ts index 9b4c622860..3eb5af9b32 100644 --- a/packages/server/scripts/supervisor.logging.test.ts +++ b/packages/server/scripts/supervisor.logging.test.ts @@ -36,7 +36,10 @@ async function runSupervisorFixture(options: { const workerPath = path.join(tempDir, "worker.mjs"); const runnerPath = path.join(tempDir, "runner.mjs"); - await writeFile(workerPath, options.workerSource); + await writeFile( + workerPath, + `process.send?.({ type: "paseo:ready", listen: "fixture" });\n${options.workerSource}`, + ); await writeFile( runnerPath, ` diff --git a/packages/server/scripts/supervisor.readiness.test.ts b/packages/server/scripts/supervisor.readiness.test.ts new file mode 100644 index 0000000000..297299d810 --- /dev/null +++ b/packages/server/scripts/supervisor.readiness.test.ts @@ -0,0 +1,122 @@ +import { spawn } from "node:child_process"; +import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import { expect, test } from "vitest"; + +async function runWorker(worker: string, publicationDelay = 0) { + const home = await mkdtemp(path.join(tmpdir(), "paseo-readiness-")); + const workerPath = path.join(home, "worker.mjs"); + const runnerPath = path.join(home, "runner.mjs"); + const eventsPath = path.join(home, "events.jsonl"); + const supervisor = new URL("./supervisor.ts", import.meta.url).href; + await writeFile(workerPath, worker); + await writeFile( + runnerPath, + ` + import { appendFile } from "node:fs/promises"; + import { runSupervisor } from ${JSON.stringify(supervisor)}; + const record = (event) => appendFile(${JSON.stringify(eventsPath)}, JSON.stringify(event) + "\\n"); + runSupervisor({ + name: "ReadinessTest", startupMessage: "starting", restartOnCrash: true, + resolveWorkerEntry: () => ${JSON.stringify(workerPath)}, workerArgs: [], workerExecArgv: [], + onWorkerReady: async ({ listen }) => { + await new Promise(resolve => setTimeout(resolve, ${publicationDelay})); + await record(listen); + }, + onWorkerExit: () => record(null), + }); + `, + ); + const env = Object.fromEntries( + Object.entries(process.env).filter(([key]) => !key.startsWith("PASEO_")), + ); + const child = spawn(process.execPath, ["--import", "tsx", runnerPath], { + cwd: fileURLToPath(new URL("../../../", import.meta.url)), + env: { ...env, HOME: home, PASEO_HOME: home }, + stdio: ["ignore", "pipe", "pipe"], + }); + let output = ""; + child.stdout.on("data", (chunk) => { + output += chunk; + }); + child.stderr.on("data", (chunk) => { + output += chunk; + }); + try { + const code = await new Promise((resolve, reject) => { + const timer = setTimeout(() => { + child.kill("SIGKILL"); + reject(new Error(output)); + }, 5_000); + child.once("error", reject); + child.once("close", (exitCode) => { + clearTimeout(timer); + resolve(exitCode); + }); + }); + const events = await readFile(eventsPath, "utf8").catch(() => ""); + return { + code, + output, + events: events + .trim() + .split("\n") + .filter(Boolean) + .map((line) => JSON.parse(line)), + }; + } finally { + await rm(home, { recursive: true, force: true }); + } +} + +test("a worker that exits before ready fails the supervisor without respawning", async () => { + const result = await runWorker(` + import { existsSync, writeFileSync } from "node:fs"; + const marker = process.argv[1] + ".started"; + if (existsSync(marker)) process.exit(0); + writeFileSync(marker, "started"); + process.exit(1); + `); + expect(result.code).toBe(1); + expect(result.output).not.toContain("Restarting worker"); +}); + +test("a replacement worker must independently reach ready", async () => { + const result = await runWorker(` + import { existsSync, writeFileSync } from "node:fs"; + const marker = process.argv[1] + ".started"; + if (existsSync(marker)) process.exit(0); + writeFileSync(marker, "started"); + process.on("message", message => { + if (message.type === "paseo:graceful-shutdown") process.exit(0); + }); + process.send({ type: "paseo:ready", listen: "test-endpoint" }); + process.send({ type: "paseo:restart" }); + `); + expect(result.code).toBe(1); + expect(result.events, result.output).toEqual(["test-endpoint", null, null]); +}); + +test("exit clears publication after an in-flight ready write", async () => { + const result = await runWorker( + ` + process.send({ type: "paseo:ready", listen: "test-endpoint" }, () => process.exit(0)); + `, + 100, + ); + expect(result.code).toBe(0); + expect(result.events.at(-1)).toBeNull(); +}); + +test("requested shutdown before first readiness exits successfully", async () => { + const result = await runWorker(` + process.on("message", message => { + if (message.type === "paseo:graceful-shutdown") process.exit(0); + }); + process.send({ type: "paseo:shutdown", reason: "cancelled_start" }); + `); + expect(result.code).toBe(0); + expect(result.events).toEqual([null]); +}); diff --git a/packages/server/scripts/supervisor.ts b/packages/server/scripts/supervisor.ts index f2d9fc7665..5d48491f2a 100644 --- a/packages/server/scripts/supervisor.ts +++ b/packages/server/scripts/supervisor.ts @@ -51,6 +51,7 @@ interface SupervisorOptions { env?: NodeJS.ProcessEnv; } | null; onWorkerReady?: (message: { listen: string }) => Promise | void; + onWorkerExit?: () => Promise | void; restartOnCrash?: boolean; onSupervisorExit?: () => Promise | void; logFile?: SupervisorLogFileOptions; @@ -129,6 +130,8 @@ export function runSupervisor(options: SupervisorOptions): SupervisorController let restarting = false; let shuttingDown = false; let exiting = false; + let lifecycleFailed = false; + let publication = Promise.resolve(); let forceKillTimer: NodeJS.Timeout | null = null; const logStream = createSupervisorLogStream(options.logFile); @@ -244,6 +247,8 @@ export function runSupervisor(options: SupervisorOptions): SupervisorController } const currentChild = child; + let reachedReady = false; + // Serialize endpoint writes with exit/clear before allowing another worker to spawn. const heartbeat = setInterval(() => { const message: SupervisorHeartbeatMessage = { type: "paseo:supervisor-heartbeat" }; if (currentChild.connected) { @@ -276,18 +281,21 @@ export function runSupervisor(options: SupervisorOptions): SupervisorController child.on("message", (msg: unknown) => { const lifecycleMessage = parseLifecycleMessage(msg); - if (!lifecycleMessage) { + if (!lifecycleMessage || child !== currentChild) { return; } if (lifecycleMessage.type === "paseo:ready") { + reachedReady = true; writeLifecycleLog("Worker ready", { listen: lifecycleMessage.listen }); - Promise.resolve(options.onWorkerReady?.({ listen: lifecycleMessage.listen })).catch( - (error) => { + publication = publication + .then(() => options.onWorkerReady?.({ listen: lifecycleMessage.listen })) + .catch((error) => { + lifecycleFailed = true; const message = error instanceof Error ? error.message : String(error); log(`Worker ready callback failed: ${message}`); - }, - ); + requestShutdown("endpoint_publication_failed"); + }); return; } @@ -303,35 +311,57 @@ export function runSupervisor(options: SupervisorOptions): SupervisorController requestRestart(reason); }); + child.on("error", (error) => { + lifecycleFailed = true; + log(`Worker spawn failed: ${error.message}`); + exitSupervisor(1); + }); + child.on("exit", (code, signal) => { clearInterval(heartbeat); clearForceKillTimer(); - const exitDescriptor = describeExit(code, signal); - writeLifecycleLog("Worker exited", { code, signal, exit: exitDescriptor }); + child = null; + publication = publication + .then(() => options.onWorkerExit?.()) + .catch((error) => { + lifecycleFailed = true; + log(`Worker exit callback failed: ${String(error)}`); + }); + void publication.then(() => { + const exitDescriptor = describeExit(code, signal); + writeLifecycleLog("Worker exited", { code, signal, exit: exitDescriptor }); - if (shuttingDown) { - log(`Worker exited (${exitDescriptor}). Supervisor shutting down.`); - exitSupervisor(0); + if (lifecycleFailed || (!reachedReady && !shuttingDown)) { + log(`Worker exited before successful readiness (${exitDescriptor}). Supervisor exiting.`); + exitSupervisor(1); + return; + } + + if (shuttingDown) { + log(`Worker exited (${exitDescriptor}). Supervisor shutting down.`); + exitSupervisor(0); + return; + } + + const crashed = + restartOnCrash && + ((code !== 0 && code !== null) || (signal !== null && signal !== "SIGTERM")); + + if (restarting || crashed) { + restarting = false; + log( + crashed + ? `Worker crashed (${exitDescriptor}). Restarting worker...` + : `Worker exited (${exitDescriptor}). Restarting worker...`, + ); + spawnWorker(); + return; + } + + log(`Worker exited (${exitDescriptor}). Supervisor exiting.`); + exitSupervisor(typeof code === "number" ? code : 1); return; - } - - const crashed = - restartOnCrash && - ((code !== 0 && code !== null) || (signal !== null && signal !== "SIGTERM")); - - if (restarting || crashed) { - restarting = false; - log( - crashed - ? `Worker crashed (${exitDescriptor}). Restarting worker...` - : `Worker exited (${exitDescriptor}). Restarting worker...`, - ); - spawnWorker(); - return; - } - - log(`Worker exited (${exitDescriptor}). Supervisor exiting.`); - exitSupervisor(typeof code === "number" ? code : 1); + }); }); }; @@ -389,7 +419,10 @@ export function runSupervisor(options: SupervisorOptions): SupervisorController writeLifecycleLog("Supervisor shutdown requested", { reason }); log(`${reason}. Stopping worker...`); if (!child) { - exitSupervisor(0); + void publication.then(() => { + exitSupervisor(lifecycleFailed ? 1 : 0); + return; + }); return; } requestWorkerShutdown(reason); diff --git a/packages/server/src/server/bootstrap.ts b/packages/server/src/server/bootstrap.ts index 81aee8aa7f..380e3618e0 100644 --- a/packages/server/src/server/bootstrap.ts +++ b/packages/server/src/server/bootstrap.ts @@ -80,12 +80,12 @@ export function parseListenString(listen: string): ListenTarget { throw new Error(`Invalid listen string: ${listen}`); } -function formatListenTarget(listenTarget: ListenTarget | null): string | null { +export function formatListenTarget(listenTarget: ListenTarget | null): string | null { if (!listenTarget) { return null; } if (listenTarget.type === "tcp") { - return `${listenTarget.host}:${listenTarget.port}`; + return `${formatHostForHttpUrl(listenTarget.host)}:${listenTarget.port}`; } return listenTarget.path; } diff --git a/packages/server/src/server/config-environment.ts b/packages/server/src/server/config-environment.ts new file mode 100644 index 0000000000..92bf9183ee --- /dev/null +++ b/packages/server/src/server/config-environment.ts @@ -0,0 +1,90 @@ +// Daemon configuration inputs. General provider credentials and executable/runtime +// controls remain available to managed launches and their agent processes. +export const DAEMON_SETTING_ENV_KEYS = [ + "MCP_DEBUG", + "OPENAI_STT_BASE_URL", + "OPENAI_TTS_BASE_URL", + "PASEO_ALLOWED_HOSTS", + "PASEO_APP_BASE_URL", + "PASEO_CORS_ORIGINS", + "PASEO_DICTATION_ENABLED", + "PASEO_DICTATION_LANGUAGE", + "PASEO_DICTATION_LOCAL_STT_MODEL", + "PASEO_DICTATION_STT_PROVIDER", + "PASEO_GIT_CONCURRENCY", + "PASEO_GIT_MAX_PROCESSES_PER_SECOND", + "PASEO_GIT_MAX_PROCESS_CONCURRENCY", + "PASEO_HOSTNAMES", + "PASEO_LISTEN", + "PASEO_LOCAL_MODELS_DIR", + "PASEO_LOG", + "PASEO_LOG_CONSOLE_FORMAT", + "PASEO_LOG_CONSOLE_LEVEL", + "PASEO_LOG_FILE_LEVEL", + "PASEO_LOG_FILE_PATH", + "PASEO_LOG_FILE_ROTATE_COUNT", + "PASEO_LOG_FILE_ROTATE_SIZE", + "PASEO_LOG_FORMAT", + "PASEO_LOG_LEVEL", + "PASEO_LOG_ROTATE_COUNT", + "PASEO_LOG_ROTATE_SIZE", + "PASEO_PASSWORD", + "PASEO_RELAY_ENABLED", + "PASEO_RELAY_ENDPOINT", + "PASEO_RELAY_PUBLIC_ENDPOINT", + "PASEO_RELAY_PUBLIC_USE_TLS", + "PASEO_RELAY_USE_TLS", + "PASEO_SERVICE_PROXY_ENABLED", + "PASEO_SERVICE_PROXY_LISTEN", + "PASEO_SERVICE_PROXY_PUBLIC_BASE_URL", + "PASEO_TRUSTED_PROXIES", + "PASEO_VOICE_LANGUAGE", + "PASEO_VOICE_LLM_PROVIDER", + "PASEO_VOICE_LOCAL_STT_MODEL", + "PASEO_VOICE_LOCAL_TTS_MODEL", + "PASEO_VOICE_LOCAL_TTS_SPEAKER_ID", + "PASEO_VOICE_LOCAL_TTS_SPEED", + "PASEO_VOICE_MODE_ENABLED", + "PASEO_VOICE_STT_PROVIDER", + "PASEO_VOICE_TTS_PROVIDER", + "PASEO_VOICE_TURN_DETECTION_PROVIDER", + "PASEO_WEB_UI_DIST_DIR", + "PASEO_WEB_UI_ENABLED", + "PORT", + "STT_CONFIDENCE_THRESHOLD", + "STT_MODEL", + "TTS_MODEL", + "TTS_VOICE", +] as const; + +const CONFIG_CONTEXT_ENV_KEYS = [ + "PASEO_NODE_ENV", + "PASEO_DESKTOP_MANAGED", + "OPENAI_API_KEY", + "OPENAI_BASE_URL", + "OPENAI_STT_API_KEY", + "OPENAI_TTS_API_KEY", +] as const; + +export function configurationEnvironment(env: NodeJS.ProcessEnv): NodeJS.ProcessEnv { + return Object.fromEntries( + [...DAEMON_SETTING_ENV_KEYS, ...CONFIG_CONTEXT_ENV_KEYS].map((key) => [key, env[key]]), + ); +} + +export function daemonLaunchEnvironment(input: { + env: NodeJS.ProcessEnv; + home: string; + mode: "managed" | "deployment"; + desktopManaged?: boolean; +}): NodeJS.ProcessEnv { + const env = { ...input.env }; + if (input.mode === "managed") { + for (const key of DAEMON_SETTING_ENV_KEYS) delete env[key]; + } + delete env.PASEO_HOST; + delete env.PASEO_DESKTOP_MANAGED; + env.PASEO_HOME = input.home; + if (input.desktopManaged) env.PASEO_DESKTOP_MANAGED = "1"; + return env; +} diff --git a/packages/server/src/server/config.ts b/packages/server/src/server/config.ts index 7ed5f16519..8a8eb39a67 100644 --- a/packages/server/src/server/config.ts +++ b/packages/server/src/server/config.ts @@ -1,3 +1,4 @@ +import { configurationEnvironment } from "./config-environment.js"; import { existsSync } from "node:fs"; import path from "node:path"; import { fileURLToPath } from "node:url"; @@ -554,7 +555,7 @@ export function resolveConfigFromPersisted( options?: ResolveConfigFromPersistedOptions, ): PaseoDaemonConfig { const resolvedOptions = options ?? {}; - const env = resolvedOptions.env ?? process.env; + const env = configurationEnvironment(resolvedOptions.env ?? process.env); const cli = resolvedOptions.cli; const relayEnabledFallback = resolvedOptions.relayEnabledFallback ?? persisted.daemon?.relay?.enabled === undefined; diff --git a/packages/server/src/server/daemon-instance.ts b/packages/server/src/server/daemon-instance.ts new file mode 100644 index 0000000000..f3101f3b28 --- /dev/null +++ b/packages/server/src/server/daemon-instance.ts @@ -0,0 +1,305 @@ +import { spawn } from "node:child_process"; +import { readFile } from "node:fs/promises"; +import path from "node:path"; +import { setTimeout as delay } from "node:timers/promises"; +import { + getPidLockInfo, + isPidRunning, + isSamePidLock, + releasePidLock, + type PidLockInfo, +} from "./pid-lock.js"; +import { daemonLaunchEnvironment } from "./config-environment.js"; +import { readPersistedConfig } from "./persisted-config.js"; +import treeKill from "tree-kill"; +const killTree = (pid: number, signal: string): Promise => + new Promise((resolve, reject) => + treeKill(pid, signal, (error) => { + if (error) { + reject(error); + return; + } + resolve(); + }), + ); + +export { daemonLaunchEnvironment } from "./config-environment.js"; +export { + isSamePidLock as isSameDaemonInstance, + type PidLockInfo as DaemonInstance, +} from "./pid-lock.js"; + +export class DaemonInstanceError extends Error { + constructor( + public readonly code: string, + message: string, + ) { + super(message); + } +} + +export async function readDaemonInstance(home: string): Promise { + const lock = await getPidLockInfo(home); + return lock && isPidRunning(lock.pid) ? lock : null; +} + +export function daemonLogPath(home: string): string { + try { + return path.resolve(home, readPersistedConfig(home).log?.file?.path ?? "daemon.log"); + } catch { + return path.join(home, "daemon.log"); + } +} + +export async function waitForDaemonReady( + home: string, + options: { + timeoutMs?: number; + instance?: PidLockInfo; + signal?: AbortSignal; + } = {}, +): Promise { + const deadline = Date.now() + (options.timeoutMs ?? 600_000); + while (true) { + options.signal?.throwIfAborted(); + const instance = await readDaemonInstance(home); + if (!instance) + throw new DaemonInstanceError( + "DAEMON_NOT_RUNNING", + `Daemon is not running for ${home}. Start with: paseo daemon start --home ${JSON.stringify(home)}`, + ); + if (options.instance && !isSamePidLock(instance, options.instance)) { + throw new DaemonInstanceError( + "DAEMON_REPLACED", + `Supervisor changed for ${home}; refusing to follow PID ${instance.pid}.`, + ); + } + if (instance.listen) return { ...instance, listen: instance.listen }; + if (Date.now() >= deadline) throw notReady(home, instance); + await delay(100, undefined, { signal: options.signal }); + } +} + +function notReady(home: string, instance: PidLockInfo): DaemonInstanceError { + return new DaemonInstanceError( + "DAEMON_NOT_READY", + `Daemon PID ${instance.pid} remains running but is not ready for ${home}.\nLogs: ${daemonLogPath(home)}\nStatus: paseo daemon status --home ${JSON.stringify(home)}\nStop: paseo daemon stop --home ${JSON.stringify(home)}`, + ); +} + +async function requestInstanceStop( + home: string, + instance: PidLockInfo, + options: { + force?: boolean; + requestShutdown?: (instance: PidLockInfo & { listen: string }) => Promise; + }, +) { + let forced = false; + let usedLifecycleRpc = false; + if (process.platform === "win32") { + if (instance.listen && options.requestShutdown) { + try { + await options.requestShutdown({ ...instance, listen: instance.listen }); + usedLifecycleRpc = true; + } catch (error) { + if (!options.force) throw error; + const current = await getPidLockInfo(home); + if (current && !isSamePidLock(instance, current)) + throw new DaemonInstanceError( + "DAEMON_REPLACED", + `Supervisor changed for ${home}; refusing forced cleanup.`, + ); + await killTree(instance.pid, "SIGKILL"); + forced = true; + } + } else if (!options.force) { + throw new DaemonInstanceError( + "STOP_NO_GRACEFUL_CHANNEL", + `PID ${instance.pid} for ${home} has no graceful shutdown channel. Use --force explicitly to terminate it.`, + ); + } else { + await killTree(instance.pid, "SIGKILL"); + forced = true; + } + } else { + try { + process.kill(instance.pid, "SIGTERM"); + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== "ESRCH") throw error; + } + } + return { forced, usedLifecycleRpc }; +} + +export async function stopDaemonInstance( + home: string, + options: { + instance?: PidLockInfo; + force?: boolean; + timeoutMs?: number; + killTimeoutMs?: number; + requestShutdown?: (instance: PidLockInfo & { listen: string }) => Promise; + } = {}, +): Promise<{ + action: "stopped" | "not_running"; + pid: number | null; + forced: boolean; + usedLifecycleRpc: boolean; +}> { + const { timeoutMs = 15_000, killTimeoutMs = 3_000 } = options; + const deadline = Date.now() + timeoutMs; + const instance = await getPidLockInfo(home); + if (options.instance && instance && !isSamePidLock(instance, options.instance)) { + throw new DaemonInstanceError( + "DAEMON_REPLACED", + `Supervisor changed for ${home}; refusing to stop PID ${instance.pid}.`, + ); + } + if (!instance || !isPidRunning(instance.pid)) { + if (instance) + await releasePidLock(home, { ownerPid: instance.pid, startedAt: instance.startedAt }); + return { + action: "not_running", + pid: instance?.pid ?? null, + forced: false, + usedLifecycleRpc: false, + }; + } + if (instance.pid <= 1 || instance.pid === process.pid) + throw new Error("Refusing to stop invalid supervisor PID"); + let { forced, usedLifecycleRpc } = await requestInstanceStop(home, instance, options); + const waitForExit = async (waitMs: number) => { + const exitDeadline = Date.now() + waitMs; + while (isPidRunning(instance.pid)) { + const current = await getPidLockInfo(home); + if (current && !isSamePidLock(instance, current)) + throw new DaemonInstanceError( + "DAEMON_REPLACED", + `Supervisor changed for ${home}; stop of PID ${instance.pid} was not confirmed.`, + ); + if (Date.now() >= exitDeadline) return false; + await delay(100); + } + return true; + }; + let stopped = await waitForExit(forced ? killTimeoutMs : Math.max(0, deadline - Date.now())); + if (!stopped && options.force && !forced) { + await killTree(instance.pid, "SIGKILL"); + forced = true; + stopped = await waitForExit(killTimeoutMs); + } + if (!stopped) + throw new DaemonInstanceError( + "STOP_NOT_CONFIRMED", + `Timed out waiting for supervisor PID ${instance.pid} in ${home} to exit${options.force ? "" : "; use --force to permit forced cleanup"}.`, + ); + await releasePidLock(home, { ownerPid: instance.pid, startedAt: instance.startedAt }); + return { action: "stopped", pid: instance.pid, forced, usedLifecycleRpc }; +} + +export async function startDaemonInstance(input: { + home: string; + command: string; + args: string[]; + env: NodeJS.ProcessEnv; + mode: "managed" | "deployment"; + desktopManaged?: boolean; + foreground?: boolean; + timeoutMs?: number; + signal?: AbortSignal; + onAcquired?: (instance: PidLockInfo) => void; + onReady?: (instance: PidLockInfo & { listen: string }) => void; +}): Promise<{ instance: PidLockInfo; spawned: boolean; exitCode?: number }> { + input.signal?.throwIfAborted(); + const existing = await readDaemonInstance(input.home); + if (existing) return { instance: existing, spawned: false }; + const { + foreground = false, + timeoutMs = 600_000, + signal, + onAcquired = () => {}, + onReady = () => {}, + } = input; + const child = spawn(input.command, input.args, { + env: daemonLaunchEnvironment(input), + detached: !foreground, + stdio: foreground ? "inherit" : "ignore", + windowsHide: true, + }); + let exit: { code: number; error?: Error } | undefined; + const exited = new Promise((resolve) => { + child.once("error", (error) => { + exit = { code: 1, error }; + resolve(1); + }); + child.once("exit", (code) => { + exit = { code: code ?? 1 }; + resolve(code ?? 1); + }); + }); + const cancel = () => { + if (child.pid && !exit) { + child.ref(); + child.kill("SIGTERM"); + } + }; + signal?.addEventListener("abort", cancel, { once: true }); + if (!foreground) child.unref(); + const deadline = foreground ? Infinity : Date.now() + timeoutMs; + let acquired: PidLockInfo | undefined; + async function waitUntilReady() { + while (true) { + signal?.throwIfAborted(); + const instance = await readDaemonInstance(input.home); + if (instance && instance.pid !== child.pid) return { instance, spawned: false }; + if (instance && !acquired) { + acquired = instance; + onAcquired(instance); + } + if (exit) { + const logPath = daemonLogPath(input.home); + const log = await readFile(logPath, "utf8").catch(() => ""); + throw new DaemonInstanceError( + "DAEMON_START_FAILED", + `Daemon failed to start (${exit.error?.message ?? `exit ${exit.code}`}). Logs: ${logPath}\n${log.split("\n").slice(-30).join("\n")}`, + ); + } + if (instance?.listen) { + const ready = { ...instance, listen: instance.listen }; + onReady(ready); + return { + instance: ready, + spawned: true, + ...(foreground ? { exitCode: await exited } : {}), + }; + } + if (Date.now() >= deadline) { + if (acquired) throw notReady(input.home, acquired); + throw new DaemonInstanceError( + "DAEMON_NOT_READY", + `Supervisor PID ${child.pid} remains running but has not published its lock for ${input.home}. Logs: ${daemonLogPath(input.home)}. Check paseo daemon status --home ${JSON.stringify(input.home)}. Stop with paseo daemon stop --home ${JSON.stringify(input.home)} once its lock is published, or signal this PID.`, + ); + } + await delay(100, undefined, { signal }); + } + } + try { + return await waitUntilReady(); + } catch (error) { + if (!(error instanceof DaemonInstanceError && error.code === "DAEMON_NOT_READY")) { + cancel(); + const stopped = await Promise.race([ + exited.then(() => true), + delay(15_000, false, { ref: false }), + ]); + if (!stopped && child.pid) { + await killTree(child.pid, "SIGKILL"); + await exited; + } + } + throw error; + } finally { + signal?.removeEventListener("abort", cancel); + } +} diff --git a/packages/server/src/server/daemon-worker.ts b/packages/server/src/server/daemon-worker.ts index f7a770d8be..2adc4ab982 100644 --- a/packages/server/src/server/daemon-worker.ts +++ b/packages/server/src/server/daemon-worker.ts @@ -1,6 +1,6 @@ import { appendFileSync, mkdirSync } from "node:fs"; import path from "node:path"; -import { createPaseoDaemon } from "./bootstrap.js"; +import { createPaseoDaemon, formatListenTarget } from "./bootstrap.js"; import { loadConfig } from "./config.js"; import { resolvePaseoHome } from "./paseo-home.js"; import { createRootLogger } from "./logger.js"; @@ -323,10 +323,7 @@ async function main() { try { await daemon.start(); const listenTarget = daemon.getListenTarget(); - const listen = - listenTarget?.type === "tcp" - ? `${listenTarget.host}:${listenTarget.port}` - : listenTarget?.path; + const listen = formatListenTarget(listenTarget); if (!listen) { throw new Error("Daemon did not expose a listen target after startup"); } diff --git a/packages/server/src/server/exports.ts b/packages/server/src/server/exports.ts index 0961e08b11..72efe5d572 100644 --- a/packages/server/src/server/exports.ts +++ b/packages/server/src/server/exports.ts @@ -96,3 +96,23 @@ export type { AgentStreamEventPayload, AgentStreamMessage, } from "@getpaseo/protocol/messages"; + +export { + readDaemonInstance, + startDaemonInstance, + stopDaemonInstance, + waitForDaemonReady, + daemonLogPath, + daemonLaunchEnvironment, + isSameDaemonInstance, + DaemonInstanceError, + type DaemonInstance, +} from "./daemon-instance.js"; +export { + readPersistedConfig, + getPersistedConfigValue, + editPersistedConfig, +} from "./persisted-config.js"; +export { resolveConfigFromPersisted } from "./config.js"; + +export { ensurePrivateDirectory } from "./private-files.js"; diff --git a/packages/server/src/server/paseo-home.test.ts b/packages/server/src/server/paseo-home.test.ts index 99a6c30d25..d8623d230b 100644 --- a/packages/server/src/server/paseo-home.test.ts +++ b/packages/server/src/server/paseo-home.test.ts @@ -1,24 +1,16 @@ -import { mkdtempSync, rmSync, statSync } from "node:fs"; +import { existsSync, mkdtempSync, rmSync } from "node:fs"; import { tmpdir } from "node:os"; import path from "node:path"; import { describe, expect, test } from "vitest"; import { resolvePaseoHome } from "./paseo-home.js"; -import { PRIVATE_DIRECTORY_MODE } from "./private-files.js"; - -const MODE_MASK = 0o777; - -function modeOf(filePath: string): number { - return statSync(filePath).mode & MODE_MASK; -} - -describe.skipIf(process.platform === "win32")("resolvePaseoHome permissions", () => { - test("creates PASEO_HOME with private permissions", () => { +describe("resolvePaseoHome", () => { + test("resolves PASEO_HOME without creating it", () => { const parent = mkdtempSync(path.join(tmpdir(), "paseo-home-parent-")); const paseoHome = path.join(parent, "home"); try { expect(resolvePaseoHome({ PASEO_HOME: paseoHome })).toBe(paseoHome); - expect(modeOf(paseoHome)).toBe(PRIVATE_DIRECTORY_MODE); + expect(existsSync(paseoHome)).toBe(false); } finally { rmSync(parent, { recursive: true, force: true }); } diff --git a/packages/server/src/server/paseo-home.ts b/packages/server/src/server/paseo-home.ts index f933653b8b..90b0410cbd 100644 --- a/packages/server/src/server/paseo-home.ts +++ b/packages/server/src/server/paseo-home.ts @@ -1,6 +1,5 @@ import os from "node:os"; import path from "node:path"; -import { ensurePrivateDirectory } from "./private-files.js"; function expandHomeDir(input: string): string { if (input.startsWith("~/")) { @@ -15,6 +14,5 @@ function expandHomeDir(input: string): string { export function resolvePaseoHome(env: NodeJS.ProcessEnv = process.env): string { const raw = env.PASEO_HOME ?? "~/.paseo"; const resolved = path.resolve(expandHomeDir(raw)); - ensurePrivateDirectory(resolved); return resolved; } diff --git a/packages/server/src/server/persisted-config-edit.test.ts b/packages/server/src/server/persisted-config-edit.test.ts new file mode 100644 index 0000000000..4b4bf4c0db --- /dev/null +++ b/packages/server/src/server/persisted-config-edit.test.ts @@ -0,0 +1,45 @@ +import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; +import { existsSync } from "node:fs"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import { expect, test } from "vitest"; +import { + editPersistedConfig, + getPersistedConfigValue, + readPersistedConfig, +} from "./persisted-config.js"; + +test("configuration edits validate before writing and preserve unrelated settings", async () => { + const root = await mkdtemp(path.join(tmpdir(), "paseo-config-edit-")); + const home = path.join(root, "home"); + try { + expect(readPersistedConfig(home)).toEqual({}); + expect(existsSync(home)).toBe(false); + for (const field of [ + "unknown", + "daemon.missing", + "daemon.__proto__.listen", + "daemon.auth.password", + ]) { + expect(() => editPersistedConfig(home, field, { value: "value" })).toThrow(); + expect(existsSync(home)).toBe(false); + } + editPersistedConfig(home, "daemon.listen", { value: "127.0.0.1:12345" }); + editPersistedConfig(home, "features.webUi.enabled", { value: true }); + const before = await readFile(path.join(home, "config.json"), "utf8"); + expect(() => editPersistedConfig(home, "features.webUi.enabled", { value: "true" })).toThrow(); + expect(() => + editPersistedConfig(home, "daemon", { value: { auth: { password: "plaintext" } } }), + ).toThrow(/set-password/); + expect(await readFile(path.join(home, "config.json"), "utf8")).toBe(before); + editPersistedConfig(home, "features.webUi.enabled", { unset: true }); + const edited = readPersistedConfig(home); + expect(getPersistedConfigValue(edited, "features.webUi.enabled")).toBeUndefined(); + expect(edited.daemon?.listen).toBe("127.0.0.1:12345"); + await writeFile(path.join(home, "config.json"), "invalid json"); + expect(() => editPersistedConfig(home, "daemon.listen", { value: "12345" })).toThrow(); + expect(await readFile(path.join(home, "config.json"), "utf8")).toBe("invalid json"); + } finally { + await rm(root, { recursive: true, force: true }); + } +}); diff --git a/packages/server/src/server/persisted-config.test.ts b/packages/server/src/server/persisted-config.test.ts index 8ea1c5c25d..2ab987eebf 100644 --- a/packages/server/src/server/persisted-config.test.ts +++ b/packages/server/src/server/persisted-config.test.ts @@ -8,7 +8,7 @@ import { PersistedConfigSchema, savePersistedConfig, } from "./persisted-config.js"; -import { PRIVATE_FILE_MODE } from "./private-files.js"; +import { PRIVATE_DIRECTORY_MODE, PRIVATE_FILE_MODE } from "./private-files.js"; const MODE_MASK = 0o777; const PERMISSIVE_FILE_MODE = 0o644; @@ -764,7 +764,8 @@ describe.skipIf(process.platform === "win32")("persisted config file permissions }); test("saves config.json with private permissions", () => { - const home = createTempHome(); + const parent = createTempHome(); + const home = path.join(parent, "home"); try { savePersistedConfig(home, { providers: { @@ -774,9 +775,10 @@ describe.skipIf(process.platform === "win32")("persisted config file permissions }, }); + expect(modeOf(home)).toBe(PRIVATE_DIRECTORY_MODE); expect(modeOf(path.join(home, "config.json"))).toBe(PRIVATE_FILE_MODE); } finally { - rmSync(home, { recursive: true, force: true }); + rmSync(parent, { recursive: true, force: true }); } }); }); diff --git a/packages/server/src/server/persisted-config.ts b/packages/server/src/server/persisted-config.ts index 1cdaf209d8..678e72370d 100644 --- a/packages/server/src/server/persisted-config.ts +++ b/packages/server/src/server/persisted-config.ts @@ -463,6 +463,84 @@ export function loadPersistedConfig(paseoHome: string, logger?: LoggerLike): Per return result.data as PersistedConfig; } +/** Observe the file without initializing a home, identity, or default configuration. */ +export function readPersistedConfig( + paseoHome: string, + options: { defaultsIfMissing?: boolean } = {}, +): PersistedConfig { + let raw: string; + try { + raw = readFileSync(getConfigPath(paseoHome), "utf8"); + } catch (error) { + if ((error as NodeJS.ErrnoException).code === "ENOENT") + return options.defaultsIfMissing ? structuredClone(DEFAULT_PERSISTED_CONFIG) : {}; + throw error; + } + return PersistedConfigSchema.parse(stripRemovedConfigFields(JSON.parse(raw))) as PersistedConfig; +} + +function configPathParts(field: string): string[] { + const parts = field.split("."); + let schema: z.ZodType = PersistedConfigSchema; + for (const part of parts) { + if (!part || ["__proto__", "prototype", "constructor"].includes(part)) { + throw new Error(`Invalid configuration path: ${field}`); + } + while ( + schema instanceof z.ZodOptional || + schema instanceof z.ZodDefault || + schema instanceof z.ZodPipe + ) + schema = (schema instanceof z.ZodPipe ? schema.in : schema.unwrap()) as z.ZodType; + if (!(schema instanceof z.ZodObject) || !Object.hasOwn(schema.shape, part)) { + throw new Error( + `Unknown configuration path: ${field}. Replace its containing object with JSON for dynamic keys.`, + ); + } + schema = schema.shape[part]; + } + return parts; +} + +export function getPersistedConfigValue(config: PersistedConfig, field: string): unknown { + return configPathParts(field).reduce( + (value, part) => + value && typeof value === "object" ? (value as Record)[part] : undefined, + config, + ); +} + +export function editPersistedConfig( + paseoHome: string, + field: string, + edit: { value: unknown } | { unset: true }, +): PersistedConfig { + const parts = configPathParts(field); + if (field === "daemon.auth" || field.startsWith("daemon.auth.")) { + throw new Error("Use daemon set-password to change the daemon password."); + } + const config = readPersistedConfig(paseoHome, { defaultsIfMissing: true }); + let object = config as Record; + for (const part of parts.slice(0, -1)) { + object[part] ??= {}; + object = object[part] as Record; + } + const key = parts.at(-1)!; + if (field === "daemon") { + const previousAuth = config.daemon?.auth; + const nextAuth = + "value" in edit && edit.value && typeof edit.value === "object" + ? (edit.value as Record).auth + : undefined; + if (JSON.stringify(previousAuth) !== JSON.stringify(nextAuth)) + throw new Error("Use daemon set-password to change the daemon password."); + } + if ("unset" in edit) delete object[key]; + else object[key] = edit.value; + savePersistedConfig(paseoHome, config); + return config; +} + export function savePersistedConfig( paseoHome: string, config: PersistedConfig, diff --git a/packages/server/src/server/pid-lock.test.ts b/packages/server/src/server/pid-lock.test.ts index 1dc6c260bb..457e1e7edc 100644 --- a/packages/server/src/server/pid-lock.test.ts +++ b/packages/server/src/server/pid-lock.test.ts @@ -1,4 +1,4 @@ -import { mkdtemp, open, rm, utimes, writeFile } from "node:fs/promises"; +import { mkdtemp, open, rm, stat, utimes, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { describe, expect, test } from "vitest"; @@ -15,7 +15,8 @@ import { describe("pid-lock ownership", () => { test("writes and releases lock for explicit owner pid", async () => { - const paseoHome = await mkdtemp(join(tmpdir(), "paseo-pid-lock-owner-")); + const parent = await mkdtemp(join(tmpdir(), "paseo-pid-lock-owner-")); + const paseoHome = join(parent, "home"); const ownerPid = process.pid + 10_000; try { @@ -27,6 +28,9 @@ describe("pid-lock ownership", () => { ) => Promise )(paseoHome, null, { ownerPid }); + if (process.platform !== "win32") { + expect((await stat(paseoHome)).mode & 0o777).toBe(0o700); + } const lock = await getPidLockInfo(paseoHome); expect(lock?.pid).toBe(ownerPid); expect(lock?.listen).toBeNull(); @@ -55,7 +59,7 @@ describe("pid-lock ownership", () => { const lockAfterOwnerRelease = await getPidLockInfo(paseoHome); expect(lockAfterOwnerRelease).toBeNull(); } finally { - await rm(paseoHome, { recursive: true, force: true }); + await rm(parent, { recursive: true, force: true }); } }); @@ -92,7 +96,7 @@ describe("pid-lock ownership", () => { } }); - test("reclaims a stale desktop heartbeat lock after desktop confirms the daemon is unreachable", async () => { + test("preserves a stale live desktop heartbeat lock", async () => { const paseoHome = await mkdtemp(join(tmpdir(), "paseo-pid-lock-stale-desktop-heartbeat-")); const replacementOwnerPid = process.pid + 10_000; @@ -113,14 +117,13 @@ describe("pid-lock ownership", () => { const staleTime = new Date(Date.now() - 10 * 60_000); await utimes(pidPath, staleTime, staleTime); - await acquirePidLock(paseoHome, null, { - ownerPid: replacementOwnerPid, - reclaimStaleDesktopLock: true, - }); + await expect( + acquirePidLock(paseoHome, null, { ownerPid: replacementOwnerPid }), + ).rejects.toThrow("Another Paseo daemon is already running"); const lock = await getPidLockInfo(paseoHome); - expect(lock?.pid).toBe(replacementOwnerPid); - expect(lock?.listen).toBeNull(); + expect(lock?.pid).toBe(process.pid); + expect(lock?.listen).toBe("127.0.0.1:6767"); } finally { await rm(paseoHome, { recursive: true, force: true }); } @@ -156,7 +159,7 @@ describe("pid-lock ownership", () => { } }); - test("reclaims a stale legacy desktop lock after desktop confirms the daemon is unreachable", async () => { + test("preserves a stale live legacy desktop lock", async () => { const paseoHome = await mkdtemp(join(tmpdir(), "paseo-pid-lock-legacy-desktop-")); const replacementOwnerPid = process.pid + 10_000; const pidPath = join(paseoHome, "paseo.pid"); @@ -176,14 +179,13 @@ describe("pid-lock ownership", () => { const staleTime = new Date(Date.now() - 10 * 60_000); await utimes(pidPath, staleTime, staleTime); - await acquirePidLock(paseoHome, null, { - ownerPid: replacementOwnerPid, - reclaimStaleDesktopLock: true, - }); + await expect( + acquirePidLock(paseoHome, null, { ownerPid: replacementOwnerPid }), + ).rejects.toThrow("Another Paseo daemon is already running"); const lock = await getPidLockInfo(paseoHome); - expect(lock?.pid).toBe(replacementOwnerPid); - expect(lock?.heartbeat).toBe(true); + expect(lock?.pid).toBe(process.pid); + expect(lock?.heartbeat).toBeUndefined(); } finally { await rm(paseoHome, { recursive: true, force: true }); } diff --git a/packages/server/src/server/pid-lock.ts b/packages/server/src/server/pid-lock.ts index 9202f49dfd..f7ad90bcd1 100644 --- a/packages/server/src/server/pid-lock.ts +++ b/packages/server/src/server/pid-lock.ts @@ -1,12 +1,12 @@ -import { open, readFile, stat, unlink, mkdir, utimes } from "node:fs/promises"; +import { open, readFile, unlink, utimes } from "node:fs/promises"; import type { FileHandle } from "node:fs/promises"; -import { existsSync } from "node:fs"; +import { ensurePrivateDirectory } from "./private-files.js"; import { join } from "node:path"; import { hostname } from "node:os"; import { z } from "zod"; export const pidLockInfoSchema = z.object({ - pid: z.number(), + pid: z.number().int().positive(), startedAt: z.string(), hostname: z.string(), uid: z.number(), @@ -36,18 +36,16 @@ export class PidLockError extends Error { } } -// Stale recovery is for abandoned locks, so keep this well above ordinary event-loop stalls. -const PID_LOCK_STALE_MS = 5 * 60_000; const PID_LOCK_HEARTBEAT_INTERVAL_MS = 30_000; const PID_LOCK_READ_RETRY_ATTEMPTS = 10; const PID_LOCK_READ_RETRY_DELAY_MS = 50; -function isPidRunning(pid: number): boolean { +export function isPidRunning(pid: number): boolean { try { process.kill(pid, 0); return true; - } catch { - return false; + } catch (error) { + return isErrnoException(error) && error.code === "EPERM"; } } @@ -55,27 +53,29 @@ function getPidFilePath(paseoHome: string): string { return join(paseoHome, "paseo.pid"); } -async function isPidLockFresh(pidPath: string): Promise { - try { - const lockStat = await stat(pidPath); - return lockStat.mtimeMs >= Date.now() - PID_LOCK_STALE_MS; - } catch { - return false; - } -} - async function touchPidLockFile(pidPath: string): Promise { const now = new Date(); await utimes(pidPath, now, now); } async function readPidLock(pidPath: string): Promise { - try { - const content = await readFile(pidPath, "utf-8"); - return parsePidLockInfo(JSON.parse(content)); - } catch { - return null; + let lastError: unknown; + for (let attempt = 0; attempt < PID_LOCK_READ_RETRY_ATTEMPTS; attempt++) { + try { + const content = await readFile(pidPath, "utf-8"); + const lock = parsePidLockInfo(JSON.parse(content)); + if (lock) return lock; + lastError = new Error("Invalid lock shape"); + } catch (error) { + if (isErrnoException(error) && error.code === "ENOENT") return null; + lastError = error; + } + await new Promise((resolve) => setTimeout(resolve, PID_LOCK_READ_RETRY_DELAY_MS)); } + throw Object.assign( + new PidLockError(`Cannot read daemon state at ${pidPath}: ${String(lastError)}`), + { code: "DAEMON_STATE_READ_FAILED" }, + ); } function resolveOwnerPid(ownerPid?: number): number { @@ -87,19 +87,9 @@ function resolveOwnerPid(ownerPid?: number): number { interface AcquirePidLockOptions { ownerPid?: number; - reclaimStaleDesktopLock?: boolean; } -function canReclaimLiveLock( - lock: PidLockInfo, - options: AcquirePidLockOptions | undefined, -): boolean { - // COMPAT(pidLockHeartbeat): v0.1.108 desktop startup has already confirmed the old daemon is - // unreachable before it launches the supervisor. Remove after 2027-01-15. - return options?.reclaimStaleDesktopLock === true && lock.desktopManaged === true; -} - -function isSamePidLock(left: PidLockInfo, right: PidLockInfo): boolean { +export function isSamePidLock(left: PidLockInfo, right: PidLockInfo): boolean { return left.pid === right.pid && left.startedAt === right.startedAt; } @@ -114,7 +104,6 @@ async function clearExistingPidLock( pidPath: string, existingLock: PidLockInfo, lockOwnerPid: number, - options: AcquirePidLockOptions | undefined, ): Promise<"already_owned" | "cleared"> { const lockOwnerRunning = isPidRunning(existingLock.pid); if (existingLock.pid === lockOwnerPid && lockOwnerRunning) { @@ -122,21 +111,14 @@ async function clearExistingPidLock( return "already_owned"; } - if (lockOwnerRunning) { - const reclaimable = canReclaimLiveLock(existingLock, options); - if (!reclaimable || (await isPidLockFresh(pidPath))) { - throw createLockHeldError(existingLock); - } - - // Re-read immediately before unlinking so a heartbeat at the stale boundary wins. - const confirmedLock = await readPidLock(pidPath); - if ( - !confirmedLock || - !isSamePidLock(existingLock, confirmedLock) || - (await isPidLockFresh(pidPath)) - ) { - throw new PidLockError("PID lock changed while checking whether it was abandoned"); - } + if (lockOwnerRunning) throw createLockHeldError(existingLock); + const confirmedLock = await readPidLock(pidPath); + if ( + !confirmedLock || + !isSamePidLock(existingLock, confirmedLock) || + isPidRunning(confirmedLock.pid) + ) { + throw new PidLockError("PID lock changed while checking whether it was abandoned"); } await unlink(pidPath).catch(() => {}); @@ -173,10 +155,7 @@ export async function acquirePidLock( ): Promise { const pidPath = getPidFilePath(paseoHome); - // Ensure paseoHome directory exists - if (!existsSync(paseoHome)) { - await mkdir(paseoHome, { recursive: true }); - } + ensurePrivateDirectory(paseoHome); // Try to read existing lock const existingLock = await readPidLock(pidPath); @@ -184,7 +163,7 @@ export async function acquirePidLock( // Check if existing lock is stale const lockOwnerPid = resolveOwnerPid(options?.ownerPid); if (existingLock) { - const result = await clearExistingPidLock(pidPath, existingLock, lockOwnerPid, options); + const result = await clearExistingPidLock(pidPath, existingLock, lockOwnerPid); if (result === "already_owned") { return; } @@ -298,7 +277,7 @@ export function startPidLockHeartbeat( export async function updatePidLock( paseoHome: string, - patch: { listen: string }, + patch: { listen: string | null }, options?: { ownerPid?: number }, ): Promise { const pidPath = getPidFilePath(paseoHome); @@ -329,7 +308,7 @@ export async function updatePidLock( export async function releasePidLock( paseoHome: string, - options?: { ownerPid?: number }, + options?: { ownerPid?: number; startedAt?: string }, ): Promise { const pidPath = getPidFilePath(paseoHome); const lockOwnerPid = resolveOwnerPid(options?.ownerPid); @@ -337,7 +316,10 @@ export async function releasePidLock( // Only remove if it's our lock const content = await readFile(pidPath, "utf-8"); const lock = parsePidLockInfo(JSON.parse(content)); - if (lock?.pid === lockOwnerPid) { + if ( + lock?.pid === lockOwnerPid && + (options?.startedAt === undefined || lock.startedAt === options.startedAt) + ) { await unlink(pidPath); } } catch { diff --git a/packages/server/src/server/workspace-git-service.observation.test.ts b/packages/server/src/server/workspace-git-service.observation.test.ts index 36c50769c8..5952307db4 100644 --- a/packages/server/src/server/workspace-git-service.observation.test.ts +++ b/packages/server/src/server/workspace-git-service.observation.test.ts @@ -1,4 +1,5 @@ import path from "node:path"; +import { pathToFileURL } from "node:url"; import type pino from "pino"; import { afterEach, beforeEach, describe, expect, test, vi } from "vitest"; import type { CheckoutSnapshotFacts, CheckoutStatusGit } from "../utils/checkout-git.js"; @@ -8,6 +9,8 @@ import { WorkspaceGitServiceImpl } from "./workspace-git-service.js"; const REPO_CWD = path.resolve("/tmp/paseo-observation-repo"); const GIT_DIR = path.join(REPO_CWD, ".git"); +// Checkout observation must not depend on installed forge CLIs or host-auth probes. +const REMOTE_URL = pathToFileURL(path.join(REPO_CWD, "remote.git")).href; const WORKTREE_A = path.resolve("/tmp/paseo-observation-worktree-a"); const WORKTREE_B = path.resolve("/tmp/paseo-observation-worktree-b"); @@ -529,7 +532,7 @@ describe("WorkspaceGitService checkout observation", () => { const getCheckoutSnapshotFacts = vi.fn(async (cwd: string) => ({ ...createCheckoutFacts(cwd), currentBranch: "feature", - remoteUrl: "https://example.com/repo.git", + remoteUrl: REMOTE_URL, resolvedBaseRef: "main", comparisonBaseRef: "origin/main", })); @@ -554,7 +557,7 @@ describe("WorkspaceGitService checkout observation", () => { currentBranch: "feature", baseRef: "main", hasRemote: true, - remoteUrl: "https://example.com/repo.git", + remoteUrl: REMOTE_URL, }), ), hasOriginRemote: vi.fn(async () => true), @@ -598,7 +601,7 @@ describe("WorkspaceGitService checkout observation", () => { const getCheckoutSnapshotFacts = vi.fn(async (cwd: string) => ({ ...createCheckoutFacts(cwd), currentBranch: "feature", - remoteUrl: "https://example.com/repo.git", + remoteUrl: REMOTE_URL, resolvedBaseRef: "main", comparisonBaseRef: "origin/main", })); @@ -655,7 +658,7 @@ describe("WorkspaceGitService checkout observation", () => { const releaseFetch = createDeferred(); const getCheckoutSnapshotFacts = vi.fn(async (cwd: string) => ({ ...createCheckoutFacts(cwd), - remoteUrl: "https://example.com/repo.git", + remoteUrl: REMOTE_URL, })); const runGitFetch = vi.fn(async (_cwd, observer) => { if (phase === "before") { @@ -710,7 +713,7 @@ describe("WorkspaceGitService checkout observation", () => { const releaseFetch = createDeferred(); const getCheckoutSnapshotFacts = vi.fn(async (cwd: string) => ({ ...createCheckoutFacts(cwd), - remoteUrl: "https://example.com/repo.git", + remoteUrl: REMOTE_URL, })); const getCheckoutRefDerivedState = vi.fn(); const service = createService(watcher, { @@ -760,7 +763,7 @@ describe("WorkspaceGitService checkout observation", () => { const getCheckoutSnapshotFacts = vi.fn(async (cwd: string) => ({ ...createCheckoutFacts(cwd), currentBranch: "feature", - remoteUrl: "https://example.com/repo.git", + remoteUrl: REMOTE_URL, resolvedBaseRef: "main", comparisonBaseRef: "origin/main", })); @@ -820,7 +823,7 @@ describe("WorkspaceGitService checkout observation", () => { const getCheckoutSnapshotFacts = vi.fn(async (cwd: string) => ({ ...createCheckoutFacts(cwd), currentBranch: "feature", - remoteUrl: "https://example.com/repo.git", + remoteUrl: REMOTE_URL, resolvedBaseRef: "main", comparisonBaseRef: "origin/main", })); @@ -849,7 +852,7 @@ describe("WorkspaceGitService checkout observation", () => { currentBranch: "feature", baseRef: "main", hasRemote: true, - remoteUrl: "https://example.com/repo.git", + remoteUrl: REMOTE_URL, }), ), hasOriginRemote: vi.fn(async () => true), @@ -893,7 +896,7 @@ describe("WorkspaceGitService checkout observation", () => { const getCheckoutSnapshotFacts = vi.fn(async (cwd: string) => ({ ...createCheckoutFacts(cwd), currentBranch: "feature", - remoteUrl: "https://example.com/repo.git", + remoteUrl: REMOTE_URL, resolvedBaseRef: "main", comparisonBaseRef: "origin/main", })); @@ -904,7 +907,7 @@ describe("WorkspaceGitService checkout observation", () => { currentBranch: "feature", baseRef: "main", hasRemote: true, - remoteUrl: "https://example.com/repo.git", + remoteUrl: REMOTE_URL, }), ), hasOriginRemote: vi.fn(async () => true), @@ -952,7 +955,7 @@ describe("WorkspaceGitService checkout observation", () => { const getCheckoutSnapshotFacts = vi.fn(async (cwd: string) => ({ ...createCheckoutFacts(cwd), currentBranch: "main", - remoteUrl: "https://example.com/repo.git", + remoteUrl: REMOTE_URL, resolvedBaseRef: "main", comparisonBaseRef: null, branchRemoteName: null, diff --git a/public-docs/cli.md b/public-docs/cli.md index ed626863f2..93b066e25b 100644 --- a/public-docs/cli.md +++ b/public-docs/cli.md @@ -276,18 +276,55 @@ Detaching is an explicit lifecycle action, not a creation flag. The agent keeps ## Daemon management +Define an instance once, then start its saved configuration: + ```bash -paseo daemon start # Start the daemon -paseo daemon start --web-ui # Start and serve the bundled web UI -paseo daemon status # Check status -paseo reload # Reload config.json (top-level alias) -paseo daemon reload # Reload config.json -paseo daemon stop # Stop the daemon +paseo daemon config set daemon.listen 127.0.0.1:6799 --home ~/paseo-test +paseo daemon config set daemon.relay.enabled false --home ~/paseo-test +paseo daemon start --home ~/paseo-test +paseo project ls --home ~/paseo-test +paseo daemon restart --home ~/paseo-test +paseo daemon stop --home ~/paseo-test ``` -Reload validates the whole file, applies runtime-safe changes, and reports `appliedPaths`, `restartRequiredPaths`, and `overrideControlledPaths`. Human output prints `paseo daemon restart` only when a changed setting needs it. Use `--json` or `--format yaml` for the structured result. Run `paseo --host reload` to reload a remote daemon's own configuration file. An older host that does not support reload returns an update-host error. +`start` runs in the background and reports the actual listening address and supervisor PID. It accepts only home selection and `--timeout ` (default 600). If waiting times out, the supervisor remains running: use the printed status, log, and stop instructions. A worker that exits before becoming ready makes startup fail. -Use `PASEO_HOME` to run multiple isolated daemon instances. +`restart` requests a replacement worker from the existing supervisor. It rereads the file and retains the supervisor's original environment and arguments. Success confirms a different ready worker, following a changed address for a home target. It never starts a stopped daemon or refreshes the supervisor binary. A timeout reports whether the request was acknowledged; it does not prove why reconnection failed. + +`stop --home` waits for that local supervisor to exit. On POSIX it signals the supervisor without contacting a TCP endpoint. On Windows it uses the ready daemon's shutdown RPC; an unbound instance requires explicit `--force`. `--force` permits forced process-tree cleanup after the graceful timeout (default 15 seconds). `stop --host` only reports **shutdown requested**; remote process exit is not verified. A service manager may start another instance after the captured supervisor exits. + +`status` separates local supervisor state, its published endpoint, the configured address, and RPC reachability. A stopped home is never probed at its configured address. An unbound live supervisor is **not ready**. If an authenticated local connection remains open but status details time out, the result stays `reachable` with a note explaining the unavailable details. Worker and provider fields are omitted. An explicit `--host` query still fails when its status request fails. + +`reload` validates the file, applies runtime-safe changes, and reports `appliedPaths`, `restartRequiredPaths`, and `overrideControlledPaths`. It never implicitly restarts. Use `--json` or `--format yaml` for structured results. An older host lacking the capability reports that it needs an update. + +The root aliases `start`, `status`, `restart`, `reload`, and `pair` use the same commands as `daemon`. Root `run` and `stop` remain agent operations. + +### Foreground deployments and migration + +Use environment overrides with the foreground deployment command: + +```bash +PASEO_LISTEN=127.0.0.1:6799 PASEO_RELAY_ENABLED=false paseo daemon run --home ~/paseo-test +``` + +It stays attached until the supervisor exits or you cancel, without a readiness timeout. Worker restart retains these launch inputs. Stop and relaunch the deployment to change them. If the home already has a live supervisor, `run` returns `already_running` without owning or launching a foreground process. + +Managed `start` ignores inherited daemon-setting overrides, including `PORT`, `PASEO_LISTEN`, relay, voice, and web UI settings. It preserves provider credentials and executable/runtime controls. `start --foreground` is removed; use `daemon run`. Former start/restart configuration flags such as `--port`, `--no-relay`, and `--web-ui` fail before side effects, with the corresponding `config set` migration. See [configuration edits](/docs/configuration#apply-changes). + +### Select one daemon + +Every daemon-connected CLI command accepts global `--home` or `--host`, before or after the command. A home selects a local supervisor's published endpoint; a host selects an explicit endpoint. There is no configured-address or default-port fallback. + +| Selectors | Result | +| ------------------------------------------ | ------------------------------------------------------ | +| `--home` | That local home, overriding both environment selectors | +| `--host` | That endpoint, overriding both environment selectors | +| Both flags, or conflicting duplicate flags | `TARGET_AMBIGUOUS` | +| Only `PASEO_HOME` or only `PASEO_HOST` | The corresponding target | +| Both environment selectors, without a flag | `TARGET_AMBIGUOUS` | +| Neither | Default local home, `~/.paseo` | + +Local-only `start`, `daemon run`, `config`, `onboard`, and `set-password` reject explicit `--host` and ignore `PASEO_HOST`. Endpoint operations retain TCP, Unix socket, Windows pipe, SSH, and pairing-offer transports. A host-side CLI controlling a container needs `--host` or `PASEO_HOST`. ## Hub @@ -329,12 +366,12 @@ The global `--host` option accepts either a local target (`host:port`, a unix so Get an offer URL from the daemon you want to control: ```bash -paseo daemon pair # asks before enabling relay, then prints the QR and link +paseo daemon pair # prints the QR and link when relay is enabled paseo daemon pair --relay # enables relay without prompting paseo daemon pair --json # structured output; never prompts ``` -Relay is off for new installations. In non-interactive or JSON mode, a disabled relay returns a `RELAY_DISABLED` error; pass `--relay` to provide explicit consent. Relay pairing is end-to-end encrypted. See [Security](/docs/security). +Relay is off for new installations. A disabled relay returns a `RELAY_DISABLED` error; pass `--relay` to provide explicit consent. For a stopped home, pairing is labelled offline; `--relay` saves relay enablement and the offer includes a start instruction. A live but unreachable home never falls back to an offline identity. Relay pairing is end-to-end encrypted. See [Security](/docs/security). Use it from anywhere: diff --git a/public-docs/configuration.md b/public-docs/configuration.md index a220420ee4..ca578a96ac 100644 --- a/public-docs/configuration.md +++ b/public-docs/configuration.md @@ -1,6 +1,6 @@ --- title: Configuration -description: Configure Paseo via config.json, environment variables, and CLI overrides. +description: Configure managed instances and foreground deployments. nav: Configuration order: 40 category: Configuration @@ -8,7 +8,7 @@ category: Configuration # Configuration -Paseo loads configuration from a single JSON file in your Paseo home directory, with optional environment variable and CLI overrides. +Managed instances load their configuration from the home’s `config.json` and defaults. Foreground deployments can override that file with environment variables. ## Where config lives @@ -18,18 +18,13 @@ By default, Paseo uses `~/.paseo` as its home directory. The configuration file ~/.paseo/config.json ``` -You can change the home directory by setting `PASEO_HOME` or passing `--home` to `paseo daemon start`. +You can change the home directory by setting `PASEO_HOME` or selecting `--home` on a CLI command. ## Precedence -Paseo merges configuration in this order: +Managed `start` and Desktop use defaults, then `config.json`. Inherited daemon-setting environment variables are removed from their launches. -1. Defaults -2. `config.json` -3. Environment variables -4. CLI flags - -Lists append across sources (for example, `hostnames` and `cors.allowedOrigins`). +Foreground `paseo daemon run`, Docker, and direct supervisor launches apply environment variables after the file. Existing legacy supervisor flags retain their precedence across worker restart. Changing deployment overrides requires stopping and relaunching that deployment. Lists append across sources, including hostnames and CORS origins. ## Example @@ -51,7 +46,19 @@ Minimal example that configures listening address, hostnames, and MCP: ## Apply changes -After saving `config.json`, reload it: +Edit the selected file through the CLI: + +```bash +paseo daemon config get daemon.listen --home ~/paseo-test +paseo daemon config set daemon.listen 127.0.0.1:6800 --home ~/paseo-test +paseo daemon config unset features.webUi.enabled --home ~/paseo-test +``` + +`get [path]` reports configured values and labels missing fields unset. It does not create a home. `set` parses JSON, otherwise treats the input as a string; `--string` forces a literal string. `--json` selects output format. Use whole-object JSON for dynamic keys containing dots. Unknown paths, invalid values, and invalid existing files are rejected without writing. Passwords use `set-password`. + +A successful edit saves the validated file, then reloads once if the instance is reachable. Output distinguishes applied changes, restart requirements, and deployment overrides. A stopped/unbound/unreachable instance reports **saved; not applied**. A failed reload reports **saved; reload failed** with a nonzero exit; the file remains saved. Edits never implicitly restart. + +After editing `config.json` directly, reload it: ```bash paseo reload @@ -69,7 +76,7 @@ New homes keep relay disabled when you remove `daemon.relay.enabled`. A daemon w Listen addresses, authentication, relay endpoints and TLS, worktree allocation, service-proxy addresses, the bundled web UI, logging, speech, voice, credentials, and local model settings require a restart. Reload applies other valid edits in the same file before reporting those paths. -Environment variables and daemon start flags remain authoritative. Reload reports a changed file setting under `overrideControlledPaths` when a launch override prevents it from taking effect. This includes startup settings such as listen addresses, passwords, relay endpoints and TLS, service-proxy and web UI settings, logging, speech, and voice configuration. List settings such as hostnames and CORS origins still append across sources, so values from `config.json` continue to apply. Remove the override and restart the daemon if you want the file value to become authoritative. +Deployment environment variables and legacy supervisor flags remain authoritative. Reload reports a changed file setting under `overrideControlledPaths` when a launch override prevents it from taking effect. This includes startup settings such as listen addresses, passwords, relay endpoints and TLS, service-proxy and web UI settings, logging, speech, and voice configuration. List settings such as hostnames and CORS origins still append across sources, so values from `config.json` continue to apply. Stop and relaunch the deployment without the override to make the file value authoritative. ## Agent providers @@ -104,13 +111,14 @@ The daemon can serve the browser web client from the same HTTP server. This is e Enable it from the CLI: ```bash -paseo daemon start --web-ui +paseo daemon config set features.webUi.enabled true +paseo daemon start ``` Or set the environment variable: ```bash -PASEO_WEB_UI_ENABLED=true paseo daemon start +PASEO_WEB_UI_ENABLED=true paseo daemon run ``` Or persist it in `config.json`: @@ -225,7 +233,7 @@ Set the persisted value in `config.json`: } ``` -`PASEO_RELAY_ENABLED=true|false` overrides the persisted value for that daemon launch. The matching `paseo daemon start --relay` and `--no-relay` flags have the same authority. Remove the launch override before changing relay from Paseo Desktop or `paseo daemon pair --relay`. +`PASEO_RELAY_ENABLED=true|false` overrides the file for a foreground deployment. Managed `start` uses the file. End and relaunch a deployment to remove its override before changing relay from the app or `paseo daemon pair --relay`. ## Common env vars diff --git a/public-docs/connectivity.md b/public-docs/connectivity.md index 425e605fd1..3263e61510 100644 --- a/public-docs/connectivity.md +++ b/public-docs/connectivity.md @@ -43,7 +43,7 @@ Set a different remote daemon port with `daemonPort`: paseo --host 'ssh://user@host?daemonPort=7777' ls -a ``` -Put `--host` before the command. `paseo daemon status` checks only the local daemon; use `paseo --host ssh://user@host ls` to verify a remote connection. `paseo --host ssh://user@host run --cwd /path/on/remote ...` requires a working directory that exists on the remote host. +Put `--host` before the command. `paseo daemon status` observes the default local home; use `paseo --host ssh://user@host daemon status` to query a remote daemon. `paseo --host ssh://user@host run --cwd /path/on/remote ...` requires a working directory that exists on the remote host. In Paseo Desktop, open **Settings → Add host → Remote SSH** and enter the same `ssh://` destination. diff --git a/public-docs/docker.md b/public-docs/docker.md index be533e66a2..f6324a09f2 100644 --- a/public-docs/docker.md +++ b/public-docs/docker.md @@ -41,6 +41,8 @@ The image: The image does not bundle agent CLIs such as Claude Code, Codex, OpenCode, Copilot, or Pi. Add the agents you use with a small child image. +Host-side CLI commands select the container explicitly, for example `paseo project ls --host 127.0.0.1:6767`. Without an endpoint selector the CLI looks for a local home’s supervisor. Container environment settings are deployment overrides; worker restart preserves them. Your container manager owns full supervisor replacement. + ## Docker Compose ```yaml diff --git a/public-docs/web-ui.md b/public-docs/web-ui.md index fd2628ed9f..9e80e8355e 100644 --- a/public-docs/web-ui.md +++ b/public-docs/web-ui.md @@ -20,16 +20,17 @@ The web app ships inside the daemon package, so the UI you serve always matches ## Enable it -The bundled web UI is off by default. Turn it on when you start the daemon: +The bundled web UI is off by default. Save the setting, then start the daemon: ```bash -paseo daemon start --web-ui +paseo daemon config set features.webUi.enabled true +paseo daemon start ``` -Or with an environment variable: +Or run a foreground deployment with an environment variable: ```bash -PASEO_WEB_UI_ENABLED=true paseo daemon start +PASEO_WEB_UI_ENABLED=true paseo daemon run ``` Or persist it in `config.json` so it survives restarts: @@ -73,7 +74,8 @@ The rest of this page builds from local to public. **Verify a direct connection By default the daemon listens on `127.0.0.1:6767`, reachable only from the same machine. To reach it from other devices, bind it to a network interface: ```bash -paseo daemon start --web-ui --listen 0.0.0.0:6767 +paseo daemon config set daemon.listen 0.0.0.0:6767 +paseo daemon start ``` > **Anyone who can reach the listening address can use your agents.** Before you bind beyond localhost, set a password and review your host allowlist. The relay pairing path avoids this entirely by keeping the daemon bound to localhost, see [Security](/docs/security). @@ -83,7 +85,7 @@ Two things to configure when you expose the daemon directly: 1. **Set a password** so only authorized clients can connect: ```bash - PASEO_PASSWORD=my-secret paseo daemon start --web-ui --listen 0.0.0.0:6767 + paseo daemon set-password ``` See [password authentication](/docs/configuration#password-authentication) for the persistent setup. Password auth controls access; it does not encrypt traffic, put TLS in front of it (below) on any untrusted network. @@ -91,7 +93,7 @@ Two things to configure when you expose the daemon directly: 2. **Allow your hostname** so the daemon's DNS-rebinding protection accepts requests for your domain: ```bash - paseo daemon start --web-ui --listen 0.0.0.0:6767 --hostnames ".example.com" + paseo daemon config set daemon.hostnames '[".example.com"]' ``` See [DNS rebinding protection](/docs/security#dns-rebinding-protection) for how the host allowlist works. @@ -179,7 +181,7 @@ If your proxy reaches the daemon from another address, as in some Docker, LAN, o `PASEO_TRUSTED_PROXIES` accepts the same comma-separated values: ```bash -PASEO_TRUSTED_PROXIES=loopback,172.16.0.0/12 paseo daemon start --web-ui +PASEO_TRUSTED_PROXIES=loopback,172.16.0.0/12 PASEO_WEB_UI_ENABLED=true paseo daemon run ``` Only use `trustedProxies: true` when your final trusted proxy overwrites client-supplied `X-Forwarded-*` headers. Otherwise a client could spoof forwarded header values. From 772ff8d6f37571aa2f3e0016827f026fefbde2ac Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Fri, 11 Sep 2026 09:05:26 +0200 Subject: [PATCH 20/46] fix(desktop): preserve Linux sandboxing wherever the host allows it (#4447) * fix(desktop): preserve Linux sandboxing wherever the host allows it Chromium checks its Linux sandbox before main.js, while package installation probes run as root and miss AppArmor restrictions on desktop users. Choose the launch flags before exec and always install the SUID alternative for native packages. Test published artifacts as an ordinary user with namespaces both denied and allowed; never repair permissions in the smoke harness. * fix(desktop): retain the Linux release build baseline * fix(ci): allow canceled desktop smoke uploads to stop * test(desktop): verify the installed RPM sandbox under AppArmor * fix(ci): resolve packaged desktop dependencies before installation * chore(desktop): keep QA evidence out of the repository --- .github/workflows/ci.yml | 45 +++++- .github/workflows/desktop-release.yml | 4 +- docs/release.md | 1 + docs/testing.md | 8 +- .../app/src/desktop/daemon/desktop-daemon.ts | 17 +++ .../desktop-diagnostic-report.test.ts | 18 +++ .../diagnostics/desktop-diagnostic-report.ts | 27 +++- packages/desktop/e2e/linux-artifact-smoke.js | 61 ++++++++ packages/desktop/e2e/packaged-app-smoke.js | 137 +++++++++++------- packages/desktop/electron-builder.yml | 6 + packages/desktop/scripts/after-pack.js | 6 + .../scripts/linux-sandbox/after-install.tpl | 54 +++++++ .../desktop/scripts/linux-sandbox/index.js | 12 ++ .../desktop/scripts/linux-sandbox/launcher.sh | 55 +++++++ packages/desktop/src/daemon/daemon-manager.ts | 6 + .../src/daemon/desktop-packaging.test.ts | 13 ++ .../src/daemon/linux-launcher.posix.test.ts | 123 ++++++++++++++++ .../desktop/src/diagnostics/sandbox.test.ts | 25 ++++ packages/desktop/src/diagnostics/sandbox.ts | 10 ++ packages/desktop/src/main.ts | 17 ++- public-docs/index.md | 21 +++ 21 files changed, 597 insertions(+), 69 deletions(-) create mode 100644 packages/desktop/e2e/linux-artifact-smoke.js create mode 100644 packages/desktop/scripts/linux-sandbox/after-install.tpl create mode 100644 packages/desktop/scripts/linux-sandbox/index.js create mode 100644 packages/desktop/scripts/linux-sandbox/launcher.sh create mode 100644 packages/desktop/src/daemon/linux-launcher.posix.test.ts create mode 100644 packages/desktop/src/diagnostics/sandbox.test.ts create mode 100644 packages/desktop/src/diagnostics/sandbox.ts diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 9a2e293848..b735b2b06c 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -178,7 +178,7 @@ jobs: name: desktop-tests (ubuntu-latest) needs: changes if: ${{ !cancelled() && (needs.changes.outputs.full != 'false' || needs.changes.outputs.desktop != 'false') }} - runs-on: ubuntu-latest + runs-on: ubuntu-24.04 timeout-minutes: 30 permissions: contents: read @@ -201,7 +201,7 @@ jobs: - name: Install virtual display if: ${{ runner.os == 'Linux' }} - run: sudo apt-get update && sudo apt-get install -y xvfb xauth xdotool + run: sudo apt-get update && sudo apt-get install -y xvfb xauth rpm xdotool - name: Install Playwright browser if: ${{ runner.os == 'Linux' }} @@ -249,17 +249,50 @@ jobs: if-no-files-found: ignore retention-days: 7 - - name: Build and smoke unpacked desktop app + - name: Build Linux desktop artifacts if: ${{ runner.os == 'Linux' }} - run: npm run build:desktop -- --publish never --linux --x64 --dir + run: npm run build:desktop -- --publish never --linux --x64 env: EP_GH_IGNORE_TIME: true - PASEO_DESKTOP_SMOKE: "1" PASEO_DESKTOP_SMOKE_ARTIFACT_DIR: ${{ runner.temp }}/desktop-smoke + - name: Smoke installed artifacts with restricted user namespaces + if: ${{ runner.os == 'Linux' }} + run: | + sudo sysctl -w kernel.apparmor_restrict_unprivileged_userns=1 + if unshare --user --map-root-user --net true; then + echo "Expected the launching user's namespace probe to be denied" + exit 1 + fi + sudo apt-get install -y ./packages/desktop/release/*.deb + node packages/desktop/e2e/linux-artifact-smoke.js packages/desktop/release disabled + env: + PASEO_DESKTOP_SMOKE_ARTIFACT_DIR: ${{ runner.temp }}/desktop-smoke/restricted + + - name: Smoke sandbox-preserving portable launches + if: ${{ runner.os == 'Linux' }} + run: | + sudo sysctl -w kernel.apparmor_restrict_unprivileged_userns=0 + unshare --user --map-root-user --net true + node packages/desktop/e2e/linux-artifact-smoke.js packages/desktop/release enabled + env: + PASEO_DESKTOP_SMOKE_ARTIFACT_DIR: ${{ runner.temp }}/desktop-smoke/userns + + - name: Smoke installed RPM with restricted user namespaces + if: ${{ runner.os == 'Linux' }} + run: | + sudo sysctl -w kernel.apparmor_restrict_unprivileged_userns=1 + sudo dpkg --remove paseo + # Ubuntu supplies the runtime libraries under Debian package names. + # RPM still performs the real payload installation and postinstall. + sudo rpm --install --nodeps packages/desktop/release/*.rpm + node packages/desktop/e2e/linux-artifact-smoke.js packages/desktop/release disabled --installed-only + env: + PASEO_DESKTOP_SMOKE_ARTIFACT_DIR: ${{ runner.temp }}/desktop-smoke/rpm + - name: Upload packaged smoke diagnostics uses: actions/upload-artifact@v4 - if: ${{ failure() && runner.os == 'Linux' }} + if: ${{ !cancelled() && runner.os == 'Linux' }} with: name: desktop-packaged-smoke-linux-x64 path: ${{ runner.temp }}/desktop-smoke diff --git a/.github/workflows/desktop-release.yml b/.github/workflows/desktop-release.yml index 5c9bc567ba..c21de2ab4e 100644 --- a/.github/workflows/desktop-release.yml +++ b/.github/workflows/desktop-release.yml @@ -261,7 +261,7 @@ jobs: NODE - name: Install Linux smoke display - run: sudo apt-get update && sudo apt-get install -y xvfb xauth + run: sudo apt-get update && sudo apt-get install -y xvfb xauth rpm - name: Build desktop release shell: bash @@ -278,7 +278,7 @@ jobs: npm run build:desktop "${build_args[@]}" - name: Upload packaged smoke diagnostics - if: failure() + if: always() uses: actions/upload-artifact@v4 with: name: desktop-smoke-linux-x64 diff --git a/docs/release.md b/docs/release.md index e66b8e6fbb..26af0182eb 100644 --- a/docs/release.md +++ b/docs/release.md @@ -192,6 +192,7 @@ npm run release:promote # Promote X.Y.Z-beta.N to stable X.Y.Z - Betas publish desktop assets and APKs for testing. They also build iOS, upload it to TestFlight, add it to the `Paseo Beta` external group, and submit it for Beta App Review. They do not submit mobile builds to the production stores. - `release:promote` creates a fresh stable tag like `v0.1.41`; the final release never reuses the beta tag - Desktop assets now come from the Electron package at `packages/desktop` +- Require the Linux artifact CI checks with both restricted and usable user namespaces to pass before publication; see [packaged desktop smoke](testing.md#packaged-desktop-smoke). Keep the installed-package and AppImage checks together. - Beta releases use Electron's `beta` update channel. Users on the stable channel only receive stable releases; users on the beta channel receive beta releases and the final stable release when it is published. - **Each beta carries its own changelog entry.** `Release Notes Sync` mirrors the matching `## X.Y.Z-beta.N` entry into that prerelease body. Promotion collapses every beta entry for the version into one final stable entry. See the Changelog policy section. diff --git a/docs/testing.md b/docs/testing.md index 9b94de5038..933dcba740 100644 --- a/docs/testing.md +++ b/docs/testing.md @@ -107,14 +107,18 @@ When a test is labeled end-to-end, it calls the real service. No environment var The packaged desktop smoke is an external observer of the production launch path. It must not add a smoke-only branch to Electron main or start the daemon itself. -The harness launches the unpacked packaged app with isolated user data and daemon state, connects to the real renderer over Chromium's debugging protocol, and requires all of these outcomes: +The harness launches the packaged app with isolated user data and daemon state, connects to the real renderer over Chromium's debugging protocol, and requires all of these outcomes: - the `paseo://app/` renderer mounts into `#root`; - the sandboxed preload exposes the desktop bridge; - the renderer starts a fresh desktop-managed daemon through the normal startup bootstrap; - the bundled CLI can query that daemon and run a terminal command. -Pull-request CI runs the Linux x64 smoke under Xvfb when the cumulative PR diff changes `packages/desktop/**`. The desktop release matrix runs the harness against each host-native packaged app before publishing. All smoke jobs upload renderer, desktop, and daemon diagnostics on failure. +Pull-request CI runs the Linux x64 smoke under Xvfb when the cumulative PR diff selects desktop coverage. The Linux job is pinned to Ubuntu 24.04 and runs twice: with AppArmor user namespace restrictions enabled, then with user namespaces available. It installs the real `.deb`, launches the real AppImage via `--appimage-extract-and-run`, and launches the extracted tar archive. It also replaces the Debian installation with the generated RPM through `rpm --install --nodeps` and checks its sandboxed launch under restrictions. Ubuntu supplies the runtime libraries under Debian package names, so this verifies the RPM payload and postinstall rather than Fedora dependency resolution. Each launch verifies the reported sandbox decision; enabled renderers must also have `NoNewPrivs: 1` and `Seccomp: 2` in `/proc`. The desktop release matrix retains its host-native smokes. Linux release builds stay on Ubuntu 22.04 to preserve their native-library baseline; the restricted-host regression runs on Ubuntu 24.04 in PR CI. + +Never repair `chrome-sandbox` in the smoke harness. The old unpacked smoke set its mode to 4755 and concealed a broken package installer. Run installer tests as root and launch tests as an ordinary user: a root-run namespace probe does not reproduce Ubuntu's AppArmor policy for desktop users. Preserve both restricted and unrestricted cases; either one alone permits another sandbox regression. + +Smoke jobs upload renderer screenshots and desktop/daemon diagnostics, including successful Linux sandbox evidence. To exercise the smoke locally on Linux: diff --git a/packages/app/src/desktop/daemon/desktop-daemon.ts b/packages/app/src/desktop/daemon/desktop-daemon.ts index cc7574dc21..87cc058a0c 100644 --- a/packages/app/src/desktop/daemon/desktop-daemon.ts +++ b/packages/app/src/desktop/daemon/desktop-daemon.ts @@ -335,3 +335,20 @@ export function readLegacySkillSelection(): Promise export async function deleteLegacySkillSelection(): Promise { await invokeDesktopCommand("delete_legacy_skill_selection"); } + +export interface DesktopSandboxDiagnostics { + enabled: boolean; + reason: string; +} + +export async function getDesktopSandboxDiagnostics(): Promise { + const result: unknown = await invokeDesktopCommand("desktop_sandbox_diagnostics"); + if ( + !isRecord(result) || + typeof result.enabled !== "boolean" || + typeof result.reason !== "string" + ) { + throw new Error("Unexpected desktop sandbox diagnostics response."); + } + return { enabled: result.enabled, reason: result.reason }; +} diff --git a/packages/app/src/diagnostics/desktop-diagnostic-report.test.ts b/packages/app/src/diagnostics/desktop-diagnostic-report.test.ts index 34c4482a4c..295344458f 100644 --- a/packages/app/src/diagnostics/desktop-diagnostic-report.test.ts +++ b/packages/app/src/diagnostics/desktop-diagnostic-report.test.ts @@ -6,6 +6,7 @@ import { function makeSources(): DesktopDiagnosticSources { return { + getSandboxDiagnostics: async () => ({ enabled: true, reason: "user namespaces available" }), getStatus: async () => ({ serverId: "server-1", status: "running", @@ -147,3 +148,20 @@ describe("desktop diagnostic report", () => { expect(report).toContain("Desktop app log tail\n Error: app log unavailable"); }); }); + +test("reports sandbox degradation and its reason even if daemon diagnostics fail", async () => { + const result = await collectDesktopDiagnosticSections({ + ...makeSources(), + getStatus: async () => { + throw new Error("daemon unavailable"); + }, + getSandboxDiagnostics: async () => ({ + enabled: false, + reason: "user namespaces unavailable; no usable SUID helper", + }), + }); + expect(result.sections.join("\n")).toContain( + "Chromium sandbox\n State: Disabled\n Reason: user namespaces unavailable; no usable SUID helper", + ); + expect(result.status).toBe("failed"); +}); diff --git a/packages/app/src/diagnostics/desktop-diagnostic-report.ts b/packages/app/src/diagnostics/desktop-diagnostic-report.ts index 531ae11fbb..22eb7e388f 100644 --- a/packages/app/src/diagnostics/desktop-diagnostic-report.ts +++ b/packages/app/src/diagnostics/desktop-diagnostic-report.ts @@ -1,4 +1,6 @@ import { + getDesktopSandboxDiagnostics, + type DesktopSandboxDiagnostics, getDesktopAppLogs, getDesktopDaemonLogs, getDesktopDaemonStatus, @@ -19,6 +21,7 @@ export interface DesktopDiagnosticCollectionResult { } export interface DesktopDiagnosticSources { + getSandboxDiagnostics: () => Promise; getStatus: () => Promise; getDaemonLogs: () => Promise; getAppLogs: () => Promise; @@ -26,6 +29,7 @@ export interface DesktopDiagnosticSources { } const DEFAULT_DESKTOP_DIAGNOSTIC_SOURCES: DesktopDiagnosticSources = { + getSandboxDiagnostics: getDesktopSandboxDiagnostics, getStatus: getDesktopDaemonStatus, getDaemonLogs: getDesktopDaemonLogs, getAppLogs: getDesktopAppLogs, @@ -38,12 +42,33 @@ export async function collectDesktopDiagnosticSections( const sections: string[] = []; let failed = false; - const [daemonResult, appLogsResult, updaterResult] = await Promise.allSettled([ + const [daemonResult, appLogsResult, updaterResult, sandboxResult] = await Promise.allSettled([ Promise.all([sources.getStatus(), sources.getDaemonLogs()]), sources.getAppLogs(), sources.getUpdaterDiagnostics(), + sources.getSandboxDiagnostics(), ]); + if (sandboxResult.status === "fulfilled") { + const sandbox = sandboxResult.value; + sections.push( + formatDiagnosticSection("Chromium sandbox", [ + { + label: "State", + value: sandbox.enabled ? "Enabled" : "Disabled", + }, + { label: "Reason", value: sandbox.reason }, + ]), + ); + } else { + failed = true; + sections.push( + formatDiagnosticSection("Chromium sandbox", [ + { label: "Error", value: toMessage(sandboxResult.reason) }, + ]), + ); + } + if (daemonResult.status === "fulfilled") { const [status, daemonLogs] = daemonResult.value; const appLogs = appLogsResult.status === "fulfilled" ? appLogsResult.value : null; diff --git a/packages/desktop/e2e/linux-artifact-smoke.js b/packages/desktop/e2e/linux-artifact-smoke.js new file mode 100644 index 0000000000..5a81bf07de --- /dev/null +++ b/packages/desktop/e2e/linux-artifact-smoke.js @@ -0,0 +1,61 @@ +// Observe published Linux artifacts without repairing their sandbox permissions. +const fs = require("node:fs"); +const os = require("node:os"); +const path = require("node:path"); +const { execFileSync } = require("node:child_process"); +const { smokePackagedDesktopApp } = require("./packaged-app-smoke.js"); + +async function main() { + if (process.getuid() === 0) throw new Error("Launch the smoke as an unprivileged user"); + const release = path.resolve(process.argv[2]); + const portableSandbox = process.argv[3] === "enabled"; + const artifactRoot = process.env.PASEO_DESKTOP_SMOKE_ARTIFACT_DIR; + const installedOnly = process.argv.includes("--installed-only"); + const extracted = fs.mkdtempSync(path.join(os.tmpdir(), "paseo-linux-artifacts-")); + const findArtifact = (suffix) => { + const matches = fs.readdirSync(release).filter((file) => file.endsWith(suffix)); + if (matches.length !== 1) throw new Error(`Expected one ${suffix} in ${release}: ${matches}`); + return path.join(release, matches[0]); + }; + try { + process.env.PASEO_DESKTOP_SMOKE_ARTIFACT_DIR = path.join(artifactRoot, "installed"); + const helper = fs.statSync("/opt/Paseo/chrome-sandbox"); + if (helper.uid !== 0 || (helper.mode & 0o7777) !== 0o4755) { + throw new Error("Installed native package did not provide a root-owned 4755 helper"); + } + await smokePackagedDesktopApp({ appPath: "/opt/Paseo", expectedSandbox: true }); + if (installedOnly) return; + + const appImage = findArtifact(".AppImage"); + fs.chmodSync(appImage, 0o755); + execFileSync(appImage, ["--appimage-extract"], { cwd: extracted, stdio: "ignore" }); + const appDir = path.join(extracted, "squashfs-root"); + const desktopEntry = fs.readFileSync(path.join(appDir, "Paseo.desktop"), "utf8"); + if (/^Exec=.*--no-sandbox/m.test(desktopEntry)) { + throw new Error("AppImage desktop entry bypasses runtime sandbox policy"); + } + process.env.PASEO_DESKTOP_SMOKE_ARTIFACT_DIR = path.join(artifactRoot, "appimage"); + await smokePackagedDesktopApp({ + appPath: appDir, + executablePath: appImage, + launchArgs: ["--appimage-extract-and-run"], + expectedSandbox: portableSandbox, + }); + + const tarDir = path.join(extracted, "tar"); + fs.mkdirSync(tarDir); + execFileSync("tar", ["-xzf", findArtifact(".tar.gz"), "-C", tarDir]); + const appPath = fs.existsSync(path.join(tarDir, "Paseo")) + ? tarDir + : path.join(tarDir, fs.readdirSync(tarDir)[0]); + process.env.PASEO_DESKTOP_SMOKE_ARTIFACT_DIR = path.join(artifactRoot, "tar"); + await smokePackagedDesktopApp({ appPath, expectedSandbox: portableSandbox }); + } finally { + fs.rmSync(extracted, { recursive: true, force: true }); + } +} + +main().catch((error) => { + console.error(error); + process.exitCode = 1; +}); diff --git a/packages/desktop/e2e/packaged-app-smoke.js b/packages/desktop/e2e/packaged-app-smoke.js index 8b121a1e7f..f3c5a30cda 100644 --- a/packages/desktop/e2e/packaged-app-smoke.js +++ b/packages/desktop/e2e/packaged-app-smoke.js @@ -68,52 +68,17 @@ function getMacMainExecutablePath(appPath) { return path.join(appPath, "Contents", "MacOS", EXECUTABLE_NAME); } -function ensureLinuxSandboxPermissions(appPath) { - if (process.platform !== "linux") { - return; - } - - const sandboxPath = path.join(appPath, "chrome-sandbox"); - if (!fs.existsSync(sandboxPath)) { - throw new Error(`Chromium sandbox helper does not exist: ${sandboxPath}`); - } - - const hasRequiredPermissions = () => { - const stat = fs.statSync(sandboxPath); - return stat.uid === 0 && (stat.mode & 0o7777) === 0o4755; - }; - if (hasRequiredPermissions()) { - return; - } - - const chown = spawnSync("sudo", ["-n", "chown", "root:root", sandboxPath], { - encoding: "utf8", - }); - const chmod = - chown.status === 0 - ? spawnSync("sudo", ["-n", "chmod", "4755", sandboxPath], { encoding: "utf8" }) - : null; - if (chown.error || chown.status !== 0 || chmod?.error || chmod?.status !== 0) { - throw new Error( - `Failed to configure Chromium sandbox helper ${sandboxPath}. Run: sudo chown root:root ${sandboxPath} && sudo chmod 4755 ${sandboxPath}.\n${chown.stderr?.trim() || chmod?.stderr?.trim() || chown.error || chmod?.error || "Permissions remained incorrect."}`, - ); - } - if (!hasRequiredPermissions()) { - throw new Error(`Chromium sandbox helper permissions remained incorrect: ${sandboxPath}`); - } -} - -function getLaunchCommand(executablePath) { +function getLaunchCommand(executablePath, args) { if (process.platform !== "linux") { return { command: executablePath, - args: [], + args, }; } return { command: "xvfb-run", - args: ["-a", "--server-args=-screen 0 1280x800x24", executablePath], + args: ["-a", "--server-args=-screen 0 1280x800x24", executablePath, ...args], }; } @@ -312,7 +277,7 @@ function formatLogs({ stdout, stderr, userData, daemonHome }) { ].join("\n\n"); } -async function writeFailureArtifacts({ page, stdout, stderr, userData, daemonHome, error }) { +async function writeSmokeArtifacts({ page, stdout, stderr, userData, daemonHome, error }) { const artifactDir = process.env.PASEO_DESKTOP_SMOKE_ARTIFACT_DIR?.trim(); if (!artifactDir) { return; @@ -320,13 +285,15 @@ async function writeFailureArtifacts({ page, stdout, stderr, userData, daemonHom fs.mkdirSync(artifactDir, { recursive: true }); fs.writeFileSync( - path.join(artifactDir, "failure.txt"), - `${error instanceof Error ? (error.stack ?? error.message) : String(error)}\n\n${formatLogs({ - stdout, - stderr, - userData, - daemonHome, - })}\n`, + path.join(artifactDir, error ? "failure.txt" : "success.txt"), + `${error instanceof Error ? (error.stack ?? error.message) : String(error ?? "Packaged desktop smoke passed")}\n\n${formatLogs( + { + stdout, + stderr, + userData, + daemonHome, + }, + )}\n`, ); const desktopLog = readIfExists(path.join(userData, "logs", "main.log")); @@ -793,6 +760,66 @@ async function stopCliDaemon({ appPath, env }) { }); } +async function openSmokeWorkspace({ appPath, env, page, daemonHome }) { + const projectPath = path.join(daemonHome, "sandbox-smoke-project"); + fs.mkdirSync(projectPath); + fs.writeFileSync(path.join(projectPath, "README.md"), "Packaged Linux sandbox smoke\n"); + const workspace = await runCliShimJsonCommand({ + appPath, + env, + args: [ + "workspace", + "create", + "--isolation", + "local", + "--path", + projectPath, + "--title", + "Sandbox smoke workspace", + ], + label: "Create packaged smoke workspace", + }); + await page.getByRole("button", { name: "Sandbox smoke workspace", exact: true }).click(); + await page.waitForURL((url) => url.pathname.endsWith(`/workspace/${workspace.workspaceId}`)); + await page.getByRole("textbox", { name: "Message agent..." }).click(); + console.log(`Packaged desktop smoke: renderer opened workspace ${workspace.workspaceId}`); +} + +async function assertSandboxState({ browser, page, expectedSandbox, stdout, stderr }) { + const diagnostics = await page.evaluate(() => + window.paseoDesktop.invoke("desktop_sandbox_diagnostics"), + ); + if (diagnostics.enabled !== expectedSandbox) { + throw new Error( + `Unexpected sandbox decision: ${JSON.stringify(diagnostics)}; expected enabled=${expectedSandbox}`, + ); + } + if (!stdout.join("").includes("[desktop] app startup")) { + throw new Error("Missing production app startup log"); + } + const session = await browser.newBrowserCDPSession(); + const { processInfo } = await session.send("SystemInfo.getProcessInfo"); + await session.detach(); + const renderers = processInfo.filter((info) => info.type === "renderer"); + if (renderers.length === 0) throw new Error("No renderer process found"); + for (const renderer of renderers) { + const status = fs.readFileSync(`/proc/${renderer.id}/status`, "utf8"); + const security = status + .split("\n") + .filter((line) => /^(NoNewPrivs|Seccomp|Uid|Gid):/.test(line)) + .join("; "); + const report = `Sandbox evidence: enabled=${expectedSandbox}; ${diagnostics.reason}; renderer ${renderer.id}: ${security}`; + console.log(report); + stdout.push(`${report}\n`); + if ( + expectedSandbox && + (!/^NoNewPrivs:\s+1$/m.test(status) || !/^Seccomp:\s+2$/m.test(status)) + ) { + throw new Error(`Renderer lacks OS sandbox: ${report}\n${stderr.join("")}`); + } + } +} + function assertLinuxDesktopIdentity(appPath) { if (process.platform === "linux") { const metadata = JSON.parse( @@ -806,11 +833,14 @@ function assertLinuxDesktopIdentity(appPath) { } } -async function smokePackagedDesktopApp({ appPath }) { - const executablePath = getExecutablePath(appPath); +async function smokePackagedDesktopApp({ + appPath, + executablePath = getExecutablePath(appPath), + launchArgs = [], + expectedSandbox, +}) { assertExecutable(executablePath, "Packaged app executable"); assertLinuxDesktopIdentity(appPath); - ensureLinuxSandboxPermissions(appPath); await smokeColdCliDaemonStart({ appPath }); const userData = createTempDir("paseo-smoke-user-data-"); @@ -834,7 +864,7 @@ async function smokePackagedDesktopApp({ appPath }) { const stdout = []; const stderr = []; - const launch = getLaunchCommand(executablePath); + const launch = getLaunchCommand(executablePath, launchArgs); console.log(`Packaged desktop smoke: launching ${launch.command} ${launch.args.join(" ")}`); const child = spawn(launch.command, launch.args, { detached: process.platform !== "win32", @@ -886,12 +916,17 @@ async function smokePackagedDesktopApp({ appPath }) { console.log("Packaged desktop smoke: renderer-started desktop daemon reported running"); await smokeCliShim({ appPath, env }); await smokeCliTerminal({ appPath, env }); + if (expectedSandbox !== undefined) { + await assertSandboxState({ browser, page, expectedSandbox, stdout, stderr }); + await openSmokeWorkspace({ appPath, env, page, daemonHome }); + } + await writeSmokeArtifacts({ page, stdout, stderr, userData, daemonHome }); await stopDaemonForCleanup(); console.log( `Packaged desktop smoke passed: real renderer and preload loaded; renderer-started desktop daemon pid ${status.pid}, listen ${status.listen}; CLI shim daemon status and terminal smoke succeeded`, ); } catch (error) { - await writeFailureArtifacts({ page, stdout, stderr, userData, daemonHome, error }).catch( + await writeSmokeArtifacts({ page, stdout, stderr, userData, daemonHome, error }).catch( (artifactError) => { console.warn(`Packaged desktop smoke: failed to write failure artifacts: ${artifactError}`); }, diff --git a/packages/desktop/electron-builder.yml b/packages/desktop/electron-builder.yml index 5fa62ef91a..fb01c7b194 100644 --- a/packages/desktop/electron-builder.yml +++ b/packages/desktop/electron-builder.yml @@ -66,7 +66,13 @@ linux: - deb - rpm - tar.gz +deb: + afterInstall: scripts/linux-sandbox/after-install.tpl +rpm: + afterInstall: scripts/linux-sandbox/after-install.tpl appImage: + # A nonempty argument list replaces builder's hardcoded --no-sandbox default. + executableArgs: ["--class=Paseo"] # Keep the AppImage filename stable across versions (no ${version}) so # electron-updater overwrites the file in place instead of renaming it to a new # versioned path. A rename orphans the old binary, breaks desktop shortcuts, and diff --git a/packages/desktop/scripts/after-pack.js b/packages/desktop/scripts/after-pack.js index e5222d68fe..9826a6a1e9 100644 --- a/packages/desktop/scripts/after-pack.js +++ b/packages/desktop/scripts/after-pack.js @@ -3,6 +3,8 @@ const path = require("path"); const { smokePackagedDesktopApp } = require("../e2e/packaged-app-smoke.js"); +const { installLinuxLauncher } = require("./linux-sandbox"); + const EXECUTABLE_NAME = "Paseo"; // electron-builder arch enum → Node.js arch string @@ -115,6 +117,10 @@ exports.default = async function afterPack(context) { pruneNativeModules(context.appOutDir, platform, arch); + if (platform === "linux") { + installLinuxLauncher(context.appOutDir); + } + if (platform === "linux" || platform === "win32") { if (arch !== process.arch) { console.log( diff --git a/packages/desktop/scripts/linux-sandbox/after-install.tpl b/packages/desktop/scripts/linux-sandbox/after-install.tpl new file mode 100644 index 0000000000..5723ea065b --- /dev/null +++ b/packages/desktop/scripts/linux-sandbox/after-install.tpl @@ -0,0 +1,54 @@ +#!/bin/bash + +if type update-alternatives >/dev/null 2>&1; then + # Remove previous link if it doesn't use update-alternatives + if [ -L '/usr/bin/${executable}' -a -e '/usr/bin/${executable}' -a "`readlink '/usr/bin/${executable}'`" != '/etc/alternatives/${executable}' ]; then + rm -f '/usr/bin/${executable}' + fi + update-alternatives --install '/usr/bin/${executable}' '${executable}' '/opt/${sanitizedProductName}/${executable}' 100 || ln -sf '/opt/${sanitizedProductName}/${executable}' '/usr/bin/${executable}' +else + ln -sf '/opt/${sanitizedProductName}/${executable}' '/usr/bin/${executable}' +fi + +# Package installation runs as root, whose namespace access says nothing about +# the desktop user under AppArmor. Always provide Chromium's SUID alternative. +chown root:root '/opt/${sanitizedProductName}/chrome-sandbox' || exit 1 +chmod 4755 '/opt/${sanitizedProductName}/chrome-sandbox' || exit 1 + +if hash update-mime-database 2>/dev/null; then + update-mime-database /usr/share/mime || true +fi + +if hash update-desktop-database 2>/dev/null; then + update-desktop-database /usr/share/applications || true +fi + +# Install apparmor profile. (Ubuntu 24+) +# First check if the version of AppArmor running on the device supports our profile. +# This is in order to keep backwards compatibility with Ubuntu 22.04 which does not support abi/4.0. +# In that case, we just skip installing the profile since the app runs fine without it on 22.04. +# +# Those apparmor_parser flags are akin to performing a dry run of loading a profile. +# https://wiki.debian.org/AppArmor/HowToUse#Dumping_profiles +# +# Unfortunately, at the moment AppArmor doesn't have a good story for backwards compatibility. +# https://askubuntu.com/questions/1517272/writing-a-backwards-compatible-apparmor-profile +if apparmor_status --enabled > /dev/null 2>&1; then + APPARMOR_PROFILE_SOURCE='/opt/${sanitizedProductName}/resources/apparmor-profile' + APPARMOR_PROFILE_TARGET='/etc/apparmor.d/${executable}' + if apparmor_parser --skip-kernel-load --debug "$APPARMOR_PROFILE_SOURCE" > /dev/null 2>&1; then + cp -f "$APPARMOR_PROFILE_SOURCE" "$APPARMOR_PROFILE_TARGET" + + # Updating the current AppArmor profile is not possible and probably not meaningful in a chroot'ed environment. + # Use cases are for example environments where images for clients are maintained. + # There, AppArmor might correctly be installed, but live updating makes no sense. + if ! { [ -x '/usr/bin/ischroot' ] && /usr/bin/ischroot; } && hash apparmor_parser 2>/dev/null; then + # Extra flags taken from dh_apparmor: + # > By using '-W -T' we ensure that any abstraction updates are also pulled in. + # https://wiki.debian.org/AppArmor/Contribute/FirstTimeProfileImport + apparmor_parser --replace --write-cache --skip-read-cache "$APPARMOR_PROFILE_TARGET" + fi + else + echo "Skipping the installation of the AppArmor profile as this version of AppArmor does not seem to support the bundled profile" + fi +fi diff --git a/packages/desktop/scripts/linux-sandbox/index.js b/packages/desktop/scripts/linux-sandbox/index.js new file mode 100644 index 0000000000..f641e721c2 --- /dev/null +++ b/packages/desktop/scripts/linux-sandbox/index.js @@ -0,0 +1,12 @@ +const fs = require("node:fs"); +const path = require("node:path"); + +// Keep one pre-Chromium entrypoint for AppRun, desktop entries, updates, and tarballs. +exports.installLinuxLauncher = function installLinuxLauncher(appOutDir) { + const launcher = path.join(appOutDir, "Paseo"); + if (!fs.existsSync(`${launcher}.bin`)) { + fs.renameSync(launcher, `${launcher}.bin`); + } + fs.copyFileSync(path.join(__dirname, "launcher.sh"), launcher); + fs.chmodSync(launcher, 0o755); +}; diff --git a/packages/desktop/scripts/linux-sandbox/launcher.sh b/packages/desktop/scripts/linux-sandbox/launcher.sh new file mode 100644 index 0000000000..8f0b6a609f --- /dev/null +++ b/packages/desktop/scripts/linux-sandbox/launcher.sh @@ -0,0 +1,55 @@ +#!/bin/sh +set -eu + +# Resolve /usr/bin alternatives and user-created symlinks before finding Electron. +launcher=$(readlink -f -- "$0") +executable="${launcher}.bin" + +# Node entrypoints are not Chromium processes. Never inject browser flags here. +if [ "${ELECTRON_RUN_AS_NODE:-}" = 1 ]; then + exec "$executable" "$@" +fi + +for arg in "$@"; do + if [ "$arg" = '--no-sandbox' ]; then + export PASEO_DESKTOP_SANDBOX_REASON='requested by --no-sandbox' + printf '[linux-sandbox] disabled: %s\n' "$PASEO_DESKTOP_SANDBOX_REASON" >&2 || true + exec "$executable" "$@" + fi +done + +# Match main's whitespace-separated debugging flags without evaluating shell code. +set -f +for flag in ${PASEO_ELECTRON_FLAGS:-}; do + if [ "$flag" = '--no-sandbox' ]; then + export PASEO_DESKTOP_SANDBOX_REASON='requested by PASEO_ELECTRON_FLAGS' + printf '[linux-sandbox] disabled: %s\n' "$PASEO_DESKTOP_SANDBOX_REASON" >&2 || true + exec "$executable" --no-sandbox "$@" + fi +done + +# Map the real UID and create a network namespace as Chromium does. A bare +# unshare --user can succeed under AppArmor while namespace capabilities fail. +if namespace_error=$(unshare --user --map-root-user --net true 2>&1); then + export PASEO_DESKTOP_SANDBOX_REASON='user namespaces available' + printf '[linux-sandbox] enabled: %s\n' "$PASEO_DESKTOP_SANDBOX_REASON" >&2 || true + exec "$executable" "$@" +fi + +helper="$(dirname -- "$launcher")/chrome-sandbox" +if [ -x "$helper" ] && [ "$(stat -Lc '%u:%a' -- "$helper")" = '0:4755' ] && + grep -q '^NoNewPrivs:[[:space:]]*0$' /proc/self/status; then + mount_options=$(findmnt -n -o VFS-OPTIONS --target "$helper" 2>/dev/null || true) + case ",$mount_options," in + *,nosuid,*|,,) ;; + *) + export PASEO_DESKTOP_SANDBOX_REASON='root-owned SUID helper available' + printf '[linux-sandbox] enabled: %s\n' "$PASEO_DESKTOP_SANDBOX_REASON" >&2 || true + exec "$executable" "$@" + ;; + esac +fi + +export PASEO_DESKTOP_SANDBOX_REASON="user namespaces unavailable${namespace_error:+ ($namespace_error)}; no usable SUID helper" +printf '[linux-sandbox] disabled: %s\n' "$PASEO_DESKTOP_SANDBOX_REASON" >&2 || true +exec "$executable" --no-sandbox "$@" diff --git a/packages/desktop/src/daemon/daemon-manager.ts b/packages/desktop/src/daemon/daemon-manager.ts index b2e19798c8..c54a252d81 100644 --- a/packages/desktop/src/daemon/daemon-manager.ts +++ b/packages/desktop/src/daemon/daemon-manager.ts @@ -44,6 +44,7 @@ import { import type { DesktopSettings } from "../settings/desktop-settings.js"; import { getDesktopSettingsStore } from "../settings/desktop-settings-electron.js"; import { isRunningUnderARM64Translation } from "../system/arm64-translation.js"; +import { describeSandbox } from "../diagnostics/sandbox.js"; import { getDesktopAppLogs } from "../diagnostics/app-logs.js"; import { getDesktopUpdaterDiagnostics } from "../diagnostics/updater.js"; import { @@ -408,6 +409,11 @@ export function createDaemonCommandHandlers(): Record restartDaemon(), desktop_daemon_logs: () => getDaemonLogs(), + desktop_sandbox_diagnostics: () => + describeSandbox({ + disabled: app.commandLine.hasSwitch("no-sandbox"), + launcherReason: process.env.PASEO_DESKTOP_SANDBOX_REASON, + }), desktop_app_logs: () => getDesktopAppLogs(), desktop_update_diagnostics: () => getDesktopUpdaterDiagnostics(), desktop_get_system_idle_time: () => powerMonitor.getSystemIdleTime() * 1000, diff --git a/packages/desktop/src/daemon/desktop-packaging.test.ts b/packages/desktop/src/daemon/desktop-packaging.test.ts index c58c3f05e4..f38e679d3e 100644 --- a/packages/desktop/src/daemon/desktop-packaging.test.ts +++ b/packages/desktop/src/daemon/desktop-packaging.test.ts @@ -177,3 +177,16 @@ describe("desktop packaging", () => { } }); }); + +it("installs the Linux helper as root-owned 4755 regardless of root's namespace access", () => { + const config = readFileSync(join(packageRoot, "electron-builder.yml"), "utf8"); + expect(config).toContain("afterInstall: scripts/linux-sandbox/after-install.tpl"); + const installer = readFileSync( + join(packageRoot, "scripts/linux-sandbox/after-install.tpl"), + "utf8", + ); + expect(installer).not.toContain("unshare"); + expect(installer).toContain("chown root:root '/opt/${sanitizedProductName}/chrome-sandbox'"); + expect(installer).toContain("chmod 4755 '/opt/${sanitizedProductName}/chrome-sandbox'"); + expect(installer).not.toContain("chmod 0755"); +}); diff --git a/packages/desktop/src/daemon/linux-launcher.posix.test.ts b/packages/desktop/src/daemon/linux-launcher.posix.test.ts new file mode 100644 index 0000000000..ba7a8ec02b --- /dev/null +++ b/packages/desktop/src/daemon/linux-launcher.posix.test.ts @@ -0,0 +1,123 @@ +import { spawnSync } from "node:child_process"; +import { chmodSync, mkdirSync, mkdtempSync, rmSync, symlinkSync, writeFileSync } from "node:fs"; +import { createRequire } from "node:module"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { expect, test } from "vitest"; + +// The launcher reads Linux /proc state as well as POSIX command interfaces. +const it = test.runIf(process.platform === "linux"); + +const require = createRequire(import.meta.url); +const afterPack = require("../../scripts/after-pack.js").default; + +async function launch( + options: { + namespaces?: boolean; + helper?: string; + mount?: string; + env?: NodeJS.ProcessEnv; + args?: string[]; + symlink?: boolean; + rerun?: boolean; + } = {}, +) { + const root = mkdtempSync(join(tmpdir(), "paseo-launcher-")); + try { + const app = join(root, "app with spaces"); + const commands = join(root, "commands"); + mkdirSync(app); + mkdirSync(commands); + writeFileSync( + join(app, "Paseo"), + `#!${process.execPath}\nconsole.log(JSON.stringify(process.argv.slice(2)));\n`, + ); + chmodSync(join(app, "Paseo"), 0o755); + // The command interface represents the host's userns policy, independent of CI's host. + writeFileSync(join(commands, "unshare"), `#!/bin/sh\nexit ${options.namespaces ? 0 : 1}\n`); + chmodSync(join(commands, "unshare"), 0o755); + for (const [name, output] of Object.entries({ + stat: options.helper ?? "1000:755", + findmnt: options.mount ?? "rw", + })) { + writeFileSync(join(commands, name), `#!/bin/sh\nprintf '%s\\n' '${output}'\n`); + chmodSync(join(commands, name), 0o755); + } + writeFileSync(join(app, "chrome-sandbox"), "helper"); + chmodSync(join(app, "chrome-sandbox"), 0o755); + await afterPack({ appOutDir: app, electronPlatformName: "linux", arch: 1 }); + if (options.rerun) await afterPack({ appOutDir: app, electronPlatformName: "linux", arch: 1 }); + const executablePath = options.symlink ? join(root, "paseo") : join(app, "Paseo"); + if (options.symlink) symlinkSync(join(app, "Paseo"), executablePath); + const args = options.args ?? ["path with spaces", "$(touch never)", "semi;colon", "*.txt"]; + const result = spawnSync(executablePath, args, { + encoding: "utf8", + env: { + ...process.env, + FORCE_COLOR: undefined, + PATH: `${commands}:${process.env.PATH}`, + APPIMAGE: "/tmp/Paseo.AppImage", + PASEO_DESKTOP_SMOKE: "0", + ...options.env, + }, + }); + expect(result.status, result.stderr).toBe(0); + return { args: JSON.parse(result.stdout), stderr: result.stderr, input: args }; + } finally { + rmSync(root, { recursive: true, force: true }); + } +} + +it("launches a portable app with the fallback on argv when user namespaces are denied", async () => { + const result = await launch(); + expect(result.args).toEqual(["--no-sandbox", ...result.input]); + expect(result.stderr).toContain("[linux-sandbox] disabled"); +}); + +it("keeps sandboxing for AppImage when the real user can create namespaces", async () => { + const result = await launch({ namespaces: true }); + expect(result.args).toEqual(result.input); + expect(result.stderr).toContain("[linux-sandbox] enabled: user namespaces available"); +}); + +it("keeps sandboxing via the installed root-owned helper when userns is denied", async () => { + const result = await launch({ helper: "0:4755", env: { APPIMAGE: "" } }); + expect(result.args).toEqual(result.input); + expect(result.stderr).toContain("[linux-sandbox] enabled: root-owned SUID helper available"); +}); + +it("does not trust 4755 on a nosuid mount", async () => { + const result = await launch({ helper: "0:4755", mount: "rw,nosuid,nodev" }); + expect(result.args).toEqual(["--no-sandbox", ...result.input]); +}); + +it("preserves sandbox flags and all Node entrypoint arguments without probing", async () => { + const result = await launch({ env: { ELECTRON_RUN_AS_NODE: "1" }, args: ["--version"] }); + expect(result.args).toEqual(["--version"]); + expect(result.stderr).toBe(""); +}); + +it("reports an explicit user override without injecting a duplicate", async () => { + const result = await launch({ namespaces: true, args: ["--no-sandbox", "--version"] }); + expect(result.args).toEqual(result.input); + expect(result.stderr).toContain("[linux-sandbox] disabled: requested by --no-sandbox"); +}); + +it("resolves symlink launches and keeps the real executable intact on repeated packaging", async () => { + const result = await launch({ symlink: true, rerun: true }); + expect(result.args).toEqual(["--no-sandbox", ...result.input]); +}); + +it("does not depend on APPIMAGE being present for an extracted portable app", async () => { + const result = await launch({ env: { APPIMAGE: "" } }); + expect(result.args).toEqual(["--no-sandbox", ...result.input]); +}); + +it("applies a debugging environment sandbox override before Chromium starts", async () => { + const result = await launch({ + namespaces: true, + env: { PASEO_ELECTRON_FLAGS: "--disable-gpu\t--no-sandbox" }, + }); + expect(result.args).toEqual(["--no-sandbox", ...result.input]); + expect(result.stderr).toContain("requested by PASEO_ELECTRON_FLAGS"); +}); diff --git a/packages/desktop/src/diagnostics/sandbox.test.ts b/packages/desktop/src/diagnostics/sandbox.test.ts new file mode 100644 index 0000000000..e9e80040ca --- /dev/null +++ b/packages/desktop/src/diagnostics/sandbox.test.ts @@ -0,0 +1,25 @@ +import { expect, it } from "vitest"; +import { describeSandbox } from "./sandbox"; + +it("reports a portable fallback independently of the renderer's Node isolation setting", () => { + expect( + describeSandbox({ + disabled: true, + launcherReason: "user namespaces unavailable; no usable SUID helper", + }), + ).toEqual({ + enabled: false, + reason: "user namespaces unavailable; no usable SUID helper", + }); +}); + +it("reports enabled defaults and explicit overrides without a packaged launcher", () => { + expect(describeSandbox({ disabled: false })).toEqual({ + enabled: true, + reason: "Chromium default", + }); + expect(describeSandbox({ disabled: true })).toEqual({ + enabled: false, + reason: "requested by --no-sandbox", + }); +}); diff --git a/packages/desktop/src/diagnostics/sandbox.ts b/packages/desktop/src/diagnostics/sandbox.ts new file mode 100644 index 0000000000..af10c25397 --- /dev/null +++ b/packages/desktop/src/diagnostics/sandbox.ts @@ -0,0 +1,10 @@ +export function describeSandbox(input: { disabled: boolean; launcherReason?: string }): { + enabled: boolean; + reason: string; +} { + return { + enabled: !input.disabled, + reason: + input.launcherReason ?? (input.disabled ? "requested by --no-sandbox" : "Chromium default"), + }; +} diff --git a/packages/desktop/src/main.ts b/packages/desktop/src/main.ts index 64cf4a9519..63ab83281a 100644 --- a/packages/desktop/src/main.ts +++ b/packages/desktop/src/main.ts @@ -330,13 +330,6 @@ if (forcedUserDataDir) { } } -// AppImage runtimes mount the app from /tmp under the user's UID, so the SUID -// chrome-sandbox helper we ship in .deb/.rpm cannot work there. Disable the -// sandbox only in that case; .deb/.rpm keep the sandbox on, matching VS Code. -if (process.platform === "linux" && process.env.APPIMAGE) { - app.commandLine.appendSwitch("no-sandbox"); -} - // Allow users to pass Chromium flags via PASEO_ELECTRON_FLAGS for debugging // rendering issues (e.g. "--disable-gpu --ozone-platform=x11"). // Must run before app.whenReady(). @@ -349,6 +342,16 @@ if (electronFlags) { log.info("[electron-flags]", electronFlags); } +if (process.platform === "linux") { + // Keep the desktop/dock identity independent of the wrapped Electron filename. + app.setDesktopName("Paseo.desktop"); + if (!app.commandLine.hasSwitch("class")) app.commandLine.appendSwitch("class", "Paseo"); + log.info("[linux-sandbox]", { + enabled: !app.commandLine.hasSwitch("no-sandbox"), + reason: process.env.PASEO_DESKTOP_SANDBOX_REASON ?? "Chromium default", + }); +} + let pendingOpenProjectPath = parseOpenProjectPathFromArgv({ argv: process.argv, isDefaultApp: process.defaultApp, diff --git a/public-docs/index.md b/public-docs/index.md index 7bf22016a4..f93216883d 100644 --- a/public-docs/index.md +++ b/public-docs/index.md @@ -16,6 +16,27 @@ Download from [paseo.sh/download](https://paseo.sh/download) or the [GitHub rele The desktop app bundles its own daemon and starts it automatically, no separate install required. On first launch you'll see a brief startup screen, then connect from your phone using **Settings → your host → Pair Device**. +### Linux + +Use the `.deb` on Debian/Ubuntu or the `.rpm` on Fedora to keep Chromium's sandbox available even when your distribution restricts user namespaces. The installer configures the bundled sandbox helper; you do not need to change system security settings. + +For an AppImage, make the download executable and open it: + +```bash +chmod +x Paseo-x86_64.AppImage +./Paseo-x86_64.AppImage +``` + +If it reports `error loading libfuse.so.2`, run without FUSE: + +```bash +./Paseo-x86_64.AppImage --appimage-extract-and-run +``` + +Alternatively, install `libfuse2t64` on Ubuntu 24.04 or newer (`sudo apt install libfuse2t64`), or your distribution's FUSE 2 compatibility package. This dependency belongs to the AppImage runtime, before Paseo starts. + +Paseo checks sandbox availability each time it launches. AppImage and extracted tar archives retain sandboxing when user namespaces work. On a restricted host without a usable installed helper, they launch with Chromium's sandbox disabled. Prefer the installed package if you require OS process isolation. **Settings → Diagnostics → App Diagnostics** reports the sandbox state and reason; the desktop log records the same decision. An explicit `--no-sandbox` argument overrides the automatic choice. + ## Server / CLI For headless machines, dev boxes, or any setup where you want the daemon running without the desktop UI: From f22a37e613e965c8ebc02e1f5565e21fd72eaf2f Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Fri, 11 Sep 2026 10:16:33 +0200 Subject: [PATCH 21/46] fix(app): let touch workspace titles use the available width (#4698) * fix(app): let touch workspace titles use the available width Omit trailing stats when the permanent touch menu replaces them. Keep concealed stats in layout only for temporary desktop shortcut hints, preserving hover and shortcut geometry. * test(app): express sidebar width checks as user journeys --- .../e2e/browser/sidebar-title-width.spec.ts | 153 ++++++++++++++++++ .../src/components/sidebar-workspace-list.tsx | 4 +- .../sidebar/sidebar-status-list.tsx | 11 +- .../sidebar/sidebar-workspace-row-content.tsx | 26 ++- .../sidebar/sidebar-workspace-row.tsx | 4 +- 5 files changed, 181 insertions(+), 17 deletions(-) create mode 100644 packages/app/e2e/browser/sidebar-title-width.spec.ts diff --git a/packages/app/e2e/browser/sidebar-title-width.spec.ts b/packages/app/e2e/browser/sidebar-title-width.spec.ts new file mode 100644 index 0000000000..80f96dc68b --- /dev/null +++ b/packages/app/e2e/browser/sidebar-title-width.spec.ts @@ -0,0 +1,153 @@ +import { rm, writeFile } from "node:fs/promises"; +import path from "node:path"; +import type { Locator, Page, TestInfo } from "@playwright/test"; +import { expect, test } from "../support/fixtures"; +import { gotoAppShell } from "../support/helpers/app"; +import { seedWorkspace, type SeededWorkspace } from "../support/helpers/seed-client"; +import { + closeSidebarDisplayPreferences, + openMobileAgentSidebar, + openSidebarDisplayPage, + selectSidebarStatusGrouping, +} from "../support/helpers/sidebar"; + +const TITLE = "Linux desktop sandbox launch support"; + +async function seedChangedWorkspace() { + const workspace = await seedWorkspace({ + repoPrefix: "sidebar-title-", + title: TITLE, + repo: { withRemote: true }, + }); + try { + await rm(path.join(workspace.repoPath, "remote.git"), { recursive: true }); + await workspace.client.renameProject(workspace.projectId, "Paseo"); + await writeFile(path.join(workspace.repoPath, "README.md"), "Changed line\n".repeat(12345)); + await workspace.client.checkoutRefresh(workspace.repoPath); + await expect + .poll(async () => { + const { entries } = await workspace.client.fetchWorkspaces(); + return entries.find((entry) => entry.id === workspace.workspaceId)?.diffStat?.additions; + }) + .toBe(12345); + return workspace; + } catch (error) { + await workspace.cleanup(); + throw error; + } +} + +function workspaceRow(page: Page) { + return page.getByRole("button", { name: new RegExp(TITLE) }).filter({ visible: true }); +} + +async function titleWidth(row: Locator) { + return row + .getByText(TITLE, { exact: true }) + .evaluate((element) => element.getBoundingClientRect().width); +} + +async function toggleTrailing(page: Page, label: "Diff stats" | "Last activity", touch = false) { + await openSidebarDisplayPage(page, "sidebar-display-show"); + await page.getByRole("menuitem", { name: label, exact: true }).click(); + if (touch) { + await page + .getByRole("button", { name: "Bottom sheet backdrop", exact: true }) + .click({ position: { x: 5, y: 5 } }); + await expect( + page.getByRole("button", { name: "Bottom sheet backdrop", exact: true }), + ).toHaveCount(0); + } else { + await closeSidebarDisplayPreferences(page); + } + await page.mouse.move(0, 0); +} + +async function expectTouchTitleWidth(page: Page) { + const row = workspaceRow(page); + const withDiff = await titleWidth(row); + await toggleTrailing(page, "Diff stats", true); + const withoutStats = await titleWidth(row); + expect(withDiff).toBeCloseTo(withoutStats, 0); + await toggleTrailing(page, "Last activity", true); + expect(await titleWidth(row)).toBeCloseTo(withoutStats, 0); + await toggleTrailing(page, "Diff stats", true); +} + +let workspace: SeededWorkspace; +test.beforeEach(async () => { + workspace = await seedChangedWorkspace(); +}); +test.afterEach(async () => { + await workspace?.cleanup(); +}); + +async function openTouchWorkspaceList(page: Page, testInfo: TestInfo) { + await page.setViewportSize({ width: 390, height: 844 }); + await page.emulateMedia({ colorScheme: "dark" }); + await gotoAppShell(page); + await openMobileAgentSidebar(page); + await expect(workspaceRow(page)).toBeVisible(); + await page.mouse.move(0, 0); + await page.screenshot({ path: testInfo.outputPath("sidebar.png") }); +} + +async function pinWorkspaceForLayout(page: Page) { + await workspace.client.setWorkspacePinned(workspace.workspaceId, true); + await expect(page.getByTestId("sidebar-pinned-section")).toBeVisible(); +} + +async function moveWorkspaceToStatusGrouping(page: Page) { + await workspace.client.setWorkspacePinned(workspace.workspaceId, false); + await selectSidebarStatusGrouping(page); +} + +async function openDesktopWorkspaceList(page: Page) { + await gotoAppShell(page); + const row = workspaceRow(page); + await expect(row).toBeVisible(); + await page.mouse.move(0, 0); + await expect(row.getByText("+12.3k", { exact: true })).toBeVisible(); + return titleWidth(row); +} + +async function expectHoverKeepsTitleWidth(page: Page, width: number, testInfo: TestInfo) { + const row = workspaceRow(page); + await row.hover(); + await expect(row.getByLabel("Workspace actions", { exact: true })).toBeVisible(); + expect(await titleWidth(row)).toBeCloseTo(width, 0); + await page.screenshot({ path: testInfo.outputPath("desktop-hover.png") }); +} + +async function expectShortcutHintsKeepTitleWidth(page: Page, width: number, testInfo: TestInfo) { + const row = workspaceRow(page); + await page.keyboard.down("Alt"); + await expect(row.getByText("1", { exact: true })).toBeVisible(); + expect(await titleWidth(row)).toBeCloseTo(width, 0); + await page.screenshot({ path: testInfo.outputPath("desktop-shortcuts.png") }); + await page.keyboard.up("Alt"); + await expect(row.getByText("1", { exact: true })).toHaveCount(0); +} + +async function expectDisablingStatsExpandsTitle(page: Page, width: number) { + await toggleTrailing(page, "Diff stats"); + expect(await titleWidth(workspaceRow(page))).toBeGreaterThan(width); +} + +test("touch titles use the space occupied by hidden stats in every grouping", async ({ + page, +}, testInfo) => { + await openTouchWorkspaceList(page, testInfo); + await expectTouchTitleWidth(page); + await pinWorkspaceForLayout(page); + await expectTouchTitleWidth(page); + await moveWorkspaceToStatusGrouping(page); + await expectTouchTitleWidth(page); +}); + +test("desktop hover and shortcut hints preserve title width", async ({ page }, testInfo) => { + const width = await openDesktopWorkspaceList(page); + await expectHoverKeepsTitleWidth(page, width, testInfo); + await expectShortcutHintsKeepTitleWidth(page, width, testInfo); + await expectDisablingStatsExpandsTitle(page, width); +}); diff --git a/packages/app/src/components/sidebar-workspace-list.tsx b/packages/app/src/components/sidebar-workspace-list.tsx index 23e4292aeb..4275099cfd 100644 --- a/packages/app/src/components/sidebar-workspace-list.tsx +++ b/packages/app/src/components/sidebar-workspace-list.tsx @@ -645,7 +645,7 @@ function WorkspaceRowRightGroup({ const trailing = useSidebarWorkspaceTrailing(); const showShortcut = showShortcutBadge && shortcutNumber !== null; const { - showTrailing, + trailingPresentation, showKebab: showKebabInSlot, showScrim, renderSlot, @@ -667,7 +667,7 @@ function WorkspaceRowRightGroup({ ) : null} {renderSlot ? ( - + { const showShortcut = showShortcutBadge && shortcutNumber !== null; const { - showTrailing, + trailingPresentation, showKebab: showKebabInSlot, showScrim, renderSlot, @@ -907,7 +908,7 @@ function StatusWorkspaceRowInnerContent({ workspace={workspace} backdrop={backdrop} trailing={trailing} - showBase={showTrailing} + trailingPresentation={trailingPresentation} showKebab={showKebabInSlot} showScrim={showScrim} reserveSlotWidth={reserveSlotWidth} @@ -938,7 +939,7 @@ function StatusWorkspaceActionSlot({ workspace, backdrop, trailing, - showBase, + trailingPresentation, showKebab, showScrim, reserveSlotWidth, @@ -958,7 +959,7 @@ function StatusWorkspaceActionSlot({ workspace: SidebarWorkspaceEntry; backdrop: SidebarSurfaceBackdrop; trailing: SidebarWorkspaceTrailing; - showBase: boolean; + trailingPresentation: SidebarWorkspaceTrailingPresentation; showKebab: boolean; showScrim: boolean; reserveSlotWidth: boolean; @@ -978,7 +979,7 @@ function StatusWorkspaceActionSlot({ const kebab = useOpenKebabMenuVisibility(showKebab); return ( - + {children}; + if (presentation === "absent") return null; + return ( + + {children} + + ); } export function SidebarWorkspaceTrailingActionOverlay({ diff --git a/packages/app/src/components/sidebar/sidebar-workspace-row.tsx b/packages/app/src/components/sidebar/sidebar-workspace-row.tsx index f019158e97..f8dcdebaf5 100644 --- a/packages/app/src/components/sidebar/sidebar-workspace-row.tsx +++ b/packages/app/src/components/sidebar/sidebar-workspace-row.tsx @@ -403,7 +403,7 @@ function WorkspaceRowTrailingActions({ const { t } = useTranslation(); const showShortcut = showShortcutBadge && shortcutNumber !== null; const { - showTrailing, + trailingPresentation, showKebab: showKebabInSlot, showScrim, renderSlot, @@ -425,7 +425,7 @@ function WorkspaceRowTrailingActions({ ) : null} {renderSlot ? ( - + Date: Fri, 11 Sep 2026 18:53:27 +0200 Subject: [PATCH 22/46] docs(changelog): clarify macOS version requirement (#4726) Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com> --- fastlane/metadata/android/en-US/changelogs/80001.txt | 2 +- fastlane/metadata/android/en-US/changelogs/80002.txt | 2 +- fastlane/metadata/android/en-US/changelogs/80003.txt | 2 +- fastlane/metadata/android/en-US/changelogs/80004.txt | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/fastlane/metadata/android/en-US/changelogs/80001.txt b/fastlane/metadata/android/en-US/changelogs/80001.txt index 5e55ccd9e3..0890a8d771 100644 --- a/fastlane/metadata/android/en-US/changelogs/80001.txt +++ b/fastlane/metadata/android/en-US/changelogs/80001.txt @@ -1,6 +1,6 @@ Paseo 0.8 adds plugin header buttons, custom providers, and richer chat components, alongside fixes for desktop updates and mobile keyboards. -Before upgrading: desktop now requires macOS 13 or newer. Plugins written for 0.7 need the 0.8 migration guide, including separate client/server entries and the revised composer pill API. +Before upgrading: the macOS desktop app now requires macOS 13 or newer; Linux and Windows support is unchanged. Plugins written for 0.7 need the 0.8 migration guide, including separate client/server entries and the revised composer pill API. Added - Added answer forms for Codex questions asked while the agent continues working diff --git a/fastlane/metadata/android/en-US/changelogs/80002.txt b/fastlane/metadata/android/en-US/changelogs/80002.txt index 5e55ccd9e3..0890a8d771 100644 --- a/fastlane/metadata/android/en-US/changelogs/80002.txt +++ b/fastlane/metadata/android/en-US/changelogs/80002.txt @@ -1,6 +1,6 @@ Paseo 0.8 adds plugin header buttons, custom providers, and richer chat components, alongside fixes for desktop updates and mobile keyboards. -Before upgrading: desktop now requires macOS 13 or newer. Plugins written for 0.7 need the 0.8 migration guide, including separate client/server entries and the revised composer pill API. +Before upgrading: the macOS desktop app now requires macOS 13 or newer; Linux and Windows support is unchanged. Plugins written for 0.7 need the 0.8 migration guide, including separate client/server entries and the revised composer pill API. Added - Added answer forms for Codex questions asked while the agent continues working diff --git a/fastlane/metadata/android/en-US/changelogs/80003.txt b/fastlane/metadata/android/en-US/changelogs/80003.txt index 5e55ccd9e3..0890a8d771 100644 --- a/fastlane/metadata/android/en-US/changelogs/80003.txt +++ b/fastlane/metadata/android/en-US/changelogs/80003.txt @@ -1,6 +1,6 @@ Paseo 0.8 adds plugin header buttons, custom providers, and richer chat components, alongside fixes for desktop updates and mobile keyboards. -Before upgrading: desktop now requires macOS 13 or newer. Plugins written for 0.7 need the 0.8 migration guide, including separate client/server entries and the revised composer pill API. +Before upgrading: the macOS desktop app now requires macOS 13 or newer; Linux and Windows support is unchanged. Plugins written for 0.7 need the 0.8 migration guide, including separate client/server entries and the revised composer pill API. Added - Added answer forms for Codex questions asked while the agent continues working diff --git a/fastlane/metadata/android/en-US/changelogs/80004.txt b/fastlane/metadata/android/en-US/changelogs/80004.txt index 5e55ccd9e3..0890a8d771 100644 --- a/fastlane/metadata/android/en-US/changelogs/80004.txt +++ b/fastlane/metadata/android/en-US/changelogs/80004.txt @@ -1,6 +1,6 @@ Paseo 0.8 adds plugin header buttons, custom providers, and richer chat components, alongside fixes for desktop updates and mobile keyboards. -Before upgrading: desktop now requires macOS 13 or newer. Plugins written for 0.7 need the 0.8 migration guide, including separate client/server entries and the revised composer pill API. +Before upgrading: the macOS desktop app now requires macOS 13 or newer; Linux and Windows support is unchanged. Plugins written for 0.7 need the 0.8 migration guide, including separate client/server entries and the revised composer pill API. Added - Added answer forms for Codex questions asked while the agent continues working From fa93c4290eaa87ae58452ab6e2012f85ae6e0c6b Mon Sep 17 00:00:00 2001 From: "paseo-bot[bot]" <266920839+paseo-bot[bot]@users.noreply.github.com> Date: Fri, 11 Sep 2026 18:58:13 +0200 Subject: [PATCH 23/46] docs(changelog): update generated release notes (#4728) Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com> --- CHANGELOG.md | 2 +- fastlane/metadata/android/en-US/changelogs/80001.txt | 3 --- fastlane/metadata/android/en-US/changelogs/80002.txt | 3 --- fastlane/metadata/android/en-US/changelogs/80003.txt | 3 --- fastlane/metadata/android/en-US/changelogs/80004.txt | 3 --- 5 files changed, 1 insertion(+), 13 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 9f5c291e87..449ef34cc3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,7 +4,7 @@ Paseo 0.8 adds plugin header buttons, custom providers, and richer chat components, alongside fixes for desktop updates and mobile keyboards. -**Before upgrading:** desktop now requires macOS 13 or newer. Plugins written for 0.7 need the [0.8 migration guide](https://paseo.sh/docs/plugins/v0.8/migration), including separate client/server entries and the revised composer pill API. +**Before upgrading:** the macOS desktop app now requires macOS 13 or newer; Linux and Windows support is unchanged. Plugins written for 0.7 need the [0.8 migration guide](https://paseo.sh/docs/plugins/v0.8/migration), including separate client/server entries and the revised composer pill API. ### Added diff --git a/fastlane/metadata/android/en-US/changelogs/80001.txt b/fastlane/metadata/android/en-US/changelogs/80001.txt index 0890a8d771..4b9ab7f0e8 100644 --- a/fastlane/metadata/android/en-US/changelogs/80001.txt +++ b/fastlane/metadata/android/en-US/changelogs/80001.txt @@ -2,7 +2,4 @@ Paseo 0.8 adds plugin header buttons, custom providers, and richer chat componen Before upgrading: the macOS desktop app now requires macOS 13 or newer; Linux and Windows support is unchanged. Plugins written for 0.7 need the 0.8 migration guide, including separate client/server entries and the revised composer pill API. -Added -- Added answer forms for Codex questions asked while the agent continues working - Full notes: https://paseo.sh/changelog diff --git a/fastlane/metadata/android/en-US/changelogs/80002.txt b/fastlane/metadata/android/en-US/changelogs/80002.txt index 0890a8d771..4b9ab7f0e8 100644 --- a/fastlane/metadata/android/en-US/changelogs/80002.txt +++ b/fastlane/metadata/android/en-US/changelogs/80002.txt @@ -2,7 +2,4 @@ Paseo 0.8 adds plugin header buttons, custom providers, and richer chat componen Before upgrading: the macOS desktop app now requires macOS 13 or newer; Linux and Windows support is unchanged. Plugins written for 0.7 need the 0.8 migration guide, including separate client/server entries and the revised composer pill API. -Added -- Added answer forms for Codex questions asked while the agent continues working - Full notes: https://paseo.sh/changelog diff --git a/fastlane/metadata/android/en-US/changelogs/80003.txt b/fastlane/metadata/android/en-US/changelogs/80003.txt index 0890a8d771..4b9ab7f0e8 100644 --- a/fastlane/metadata/android/en-US/changelogs/80003.txt +++ b/fastlane/metadata/android/en-US/changelogs/80003.txt @@ -2,7 +2,4 @@ Paseo 0.8 adds plugin header buttons, custom providers, and richer chat componen Before upgrading: the macOS desktop app now requires macOS 13 or newer; Linux and Windows support is unchanged. Plugins written for 0.7 need the 0.8 migration guide, including separate client/server entries and the revised composer pill API. -Added -- Added answer forms for Codex questions asked while the agent continues working - Full notes: https://paseo.sh/changelog diff --git a/fastlane/metadata/android/en-US/changelogs/80004.txt b/fastlane/metadata/android/en-US/changelogs/80004.txt index 0890a8d771..4b9ab7f0e8 100644 --- a/fastlane/metadata/android/en-US/changelogs/80004.txt +++ b/fastlane/metadata/android/en-US/changelogs/80004.txt @@ -2,7 +2,4 @@ Paseo 0.8 adds plugin header buttons, custom providers, and richer chat componen Before upgrading: the macOS desktop app now requires macOS 13 or newer; Linux and Windows support is unchanged. Plugins written for 0.7 need the 0.8 migration guide, including separate client/server entries and the revised composer pill API. -Added -- Added answer forms for Codex questions asked while the agent continues working - Full notes: https://paseo.sh/changelog From a7ce602c83c1230c76c30fb6f2a6e2d952962e37 Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Sat, 12 Sep 2026 08:47:44 +0200 Subject: [PATCH 24/46] fix(agents): restore archived Codex workspaces without losing focus (#4736) * fix(agents): restore archived Codex workspaces without losing focus History selection must survive without cached agent records, and reading an archived Codex thread must not acquire an interactive writer. Keep workspace restore explicit, serialize native lifecycle transitions, and leave transcript lifetime with its owner. * test(app): express Codex restore specs as user journeys * test(app): finish restore fixture cleanup after teardown errors * test(app): align restore coverage and await fixture readiness Workspace restore leaves agents archived until explicit Unarchive. Keep that distinction covered for multiple agents. Compile the recovery fixture before its interaction test starts, and wait for sidebar agent hydration before asserting tab presence. --- docs/agent-lifecycle.md | 32 +- docs/timeline-sync.md | 5 + .../e2e/browser/root-error-recovery.spec.ts | 6 + .../app/e2e/browser/sidebar-model-b.spec.ts | 6 +- .../worktree-codex-restore.real.spec.ts | 63 ++++ .../app/e2e/browser/worktree-restore.spec.ts | 9 +- .../helpers/codex-workspace-restore.ts | 277 ++++++++++++++++++ .../workspace/[workspaceId]/index.tsx | 17 +- packages/app/src/panels/agent-panel.tsx | 26 +- .../directory-sync/agent-replica.test.ts | 50 +++- .../runtime/directory-sync/agent-replica.ts | 2 +- .../directory-sync/internal/agent-store.ts | 36 ++- .../screens/workspace/workspace-screen.tsx | 6 - .../src/stores/workspace-layout-actions.ts | 29 +- .../src/stores/workspace-layout-store.test.ts | 88 ++++-- .../app/src/stores/workspace-layout-store.ts | 73 ++--- .../src/utils/agent-directory-sync.test.ts | 97 +++--- .../app/src/workspace-recovery/model.test.ts | 23 +- packages/app/src/workspace-recovery/model.ts | 16 - .../use-workspace-recovery.ts | 13 +- .../workspace-tabs/agent-visibility.test.ts | 43 +-- .../src/workspace-tabs/agent-visibility.ts | 17 +- .../src/server/agent/agent-loading.test.ts | 2 +- .../src/server/agent/agent-manager.test.ts | 78 ++++- .../server/src/server/agent/agent-manager.ts | 96 ++++-- .../providers/codex-app-server-agent.test.ts | 22 +- .../agent/providers/codex-app-server-agent.ts | 60 ++-- .../codex-history-lifecycle.real.e2e.test.ts | 151 ++++++++++ 28 files changed, 945 insertions(+), 398 deletions(-) create mode 100644 packages/app/e2e/browser/worktree-codex-restore.real.spec.ts create mode 100644 packages/app/e2e/support/helpers/codex-workspace-restore.ts create mode 100644 packages/server/src/server/agent/providers/codex-history-lifecycle.real.e2e.test.ts diff --git a/docs/agent-lifecycle.md b/docs/agent-lifecycle.md index ddf69fd7c5..57bd7889cb 100644 --- a/docs/agent-lifecycle.md +++ b/docs/agent-lifecycle.md @@ -109,16 +109,28 @@ archived workspace. History navigation must not infer workspace lifecycle from ` or mutate either lifecycle. The workspace route asks the daemon for authoritative recovery state; only the route's explicit Unarchive or Restore action changes the archived workspace. -History navigation preserves the selected agent as an explicit recovery target. If both that agent -and its workspace are archived, the workspace recovery action restores the workspace and unarchives -the selected agent as one user action. Other archived agents in the restored workspace remain -recoverable from History. Opening one pins its tab and renders the archived-agent callout. Authoritative -timeline catch-up may load provider history with a runtime-only `history` resume purpose, which must -leave both Paseo's `archivedAt` and the provider's native archive state unchanged. **Unarchive** remains -the only transition back to an interactive runtime: it runs the provider's native unarchive hook -(including Codex `thread/unarchive`) before the normal agent resume and timeline hydration flow. A -provider session can be archived outside Paseo while its Paseo agent remains active. Interactive -resume repairs that drift through the provider's native unarchive hook; history resume does not. +History navigation opens the selected agent without changing either archive state. Workspace +**Restore** recovers only the workspace; the selected archived agent stays open with its callout. +The agent's **Unarchive** runs the provider's native unarchive hook before interactive resume and +history hydration. Other archived agents stay archived. + +Opening an agent is a navigation choice, independent of whether its details are cached. The +layout retains that choice across reload while the panel fetches the agent from the daemon. +Once the daemon reports the agent active, its tab follows normal archive propagation again. +An empty active list cannot cancel an explicit History selection. Agent-detail loading does +not own selection or release the explicit open. + +Persisted resume, native restore, and both live and stored-only archive enter the same per-agent +lifecycle queue. Resume chooses its history or interactive purpose from the durable record after +entering that queue. Shutdown must finish before the manager releases runtime ownership; a failed +close retains the runtime for cleanup and blocks replacement through that close operation. + +Authoritative timeline catch-up can use a runtime-only `history` resume purpose. For Codex, that +purpose initializes a temporary app-server, reads the persisted thread and child histories, and +releases the process before returning. It never loads, resumes, or unarchives a native thread, +including legacy records whose native archive failed. The retained history session contains only +the read results. Interactive resume remains responsible for repairing a provider session archived +outside Paseo while its Paseo agent is active. Provider session connection owns every process it spawns until the session is registered with `AgentManager`. If initialization, persisted-session resume, or initial history hydration fails, diff --git a/docs/timeline-sync.md b/docs/timeline-sync.md index 5d3464a30b..02c5723162 100644 --- a/docs/timeline-sync.md +++ b/docs/timeline-sync.md @@ -95,6 +95,11 @@ owner wraps cached preparation, network catch-up, accepted timeline application, behind one interface. React supplies transport and projection operations without selecting a cache path or issuing a separate persistence notification. +Active-agent list reconciliation does not own transcript lifetime. An archived agent's fresh +history response can arrive before the active-list response that omits it. Clearing history there +would discard accepted rows while the viewed owner still considers them synchronized. Entity +deletion clears the transcript; list membership changes do not. + Removing the host from the registry is the destructive boundary: it stops the runtime and clears the session and host-scoped setup state together. diff --git a/packages/app/e2e/browser/root-error-recovery.spec.ts b/packages/app/e2e/browser/root-error-recovery.spec.ts index 26fc70c4bb..55f5f37b42 100644 --- a/packages/app/e2e/browser/root-error-recovery.spec.ts +++ b/packages/app/e2e/browser/root-error-recovery.spec.ts @@ -4,9 +4,15 @@ import { expect, test, type Page, type TestInfo } from "@playwright/test"; let server: Server; let appUrl: string; test.beforeAll(async () => { + // Compile this separate fixture entrypoint in setup, like the main app warmup. + // Cold Metro compilation must not consume the recovery interaction's timeout. + test.setTimeout(120_000); const metroPort = process.env.E2E_METRO_PORT; if (!metroPort) throw new Error("E2E_METRO_PORT is required"); const bundle = `http://localhost:${metroPort}/packages/app/e2e/support/recovery-app.bundle?platform=web&dev=true&minify=false&hot=false&lazy=true&transform.engine=hermes&transform.routerRoot=src%2Fapp`; + const compiled = await fetch(bundle, { signal: AbortSignal.timeout(120_000) }); + if (!compiled.ok) throw new Error(`Recovery fixture compilation failed: HTTP ${compiled.status}`); + await compiled.arrayBuffer(); server = createServer((_request, response) => { response.setHeader("Content-Type", "text/html"); response.end( diff --git a/packages/app/e2e/browser/sidebar-model-b.spec.ts b/packages/app/e2e/browser/sidebar-model-b.spec.ts index 33e6ff8ddc..336ed97245 100644 --- a/packages/app/e2e/browser/sidebar-model-b.spec.ts +++ b/packages/app/e2e/browser/sidebar-model-b.spec.ts @@ -7,7 +7,6 @@ import { getServerId } from "../support/helpers/server-id"; import { projectEquivalenceViewKey } from "../support/helpers/project-view-key"; import { selectSidebarStatusGrouping } from "../support/helpers/sidebar"; import { waitForSidebarHydration } from "../support/helpers/workspace-ui"; -import { getVisibleWorkspaceAgentTabIds } from "../support/helpers/workspace-tabs"; // Model B sidebar shape: every project — git or non-git, single- or // multi-workspace — renders as the same expandable parent, the deepest sidebar @@ -96,8 +95,9 @@ test.describe("Model B sidebar shape", () => { try { // Open the workspace and materialize both an agent tab and a terminal tab. await gotoWorkspace(page, mock.workspaceId); - const agentTabs = await getVisibleWorkspaceAgentTabIds(page); - expect(agentTabs).toContain(`workspace-tab-agent_${mock.agentId}`); + await expect( + page.getByTestId(`workspace-tab-agent_${mock.agentId}`).filter({ visible: true }), + ).toBeVisible(); await clickNewTerminal(page); await expect( diff --git a/packages/app/e2e/browser/worktree-codex-restore.real.spec.ts b/packages/app/e2e/browser/worktree-codex-restore.real.spec.ts new file mode 100644 index 0000000000..8b9010e742 --- /dev/null +++ b/packages/app/e2e/browser/worktree-codex-restore.real.spec.ts @@ -0,0 +1,63 @@ +import { test } from "../support/helpers/codex-workspace-restore"; + +test.use({ + e2eDaemonConfig: { version: 1, daemon: { mcp: { enabled: true } } }, + trace: "on", + video: "on", +}); +test.describe.configure({ timeout: 600_000 }); + +test("a fresh client restores an archived Codex agent across a reload without agent cache", async ({ + codexRestore, +}) => { + await test.step("Create, finish, and archive Codex before this browser connects", async () => { + await codexRestore.createWorktreeWithCodex(); + await codexRestore.archiveBeforeBrowserConnects(); + }); + await test.step("Connect the fresh browser, open History, and restore the workspace", async () => { + await codexRestore.connectFreshBrowser(); + await codexRestore.openArchivedWorkspaceFromHistory(); + await codexRestore.restoreWorkspace(); + await codexRestore.expectArchivedAgentSelectedWithHistory(); + }); + await test.step("Reload without cached agent data and keep the selected archived agent", async () => { + await codexRestore.reloadWithoutAgentCache(); + await codexRestore.waitForWorkspaceHydration(); + await codexRestore.expectArchivedAgentSelectedWithHistory(); + }); + await test.step("Unarchive the agent and keep its idle composer after another cache-empty reload", async () => { + await codexRestore.unarchiveAgent(); + await codexRestore.reloadWithoutAgentCache(); + await codexRestore.expectIdleAgentWithVisibleComposer(); + }); +}); + +test("restore an archived worktree, then unarchive its completed Codex agent", async ({ + codexRestore, +}) => { + await test.step("1. Create a managed worktree with a real Codex agent through Paseo MCP", async () => { + await codexRestore.createWorktreeWithCodex(); + }); + await test.step("2. Wait for Codex to finish and show its reply", async () => { + await codexRestore.waitForCompletedReply(); + }); + await test.step("3. Archive the workspace from its sidebar menu", async () => { + await codexRestore.archiveWorkspaceFromSidebar(); + }); + await test.step("4. Assert workspace and agent are archived and the worktree is removed", async () => { + await codexRestore.expectWorkspaceAndAgentArchived(); + }); + await test.step("5. Open the archived workspace from History", async () => { + await codexRestore.openArchivedWorkspaceFromHistory(); + }); + await test.step("6. Restore the workspace", async () => { + await codexRestore.restoreWorkspace(); + await codexRestore.expectWorktreeRestoredWithArchivedAgentSelected(); + }); + await test.step("7. Click Unarchive on the agent", async () => { + await codexRestore.unarchiveAgent(); + }); + await test.step("8. Expect an idle agent with a visible editable composer", async () => { + await codexRestore.expectIdleAgentWithVisibleComposer(60_000); + }); +}); diff --git a/packages/app/e2e/browser/worktree-restore.spec.ts b/packages/app/e2e/browser/worktree-restore.spec.ts index 4f6096ed07..3926b43a97 100644 --- a/packages/app/e2e/browser/worktree-restore.spec.ts +++ b/packages/app/e2e/browser/worktree-restore.spec.ts @@ -265,7 +265,7 @@ test.describe("Worktree restore", () => { ).toHaveText(switchedBranch, { timeout: 30_000 }); }); - test("recovers the selected agent with its workspace and later rescues another archived agent", async ({ + test("restores the workspace before explicitly unarchiving each selected agent", async ({ page, }) => { const { agents, worktree } = await createArchivedMissingWorktree("restore-agents", { @@ -292,9 +292,16 @@ test.describe("Worktree restore", () => { await expect( page.getByTestId(`workspace-tab-agent_${firstAgent.id}`).filter({ visible: true }).first(), ).toBeVisible({ timeout: 30_000 }); + await expect( + page.getByTestId(`workspace-tab-agent_${firstAgent.id}`).filter({ visible: true }).first(), + ).toHaveAttribute("aria-selected", "true"); + expect(await fetchAgentArchivedAt(client, firstAgent.id)).not.toBeNull(); + expect(await fetchAgentArchivedAt(client, secondAgent.id)).not.toBeNull(); + await page.getByRole("button", { name: "Unarchive", exact: true }).click(); await expect .poll(() => fetchAgentArchivedAt(client, firstAgent.id), { timeout: 30_000 }) .toBeNull(); + expect(await fetchAgentArchivedAt(client, secondAgent.id)).not.toBeNull(); await expect(page.getByRole("button", { name: "Unarchive" })).toHaveCount(0, { timeout: 30_000, }); diff --git a/packages/app/e2e/support/helpers/codex-workspace-restore.ts b/packages/app/e2e/support/helpers/codex-workspace-restore.ts new file mode 100644 index 0000000000..7595b4eb20 --- /dev/null +++ b/packages/app/e2e/support/helpers/codex-workspace-restore.ts @@ -0,0 +1,277 @@ +import { existsSync } from "node:fs"; +import { readFile } from "node:fs/promises"; +import path from "node:path"; +import { Client } from "@modelcontextprotocol/sdk/client/index.js"; +import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js"; +import { z } from "zod"; +import type { TestInfo } from "@playwright/test"; +import { test as base, expect, type Page } from "../fixtures"; +import { buildHostAgentDetailRoute } from "@/utils/host-routes"; +import { openSessions } from "./archive-tab"; +import { getE2EDaemonPort } from "./daemon-port"; +import { assertComposerIdle } from "./rewind-flow"; +import { getServerId } from "./server-id"; +import { archiveWorkspaceFromSidebar, expectWorkspaceAbsentFromSidebar } from "./sidebar"; +import { createTempGitRepo } from "./workspace"; +import { waitForSidebarHydration } from "./workspace-ui"; +import type { SeedDaemonClient } from "./seed-client"; + +const REPLY = "CODEX_WORKTREE_RESTORE_READY"; +const workspaceSchema = z.object({ + workspaceId: z.string(), + projectId: z.string(), + cwd: z.string(), + isolation: z.literal("worktree"), +}); + +async function callPaseoTool( + mcp: Client, + name: string, + args: Record, +): Promise { + const result = await mcp.callTool({ name, arguments: args }, undefined, { timeout: 240_000 }); + expect(result).not.toMatchObject({ isError: true }); + return result.structuredContent; +} + +async function defaultCodexModel(mcp: Client): Promise { + const result = z + .object({ models: z.array(z.object({ id: z.string(), isDefault: z.boolean() })) }) + .parse(await callPaseoTool(mcp, "list_models", { provider: "codex" })); + const model = result.models.find((entry) => entry.isDefault); + if (!model) throw new Error("Codex did not advertise a default model"); + return model.id; +} + +function recordTimelineWire(page: Page): string[] { + const wire: string[] = []; + page.on("websocket", (socket) => { + const recordFrame = (direction: string, message: string) => { + if (/agent.*timeline|fetch_agents|agent_update/.test(message)) { + wire.push(JSON.stringify({ at: Date.now(), direction, message })); + } + }; + socket.on("framesent", ({ payload }) => recordFrame("sent", payload.toString())); + socket.on("framereceived", ({ payload }) => recordFrame("received", payload.toString())); + }); + return wire; +} + +async function createCodexRestoreJourney(page: Page, client: SeedDaemonClient, info: TestInfo) { + const repo = await createTempGitRepo("codex-worktree-restore-"); + const mcp = new Client({ name: "worktree-codex-restore-playwright", version: "1.0.0" }); + const wire = recordTimelineWire(page); + let workspace: z.infer | undefined; + let agentId: string | undefined; + + function created() { + if (!workspace || !agentId) throw new Error("Create the Codex worktree before using it"); + return { ...workspace, agentId }; + } + + async function expectSelected() { + await expect(page.getByTestId(`workspace-tab-agent_${created().agentId}`)).toHaveAttribute( + "aria-selected", + "true", + ); + } + + return { + async createWorktreeWithCodex() { + await mcp.connect( + new StreamableHTTPClientTransport( + new URL(`http://127.0.0.1:${getE2EDaemonPort()}/mcp/agents`), + ), + ); + workspace = workspaceSchema.parse( + await callPaseoTool(mcp, "create_workspace", { + isolation: "worktree", + path: repo.path, + baseBranch: "main", + title: "Codex worktree restore reproduction", + }), + ); + const agent = z.object({ agentId: z.string() }).parse( + await callPaseoTool(mcp, "create_agent", { + workspaceId: workspace.workspaceId, + provider: `codex/${await defaultCodexModel(mcp)}`, + settings: { modeId: "full-access", thinkingOptionId: "low" }, + title: "Codex worktree restore reproduction", + initialPrompt: `Reply with exactly ${REPLY} and nothing else. Do not use tools.`, + background: true, + }), + ); + agentId = agent.agentId; + }, + async waitForCompletedReply() { + const target = created(); + await page.goto(buildHostAgentDetailRoute(getServerId(), target.agentId, target.workspaceId)); + await waitForSidebarHydration(page); + await expect(page.getByTestId("assistant-message")).toContainText(REPLY, { + timeout: 240_000, + }); + await expect + .poll(() => client.fetchAgent({ agentId: target.agentId })) + .toMatchObject({ + agent: { status: "idle", archivedAt: null }, + }); + await assertComposerIdle({ page }); + }, + async archiveBeforeBrowserConnects() { + const target = created(); + await client.waitForFinish(target.agentId, 240_000); + expect((await client.archiveWorkspace(target.workspaceId)).error).toBeNull(); + await expect.poll(() => existsSync(target.cwd)).toBe(false); + await expect + .poll(() => client.fetchAgent({ agentId: target.agentId })) + .toMatchObject({ + agent: { archivedAt: expect.any(String) }, + }); + }, + async connectFreshBrowser() { + await page.goto("/"); + }, + async archiveWorkspaceFromSidebar() { + await archiveWorkspaceFromSidebar(page, created().workspaceId); + }, + async expectWorkspaceAndAgentArchived() { + const target = created(); + await expectWorkspaceAbsentFromSidebar(page, target.workspaceId); + await expect + .poll(() => client.fetchAgent({ agentId: target.agentId })) + .toMatchObject({ + agent: { archivedAt: expect.any(String) }, + }); + await expect.poll(() => existsSync(target.cwd), { timeout: 30_000 }).toBe(false); + await expect + .poll(() => client.fetchWorkspaces()) + .not.toMatchObject({ + entries: expect.arrayContaining([expect.objectContaining({ id: target.workspaceId })]), + }); + }, + async openArchivedWorkspaceFromHistory() { + await openSessions(page); + const row = page.getByTestId(`agent-row-${getServerId()}-${created().agentId}`); + await expect(row).toContainText("Archived"); + await row.click(); + await expect(page.getByText("Workspace archived", { exact: true })).toBeVisible(); + }, + async restoreWorkspace() { + await page.getByRole("button", { name: "Restore", exact: true }).click(); + await expect(page.getByText("Workspace archived", { exact: true })).toHaveCount(0, { + timeout: 60_000, + }); + }, + async expectWorktreeRestoredWithArchivedAgentSelected() { + await expect.poll(() => existsSync(created().cwd), { timeout: 30_000 }).toBe(true); + await expectSelected(); + await expect(page.getByText("This agent is archived", { exact: true })).toBeVisible(); + await info.attach("after-workspace-restore", { + body: JSON.stringify(await client.fetchAgent({ agentId: created().agentId }), null, 2), + contentType: "application/json", + }); + }, + async expectArchivedAgentSelectedWithHistory() { + await expectSelected(); + await expect(page.getByText("This agent is archived", { exact: true })).toBeVisible(); + await expect(page.getByTestId("assistant-message")).toContainText(REPLY); + }, + async reloadWithoutAgentCache() { + const url = page.url(); + const cdp = await page.context().newCDPSession(page); + // Unmount first so an open connection cannot repopulate the cache while it is cleared. + await page.goto("about:blank"); + await cdp.send("Storage.clearDataForOrigin", { + origin: new URL(url).origin, + storageTypes: "indexeddb,cache_storage", + }); + await cdp.send("Network.clearBrowserCache"); + await cdp.send("Network.setCacheDisabled", { cacheDisabled: true }); + await page.goto(url); + await cdp.detach(); + }, + async waitForWorkspaceHydration() { + await waitForSidebarHydration(page); + }, + async unarchiveAgent() { + await page.getByRole("button", { name: "Unarchive", exact: true }).click({ timeout: 30_000 }); + await expect(page.getByText("This agent is archived", { exact: true })).toHaveCount(0, { + timeout: 60_000, + }); + }, + async expectIdleAgentWithVisibleComposer(statusTimeout?: number) { + await expect + .poll(() => client.fetchAgent({ agentId: created().agentId }), { timeout: statusTimeout }) + .toMatchObject({ agent: { status: "idle", archivedAt: null } }); + await expectSelected(); + await expect(page.getByRole("textbox", { name: "Message agent..." })).toBeVisible(); + await assertComposerIdle({ page }); + await info.attach("restored-agent-screen", { + body: await page.screenshot(), + contentType: "image/png", + }); + await info.attach("restored-agent-accessibility", { + body: await page.locator("body").ariaSnapshot(), + contentType: "text/plain", + }); + }, + async dispose() { + const testFailed = info.status !== info.expectedStatus; + const errors: unknown[] = []; + try { + await info.attach("final-screen", { + body: await page.screenshot(), + contentType: "image/png", + }); + await info.attach("timeline-wire", { body: wire.join("\n"), contentType: "text/plain" }); + await info.attach("final-agent", { + body: JSON.stringify(agentId ? await client.fetchAgent({ agentId }) : null, null, 2), + contentType: "application/json", + }); + await info.attach("daemon-log", { + body: await readFile(path.join(process.env.E2E_PASEO_HOME!, "daemon.log")), + contentType: "text/plain", + }); + } catch (error) { + errors.push(error); + } + const cleanup: Array<() => Promise> = [() => mcp.close()]; + if (!testFailed || process.env.E2E_KEEP_PASEO_HOME !== "1") { + const projectId = workspace?.projectId; + if (projectId) cleanup.push(() => client.removeProject(projectId)); + cleanup.push(() => repo.cleanup()); + } + for (const release of cleanup) { + try { + await release(); + } catch (error) { + errors.push(error); + } + } + if (errors.length > 0) { + const error = new AggregateError(errors, "Codex restore fixture teardown failed"); + if (!testFailed) throw error; + // Keep the journey failure primary while reporting teardown failures in the test log. + console.error(error); + } + }, + }; +} + +export const test = base.extend<{ + codexRestore: Awaited>; +}>({ + // This fixture owns cleanup so a failed reproduction can retain its daemon state. + projectOwnership: async ({ e2eWorkerClient }, provide) => { + void e2eWorkerClient; + await provide(); + }, + codexRestore: async ({ page, e2eWorkerClient }, provide, info) => { + const journey = await createCodexRestoreJourney(page, e2eWorkerClient, info); + try { + await provide(journey); + } finally { + await journey.dispose(); + } + }, +}); diff --git a/packages/app/src/app/h/[serverId]/workspace/[workspaceId]/index.tsx b/packages/app/src/app/h/[serverId]/workspace/[workspaceId]/index.tsx index 8396d00b36..7dc11b08f1 100644 --- a/packages/app/src/app/h/[serverId]/workspace/[workspaceId]/index.tsx +++ b/packages/app/src/app/h/[serverId]/workspace/[workspaceId]/index.tsx @@ -115,8 +115,7 @@ function HostWorkspaceRouteContent() { const hasHydratedWorkspaces = useHasHydratedWorkspaces(serverId); const workspaceExists = useWorkspaceExists(serverId, workspaceId); const openIntent = useMemo(() => parseWorkspaceOpenIntent(openValue), [openValue]); - const recoveryAgentId = openIntent?.kind === "agent" ? openIntent.agentId : null; - const isAgentOpenIntent = recoveryAgentId !== null; + const isAgentOpenIntent = openIntent?.kind === "agent"; const isOpenIntentWaitingForWorkspace = Boolean( isAgentOpenIntent && (!hasHydratedWorkspaces || !workspaceExists), ); @@ -191,16 +190,10 @@ function HostWorkspaceRouteContent() { return null; } - return ; + return ; } -function WorkspaceDeck({ - recoveryRequested, - recoveryAgentId, -}: { - recoveryRequested: boolean; - recoveryAgentId: string | null; -}) { +function WorkspaceDeck({ recoveryRequested }: { recoveryRequested: boolean }) { const activeSelection = useActiveWorkspaceSelection(); const [retainedSelections, setRetainedSelections] = useState(() => activeSelection ? [{ selection: activeSelection, inactiveSince: null }] : [], @@ -272,7 +265,6 @@ function WorkspaceDeck({ selection={selection} active={active} recoveryRequested={recoveryRequested} - recoveryAgentId={recoveryAgentId} onUnmountInactive={unmountWorkspaceSelection} /> ); @@ -286,13 +278,11 @@ function WorkspaceDeckEntry({ selection, active, recoveryRequested, - recoveryAgentId, onUnmountInactive, }: { selection: ActiveWorkspaceSelection; active: boolean; recoveryRequested: boolean; - recoveryAgentId: string | null; onUnmountInactive: (selection: ActiveWorkspaceSelection) => void; }) { const hasHydratedWorkspaces = useHasHydratedWorkspaces(selection.serverId); @@ -323,7 +313,6 @@ function WorkspaceDeckEntry({ workspaceId={selection.workspaceId} isRouteFocused={active} recoveryRequested={active && recoveryRequested} - recoveryAgentId={active ? recoveryAgentId : null} /> ); diff --git a/packages/app/src/panels/agent-panel.tsx b/packages/app/src/panels/agent-panel.tsx index c2129c654c..84f86bf745 100644 --- a/packages/app/src/panels/agent-panel.tsx +++ b/packages/app/src/panels/agent-panel.tsx @@ -626,8 +626,6 @@ function AgentPanelBody({ const [lookupState, setLookupState] = useState({ tag: "idle" }); const lookupAttemptTokenRef = useRef(0); const retryAgentLookup = useCallback(() => setLookupState({ tag: "idle" }), []); - const workspaceKey = buildWorkspaceTabPersistenceKey({ serverId, workspaceId }); - const resolvePendingAgent = useWorkspaceLayoutStore((state) => state.resolvePendingAgent); useEffect(() => { lookupAttemptTokenRef.current += 1; @@ -639,9 +637,6 @@ function AgentPanelBody({ return; } if (agentState.id) { - if (workspaceKey) { - resolvePendingAgent(workspaceKey, agentId); - } if (lookupState.tag !== "idle") { setLookupState({ tag: "idle" }); } @@ -664,9 +659,6 @@ function AgentPanelBody({ return; } if (!result) { - if (workspaceKey) { - resolvePendingAgent(workspaceKey, agentId); - } setLookupState({ tag: "not_found", message: `Agent not found: ${agentId}`, @@ -675,9 +667,6 @@ function AgentPanelBody({ } storeFetchedAgentDetail({ serverId, result }); - if (workspaceKey) { - resolvePendingAgent(workspaceKey, agentId); - } setLookupState({ tag: "idle" }); return; }) @@ -687,25 +676,12 @@ function AgentPanelBody({ } const message = toErrorMessage(error); if (isNotFoundErrorMessage(message)) { - if (workspaceKey) { - resolvePendingAgent(workspaceKey, agentId); - } setLookupState({ tag: "not_found", message }); return; } setLookupState({ tag: "error", message }); }); - }, [ - agentId, - agentState.id, - client, - hasSession, - isConnected, - lookupState.tag, - resolvePendingAgent, - serverId, - workspaceKey, - ]); + }, [agentId, agentState.id, client, hasSession, isConnected, lookupState.tag, serverId]); if (lookupState.tag === "not_found") { return ( diff --git a/packages/app/src/runtime/directory-sync/agent-replica.test.ts b/packages/app/src/runtime/directory-sync/agent-replica.test.ts index d7deff40a7..316e8e03bf 100644 --- a/packages/app/src/runtime/directory-sync/agent-replica.test.ts +++ b/packages/app/src/runtime/directory-sync/agent-replica.test.ts @@ -1,7 +1,11 @@ import { describe, expect, it } from "vitest"; import type { DaemonClient, FetchAgentsEntry } from "@getpaseo/client/internal/daemon-client"; import type { AgentSnapshotPayload } from "@getpaseo/protocol/messages"; -import { selectAgentTurnPresentation, useSessionStore } from "@/stores/session-store"; +import { + selectAgentTimelineState, + selectAgentTurnPresentation, + useSessionStore, +} from "@/stores/session-store"; import { normalizeAgentSnapshot } from "@/utils/agent-snapshots"; import type { DirectoryReplicaMutation } from "@/runtime/replica-cache"; import { AgentDirectoryReplica } from "./agent-replica"; @@ -53,6 +57,50 @@ function entry(agent: AgentSnapshotPayload): FetchAgentsEntry { } describe("AgentDirectoryReplica", () => { + it.each(["delta", "snapshot"] as const)( + "does not erase freshly fetched history when the active directory removes it by %s", + (removal) => { + const serverId = `agent-replica-history-${removal}`; + const store = useSessionStore.getState(); + store.initializeSession(serverId, null); + const replica = new AgentDirectoryReplica( + serverId, + () => undefined, + () => undefined, + ); + const archived = { ...payload("archived"), archivedAt: "2026-09-11T00:00:00.000Z" }; + replica.submitTimelineAgent(replica.captureTimeline("agent"), archived); + store.applyAgentTimelineResponseState(serverId, "agent", { + items: [ + { + kind: "assistant_message", + id: "reply", + text: "server history", + timestamp: new Date(), + timelineCursor: { epoch: "e", seq: 1 }, + }, + ], + head: [], + range: { epoch: "e", startSeq: 1, endSeq: 1 }, + older: "none", + newer: false, + synchronized: true, + acknowledgedClientMessageIds: [], + }); + if (removal === "delta") replica.applyDelta({ kind: "remove", agentId: "agent" }); + else replica.commitSnapshot([], []); + expect(selectAgentTimelineState(store.getSession(serverId), "agent")).toMatchObject({ + status: "synced", + items: [{ text: "server history" }], + }); + replica.remove("agent"); + expect(selectAgentTimelineState(store.getSession(serverId), "agent")).toMatchObject({ + status: "cold", + }); + store.clearSession(serverId); + }, + ); + it("does not let a late cache read replace newer live turn state", () => { const serverId = "agent-replica-late-cache"; const store = useSessionStore.getState(); diff --git a/packages/app/src/runtime/directory-sync/agent-replica.ts b/packages/app/src/runtime/directory-sync/agent-replica.ts index 1bdf3ea3de..dac4ec5d28 100644 --- a/packages/app/src/runtime/directory-sync/agent-replica.ts +++ b/packages/app/src/runtime/directory-sync/agent-replica.ts @@ -134,7 +134,7 @@ export class AgentDirectoryReplica { if (!this.members.has(agentId)) this.advance(agentId); } for (const agentId of previous.keys()) { - if (!nextIds.has(agentId)) this.storeProjection.remove(agentId); + if (!nextIds.has(agentId)) this.storeProjection.removeFromDirectory(agentId); } this.members.clear(); this.pendingCacheReads.clear(); diff --git a/packages/app/src/runtime/directory-sync/internal/agent-store.ts b/packages/app/src/runtime/directory-sync/internal/agent-store.ts index 0566da5aaf..97c8208da5 100644 --- a/packages/app/src/runtime/directory-sync/internal/agent-store.ts +++ b/packages/app/src/runtime/directory-sync/internal/agent-store.ts @@ -12,6 +12,15 @@ import { useDraftStore } from "@/stores/draft-store"; import { getInitDeferred, getInitKey, rejectInitDeferred } from "@/utils/agent-initialization"; import { reduceTurnLiveness, type TurnLivenessTransition } from "@/timeline/turn-liveness"; +function withoutAgent(agentId: string) { + return (current: Map): Map => { + if (!current.has(agentId)) return current; + const next = new Map(current); + next.delete(agentId); + return next; + }; +} + function mergeSnapshotTurn(previous: Agent | undefined, incoming: Agent): Agent { if (!previous) return incoming; const activeTurn = @@ -65,7 +74,7 @@ export class AgentStoreProjection { agent?: Agent; } { if (delta.kind === "remove") { - this.remove(delta.agentId); + this.removeFromDirectory(delta.agentId); return { agentId: delta.agentId, stoppedRunning: false }; } const normalized = normalizeAgentSnapshot(delta.agent, this.serverId); @@ -141,28 +150,19 @@ export class AgentStoreProjection { useSessionStore.getState().setPendingPermissions(this.serverId, pending); } - remove(agentId: string): void { + removeFromDirectory(agentId: string): void { const store = useSessionStore.getState(); - const removeKey = (current: Map): Map => { - if (!current.has(agentId)) return current; - const next = new Map(current); - next.delete(agentId); - return next; - }; + const removeKey = withoutAgent(agentId); clearArchiveAgentPending({ queryClient, serverId: this.serverId, agentId }); store.setAgents(this.serverId, removeKey); store.setAgentDetails(this.serverId, removeKey); store.setQueuedMessages(this.serverId, removeKey); - store.setAgentTimelineCursor(this.serverId, removeKey); store.setInitializingAgents(this.serverId, removeKey); store.setPendingPermissions(this.serverId, (current) => { const next = new Map(current); for (const [key, pending] of next) if (pending.agentId === agentId) next.delete(key); return next.size === current.size ? current : next; }); - store.setAgentAuthoritativeHistoryApplied(this.serverId, agentId, false); - store.setAgentStreamTail(this.serverId, removeKey); - store.clearAgentStreamHead(this.serverId, agentId); useSessionStore.setState((state) => { if (!state.agentLastActivity.has(agentId)) return state; const agentLastActivity = new Map(state.agentLastActivity); @@ -178,6 +178,18 @@ export class AgentStoreProjection { } } + remove(agentId: string): void { + this.removeFromDirectory(agentId); + // Only entity deletion destroys the transcript. A filtered active-list + // response can arrive after a fresh history response for an archived agent. + const store = useSessionStore.getState(); + const removeKey = withoutAgent(agentId); + store.setAgentTimelineCursor(this.serverId, removeKey); + store.setAgentAuthoritativeHistoryApplied(this.serverId, agentId, false); + store.setAgentStreamTail(this.serverId, removeKey); + store.clearAgentStreamHead(this.serverId, agentId); + } + archive(agentId: string, archivedAt: string): Agent | null { let archived: Agent | null = null; useSessionStore.getState().setAgents(this.serverId, (current) => { diff --git a/packages/app/src/screens/workspace/workspace-screen.tsx b/packages/app/src/screens/workspace/workspace-screen.tsx index d9510f517f..a898fc0472 100644 --- a/packages/app/src/screens/workspace/workspace-screen.tsx +++ b/packages/app/src/screens/workspace/workspace-screen.tsx @@ -257,13 +257,11 @@ interface WorkspaceScreenProps { workspaceId: string; isRouteFocused?: boolean; recoveryRequested?: boolean; - recoveryAgentId?: string | null; } type WorkspaceScreenContentProps = WorkspaceScreenProps & { isRouteFocused: boolean; recoveryRequested: boolean; - recoveryAgentId: string | null; }; function trimNonEmpty(value: string | null | undefined): string | null { @@ -859,7 +857,6 @@ export const WorkspaceScreen = memo(function WorkspaceScreen({ workspaceId, isRouteFocused, recoveryRequested, - recoveryAgentId, }: WorkspaceScreenProps) { const navigationFocused = useIsFocused(); useEffect(() => { @@ -874,7 +871,6 @@ export const WorkspaceScreen = memo(function WorkspaceScreen({ workspaceId={workspaceId} isRouteFocused={isRouteFocused ?? navigationFocused} recoveryRequested={recoveryRequested ?? false} - recoveryAgentId={recoveryAgentId ?? null} /> ); }); @@ -1539,7 +1535,6 @@ function WorkspaceScreenContent({ workspaceId, isRouteFocused, recoveryRequested, - recoveryAgentId, }: WorkspaceScreenContentProps) { const { t } = useTranslation(); const _insets = useSafeAreaInsets(); @@ -1665,7 +1660,6 @@ function WorkspaceScreenContent({ const workspaceRecovery = useWorkspaceRecovery({ serverId: normalizedServerId, workspaceId: normalizedWorkspaceId, - agentId: recoveryAgentId, enabled: shouldInspectWorkspaceRecovery( hasHydratedWorkspaces, workspaceDescriptor, diff --git a/packages/app/src/stores/workspace-layout-actions.ts b/packages/app/src/stores/workspace-layout-actions.ts index 9496479f4e..d46952626e 100644 --- a/packages/app/src/stores/workspace-layout-actions.ts +++ b/packages/app/src/stores/workspace-layout-actions.ts @@ -252,7 +252,6 @@ interface ReorderPaneTabsInLayoutInput { export interface WorkspaceTabReconcileState { layout: WorkspaceLayout; pinnedAgentIds?: ReadonlySet | null; - pendingAgentIds?: ReadonlySet | null; hiddenAgentIds?: ReadonlySet | null; explorerSidebarPaneId: string | null; } @@ -262,7 +261,6 @@ export interface WorkspaceTabSnapshot { terminalsHydrated: boolean; activeAgentIds: Iterable; autoOpenAgentIds: Iterable; - knownAgentIds: Iterable; knownTerminalIds?: Iterable; standaloneTerminalIds: Iterable; hasActivePendingTerminalCreate?: boolean; @@ -2238,17 +2236,10 @@ interface EntityTabGroup { function applyPinnedAndHidden(input: { baseAgentIds: Set; pinnedAgentIds: Set; - pendingAgentIds: Set; hiddenAgentIds: Set; - knownAgentIds: Set; }): Set { - const { baseAgentIds, pinnedAgentIds, pendingAgentIds, hiddenAgentIds, knownAgentIds } = input; - const result = new Set(baseAgentIds); - for (const agentId of pinnedAgentIds) { - if (knownAgentIds.has(agentId) || pendingAgentIds.has(agentId)) { - result.add(agentId); - } - } + const { baseAgentIds, pinnedAgentIds, hiddenAgentIds } = input; + const result = new Set([...baseAgentIds, ...pinnedAgentIds]); for (const agentId of hiddenAgentIds) { result.delete(agentId); } @@ -2414,12 +2405,17 @@ export function reconcileWorkspaceTabs( const originalFocusedTabId = findPaneById(nextLayout.root, nextLayout.focusedPaneId)?.focusedTabId ?? null; let reconciledFocusedTabId = originalFocusedTabId; - const pinnedAgentIds = new Set(state.pinnedAgentIds ?? []); - const pendingAgentIds = new Set(state.pendingAgentIds ?? []); const hiddenAgentIds = new Set(state.hiddenAgentIds ?? []); const activeAgentIds = normalizeStringSet(snapshot.activeAgentIds); const autoOpenAgentIds = normalizeStringSet(snapshot.autoOpenAgentIds); - const knownAgentIds = normalizeStringSet(snapshot.knownAgentIds); + // An explicit open owns the tab until the agent joins the active directory. + // From then on it follows the normal server archive lifecycle. Detail/cache + // hydration never decides whether the user's target is allowed to stay open. + const pinnedAgentIds = new Set( + [...(state.pinnedAgentIds ?? [])].filter( + (agentId) => !snapshot.agentsHydrated || !activeAgentIds.has(agentId), + ), + ); const standaloneTerminalIds = normalizeStringSet(snapshot.standaloneTerminalIds); const knownTerminalIds = snapshot.knownTerminalIds ? normalizeStringSet(snapshot.knownTerminalIds) @@ -2427,16 +2423,12 @@ export function reconcileWorkspaceTabs( const visibleAgentIds = applyPinnedAndHidden({ baseAgentIds: activeAgentIds, pinnedAgentIds, - pendingAgentIds, hiddenAgentIds, - knownAgentIds, }); const autoOpenSet = applyPinnedAndHidden({ baseAgentIds: autoOpenAgentIds, pinnedAgentIds, - pendingAgentIds, hiddenAgentIds, - knownAgentIds, }); const initialTabs = collectAllTabs(nextLayout.root); @@ -2513,5 +2505,6 @@ export function reconcileWorkspaceTabs( return { ...state, layout: nextLayout, + pinnedAgentIds, }; } diff --git a/packages/app/src/stores/workspace-layout-store.test.ts b/packages/app/src/stores/workspace-layout-store.test.ts index e53f9c1911..4f861a04ee 100644 --- a/packages/app/src/stores/workspace-layout-store.test.ts +++ b/packages/app/src/stores/workspace-layout-store.test.ts @@ -450,7 +450,7 @@ describe("workspace-layout-store version 2 migration", () => { }); }); - it("writes version 2 with the persisted Explorer key accepted by released v0.6", async () => { + it("writes version 2 preserving the existing Explorer key", async () => { await persistVersionOneLayout("v0.6"); const restored = createWorkspaceLayoutStore(createDeterministicWorkspaceLayoutIds()); @@ -463,6 +463,7 @@ describe("workspace-layout-store version 2 migration", () => { "explorerPaneIdByWorkspace", "explorerSidebarWidthByWorkspace", "layoutByWorkspace", + "pinnedAgentIdsByWorkspace", "sidePaneIdByWorkspace", "splitSizesByWorkspace", ]); @@ -1483,7 +1484,6 @@ describe("workspace-layout-store actions", () => { terminalsHydrated: true, activeAgentIds: [], autoOpenAgentIds: [], - knownAgentIds: [], knownTerminalIds: ["terminal-1"], standaloneTerminalIds: ["terminal-1"], hasActivePendingTerminalCreate: true, @@ -1497,7 +1497,6 @@ describe("workspace-layout-store actions", () => { terminalsHydrated: true, activeAgentIds: [], autoOpenAgentIds: [], - knownAgentIds: [], knownTerminalIds: ["terminal-1"], standaloneTerminalIds: ["terminal-1"], }); @@ -1580,7 +1579,6 @@ describe("workspace-layout-store actions", () => { terminalsHydrated: true, activeAgentIds: ["agent-1", "agent-2"], autoOpenAgentIds: ["agent-1", "agent-2"], - knownAgentIds: ["agent-1", "agent-2"], standaloneTerminalIds: [], }); @@ -2943,6 +2941,7 @@ describe("workspace-layout-store actions", () => { expect(persisted).toEqual({ layoutByWorkspace: { [workspaceKey]: layout }, + pinnedAgentIdsByWorkspace: {}, splitSizesByWorkspace: currentState.splitSizesByWorkspace, explorerSidebarWidthByWorkspace: currentState.explorerSidebarWidthByWorkspace, explorerPaneIdByWorkspace: {}, @@ -3092,7 +3091,7 @@ describe("workspace-layout-store actions", () => { expect(findPaneById(layout.root, "explorer")?.hidden).toBe(true); }); - it("keeps explicitly opened pinned agents in memory per workspace without persisting them", () => { + it("persists explicit agent opens per workspace", () => { const workspaceKey = createWorkspaceKey(); const otherWorkspaceKey = buildWorkspaceTabPersistenceKey({ serverId: SERVER_ID, @@ -3131,7 +3130,9 @@ describe("workspace-layout-store actions", () => { const partialize = workspaceLayoutStore.persist.getOptions().partialize; expect(partialize).toBeTypeOf("function"); - expect(partialize?.(state)).not.toHaveProperty("pinnedAgentIdsByWorkspace"); + expect(partialize?.(state)).toHaveProperty("pinnedAgentIdsByWorkspace", { + [otherWorkspaceKey as string]: ["agent-2"], + }); }); it("keeps hidden agent intents in memory per workspace without persisting them", () => { @@ -3166,6 +3167,7 @@ describe("workspace-layout-store actions", () => { expect(partialize).toBeTypeOf("function"); expect(partialize?.(state)).toEqual({ layoutByWorkspace: {}, + pinnedAgentIdsByWorkspace: {}, splitSizesByWorkspace: {}, explorerSidebarWidthByWorkspace: {}, explorerPaneIdByWorkspace: {}, @@ -3258,7 +3260,6 @@ describe("workspace-layout-store actions", () => { terminalsHydrated: true, activeAgentIds: ["agent-1"], autoOpenAgentIds: ["agent-1"], - knownAgentIds: ["agent-1", "agent-2"], standaloneTerminalIds: ["term-1"], hasActivePendingDraftCreate: false, }); @@ -3314,7 +3315,6 @@ describe("workspace-layout-store actions", () => { terminalsHydrated: true, activeAgentIds: ["agent-1"], autoOpenAgentIds: ["agent-1"], - knownAgentIds: ["agent-1"], standaloneTerminalIds: [], hasActivePendingDraftCreate: false, }); @@ -3346,7 +3346,6 @@ describe("workspace-layout-store actions", () => { terminalsHydrated: true, activeAgentIds: ["agent-1"], autoOpenAgentIds: ["agent-1"], - knownAgentIds: ["agent-1"], standaloneTerminalIds: [], hasActivePendingDraftCreate: false, }); @@ -3362,7 +3361,6 @@ describe("workspace-layout-store actions", () => { terminalsHydrated: true, activeAgentIds: ["agent-1"], autoOpenAgentIds: ["agent-1"], - knownAgentIds: ["agent-1"], standaloneTerminalIds: [], hasActivePendingDraftCreate: false, }); @@ -3380,7 +3378,6 @@ describe("workspace-layout-store actions", () => { terminalsHydrated: true, activeAgentIds: [], autoOpenAgentIds: [], - knownAgentIds: [], knownTerminalIds: [], standaloneTerminalIds: [], hasActivePendingDraftCreate: false, @@ -3444,7 +3441,6 @@ describe("workspace-layout-store actions", () => { terminalsHydrated: false, activeAgentIds: [], autoOpenAgentIds: [], - knownAgentIds: [], knownTerminalIds: [], standaloneTerminalIds: [], }); @@ -3467,7 +3463,6 @@ describe("workspace-layout-store actions", () => { terminalsHydrated: true, activeAgentIds: [], autoOpenAgentIds: [], - knownAgentIds: [], knownTerminalIds: [], standaloneTerminalIds: [], hasActivePendingDraftCreate: false, @@ -3495,7 +3490,6 @@ describe("workspace-layout-store actions", () => { terminalsHydrated: true, activeAgentIds: ["parent-agent", "child-agent"], autoOpenAgentIds: ["parent-agent"], - knownAgentIds: ["parent-agent", "child-agent"], standaloneTerminalIds: [], hasActivePendingDraftCreate: false, }); @@ -3522,7 +3516,6 @@ describe("workspace-layout-store actions", () => { terminalsHydrated: true, activeAgentIds: ["parent-agent", "child-agent"], autoOpenAgentIds: ["parent-agent"], - knownAgentIds: ["parent-agent", "child-agent"], standaloneTerminalIds: [], hasActivePendingDraftCreate: false, }); @@ -3549,7 +3542,6 @@ describe("workspace-layout-store actions", () => { terminalsHydrated: true, activeAgentIds: ["parent-agent"], autoOpenAgentIds: ["parent-agent"], - knownAgentIds: ["parent-agent", "child-agent"], standaloneTerminalIds: [], hasActivePendingDraftCreate: false, }); @@ -3571,7 +3563,6 @@ describe("workspace-layout-store actions", () => { terminalsHydrated: true, activeAgentIds: ["child-agent"], autoOpenAgentIds: [], - knownAgentIds: ["child-agent"], standaloneTerminalIds: [], hasActivePendingDraftCreate: false, }); @@ -3609,7 +3600,6 @@ describe("workspace-layout-store actions", () => { terminalsHydrated: true, activeAgentIds: [], autoOpenAgentIds: [], - knownAgentIds: [], knownTerminalIds: ["term-script", "term-manual"], standaloneTerminalIds: ["term-manual"], hasActivePendingDraftCreate: false, @@ -3629,7 +3619,6 @@ describe("workspace-layout-store actions", () => { terminalsHydrated: true, activeAgentIds: [], autoOpenAgentIds: [], - knownAgentIds: [], knownTerminalIds: ["term-script"], standaloneTerminalIds: [], hasActivePendingDraftCreate: false, @@ -3675,7 +3664,7 @@ describe("workspace-layout-store actions", () => { expect(Array.from(state.pinnedAgentIdsByWorkspace[workspaceKey] ?? [])).toEqual(["agent-1"]); }); - it("keeps an explicitly pinned archived agent before its detail is hydrated", () => { + it("keeps an explicitly opened agent when its detail leaves client memory", () => { const workspaceKey = createWorkspaceKey(); const store = workspaceLayoutStore.getState(); @@ -3690,7 +3679,6 @@ describe("workspace-layout-store actions", () => { terminalsHydrated: true, activeAgentIds: [], autoOpenAgentIds: [], - knownAgentIds: [], standaloneTerminalIds: [], }); @@ -3706,7 +3694,6 @@ describe("workspace-layout-store actions", () => { terminalsHydrated: true, activeAgentIds: [], autoOpenAgentIds: [], - knownAgentIds: [], standaloneTerminalIds: [], }); @@ -3717,13 +3704,11 @@ describe("workspace-layout-store actions", () => { .map((tab) => tab.tabId), ).toEqual(["agent_archived-agent"]); - store.resolvePendingAgent(workspaceKey, "archived-agent"); store.reconcileTabs(workspaceKey, { agentsHydrated: true, terminalsHydrated: true, activeAgentIds: [], autoOpenAgentIds: [], - knownAgentIds: [], standaloneTerminalIds: [], }); @@ -3732,7 +3717,7 @@ describe("workspace-layout-store actions", () => { .getWorkspaceTabs(workspaceKey) .filter((tab) => tab.target.kind === "agent") .map((tab) => tab.tabId), - ).toEqual([]); + ).toEqual(["agent_archived-agent"]); store.openTab({ workspaceKey: workspaceKey, @@ -3745,7 +3730,6 @@ describe("workspace-layout-store actions", () => { terminalsHydrated: true, activeAgentIds: [], autoOpenAgentIds: [], - knownAgentIds: [], standaloneTerminalIds: [], }); @@ -3757,6 +3741,57 @@ describe("workspace-layout-store actions", () => { ).toEqual(["agent_archived-agent"]); }); + it("restores the selected historical agent before any server data or cache is available", async () => { + const workspaceKey = createWorkspaceKey(); + workspaceLayoutStore.getState().openTab({ + workspaceKey, + target: { kind: "agent", agentId: "archived-agent" }, + intent: "reveal", + pin: true, + }); + const restored = createWorkspaceLayoutStore(createDeterministicWorkspaceLayoutIds()); + await restored.persist.rehydrate(); + restored.getState().reconcileTabs(workspaceKey, { + agentsHydrated: true, + terminalsHydrated: true, + activeAgentIds: [], + autoOpenAgentIds: [], + standaloneTerminalIds: [], + }); + const layout = restored.getState().layoutByWorkspace[workspaceKey]; + expect(findPaneById(layout.root, layout.focusedPaneId)?.focusedTabId).toBe( + "agent_archived-agent", + ); + }); + + it("follows server archive after an explicitly opened agent becomes active", () => { + const workspaceKey = createWorkspaceKey(); + const store = workspaceLayoutStore.getState(); + store.openTab({ + workspaceKey, + target: { kind: "agent", agentId: "agent-1" }, + intent: "reveal", + pin: true, + }); + store.reconcileTabs(workspaceKey, { + agentsHydrated: true, + terminalsHydrated: true, + activeAgentIds: ["agent-1"], + autoOpenAgentIds: ["agent-1"], + standaloneTerminalIds: [], + }); + store.reconcileTabs(workspaceKey, { + agentsHydrated: true, + terminalsHydrated: true, + activeAgentIds: [], + autoOpenAgentIds: [], + standaloneTerminalIds: [], + }); + expect(store.getWorkspaceTabs(workspaceKey).some((tab) => tab.target.kind === "agent")).toBe( + false, + ); + }); + it("atomically reveals, focuses, and pins an archived agent against reconciliation", () => { const workspaceKey = createWorkspaceKey(); const store = workspaceLayoutStore.getState(); @@ -3783,7 +3818,6 @@ describe("workspace-layout-store actions", () => { terminalsHydrated: true, activeAgentIds: ["first-agent", "second-agent"], autoOpenAgentIds: ["first-agent", "second-agent"], - knownAgentIds: ["first-agent", "second-agent"], standaloneTerminalIds: [], }); diff --git a/packages/app/src/stores/workspace-layout-store.ts b/packages/app/src/stores/workspace-layout-store.ts index 974b8c6964..afb1d2694d 100644 --- a/packages/app/src/stores/workspace-layout-store.ts +++ b/packages/app/src/stores/workspace-layout-store.ts @@ -107,7 +107,6 @@ interface WorkspaceLayoutStore { splitSizesByWorkspace: Record>; explorerSidebarWidthByWorkspace: Record; pinnedAgentIdsByWorkspace: Record>; - pendingAgentIdsByWorkspace: Record>; hiddenAgentIdsByWorkspace: Record>; focusRestorationByWorkspace: Record; explorerSidebarPaneIdByWorkspace: Record; @@ -130,7 +129,6 @@ interface WorkspaceLayoutStore { setTabState: (workspaceKey: string, tabId: string, state: JsonValue | undefined) => void; convertDraftToAgent: (workspaceKey: string, tabId: string, agentId: string) => string | null; reconcileTabs: (workspaceKey: string, snapshot: WorkspaceTabSnapshot) => void; - resolvePendingAgent: (workspaceKey: string, agentId: string) => void; reorderTabs: (workspaceKey: string, tabIds: string[]) => void; getWorkspaceTabs: (workspaceKey: string) => WorkspaceTab[]; splitPane: ( @@ -270,6 +268,7 @@ const WorkspaceLayoutStorageSchema: z.ZodType = z.strictObject( parentTabIdByTabId: z.record(z.string(), z.string()).optional(), }); const WorkspaceLayoutPersistedStateSchema = z.strictObject({ + pinnedAgentIdsByWorkspace: z.record(z.string(), z.array(z.string())).optional(), layoutByWorkspace: z.record(z.string(), WorkspaceLayoutStorageSchema), splitSizesByWorkspace: z.record(z.string(), z.record(z.string(), z.array(z.number()))).optional(), explorerSidebarWidthByWorkspace: z.record(z.string(), z.number()).optional(), @@ -764,7 +763,6 @@ export function createWorkspaceLayoutStore( splitSizesByWorkspace: {}, explorerSidebarWidthByWorkspace: {}, pinnedAgentIdsByWorkspace: {}, - pendingAgentIdsByWorkspace: {}, hiddenAgentIdsByWorkspace: {}, focusRestorationByWorkspace: {}, explorerSidebarPaneIdByWorkspace: {}, @@ -830,13 +828,6 @@ export function createWorkspaceLayoutStore( normalizedTarget.agentId, ) : state.pinnedAgentIdsByWorkspace, - pendingAgentIdsByWorkspace: shouldPinAgent - ? addAgentIdToWorkspaceSet( - state.pendingAgentIdsByWorkspace, - normalizedWorkspaceKey, - normalizedTarget.agentId, - ) - : state.pendingAgentIdsByWorkspace, layoutByWorkspace: { ...state.layoutByWorkspace, [normalizedWorkspaceKey]: input.parentTabId @@ -1212,7 +1203,6 @@ export function createWorkspaceLayoutStore( { layout: currentLayout, pinnedAgentIds: state.pinnedAgentIdsByWorkspace[normalizedWorkspaceKey] ?? null, - pendingAgentIds: state.pendingAgentIdsByWorkspace[normalizedWorkspaceKey] ?? null, hiddenAgentIds: state.hiddenAgentIdsByWorkspace[normalizedWorkspaceKey] ?? null, explorerSidebarPaneId, }, @@ -1223,11 +1213,25 @@ export function createWorkspaceLayoutStore( explorerSidebarPaneId, currentLayout.focusedPaneId, ); - if (nextLayout === rawLayout) { + let pinnedAgentIdsByWorkspace = state.pinnedAgentIdsByWorkspace; + for (const agentId of state.pinnedAgentIdsByWorkspace[normalizedWorkspaceKey] ?? []) { + if (!nextState.pinnedAgentIds?.has(agentId)) { + pinnedAgentIdsByWorkspace = removeAgentIdFromWorkspaceSet( + pinnedAgentIdsByWorkspace, + normalizedWorkspaceKey, + agentId, + ); + } + } + if ( + nextLayout === rawLayout && + pinnedAgentIdsByWorkspace === state.pinnedAgentIdsByWorkspace + ) { return state; } return { + pinnedAgentIdsByWorkspace, layoutByWorkspace: { ...state.layoutByWorkspace, [normalizedWorkspaceKey]: nextLayout, @@ -1235,25 +1239,6 @@ export function createWorkspaceLayoutStore( }; }); }, - resolvePendingAgent: (workspaceKey, agentId) => { - const normalizedWorkspaceKey = trimNonEmpty(workspaceKey); - const normalizedAgentId = trimNonEmpty(agentId); - if (!normalizedWorkspaceKey || !normalizedAgentId) { - return; - } - - set((state) => { - const pendingAgentIdsByWorkspace = removeAgentIdFromWorkspaceSet( - state.pendingAgentIdsByWorkspace, - normalizedWorkspaceKey, - normalizedAgentId, - ); - if (pendingAgentIdsByWorkspace === state.pendingAgentIdsByWorkspace) { - return state; - } - return { pendingAgentIdsByWorkspace }; - }); - }, reorderTabs: (workspaceKey, tabIds) => { const normalizedWorkspaceKey = trimNonEmpty(workspaceKey); if (!normalizedWorkspaceKey) { @@ -1676,11 +1661,6 @@ export function createWorkspaceLayoutStore( delete nextPinnedAgentIdsByWorkspace[normalizedWorkspaceKey]; return { pinnedAgentIdsByWorkspace: nextPinnedAgentIdsByWorkspace, - pendingAgentIdsByWorkspace: removeAgentIdFromWorkspaceSet( - state.pendingAgentIdsByWorkspace, - normalizedWorkspaceKey, - normalizedAgentId, - ), }; } @@ -1692,11 +1672,6 @@ export function createWorkspaceLayoutStore( ...state.pinnedAgentIdsByWorkspace, [normalizedWorkspaceKey]: nextPinnedAgentIds, }, - pendingAgentIdsByWorkspace: removeAgentIdFromWorkspaceSet( - state.pendingAgentIdsByWorkspace, - normalizedWorkspaceKey, - normalizedAgentId, - ), }; }); }, @@ -1756,7 +1731,6 @@ export function createWorkspaceLayoutStore( normalizedWorkspaceKey in state.splitSizesByWorkspace || normalizedWorkspaceKey in state.explorerSidebarWidthByWorkspace || normalizedWorkspaceKey in state.pinnedAgentIdsByWorkspace || - normalizedWorkspaceKey in state.pendingAgentIdsByWorkspace || normalizedWorkspaceKey in state.hiddenAgentIdsByWorkspace || normalizedWorkspaceKey in state.focusRestorationByWorkspace || normalizedWorkspaceKey in state.explorerSidebarPaneIdByWorkspace || @@ -1774,8 +1748,6 @@ export function createWorkspaceLayoutStore( } = state.explorerSidebarWidthByWorkspace; const { [normalizedWorkspaceKey]: _pinned, ...pinnedAgentIdsByWorkspace } = state.pinnedAgentIdsByWorkspace; - const { [normalizedWorkspaceKey]: _pending, ...pendingAgentIdsByWorkspace } = - state.pendingAgentIdsByWorkspace; const { [normalizedWorkspaceKey]: _hidden, ...hiddenAgentIdsByWorkspace } = state.hiddenAgentIdsByWorkspace; const { [normalizedWorkspaceKey]: _restoration, ...focusRestorationByWorkspace } = @@ -1791,7 +1763,6 @@ export function createWorkspaceLayoutStore( splitSizesByWorkspace, explorerSidebarWidthByWorkspace, pinnedAgentIdsByWorkspace, - pendingAgentIdsByWorkspace, hiddenAgentIdsByWorkspace, focusRestorationByWorkspace, explorerSidebarPaneIdByWorkspace, @@ -1817,6 +1788,12 @@ export function createWorkspaceLayoutStore( } return { layoutByWorkspace, + pinnedAgentIdsByWorkspace: Object.fromEntries( + Object.entries(state.pinnedAgentIdsByWorkspace).map(([key, agentIds]) => [ + key, + Array.from(agentIds), + ]), + ), splitSizesByWorkspace: state.splitSizesByWorkspace, explorerSidebarWidthByWorkspace: state.explorerSidebarWidthByWorkspace, explorerPaneIdByWorkspace: state.explorerSidebarPaneIdByWorkspace, @@ -1855,6 +1832,12 @@ export function createWorkspaceLayoutStore( return { ...currentState, layoutByWorkspace, + pinnedAgentIdsByWorkspace: Object.fromEntries( + Object.entries(result.data.pinnedAgentIdsByWorkspace ?? {}).map(([key, agentIds]) => [ + key, + new Set(agentIds), + ]), + ), splitSizesByWorkspace: result.data.splitSizesByWorkspace ?? {}, explorerSidebarWidthByWorkspace: result.data.explorerSidebarWidthByWorkspace ?? diff --git a/packages/app/src/utils/agent-directory-sync.test.ts b/packages/app/src/utils/agent-directory-sync.test.ts index c8e2479894..0362aa56de 100644 --- a/packages/app/src/utils/agent-directory-sync.test.ts +++ b/packages/app/src/utils/agent-directory-sync.test.ts @@ -187,55 +187,60 @@ describe("replaceFetchedAgentDirectory", () => { store.clearSession(serverId); }); - it("removes every replica-owned artifact for a removed agent", () => { - const serverId = "server-removal"; - const agentId = "removed-agent"; - const store = useSessionStore.getState(); - store.initializeSession(serverId, null as unknown as DaemonClient); - const agent = { - ...normalizeAgentSnapshot(createAgentPayload({ id: agentId }), serverId), - projectPlacement: null, - }; - store.setAgents(serverId, new Map([[agentId, agent]])); - store.setAgentDetails(serverId, new Map([[agentId, agent]])); - store.setQueuedMessages( - serverId, - new Map([[agentId, [{ id: "queued", text: "next", attachments: [] }]]]), - ); - store.setAgentTimelineCursor( - serverId, - new Map([[agentId, { epoch: "epoch", startSeq: 1, endSeq: 2 }]]), - ); - store.setPendingPermissions( - serverId, - new Map([["permission", { key: "permission", agentId, request: null as never }]]), - ); - store.setInitializingAgents(serverId, new Map([[agentId, true]])); - setAgentArchiving({ queryClient, serverId, agentId, isArchiving: true }); + it.each(["directory", "entity"] as const)( + "cleans removed %s state without invalidating an independent transcript", + (kind) => { + const serverId = "server-removal"; + const agentId = "removed-agent"; + const store = useSessionStore.getState(); + store.initializeSession(serverId, null as unknown as DaemonClient); + const agent = { + ...normalizeAgentSnapshot(createAgentPayload({ id: agentId }), serverId), + projectPlacement: null, + }; + store.setAgents(serverId, new Map([[agentId, agent]])); + store.setAgentDetails(serverId, new Map([[agentId, agent]])); + store.setQueuedMessages( + serverId, + new Map([[agentId, [{ id: "queued", text: "next", attachments: [] }]]]), + ); + store.setAgentTimelineCursor( + serverId, + new Map([[agentId, { epoch: "epoch", startSeq: 1, endSeq: 2 }]]), + ); + store.setPendingPermissions( + serverId, + new Map([["permission", { key: "permission", agentId, request: null as never }]]), + ); + store.setInitializingAgents(serverId, new Map([[agentId, true]])); + setAgentArchiving({ queryClient, serverId, agentId, isArchiving: true }); - new AgentStoreProjection(serverId).applyDelta({ kind: "remove", agentId }); + const projection = new AgentStoreProjection(serverId); + if (kind === "directory") projection.applyDelta({ kind: "remove", agentId }); + else projection.remove(agentId); - const session = useSessionStore.getState().sessions[serverId]; - expect({ - agents: session?.agents.has(agentId), - details: session?.agentDetails.has(agentId), - queued: session?.queuedMessages.has(agentId), - cursor: session?.agentTimelineCursor.has(agentId), - permissions: session?.pendingPermissions.size, - initializing: session?.initializingAgents.has(agentId), - archivePending: isAgentArchiving({ queryClient, serverId, agentId }), - }).toEqual({ - agents: false, - details: false, - queued: false, - cursor: false, - permissions: 0, - initializing: false, - archivePending: false, - }); + const session = useSessionStore.getState().sessions[serverId]; + expect({ + agents: session?.agents.has(agentId), + details: session?.agentDetails.has(agentId), + queued: session?.queuedMessages.has(agentId), + cursor: session?.agentTimelineCursor.has(agentId), + permissions: session?.pendingPermissions.size, + initializing: session?.initializingAgents.has(agentId), + archivePending: isAgentArchiving({ queryClient, serverId, agentId }), + }).toEqual({ + agents: false, + details: false, + queued: false, + cursor: kind === "directory", + permissions: 0, + initializing: false, + archivePending: false, + }); - store.clearSession(serverId); - }); + store.clearSession(serverId); + }, + ); it("keeps newer metadata while accepting usage-only updates and legacy workspace ownership", () => { const serverId = "server-usage"; diff --git a/packages/app/src/workspace-recovery/model.test.ts b/packages/app/src/workspace-recovery/model.test.ts index 46f7aba864..c4a6de7a21 100644 --- a/packages/app/src/workspace-recovery/model.test.ts +++ b/packages/app/src/workspace-recovery/model.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from "vitest"; -import { recoverWorkspaceSelection, resolveWorkspaceRecoveryModel } from "./model"; +import { resolveWorkspaceRecoveryModel } from "./model"; describe("resolveWorkspaceRecoveryModel", () => { it("keeps newer recovery actions visible but non-actionable", () => { @@ -29,24 +29,3 @@ describe("resolveWorkspaceRecoveryModel", () => { }); }); }); - -describe("recoverWorkspaceSelection", () => { - it("restores the workspace and selected archived agent as one recovery action", async () => { - const operations: string[] = []; - - await recoverWorkspaceSelection({ - workspaceId: "workspace-1", - agentId: "agent-1", - client: { - restoreWorkspace: async (workspaceId) => { - operations.push(`workspace:${workspaceId}`); - }, - refreshAgent: async (agentId) => { - operations.push(`agent:${agentId}`); - }, - }, - }); - - expect(operations).toEqual(["workspace:workspace-1", "agent:agent-1"]); - }); -}); diff --git a/packages/app/src/workspace-recovery/model.ts b/packages/app/src/workspace-recovery/model.ts index 2c85a06f4a..5d5ebf15e0 100644 --- a/packages/app/src/workspace-recovery/model.ts +++ b/packages/app/src/workspace-recovery/model.ts @@ -34,22 +34,6 @@ export interface WorkspaceRecoveryController { retryInspection: () => void; } -export interface WorkspaceSelectionRecoveryClient { - restoreWorkspace: (workspaceId: string) => Promise; - refreshAgent: (agentId: string) => Promise; -} - -export async function recoverWorkspaceSelection(input: { - client: WorkspaceSelectionRecoveryClient; - workspaceId: string; - agentId?: string | null; -}): Promise { - await input.client.restoreWorkspace(input.workspaceId); - if (input.agentId) { - await input.client.refreshAgent(input.agentId); - } -} - function resolveRecoveryPhase(input: { pending: boolean; error: string | null; diff --git a/packages/app/src/workspace-recovery/use-workspace-recovery.ts b/packages/app/src/workspace-recovery/use-workspace-recovery.ts index ab19957190..d91aa3ce6e 100644 --- a/packages/app/src/workspace-recovery/use-workspace-recovery.ts +++ b/packages/app/src/workspace-recovery/use-workspace-recovery.ts @@ -4,11 +4,7 @@ import { useFetchQuery } from "@/data/query"; import { useHostRuntimeClient, useHostRuntimeIsConnected } from "@/runtime/host-runtime"; import { useSessionStore } from "@/stores/session-store"; import { toErrorMessage } from "@/utils/error-messages"; -import { - recoverWorkspaceSelection, - resolveWorkspaceRecoveryModel, - type WorkspaceRecoveryController, -} from "./model"; +import { resolveWorkspaceRecoveryModel, type WorkspaceRecoveryController } from "./model"; export type { WorkspaceRecoveryController, WorkspaceRecoveryModel } from "./model"; @@ -32,7 +28,6 @@ function waitForMinimumRecoveryLoadingTime(): Promise { export function useWorkspaceRecovery(input: { serverId: string; workspaceId: string; - agentId?: string | null; enabled: boolean; }): WorkspaceRecoveryController { const client = useHostRuntimeClient(input.serverId); @@ -61,11 +56,7 @@ export function useWorkspaceRecovery(input: { } await waitForRecoveryLoadingPresentation(); await waitForMinimumRecoveryLoadingTime(); - await recoverWorkspaceSelection({ - client, - workspaceId: input.workspaceId, - agentId: input.agentId, - }); + await client.restoreWorkspace(input.workspaceId); }, }); diff --git a/packages/app/src/workspace-tabs/agent-visibility.test.ts b/packages/app/src/workspace-tabs/agent-visibility.test.ts index bece625a89..3b16c6b8d5 100644 --- a/packages/app/src/workspace-tabs/agent-visibility.test.ts +++ b/packages/app/src/workspace-tabs/agent-visibility.test.ts @@ -61,7 +61,7 @@ function makeAgent(input: { const WORKSPACE_ID = "ws-1"; describe("workspace agent visibility", () => { - it("keeps subagents active and known while excluding them from auto-open", () => { + it("keeps subagents active while excluding them from auto-open", () => { const parent = makeAgent({ id: "parent-agent", cwd: "/repo/worktree", @@ -84,10 +84,9 @@ describe("workspace agent visibility", () => { expect(result.activeAgentIds).toEqual(new Set(["parent-agent", "child-agent"])); expect(result.autoOpenAgentIds).toEqual(new Set(["parent-agent"])); - expect(result.knownAgentIds).toEqual(new Set(["parent-agent", "child-agent"])); }); - it("keeps archived subagents known but excludes them from active and auto-open", () => { + it("excludes archived subagents from active and auto-open", () => { const archivedChild = makeAgent({ id: "archived-child", cwd: "/repo/worktree", @@ -103,7 +102,6 @@ describe("workspace agent visibility", () => { expect(result.activeAgentIds).toEqual(new Set()); expect(result.autoOpenAgentIds).toEqual(new Set()); - expect(result.knownAgentIds).toEqual(new Set(["archived-child"])); }); it("excludes a child from auto-open even when its snapshot arrives before the parent", () => { @@ -129,7 +127,6 @@ describe("workspace agent visibility", () => { expect(result.activeAgentIds).toEqual(new Set(["child-agent", "parent-agent"])); expect(result.autoOpenAgentIds).toEqual(new Set(["parent-agent"])); - expect(result.knownAgentIds).toEqual(new Set(["child-agent", "parent-agent"])); }); it("auto-opens a subagent whose parent belongs to another workspace", () => { @@ -155,10 +152,9 @@ describe("workspace agent visibility", () => { expect(result.activeAgentIds).toEqual(new Set(["child-agent"])); expect(result.autoOpenAgentIds).toEqual(new Set(["child-agent"])); - expect(result.knownAgentIds).toEqual(new Set(["child-agent"])); }); - it("keeps archived agents out of activeAgentIds but present in knownAgentIds", () => { + it("excludes archived agents from the active directory", () => { const visible = makeAgent({ id: "visible-agent", cwd: "/repo/worktree", @@ -191,12 +187,9 @@ describe("workspace agent visibility", () => { expect(result.activeAgentIds).toEqual(new Set(["visible-agent"])); expect(result.autoOpenAgentIds).toEqual(new Set(["visible-agent"])); - expect(result.knownAgentIds.has("visible-agent")).toBe(true); - expect(result.knownAgentIds.has("archived-agent")).toBe(true); - expect(result.knownAgentIds.has("other-workspace-agent")).toBe(false); }); - it("treats lazy historical details as known without making them active", () => { + it("does not make historical details active", () => { const active = makeAgent({ id: "active-agent", cwd: "/repo/worktree", @@ -216,7 +209,6 @@ describe("workspace agent visibility", () => { }); expect(result.activeAgentIds).toEqual(new Set(["active-agent"])); - expect(result.knownAgentIds).toEqual(new Set(["active-agent", "historical-agent"])); }); it("prunes archived agent tabs so archiving on one client closes tabs on all clients", () => { @@ -281,7 +273,6 @@ describe("workspace agent visibility", () => { }); expect(result.activeAgentIds).toEqual(new Set(["stamped-agent"])); - expect(result.knownAgentIds).toEqual(new Set(["stamped-agent"])); }); it("excludes a stamped agent whose workspaceId belongs to another workspace sharing the cwd", () => { @@ -302,7 +293,6 @@ describe("workspace agent visibility", () => { }); expect(result.activeAgentIds).toEqual(new Set()); - expect(result.knownAgentIds).toEqual(new Set()); }); it("excludes agents without a workspaceId", () => { @@ -316,14 +306,12 @@ describe("workspace agent visibility", () => { }); expect(result.activeAgentIds).toEqual(new Set()); - expect(result.knownAgentIds).toEqual(new Set()); }); it("builds the tab reconciliation snapshot without callers unpacking agent visibility", () => { const agentVisibility = { activeAgentIds: new Set(["active-agent"]), autoOpenAgentIds: new Set(["root-agent"]), - knownAgentIds: new Set(["active-agent", "archived-agent"]), }; expect( @@ -341,7 +329,6 @@ describe("workspace agent visibility", () => { terminalsHydrated: true, activeAgentIds: agentVisibility.activeAgentIds, autoOpenAgentIds: agentVisibility.autoOpenAgentIds, - knownAgentIds: agentVisibility.knownAgentIds, knownTerminalIds: ["terminal-1", "script-terminal"], standaloneTerminalIds: ["terminal-1"], hasActivePendingTerminalCreate: false, @@ -354,12 +341,10 @@ describe("workspace agent visibility", () => { const a = { activeAgentIds: new Set(["a", "b"]), autoOpenAgentIds: new Set(["a"]), - knownAgentIds: new Set(["a", "b", "c"]), }; const b = { activeAgentIds: new Set(["a", "b"]), autoOpenAgentIds: new Set(["a"]), - knownAgentIds: new Set(["a", "b", "c"]), }; expect(workspaceAgentVisibilityEqual(a, b)).toBe(true); }); @@ -368,12 +353,10 @@ describe("workspace agent visibility", () => { const a = { activeAgentIds: new Set(["a"]), autoOpenAgentIds: new Set(["a"]), - knownAgentIds: new Set(["a"]), }; const b = { activeAgentIds: new Set(["b"]), autoOpenAgentIds: new Set(["a"]), - knownAgentIds: new Set(["a"]), }; expect(workspaceAgentVisibilityEqual(a, b)).toBe(false); }); @@ -382,26 +365,10 @@ describe("workspace agent visibility", () => { const a = { activeAgentIds: new Set(["a", "b"]), autoOpenAgentIds: new Set(["a"]), - knownAgentIds: new Set(["a", "b"]), }; const b = { activeAgentIds: new Set(["a", "b"]), autoOpenAgentIds: new Set(["b"]), - knownAgentIds: new Set(["a", "b"]), - }; - expect(workspaceAgentVisibilityEqual(a, b)).toBe(false); - }); - - it("returns false when knownAgentIds differ", () => { - const a = { - activeAgentIds: new Set(["a"]), - autoOpenAgentIds: new Set(["a"]), - knownAgentIds: new Set(["a"]), - }; - const b = { - activeAgentIds: new Set(["a"]), - autoOpenAgentIds: new Set(["a"]), - knownAgentIds: new Set(["a", "b"]), }; expect(workspaceAgentVisibilityEqual(a, b)).toBe(false); }); @@ -410,12 +377,10 @@ describe("workspace agent visibility", () => { const a = { activeAgentIds: new Set(), autoOpenAgentIds: new Set(), - knownAgentIds: new Set(), }; const b = { activeAgentIds: new Set(), autoOpenAgentIds: new Set(), - knownAgentIds: new Set(), }; expect(workspaceAgentVisibilityEqual(a, b)).toBe(true); }); diff --git a/packages/app/src/workspace-tabs/agent-visibility.ts b/packages/app/src/workspace-tabs/agent-visibility.ts index 1f8f5c5ed4..399b786ef6 100644 --- a/packages/app/src/workspace-tabs/agent-visibility.ts +++ b/packages/app/src/workspace-tabs/agent-visibility.ts @@ -6,7 +6,6 @@ import { normalizeWorkspaceOpaqueId } from "@/utils/workspace-identity"; export interface WorkspaceAgentVisibility { activeAgentIds: Set; autoOpenAgentIds: Set; - knownAgentIds: Set; } function agentBelongsToWorkspace(agent: Agent, workspaceId: string): boolean { @@ -24,13 +23,11 @@ export function deriveWorkspaceAgentVisibility(input: { return { activeAgentIds: new Set(), autoOpenAgentIds: new Set(), - knownAgentIds: new Set(), }; } const activeAgentIds = new Set(); const autoOpenAgentIds = new Set(); - const knownAgentIds = new Set(); const agentsById = new Map([ ...(agentDetails?.entries() ?? []), ...(sessionAgents?.entries() ?? []), @@ -39,7 +36,6 @@ export function deriveWorkspaceAgentVisibility(input: { if (!agentBelongsToWorkspace(agent, workspaceId)) { continue; } - knownAgentIds.add(agent.id); if (!agent.archivedAt) { activeAgentIds.add(agent.id); const parentAgent = agent.parentAgentId ? agentsById.get(agent.parentAgentId) : undefined; @@ -48,14 +44,7 @@ export function deriveWorkspaceAgentVisibility(input: { } } } - for (const agent of agentDetails?.values() ?? []) { - if (!agentBelongsToWorkspace(agent, workspaceId)) { - continue; - } - knownAgentIds.add(agent.id); - } - - return { activeAgentIds, autoOpenAgentIds, knownAgentIds }; + return { activeAgentIds, autoOpenAgentIds }; } export function buildWorkspaceTabSnapshot(input: { @@ -72,7 +61,6 @@ export function buildWorkspaceTabSnapshot(input: { terminalsHydrated: input.terminalsHydrated, activeAgentIds: input.agentVisibility.activeAgentIds, autoOpenAgentIds: input.agentVisibility.autoOpenAgentIds, - knownAgentIds: input.agentVisibility.knownAgentIds, knownTerminalIds: input.knownTerminalIds, standaloneTerminalIds: input.standaloneTerminalIds, hasActivePendingTerminalCreate: input.hasActivePendingTerminalCreate, @@ -86,8 +74,7 @@ export function workspaceAgentVisibilityEqual( ): boolean { return ( setsEqual(a.activeAgentIds, b.activeAgentIds) && - setsEqual(a.autoOpenAgentIds, b.autoOpenAgentIds) && - setsEqual(a.knownAgentIds, b.knownAgentIds) + setsEqual(a.autoOpenAgentIds, b.autoOpenAgentIds) ); } diff --git a/packages/server/src/server/agent/agent-loading.test.ts b/packages/server/src/server/agent/agent-loading.test.ts index e4943a079d..0f58d94c62 100644 --- a/packages/server/src/server/agent/agent-loading.test.ts +++ b/packages/server/src/server/agent/agent-loading.test.ts @@ -69,7 +69,7 @@ test("loads archived records for history and active records with the interactive await ensureAgentLoaded(archived.id, { agentManager: manager, agentStorage: storage, logger }); await ensureAgentLoaded(active.id, { agentManager: manager, agentStorage: storage, logger }); - expect(resumeOptions).toEqual([{ purpose: "history" }, undefined]); + expect(resumeOptions).toEqual([{ purpose: "history" }, { purpose: "interactive" }]); } finally { await Promise.all([ manager.closeAgent(archivedId).catch(() => undefined), diff --git a/packages/server/src/server/agent/agent-manager.test.ts b/packages/server/src/server/agent/agent-manager.test.ts index f4d4069d26..3ea6413561 100644 --- a/packages/server/src/server/agent/agent-manager.test.ts +++ b/packages/server/src/server/agent/agent-manager.test.ts @@ -37,6 +37,7 @@ import type { AgentProvider, AgentPersistenceHandle, AgentRunOptions, + AgentResumeSessionOptions, AgentRunResult, AgentSession, AgentSessionConfig, @@ -5095,14 +5096,14 @@ test("archiveSnapshot clears persisted attention and normalizes running status", const archivedRecord = await manager.archiveSnapshot(snapshot.id, archivedAt); expect(archivedRecord.archivedAt).toBe(archivedAt); - expect(archivedRecord.lastStatus).toBe("idle"); + expect(archivedRecord.lastStatus).toBe("closed"); expect(archivedRecord.requiresAttention).toBe(false); expect(archivedRecord.attentionReason).toBeNull(); expect(archivedRecord.attentionTimestamp).toBeNull(); const persisted = await storage.get(snapshot.id); expect(persisted?.archivedAt).toBe(archivedAt); - expect(persisted?.lastStatus).toBe("idle"); + expect(persisted?.lastStatus).toBe("closed"); expect(persisted?.requiresAttention).toBe(false); expect(persisted?.attentionReason).toBeNull(); expect(persisted?.attentionTimestamp).toBeNull(); @@ -9616,7 +9617,7 @@ test("ensureUnarchivedAgentLoaded does not resume an archived agent", async () = } }); -test("ensureUnarchivedAgentLoaded closes a runtime archived while it resumes", async () => { +test("a stored-only archive waits for an in-flight resume and then closes it", async () => { const workdir = mkdtempSync(join(tmpdir(), "agent-manager-archived-resume-race-")); const storage = new AgentStorage(join(workdir, "agents"), logger); const resumeStarted = deferred(); @@ -9640,16 +9641,17 @@ test("ensureUnarchivedAgentLoaded closes a runtime archived while it resumes", a }); await manager.closeAgent(agent.id); - const load = ensureUnarchivedAgentLoaded(agent.id, { + const load = ensureAgentLoaded(agent.id, { agentManager: manager, agentStorage: storage, logger, }); await resumeStarted.promise; - await manager.archiveSnapshot(agent.id, new Date().toISOString()); + const archive = manager.archiveSnapshot(agent.id, new Date().toISOString()); resumeAllowed.resolve(); + await archive; - await expect(load).rejects.toThrow(`Agent is archived: ${agent.id}`); + await expect(load).resolves.toMatchObject({ id: agent.id }); expect(manager.getAgent(agent.id)).toBeNull(); expect((await storage.get(agent.id))?.archivedAt).toEqual(expect.any(String)); } finally { @@ -9659,7 +9661,7 @@ test("ensureUnarchivedAgentLoaded closes a runtime archived while it resumes", a } }); -test("ensureUnarchivedAgentLoaded fences an archived agent after joining a shared resume", async () => { +test("a stored-only archive closes a shared resume before a later protected load returns", async () => { const workdir = mkdtempSync(join(tmpdir(), "agent-manager-archived-shared-resume-")); const storage = new AgentStorage(join(workdir, "agents"), logger); const resumeStarted = deferred(); @@ -9694,8 +9696,9 @@ test("ensureUnarchivedAgentLoaded fences an archived agent after joining a share agentStorage: storage, logger, }); - await manager.archiveSnapshot(agent.id, new Date().toISOString()); + const archive = manager.archiveSnapshot(agent.id, new Date().toISOString()); resumeAllowed.resolve(); + await archive; await sharedLoad; await expect(protectedLoad).rejects.toThrow(`Agent is archived: ${agent.id}`); @@ -9933,7 +9936,7 @@ test("concurrent explicit closes tear down the runtime once", async () => { } }); -test("provider close failure still persists and emits a resumable closed agent", async () => { +test("provider close failure retains the runtime for cleanup instead of allowing another writer", async () => { const workdir = mkdtempSync(join(tmpdir(), "agent-manager-close-failure-")); const storage = new AgentStorage(join(workdir, "agents"), logger); const client = new (class extends TestAgentClient { @@ -9953,12 +9956,10 @@ test("provider close failure still persists and emits a resumable closed agent", "00000000-0000-4000-8000-000000000217", { workspaceId: undefined }, ); - const closed = waitForAgentLifecycle(manager, created.id, "closed"); - await expect(manager.closeAgent(created.id)).rejects.toThrow("provider cleanup failed"); - await closed; + expect(manager.getAgent(created.id)?.session).toBe(created.session); const stored = await storage.get(created.id); - expect(stored).toMatchObject({ lastStatus: "closed" }); + expect(stored).toMatchObject({ lastStatus: "idle" }); expect(stored?.archivedAt).toBeFalsy(); await expect( @@ -10973,3 +10974,54 @@ test("onWorkspaceStateMayHaveChanged is not called for running shell tool calls" expect(onWorkspaceStateMayHaveChanged).not.toHaveBeenCalled(); }); + +test("concurrent native restores run once before resuming the same agent", async () => { + const workdir = mkdtempSync(join(tmpdir(), "agent-manager-unarchive-ownership-")); + const storage = new AgentStorage(join(workdir, "agents"), logger); + const restoreStarted = deferred(); + const restoreAllowed = deferred(); + const operations: string[] = []; + const client = new (class extends TestAgentClient { + async unarchiveNativeSession(): Promise { + operations.push("restore"); + restoreStarted.resolve(); + await restoreAllowed.promise; + } + override async resumeSession( + handle: AgentPersistenceHandle, + config?: Partial, + _launchContext?: AgentLaunchContext, + options?: AgentResumeSessionOptions, + ): Promise { + operations.push(`resume:${options?.purpose}`); + return super.resumeSession(handle, config); + } + })(); + const manager = new AgentManager({ clients: { codex: client }, registry: storage, logger }); + let agentId: string | undefined; + try { + const created = await manager.createAgent({ provider: "codex", cwd: workdir }, undefined, { + workspaceId: undefined, + }); + agentId = created.id; + await manager.archiveAgent(agentId); + const first = manager.unarchiveSnapshot(agentId); + await restoreStarted.promise; + const second = manager.unarchiveSnapshot(agentId); + const load = ensureAgentLoaded(agentId, { + agentManager: manager, + agentStorage: storage, + logger, + }); + restoreAllowed.resolve(); + await Promise.all([first, second, load]); + expect(operations).toEqual(["restore", "resume:interactive"]); + expect((await storage.get(agentId))?.archivedAt).toBeNull(); + expect(manager.getAgent(agentId)?.persistence?.sessionId).toBe(created.persistence?.sessionId); + } finally { + restoreAllowed.resolve(); + if (agentId) await manager.closeAgent(agentId); + await storage.flush(); + rmSync(workdir, { recursive: true, force: true }); + } +}); diff --git a/packages/server/src/server/agent/agent-manager.ts b/packages/server/src/server/agent/agent-manager.ts index 70b4e62dbc..adbb25bf34 100644 --- a/packages/server/src/server/agent/agent-manager.ts +++ b/packages/server/src/server/agent/agent-manager.ts @@ -1282,8 +1282,20 @@ export class AgentManager { }, resumeOptions?: AgentResumeSessionOptions, ): Promise { + const resolvedAgentId = validateAgentId( + agentId ?? this.idFactory(), + "resumeAgentFromPersistence", + ); return this.trackAgentRegistrationOperation( - this.resumeAgentFromPersistenceInternal(handle, overrides, agentId, options, resumeOptions), + this.runLifecycleMutation(resolvedAgentId, () => + this.resumeAgentFromPersistenceInternal( + handle, + overrides, + resolvedAgentId, + options, + resumeOptions, + ), + ), ); } @@ -1317,6 +1329,12 @@ export class AgentManager { resolvedAgentId, ); + // Decide residency from durable state inside the lifecycle lane. A loader may + // have read the record before a queued archive or restore completed. + const record = this.registry ? await this.registry.get(resolvedAgentId) : null; + const currentResumeOptions = record + ? { purpose: record.archivedAt ? ("history" as const) : ("interactive" as const) } + : resumeOptions; const client = this.requireClient(handle.provider); const available = await client.isAvailable(); if (!available) { @@ -1333,7 +1351,7 @@ export class AgentManager { undefined, { reason: "resume", - purpose: resumeOptions?.purpose ?? "interactive", + purpose: currentResumeOptions?.purpose ?? "interactive", workspaceId: options?.workspaceId ?? null, }, ); @@ -1342,7 +1360,7 @@ export class AgentManager { handle, providerLaunchConfig, launchContext, - resumeOptions, + currentResumeOptions, ); await this.requireExternalMcpSupport(session, storedConfig); return this.registerSession(session, storedConfig, resolvedAgentId, { @@ -1649,14 +1667,11 @@ export class AgentManager { "agent.manager.close.start", ); await this.drainSessionEvents(agentId); + // Retain ownership until shutdown succeeds. A failed close may still own a + // native writer, so publishing a resumable closed snapshot would orphan it. + await agent.session.close(); this.cancelRunningProviderSubagents(agentId); const closedAgent = this.prepareAgentForClosure(agent, "agent closed"); - let closeError: unknown; - try { - await agent.session.close(); - } catch (error) { - closeError = error; - } let persistError: unknown; try { @@ -1674,9 +1689,6 @@ export class AgentManager { "agent.manager.close.complete", ); - if (closeError !== undefined) { - throw closeError; - } if (persistError !== undefined) { throw persistError; } @@ -1700,7 +1712,10 @@ export class AgentManager { return this.runLifecycleMutation(agentId, () => this.archiveAgentUnlocked(agentId)); } - private async archiveAgentUnlocked(agentId: string): Promise<{ archivedAt: string }> { + private async archiveAgentUnlocked( + agentId: string, + requestedArchivedAt?: string, + ): Promise<{ archivedAt: string }> { const agent = this.requireAgent(agentId); if (!this.registry) { throw new Error("Agent storage is not configured"); @@ -1714,7 +1729,7 @@ export class AgentManager { throw new Error(`Agent ${agentId} not found in storage after snapshot`); } - const { archivedAt } = await this.markRecordArchived(stored); + const { archivedAt } = await this.markRecordArchived(stored, requestedArchivedAt); agent.updatedAt = new Date(archivedAt); await this.closeAgentRuntime(agentId); await this.syncNativeArchiveState(stored.provider, stored.persistence, "archive"); @@ -1764,14 +1779,16 @@ export class AgentManager { } else if (this.agents.has(currentChild.id)) { await this.archiveAgentUnlocked(currentChild.id); } else { - await this.archiveSnapshot(currentChild.id, new Date().toISOString()); + await this.archiveSnapshotUnlocked(currentChild.id, new Date().toISOString()); } }); } } - private async markRecordArchived(record: StoredAgentRecord): Promise { - const archivedAt = new Date().toISOString(); + private async markRecordArchived( + record: StoredAgentRecord, + archivedAt = new Date().toISOString(), + ): Promise { const archivedRecord = await this.persistArchivedRecord(record, { archivedAt, updatedAt: archivedAt, @@ -2109,12 +2126,23 @@ export class AgentManager { } async archiveSnapshot(agentId: string, archivedAt: string): Promise { + return this.runLifecycleMutation(agentId, () => + this.archiveSnapshotUnlocked(agentId, archivedAt), + ); + } + + private async archiveSnapshotUnlocked( + agentId: string, + archivedAt: string, + ): Promise { const registry = this.requireRegistry(); - const liveAgent = this.getAgent(agentId); - if (liveAgent) { - await this.persistSnapshot(liveAgent, { - internal: liveAgent.internal, - }); + // A stored-only archive can have waited behind a persisted resume. Reuse the + // live archive transition so its newly acquired runtime is closed as well. + if (this.agents.has(agentId)) { + await this.archiveAgentUnlocked(agentId, archivedAt); + const archivedRecord = await registry.get(agentId); + if (!archivedRecord) throw new Error(`Agent not found: ${agentId}`); + return archivedRecord; } const record = await registry.get(agentId); @@ -2126,14 +2154,8 @@ export class AgentManager { await this.syncNativeArchiveState(record.provider, record.persistence, "archive"); - if (this.agents.has(agentId)) { - this.notifyAgentState(agentId); - } else { - this.discardRetainedAgentState(agentId); - if (!nextRecord.internal) { - this.dispatchStoredAgentState(nextRecord); - } - } + this.discardRetainedAgentState(agentId); + if (!nextRecord.internal) this.dispatchStoredAgentState(nextRecord); await this.fireAgentArchived(agentId); await this.cascadeArchiveChildren(agentId); @@ -2144,6 +2166,15 @@ export class AgentManager { async unarchiveSnapshot( agentId: string, updates?: { workspaceId?: string; labels?: AgentLabelPatch }, + ): Promise { + return this.runLifecycleMutation(agentId, () => + this.unarchiveSnapshotUnlocked(agentId, updates), + ); + } + + private async unarchiveSnapshotUnlocked( + agentId: string, + updates?: { workspaceId?: string; labels?: AgentLabelPatch }, ): Promise { const registry = this.requireRegistry(); const record = await registry.get(agentId); @@ -2151,8 +2182,9 @@ export class AgentManager { return false; } - // Archived history may have loaded a runtime that still owns the native writer. - await this.closeAgent(agentId); + // Close and native restore share the lifecycle lane with persisted resume. + // No new history or interactive runtime can acquire the writer between them. + if (this.agents.has(agentId)) await this.closeAgentRuntime(agentId); await this.syncNativeArchiveState(record.provider, record.persistence, "restore"); await registry.upsert({ diff --git a/packages/server/src/server/agent/providers/codex-app-server-agent.test.ts b/packages/server/src/server/agent/providers/codex-app-server-agent.test.ts index 15e7cfdf54..b86418814d 100644 --- a/packages/server/src/server/agent/providers/codex-app-server-agent.test.ts +++ b/packages/server/src/server/agent/providers/codex-app-server-agent.test.ts @@ -111,7 +111,7 @@ interface CodexSessionTestAccess { ensureThreadLoaded(): Promise; handleToolApprovalRequest(params: unknown): Promise; handleNotification(method: string, params: unknown): void; - loadPersistedHistory(): Promise; + loadPersistedHistory(client: CodexClientLike | null): Promise; refreshResolvedCollaborationMode(): void; serviceTier: "fast" | null; planModeEnabled: boolean; @@ -124,7 +124,7 @@ interface CodexClientLike { } type CodexTestSession = AgentSession & { - connected: boolean; + connectionState: "disconnected" | "history-ready" | "connected"; currentThreadId: string | null; activeForegroundTurnId: string | null; client: CodexClientLike | null; @@ -165,7 +165,7 @@ function createSession( options.goalsEnabled === true, options.autoReviewEnabled === true, ) as CodexTestSession; - session.connected = true; + session.connectionState = "connected"; session.currentThreadId = "test-thread"; session.activeForegroundTurnId = "test-turn"; return session; @@ -1435,7 +1435,7 @@ describe("Codex app-server provider", () => { purpose: "history", }); - expect(threadRequests).toEqual(["thread/loaded/list", "thread/resume", "thread/read"]); + expect(threadRequests).toEqual(["thread/read"]); await session.close(); appServer.assertNoErrors(); }); @@ -3931,7 +3931,7 @@ describe("Codex app-server provider", () => { }), }; - await asInternals(session).loadPersistedHistory(); + await asInternals(session).loadPersistedHistory(session.client); const history: AgentStreamEvent[] = []; for await (const event of session.streamHistory()) { @@ -3985,7 +3985,7 @@ describe("Codex app-server provider", () => { })), }; - await asInternals(session).loadPersistedHistory(); + await asInternals(session).loadPersistedHistory(session.client); expect(asInternals(session).codexUserMessageTurns().resolve("message-history")).toEqual({ index: 0, @@ -4054,7 +4054,7 @@ describe("Codex app-server provider", () => { }), }; - await asInternals(session).loadPersistedHistory(); + await asInternals(session).loadPersistedHistory(session.client); const history: AgentStreamEvent[] = []; for await (const event of session.streamHistory()) { @@ -4216,7 +4216,7 @@ describe("Codex app-server provider", () => { }), }; - await asInternals(session).loadPersistedHistory(); + await asInternals(session).loadPersistedHistory(session.client); const history: AgentStreamEvent[] = []; for await (const event of session.streamHistory()) { @@ -4281,7 +4281,7 @@ describe("Codex app-server provider", () => { }), }; - await asInternals(session).loadPersistedHistory(); + await asInternals(session).loadPersistedHistory(session.client); const history: AgentStreamEvent[] = []; for await (const event of session.streamHistory()) { @@ -4455,7 +4455,7 @@ describe("Codex app-server provider", () => { }), }; - await asInternals(session).loadPersistedHistory(); + await asInternals(session).loadPersistedHistory(session.client); const history: AgentStreamEvent[] = []; for await (const event of session.streamHistory()) { @@ -4516,7 +4516,7 @@ describe("Codex app-server provider", () => { }), }; - await asInternals(session).loadPersistedHistory(); + await asInternals(session).loadPersistedHistory(session.client); const history: AgentStreamEvent[] = []; for await (const event of session.streamHistory()) { diff --git a/packages/server/src/server/agent/providers/codex-app-server-agent.ts b/packages/server/src/server/agent/providers/codex-app-server-agent.ts index 7123db17cc..5e2cb3795a 100644 --- a/packages/server/src/server/agent/providers/codex-app-server-agent.ts +++ b/packages/server/src/server/agent/providers/codex-app-server-agent.ts @@ -3371,7 +3371,7 @@ export class CodexAppServerAgentSession implements AgentSession { // `thread/compacted` and a completed `contextCompaction` item. private unpairedCompactionNotificationCompletions = 0; private unpairedCompactionItemCompletions = 0; - private connected = false; + private connectionState: "disconnected" | "history-ready" | "connected" = "disconnected"; private connectionPromise: Promise | null = null; private closed = false; private collaborationModes: Array<{ @@ -3444,7 +3444,7 @@ export class CodexAppServerAgentSession implements AgentSession { if (this.closed) { throw this.createClosedError(); } - if (this.connected) return; + if (this.connectionState !== "disconnected") return; if (this.connectionPromise) { await this.connectionPromise; if (this.closed) { @@ -3468,6 +3468,11 @@ export class CodexAppServerAgentSession implements AgentSession { } private async establishConnection(): Promise { + if (this.initialResumePurpose === "history") { + await this.readArchivedHistory(); + this.connectionState = "history-ready"; + return; + } const child = await this.spawnAppServer(); const client = new CodexAppServerClient(child, this.logger, () => this.traceContext()); if (this.closed) { @@ -3490,16 +3495,14 @@ export class CodexAppServerAgentSession implements AgentSession { await this.loadSkills(); if (this.currentThreadId) { - await this.ensureThreadLoaded({ - allowArchivedHistory: this.initialResumePurpose === "history", - }); - await this.loadPersistedHistory(); + await this.ensureThreadLoaded(); + await this.loadPersistedHistory(this.client); } if (this.closed) { throw this.createClosedError(); } - this.connected = true; + this.connectionState = "connected"; } catch (error) { try { if (this.client === client) { @@ -3550,7 +3553,7 @@ export class CodexAppServerAgentSession implements AgentSession { } private handleUnexpectedTermination(error: Error): void { - this.connected = false; + this.connectionState = "disconnected"; const hasActiveRootTurn = this.activeForegroundTurnId !== null || this.currentTurnId !== null; this.clearPendingPermissions({ preservePlanApprovals: !hasActiveRootTurn }); if (hasActiveRootTurn) { @@ -3782,9 +3785,20 @@ export class CodexAppServerAgentSession implements AgentSession { ); } - private async loadPersistedHistory(): Promise { - if (!this.client || !this.currentThreadId) return; - const client = this.client; + private async readArchivedHistory(): Promise { + const child = await this.spawnAppServer(); + const client = new CodexAppServerClient(child, this.logger, () => this.traceContext()); + try { + await client.request("initialize", buildCodexAppServerInitializeParams()); + client.notify("initialized", {}); + await this.loadPersistedHistory(client); + } finally { + await client.dispose(); + } + } + + private async loadPersistedHistory(client: CodexAppServerClientLike | null): Promise { + if (!client || !this.currentThreadId) return; const threadId = this.currentThreadId; const history = await loadCodexThreadHistoryTimeline({ @@ -3865,9 +3879,7 @@ export class CodexAppServerAgentSession implements AgentSession { } } - private async ensureThreadLoaded( - options: { allowArchivedHistory?: boolean } = {}, - ): Promise { + private async ensureThreadLoaded(): Promise { if (!this.client || !this.currentThreadId) return; const params: Record = { threadId: this.currentThreadId }; const developerInstructions = composeSystemPromptParts( @@ -3892,16 +3904,6 @@ export class CodexAppServerAgentSession implements AgentSession { } catch (error) { const threadId = this.currentThreadId; const message = error instanceof Error ? error.message : String(error); - if ( - options.allowArchivedHistory === true && - isArchivedCodexThreadResumeError(error, threadId) - ) { - this.logger.info( - { threadId }, - "Loading archived Codex thread history without resuming the native session", - ); - return; - } if (isArchivedCodexThreadResumeError(error, threadId)) { try { await this.client.request("thread/unarchive", { threadId }); @@ -3975,7 +3977,7 @@ export class CodexAppServerAgentSession implements AgentSession { return expandCodexCustomPrompt(parsed.body, args); } - if (!this.connected) { + if (this.connectionState === "disconnected") { await this.connect(); } else { await this.loadSkills(); @@ -4397,7 +4399,7 @@ export class CodexAppServerAgentSession implements AgentSession { async getRuntimeInfo(): Promise { if (this.cachedRuntimeInfo) return { ...this.cachedRuntimeInfo }; - if (!this.connected) { + if (this.connectionState === "disconnected") { await this.connect(); } if (!this.currentThreadId) { @@ -4770,7 +4772,7 @@ export class CodexAppServerAgentSession implements AgentSession { this.cachedRuntimeInfo = null; this.persistedHistory = []; this.historyPending = false; - await this.loadPersistedHistory(); + await this.loadPersistedHistory(this.client); this.reconcileAsyncQuestionsAfterRewind(); }, }); @@ -4859,7 +4861,7 @@ export class CodexAppServerAgentSession implements AgentSession { private async disposeClient(): Promise { const client = this.client; - this.connected = false; + this.connectionState = "disconnected"; this.currentTurnId = null; if (client) { await client.dispose(); @@ -4869,7 +4871,7 @@ export class CodexAppServerAgentSession implements AgentSession { async listCommands(): Promise { const prompts = await listCodexCustomPrompts(); - if (!this.connected) { + if (this.connectionState === "disconnected") { await this.connect(); } else { await this.loadSkills(); diff --git a/packages/server/src/server/agent/providers/codex-history-lifecycle.real.e2e.test.ts b/packages/server/src/server/agent/providers/codex-history-lifecycle.real.e2e.test.ts new file mode 100644 index 0000000000..0d14f29930 --- /dev/null +++ b/packages/server/src/server/agent/providers/codex-history-lifecycle.real.e2e.test.ts @@ -0,0 +1,151 @@ +import { spawn, type ChildProcessWithoutNullStreams } from "node:child_process"; +import { mkdtemp, rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import { expect, test } from "vitest"; +import { z } from "zod"; +import { CodexAppServerClient } from "./codex/app-server-transport.js"; +import { createTestLogger } from "../../../test-utils/test-logger.js"; +import { CodexAppServerAgentClient, CodexAppServerAgentSession } from "./codex-app-server-agent.js"; +import type { AgentSession } from "../agent-sdk-types.js"; + +async function readNativeThreadPath(threadId: string): Promise { + const client = new CodexAppServerClient( + spawn("codex", ["app-server"], { stdio: ["pipe", "pipe", "pipe"] }), + createTestLogger(), + ); + try { + await client.request("initialize", { + clientInfo: { name: "paseo-archive-regression", version: "1.0.0" }, + }); + client.notify("initialized", {}); + const response = await client.request("thread/read", { threadId }); + return z.object({ thread: z.object({ path: z.string() }) }).parse(response).thread.path; + } finally { + await client.dispose(); + } +} + +// Real native processes and a real completion. The second case represents records +// archived by older Paseo versions whose best-effort native archive failed. +test.runIf(process.env.PASEO_NATIVE_ARCHIVE_QA === "1").each([true, false])( + "Codex history releases its process and leaves native archive unchanged (native archived: %s)", + async (nativeArchived) => { + const cwd = await mkdtemp(path.join(tmpdir(), "codex-history-lifecycle-")); + const logger = createTestLogger(); + const provider = new CodexAppServerAgentClient(logger); + const spawned: ChildProcessWithoutNullStreams[] = []; + let active: AgentSession | undefined; + let history: CodexAppServerAgentSession | undefined; + try { + const catalog = await provider.fetchCatalog({ scope: "global", force: false }); + const model = catalog.models.find((entry) => entry.isDefault)!; + const config = { + provider: "codex", + cwd, + model: model.id, + modeId: "full-access", + thinkingOptionId: "low", + }; + const initialProcesses: ChildProcessWithoutNullStreams[] = []; + const initial = new CodexAppServerAgentSession(config, null, logger, async () => { + const child = spawn("codex", ["app-server"], { stdio: ["pipe", "pipe", "pipe"] }); + initialProcesses.push(child); + return child; + }); + active = initial; + await initial.connect(); + const reply = await active.run( + "Reply with exactly HISTORY_REMAINS_READ_ONLY. Do not use tools.", + ); + expect(reply.finalText).toContain("HISTORY_REMAINS_READ_ONLY"); + const handle = active.describePersistence()!; + await active.close(); + active = undefined; + expect( + initialProcesses.every((child) => child.exitCode !== null || child.signalCode !== null), + ).toBe(true); + if (nativeArchived) await provider.archiveNativeSession(handle); + + const nativePath = await readNativeThreadPath(handle.sessionId); + expect(nativePath.includes(`${path.sep}archived_sessions${path.sep}`)).toBe(nativeArchived); + history = new CodexAppServerAgentSession( + config, + handle, + logger, + async () => { + const child = spawn("codex", ["app-server"], { stdio: ["pipe", "pipe", "pipe"] }); + spawned.push(child); + return child; + }, + {}, + false, + false, + false, + undefined, + "history", + ); + await history.connect(); + const text: string[] = []; + for await (const event of history.streamHistory()) { + if (event.type === "timeline" && event.item.type === "assistant_message") + text.push(event.item.text); + } + expect(text.join("\n")).toContain("HISTORY_REMAINS_READ_ONLY"); + expect(spawned).toHaveLength(1); + expect( + spawned.filter((child) => child.exitCode === null && child.signalCode === null), + ).toEqual([]); + + expect(await readNativeThreadPath(handle.sessionId)).toBe(nativePath); + + // Native unarchive is possible after reading history; history held no writer. + await provider.unarchiveNativeSession(handle); + active = await provider.resumeSession(handle, config); + expect(active.describePersistence()?.sessionId).toBe(handle.sessionId); + const followUp = await active.run( + "Repeat exactly your previous assistant reply from this conversation. Do not use tools.", + ); + expect(followUp.finalText).toContain("HISTORY_REMAINS_READ_ONLY"); + expect(active.describePersistence()?.sessionId).toBe(handle.sessionId); + } finally { + await history?.close(); + await active?.close(); + await rm(cwd, { recursive: true, force: true }); + } + }, + 120_000, +); + +test.runIf(process.env.PASEO_NATIVE_ARCHIVE_QA === "1")( + "a failed Codex history read releases its temporary process", + async () => { + const spawned: ChildProcessWithoutNullStreams[] = []; + const session = new CodexAppServerAgentSession( + { provider: "codex", cwd: tmpdir() }, + { sessionId: "00000000-0000-4000-8000-000000000001" }, + createTestLogger(), + async () => { + const child = spawn("codex", ["app-server"], { stdio: ["pipe", "pipe", "pipe"] }); + spawned.push(child); + return child; + }, + {}, + false, + false, + false, + undefined, + "history", + ); + try { + await expect(session.connect()).rejects.toThrow(); + expect(spawned).toHaveLength(1); + expect(spawned.every((child) => child.exitCode !== null || child.signalCode !== null)).toBe( + true, + ); + } finally { + await session.close(); + } + }, + 30_000, +); From 2d7733cd6514a4b3422870163b749395ca2634e6 Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Sat, 12 Sep 2026 08:49:25 +0200 Subject: [PATCH 25/46] fix(app): keep incomplete Markdown formatted while streaming (#4742) * fix(app): keep incomplete Markdown formatted while streaming Treat unfinished formatting at the growing assistant-message tail as provisional so delimiters and link destinations do not flash while tokens arrive. Completed messages retain ordinary Markdown parsing. * test(app): express streaming Markdown checks as user actions --- .../e2e/browser/streaming-markdown.spec.ts | 21 +++ .../e2e/support/helpers/streaming-markdown.ts | 81 ++++++++++ packages/app/src/components/message.tsx | 10 +- .../utils/assistant-markdown-parser.test.ts | 141 ++++++++++++++++ .../src/utils/assistant-markdown-parser.ts | 7 +- .../app/src/utils/streaming-markdown/index.ts | 153 ++++++++++++++++++ 6 files changed, 411 insertions(+), 2 deletions(-) create mode 100644 packages/app/e2e/browser/streaming-markdown.spec.ts create mode 100644 packages/app/e2e/support/helpers/streaming-markdown.ts create mode 100644 packages/app/src/utils/streaming-markdown/index.ts diff --git a/packages/app/e2e/browser/streaming-markdown.spec.ts b/packages/app/e2e/browser/streaming-markdown.spec.ts new file mode 100644 index 0000000000..00aa16e7cc --- /dev/null +++ b/packages/app/e2e/browser/streaming-markdown.spec.ts @@ -0,0 +1,21 @@ +import { test } from "../support/fixtures"; +import { + expectFinishedMarkdown, + expectReloadedMarkdown, + expectUnfinishedBold, + expectUnfinishedLink, + requestStreamingMarkdown, + withStreamingMarkdown, +} from "../support/helpers/streaming-markdown"; + +test("formats unfinished Markdown while streaming and preserves the completed rendering", async ({ + page, +}, testInfo) => { + await withStreamingMarkdown(page, testInfo, async (agent) => { + await requestStreamingMarkdown(agent); + await expectUnfinishedBold(page); + await expectUnfinishedLink(page, testInfo); + await expectFinishedMarkdown(page, agent, testInfo); + await expectReloadedMarkdown(page); + }); +}); diff --git a/packages/app/e2e/support/helpers/streaming-markdown.ts b/packages/app/e2e/support/helpers/streaming-markdown.ts new file mode 100644 index 0000000000..144b706f5d --- /dev/null +++ b/packages/app/e2e/support/helpers/streaming-markdown.ts @@ -0,0 +1,81 @@ +import { expect, type Page, type TestInfo } from "@playwright/test"; +import { openAgentRoute, seedMockAgentWorkspace, type MockAgentWorkspace } from "./mock-agent"; + +export async function withStreamingMarkdown( + page: Page, + testInfo: TestInfo, + run: (agent: MockAgentWorkspace) => Promise, +): Promise { + testInfo.setTimeout(120_000); + const agent = await seedMockAgentWorkspace({ + repoPrefix: "streaming-markdown-", + title: "Streaming Markdown", + featureValues: { + mockStreamingAssistantResponse: + "**Bold text stays bold** and [Paseo docs](https://example.com/documentation). Done.", + mockStreamingAssistantIntervalMs: 400, + }, + }); + try { + await openAgentRoute(page, agent); + await run(agent); + } finally { + await agent.cleanup(); + } +} + +export async function requestStreamingMarkdown(agent: MockAgentWorkspace): Promise { + await agent.client.sendAgentMessage(agent.agentId, "Show the formatted streaming response."); +} + +export async function expectUnfinishedBold(page: Page): Promise { + const message = page.getByTestId("assistant-message").last(); + const bold = message.locator('[data-paseo-markdown-tag="strong"]'); + await expect(bold).toContainText("Bold"); + await expect(message).not.toContainText("stays bold"); + await expect(bold).toHaveCSS("font-weight", "500"); + await expect(message).not.toContainText("*"); +} + +export async function expectUnfinishedLink(page: Page, testInfo: TestInfo): Promise { + const message = page.getByTestId("assistant-message").last(); + await expect(message).toContainText("Paseo docs"); + await expect(message.getByRole("link", { name: "Paseo docs" })).toHaveCount(0); + await expect(message).not.toContainText("["); + await expect(message).not.toContainText("https:"); + await captureMarkdown(page, testInfo, "unfinished-link"); +} + +export async function expectFinishedMarkdown( + page: Page, + agent: MockAgentWorkspace, + testInfo: TestInfo, +): Promise { + await agent.client.waitForFinish(agent.agentId, 30_000); + await expectCompletedMarkdown(page); + await captureMarkdown(page, testInfo, "completed-markdown"); +} + +export async function expectReloadedMarkdown(page: Page): Promise { + await page.reload({ waitUntil: "domcontentloaded" }); + await expectCompletedMarkdown(page); +} + +async function captureMarkdown(page: Page, testInfo: TestInfo, name: string): Promise { + await testInfo.attach(name, { + body: await page.screenshot({ path: testInfo.outputPath(`${name}.png`) }), + contentType: "image/png", + }); +} + +async function expectCompletedMarkdown(page: Page): Promise { + const message = page.getByTestId("assistant-message").last(); + await expect(message).toHaveText("Bold text stays bold and Paseo docs. Done."); + await expect( + message.getByRole("link", { name: "Paseo docs" }).and(message.locator("a")), + ).toHaveAttribute("href", "https://example.com/documentation"); + await expect(message.locator('[data-paseo-markdown-tag="strong"]')).toHaveCSS( + "font-weight", + "500", + ); +} diff --git a/packages/app/src/components/message.tsx b/packages/app/src/components/message.tsx index 830eb265ea..a8a56dee89 100644 --- a/packages/app/src/components/message.tsx +++ b/packages/app/src/components/message.tsx @@ -1501,6 +1501,10 @@ export const AssistantMessage = memo(function AssistantMessage({ }: AssistantMessageProps) { const { t } = useTranslation(); const markdownParser = useMemo(createAssistantMarkdownParser, []); + const streamingMarkdownParser = useMemo( + () => createAssistantMarkdownParser({ streaming: true }), + [], + ); const renderedMessage = useMemo(() => capAssistantMessageForRender(message), [message]); // Paint a paced prefix while the turn is streaming so text arrives at a steady // rate instead of in whatever lumps the daemon's coalescing window produced. @@ -1982,7 +1986,11 @@ export const AssistantMessage = memo(function AssistantMessage({ diff --git a/packages/app/src/utils/assistant-markdown-parser.test.ts b/packages/app/src/utils/assistant-markdown-parser.test.ts index 70d806a1fb..05b1366e1c 100644 --- a/packages/app/src/utils/assistant-markdown-parser.test.ts +++ b/packages/app/src/utils/assistant-markdown-parser.test.ts @@ -2,6 +2,147 @@ import { describe, expect, it } from "vitest"; import { createAssistantMarkdownParser } from "./assistant-markdown-parser"; describe("createAssistantMarkdownParser", () => { + it("keeps bold text bold through every partial closing marker", () => { + const parser = createAssistantMarkdownParser({ streaming: true }); + + for (const source of ["**bold", "**bold*", "**bold**"]) { + expect(parser.renderInline(source)).toBe("bold"); + } + }); + + it("shows the growing link label and only links a complete destination", () => { + const parser = createAssistantMarkdownParser({ streaming: true }); + const source = "[docs](https://example.com/path)"; + for (let length = 1; length < source.length; length++) { + expect(parser.renderInline(source.slice(0, length))).toBe( + "docs".slice(0, Math.max(0, length - 1)), + ); + } + expect(parser.renderInline(source)).toBe('docs'); + }); + + it.each([ + ["**", "strong"], + ["__", "strong"], + ["*", "em"], + ["_", "em"], + ["~~", "s"], + ["`", "code"], + ["``", "code"], + ])("keeps %s formatting stable as text and closing markers arrive", (marker, tag) => { + const parser = createAssistantMarkdownParser({ streaming: true }); + for (let length = 1; length <= 4; length++) { + expect(parser.renderInline(marker + "text".slice(0, length))).toBe( + `<${tag}>${"text".slice(0, length)}`, + ); + } + for (let length = 0; length <= marker.length; length++) { + expect(parser.renderInline(marker + "text" + marker.slice(0, length))).toBe( + `<${tag}>text`, + ); + } + }); + + it("keeps combined and nested emphasis stable through closing markers", () => { + const parser = createAssistantMarkdownParser({ streaming: true }); + for (const closing of ["", "*", "**", "***"]) { + expect(parser.renderInline("***both" + closing)).toBe("both"); + } + expect(parser.renderInline("**bold and *italic")).toBe( + "bold and italic", + ); + expect(parser.renderInline("*italic and **bold")).toBe( + "italic and bold", + ); + }); + + it.each(["*", "**", "***", "_", "__", "~~", "`", "``"])( + "hides an opening %s while waiting for its text", + (marker) => { + expect( + createAssistantMarkdownParser({ streaming: true }).renderInline("hello " + marker), + ).toBe("hello "); + }, + ); + + it.each([ + "[docs](https://example.com/a(b)c)", + '[docs](https://example.com "a title)")', + "[docs]()", + "[docs](file:///tmp/example.ts)", + ])("waits for the entire destination of %s", (source) => { + const parser = createAssistantMarkdownParser({ streaming: true }); + for (let length = 6; length < source.length; length++) { + expect(parser.renderInline(source.slice(0, length))).toBe("docs"); + } + expect(parser.renderInline(source)).toBe(createAssistantMarkdownParser().renderInline(source)); + }); + + it("preserves formatting in incomplete labels and around incomplete links", () => { + const parser = createAssistantMarkdownParser({ streaming: true }); + expect(parser.renderInline("[**bold")).toBe("bold"); + expect(parser.renderInline("[**bold**](https://exam")).toBe("bold"); + expect(parser.renderInline("**see [docs](https://exam")).toBe("see docs"); + }); + + it("does not auto-link a URL label before the destination is complete", () => { + const parser = createAssistantMarkdownParser({ streaming: true }); + expect(parser.renderInline("Read [example.com](https://exam")).toBe("Read example.com"); + expect(parser.renderInline("Read [example.com](https://example.org)")).toBe( + 'Read example.com', + ); + }); + + it.each([ + "\\*literal", + "\\[literal", + "some_identifier", + "some__identifier", + "`**literal [link](url`", + "``a ` b``", + "**already closed** after", + "[bad](javascript:alert(1))", + " { + expect(createAssistantMarkdownParser({ streaming: true }).renderInline(source)).toBe( + createAssistantMarkdownParser().renderInline(source), + ); + }); + + it.each([ + "```md\n**literal [link](url", + "~~~md\n**literal [link](url", + " **literal [link](url", + "**earlier\n\nplain tail", + "- **earlier\n- plain tail", + ])("leaves literal code and earlier blocks alone: %s", (source) => { + expect(createAssistantMarkdownParser({ streaming: true }).render(source)).toBe( + createAssistantMarkdownParser().render(source), + ); + }); + + it("completes inline formatting inside the final list item", () => { + expect(createAssistantMarkdownParser({ streaming: true }).render("- first\n- **bold")).toBe( + "
    \n
  • first
  • \n
  • bold
  • \n
\n", + ); + }); + + it("hides incomplete images until their source is complete", () => { + const parser = createAssistantMarkdownParser({ streaming: true }); + expect(parser.renderInline("before ![alt](https://exam")).toBe("before "); + expect(parser.renderInline("before ![alt](https://example.com/image.png)")).toBe( + 'before alt', + ); + }); + + it("keeps ordinary parsing for completed messages", () => { + const parser = createAssistantMarkdownParser(); + expect(parser.renderInline("**unfinished")).toBe("**unfinished"); + expect(parser.renderInline("[unfinished")).toBe("[unfinished"); + }); + it("renders agent text verbatim", () => { const parser = createAssistantMarkdownParser(); diff --git a/packages/app/src/utils/assistant-markdown-parser.ts b/packages/app/src/utils/assistant-markdown-parser.ts index 5e64fb629f..e1e8f6f38a 100644 --- a/packages/app/src/utils/assistant-markdown-parser.ts +++ b/packages/app/src/utils/assistant-markdown-parser.ts @@ -1,7 +1,8 @@ import type MarkdownIt from "markdown-it"; import { createMarkdownParser } from "@/utils/markdown-parser"; +import { enableStreamingMarkdown } from "@/utils/streaming-markdown"; -export function createAssistantMarkdownParser(): MarkdownIt { +export function createAssistantMarkdownParser({ streaming = false } = {}): MarkdownIt { const parser = createMarkdownParser({ linkify: true }); const defaultValidateLink = parser.validateLink.bind(parser); @@ -10,5 +11,9 @@ export function createAssistantMarkdownParser(): MarkdownIt { parser.validateLink = (url: string) => url.trim().toLowerCase().startsWith("file://") || defaultValidateLink(url); + if (streaming) { + enableStreamingMarkdown(parser); + } + return parser; } diff --git a/packages/app/src/utils/streaming-markdown/index.ts b/packages/app/src/utils/streaming-markdown/index.ts new file mode 100644 index 0000000000..b70b464d76 --- /dev/null +++ b/packages/app/src/utils/streaming-markdown/index.ts @@ -0,0 +1,153 @@ +import type MarkdownIt from "markdown-it"; +import type StateInline from "markdown-it/lib/rules_inline/state_inline.mjs"; + +const STREAMING_TAIL = Symbol("streaming markdown tail"); + +/** Adds provisional inline formatting without changing the source or literal code blocks. */ +export function enableStreamingMarkdown(parser: MarkdownIt): void { + parser.core.ruler.at("inline", (state) => { + const tail = state.tokens.findLast((token) => token.nesting !== -1); + for (const token of state.tokens) { + if (token.type === "inline") { + const env = { ...state.env, [STREAMING_TAIL]: token === tail }; + token.children = []; + state.md.inline.parse(token.content, state.md, env, token.children); + } + } + }); + parser.inline.ruler.before("backticks", "streaming_code", completeCode); + parser.inline.ruler.before("strikethrough", "streaming_marker", hideOpeningMarker); + parser.inline.ruler.after("image", "streaming_link", completeLink); + parser.inline.ruler2.after("balance_pairs", "streaming_emphasis", completeEmphasis); + parser.core.ruler.after("linkify", "streaming_link_text", (state) => { + for (const block of state.tokens) { + for (const token of block.children ?? []) { + if (token.type === "streaming_link_text") token.type = "text"; + } + } + }); +} + +function isStreamingTail(state: StateInline): boolean { + return state.env[STREAMING_TAIL] === true && state.posMax === state.src.length; +} + +function hideOpeningMarker(state: StateInline, silent: boolean): boolean { + if (silent || !isStreamingTail(state)) return false; + if (!/^(?:\*{1,3}|_{1,3}|~{1,2})$/.test(state.src.slice(state.pos))) return false; + if (state.scanDelims(state.pos, state.src[state.pos] !== "_").can_close) return false; + state.pos = state.posMax; + return true; +} + +function completeCode(state: StateInline, silent: boolean): boolean { + if (silent || !isStreamingTail(state) || state.src[state.pos] !== "`") return false; + const runs = /`+/g; + runs.lastIndex = state.pos; + const opening = runs.exec(state.src)!; + let closing: RegExpExecArray | null; + while ((closing = runs.exec(state.src))) { + if (closing[0].length === opening[0].length) return false; + } + let content = state.src.slice(state.pos + opening[0].length); + // A shorter trailing run may be the first characters of the closing marker. + const partialClosing = content.match(/`+$/); + if (partialClosing && partialClosing[0].length < opening[0].length) { + content = content.slice(0, -partialClosing[0].length); + } + if (content) { + const token = state.push("code_inline", "code", 0); + token.markup = opening[0]; + token.content = content.replace(/\n/g, " ").replace(/^ (.+) $/, "$1"); + } + state.pos = state.posMax; + return true; +} + +function completeLink(state: StateInline, silent: boolean): boolean { + if (silent || !isStreamingTail(state)) return false; + const image = state.src[state.pos] === "!"; + const start = state.pos + (image ? 1 : 0); + if (state.src[start] !== "[") return false; + const labelEnd = state.md.helpers.parseLinkLabel(state, start, false); + if (labelEnd !== -1 && labelEnd + 1 < state.posMax) { + if (state.src[labelEnd + 1] !== "(" || hasClosedDestination(state.src, labelEnd + 1)) { + return false; + } + } + const end = state.posMax; + if (!image) { + if (state.pending) state.pushPending(); + const firstLabelToken = state.tokens.length; + state.pos = start + 1; + state.posMax = labelEnd === -1 ? end : labelEnd; + state.level++; + state.md.inline.tokenize(state); + state.level--; + // Keep URL-shaped labels out of the automatic linkifier until the real + // destination arrives. Restore ordinary text before tokens reach the view. + for (let index = firstLabelToken; index < state.tokens.length; index++) { + if (state.tokens[index].type === "text") state.tokens[index].type = "streaming_link_text"; + } + } + state.pos = state.posMax = end; + return true; +} + +function hasClosedDestination(source: string, start: number): boolean { + let depth = 0; + let quote = ""; + for (let index = start; index < source.length; index++) { + const char = source[index]; + if (char === "\\") { + index++; + } else if (quote) { + if (char === quote) quote = ""; + } else if (char === "<") { + quote = ">"; + } else if ((char === '"' || char === "'") && /\s/.test(source[index - 1])) { + quote = char; + } else if (char === "(") { + depth++; + } else if (char === ")" && --depth === 0) { + return true; + } + } + return false; +} + +function completeEmphasis(state: StateInline): boolean { + if (!isStreamingTail(state)) return false; + const open = []; + let closedThrough = -1; + // Reuse markdown-it's escape, word-boundary, nesting and delimiter matching + // decisions. Only unmatched openers outside already-closed spans reach EOF. + for (const [index, delimiter] of state.delimiters.entries()) { + if (delimiter.end !== -1) { + closedThrough = Math.max(closedThrough, delimiter.end); + } else if (index > closedThrough && delimiter.open) { + open.push(delimiter); + } + } + for (const delimiter of open.toReversed()) { + if (delimiter.marker === 126 && /(? Date: Sat, 12 Sep 2026 03:50:09 -0300 Subject: [PATCH 26/46] fix(server): show the right thinking options for each Cursor model (#4180) * fix(server): probe Cursor thinking options per model Cursor ACP only reports thought_level for the current model, so the catalog was copying Haiku's Off/On onto Grok 4.6 until the next probe happened to land on Grok. Co-authored-by: Cursor * fix(server): drop inherited thinking options when an ACP catalog probe fails A rejected model switch was leaving the probe session's thinking list on the failed model, so Grok could still advertise Haiku's Off/On. Co-authored-by: Cursor * fix(server): probe per-model thinking when session/new has no thought_level Composer/Auto as the Cursor ACP default omits thought_level, so the catalog helper skipped every model switch and Grok kept an empty thinking list. Co-authored-by: Cursor * fix(server): discover model options without changing preferences Use the provider model catalog extension instead of probing model switches that persist user preferences. Keep provider-specific discovery in each shim and validate extension responses at the boundary. --------- Co-authored-by: Cursor Co-authored-by: Mohamed Boudra --- docs/providers.md | 7 + .../server/agent/providers/acp-agent.test.ts | 7 +- .../src/server/agent/providers/acp-agent.ts | 9 +- .../agent/providers/cursor-acp-agent.test.ts | 7 + .../agent/providers/cursor-acp-agent.ts | 62 ++++- .../providers/cursor-acp-catalog.test.ts | 213 ++++++++++++++++++ .../agent/providers/kimi-acp-agent.test.ts | 32 ++- .../server/agent/providers/kimi-acp-agent.ts | 40 ++-- 8 files changed, 340 insertions(+), 37 deletions(-) create mode 100644 packages/server/src/server/agent/providers/cursor-acp-catalog.test.ts diff --git a/docs/providers.md b/docs/providers.md index f47d408d54..992b6c555f 100644 --- a/docs/providers.md +++ b/docs/providers.md @@ -49,6 +49,13 @@ Copilot custom agents are exposed through ACP session config, not the slash-comm ACP permission options are rendered as ordered actions and Paseo returns the selected option's exact `optionId`. Agents can therefore encode a single-choice question as multiple options of the same allow kind. Auto-accept does not resolve those chooser requests; they always wait for the user. +ACP shims can own model discovery through `catalogModelResolver`; the shared client owns the probe +process and refresh deadline. Keep vendor RPCs in the shim. Cursor uses +`cursor/list_available_models` because switching models during discovery writes its saved CLI +preferences and selection history. Cursor versions without that extension must be updated. Kimi +still probes model selections in its own shim. The initial session supplies modes and the current +model; it does not override the model list returned by a resolver. + ### Direct Implement the `AgentClient` and `AgentSession` interfaces from `agent-sdk-types.ts` yourself. This gives full control but requires you to handle process management, streaming, permissions, and session persistence from scratch. diff --git a/packages/server/src/server/agent/providers/acp-agent.test.ts b/packages/server/src/server/agent/providers/acp-agent.test.ts index 1c77a6a25d..237e98892c 100644 --- a/packages/server/src/server/agent/providers/acp-agent.test.ts +++ b/packages/server/src/server/agent/providers/acp-agent.test.ts @@ -1794,10 +1794,9 @@ describe("ACPAgentClient modelTransformer", () => { describe("ACPAgentClient catalog discovery without a model resolver", () => { test("never switches models during catalog discovery even with multiple models and a thinking picker", async () => { - // The per-model probing that switches models lives on KimiACPAgentClient - // (see kimi-acp-agent.test.ts). The base client ships no catalog model resolver, so a - // slow or nonconforming ACP can't stall its catalog probe on extra setSessionConfigOption - // round trips. + // Model discovery extensions live on providers that opt in. The base + // client ships no catalog model resolver, so a slow or nonconforming ACP can't + // stall its catalog probe on extra setSessionConfigOption round trips. const setSessionConfigOption = vi.fn(); class TestACPAgentClient extends ACPAgentClient { diff --git a/packages/server/src/server/agent/providers/acp-agent.ts b/packages/server/src/server/agent/providers/acp-agent.ts index 0adb620136..4b14f924ef 100644 --- a/packages/server/src/server/agent/providers/acp-agent.ts +++ b/packages/server/src/server/agent/providers/acp-agent.ts @@ -393,7 +393,7 @@ export type ACPExtensionCommandsParser = ( * the catalog plumbing. */ export interface ACPCatalogModelResolverContext { - connection: ClientSideConnection; + connection: Pick; sessionId: string; models: AgentModelDefinition[]; configOptions: SessionConfigOption[] | null | undefined; @@ -404,10 +404,9 @@ export interface ACPCatalogModelResolverContext { } /** - * Optional hook that refines the catalog's model list using the live probe session. - * The base client ships no resolver — catalog discovery derives models from the initial - * session response and never mutates the probe. Providers that need per-model data (Kimi) - * inject a resolver so the extra round trips stay off every other ACP. + * Providers own model discovery through this hook, including extension RPCs. Without a + * resolver, the base client derives models from the initial session response. The client + * owns the probe lifetime and refresh deadline; resolvers return the complete model list. */ export type ACPCatalogModelResolver = ( context: ACPCatalogModelResolverContext, diff --git a/packages/server/src/server/agent/providers/cursor-acp-agent.test.ts b/packages/server/src/server/agent/providers/cursor-acp-agent.test.ts index a38fb77128..fbe4c762e1 100644 --- a/packages/server/src/server/agent/providers/cursor-acp-agent.test.ts +++ b/packages/server/src/server/agent/providers/cursor-acp-agent.test.ts @@ -33,6 +33,13 @@ describe("CursorACPAgentClient model discovery", () => { child: { kill: vi.fn(), exitCode: 0, signalCode: null, once: vi.fn() }, connection: { newSession: vi.fn().mockResolvedValue(this.response), + extMethod: async () => ({ + models: (this.response.models?.availableModels ?? []).map((model) => ({ + value: model.modelId, + name: model.name, + configOptions: [], + })), + }), }, initialize: { agentCapabilities: {} }, } as SpawnedACPProcess; diff --git a/packages/server/src/server/agent/providers/cursor-acp-agent.ts b/packages/server/src/server/agent/providers/cursor-acp-agent.ts index 6dce76c973..0a7ce428b3 100644 --- a/packages/server/src/server/agent/providers/cursor-acp-agent.ts +++ b/packages/server/src/server/agent/providers/cursor-acp-agent.ts @@ -1,6 +1,13 @@ +import { zSessionConfigOption } from "@agentclientprotocol/sdk/dist/schema/zod.gen.js"; import type { Logger } from "pino"; +import { z } from "zod"; -import type { ACPConfigFeatureOption } from "./acp-agent.js"; +import type { AgentModelDefinition } from "../agent-sdk-types.js"; +import { + deriveSelectorOptions, + type ACPCatalogModelResolverContext, + type ACPConfigFeatureOption, +} from "./acp-agent.js"; import { GenericACPAgentClient } from "./generic-acp-agent.js"; interface CursorACPAgentClientOptions { @@ -26,6 +33,58 @@ export const CURSOR_FAST_FEATURE_OPTION: ACPConfigFeatureOption = { icon: "zap", }; +const CursorModelCatalogSchema = z.object({ + models: z.array( + z.object({ + value: z.string().min(1), + name: z.string(), + configOptions: z.array(zSessionConfigOption), + }), + ), +}); + +// Cursor model switches persist CLI preferences, even in a throwaway probe session. +// Its extension returns each model's parameter definitions without selecting it. +export async function resolveCursorCatalogModels({ + connection, + models, + provider, + runRequest, +}: ACPCatalogModelResolverContext): Promise { + const catalog = await runRequest(() => fetchCursorModelCatalog(connection)); + const currentModelId = models.find((model) => model.isDefault)?.id; + + return catalog.models.map((model) => { + const thinkingOptions = deriveSelectorOptions(model.configOptions, "thought_level"); + const defaultThinkingOptionId = thinkingOptions.find((option) => option.isDefault)?.id; + return { + provider, + id: model.value, + label: model.name, + isDefault: model.value === currentModelId, + thinkingOptions: thinkingOptions.length > 0 ? thinkingOptions : undefined, + defaultThinkingOptionId, + }; + }); +} + +async function fetchCursorModelCatalog(connection: ACPCatalogModelResolverContext["connection"]) { + try { + const response = await connection.extMethod("cursor/list_available_models", {}); + return CursorModelCatalogSchema.parse(response); + } catch (error) { + const extensionUnavailable = + typeof error === "object" && error !== null && "code" in error && error.code === -32601; + if (extensionUnavailable) { + throw new Error( + "Update Cursor CLI: this version does not support cursor/list_available_models.", + { cause: error }, + ); + } + throw error; + } +} + export class CursorACPAgentClient extends GenericACPAgentClient { constructor(options: CursorACPAgentClientOptions) { super({ @@ -40,6 +99,7 @@ export class CursorACPAgentClient extends GenericACPAgentClient { initialCommandsWaitTimeoutMs: CURSOR_INITIAL_COMMANDS_WAIT_TIMEOUT_MS, clientCapabilityMeta: CURSOR_CLIENT_CAPABILITY_META, configFeatureOptions: [CURSOR_FAST_FEATURE_OPTION], + catalogModelResolver: resolveCursorCatalogModels, }); } } diff --git a/packages/server/src/server/agent/providers/cursor-acp-catalog.test.ts b/packages/server/src/server/agent/providers/cursor-acp-catalog.test.ts new file mode 100644 index 0000000000..9facfda374 --- /dev/null +++ b/packages/server/src/server/agent/providers/cursor-acp-catalog.test.ts @@ -0,0 +1,213 @@ +import type { SessionConfigOption } from "@agentclientprotocol/sdk"; +import { expect, test } from "vitest"; +import { ZodError } from "zod"; + +import { createTestLogger } from "../../../test-utils/test-logger.js"; +import { deriveModelDefinitionsFromACP, type ACPCatalogModelResolverContext } from "./acp-agent.js"; +import { resolveCursorCatalogModels } from "./cursor-acp-agent.js"; + +const modelOption: SessionConfigOption = { + id: "model", + name: "Model", + category: "model", + type: "select", + currentValue: "claude-haiku-4-5", + options: [ + { value: "claude-haiku-4-5", name: "Haiku 4.5" }, + { value: "grok-4.6", name: "Grok 4.6" }, + ], +}; + +const booleanThinking: SessionConfigOption = { + id: "thinking", + name: "Thinking", + category: "thought_level", + type: "select", + currentValue: "true", + options: [ + { value: "false", name: "Off" }, + { value: "true", name: "On" }, + ], +}; + +const effortThinking: SessionConfigOption = { + id: "effort", + name: "Effort", + category: "thought_level", + type: "select", + currentValue: "xhigh", + options: [ + { value: "low", name: "Low" }, + { value: "medium", name: "Medium" }, + { value: "high", name: "High" }, + { value: "xhigh", name: "Extra High" }, + ], +}; + +class CursorCatalogConnection { + selectedModel = "claude-haiku-4-5"; + selectionHistory = ["claude-haiku-4-5"]; + error: unknown = null; + catalog: Record = { + models: [ + { value: "claude-haiku-4-5", name: "Haiku 4.5", configOptions: [booleanThinking] }, + { value: "grok-4.6", name: "Grok 4.6", configOptions: [effortThinking] }, + ], + }; + + async extMethod(method: string, params: Record) { + if (method !== "cursor/list_available_models" || Object.keys(params).length !== 0) { + throw new Error(`Unsupported catalog request: ${method}`); + } + if (this.error) throw this.error; + return this.catalog; + } + + async setSessionConfigOption({ value }: { value: string }) { + this.selectedModel = value; + this.selectionHistory.push(value); + const thinking = value === "grok-4.6" ? effortThinking : booleanThinking; + return { configOptions: [thinking] }; + } +} + +function createCatalogContext( + connection: CursorCatalogConnection, + configOptions: SessionConfigOption[] = [modelOption, booleanThinking], +): ACPCatalogModelResolverContext { + return { + connection, + sessionId: "catalog-session", + models: deriveModelDefinitionsFromACP("acp", null, configOptions), + configOptions, + runRequest: (request) => request(), + transformConfigOptions: (options) => options, + logger: createTestLogger(), + provider: "acp", + }; +} + +test("lists each model's thinking options without changing Cursor preferences", async () => { + const connection = new CursorCatalogConnection(); + const context = createCatalogContext(connection); + + const models = await resolveCursorCatalogModels(context); + + expect(connection.selectedModel).toBe("claude-haiku-4-5"); + expect(connection.selectionHistory).toEqual(["claude-haiku-4-5"]); + expect( + models.map(({ id, isDefault, thinkingOptions, defaultThinkingOptionId }) => ({ + id, + isDefault, + thinking: thinkingOptions?.map((option) => option.id), + defaultThinkingOptionId, + })), + ).toEqual([ + { + id: "claude-haiku-4-5", + isDefault: true, + thinking: ["false", "true"], + defaultThinkingOptionId: "true", + }, + { + id: "grok-4.6", + isDefault: false, + thinking: ["low", "medium", "high", "xhigh"], + defaultThinkingOptionId: "xhigh", + }, + ]); +}); + +test("reports that Cursor must be updated when the model extension is unavailable", async () => { + const connection = new CursorCatalogConnection(); + connection.error = { code: -32601, message: "Method not found" }; + + await expect(resolveCursorCatalogModels(createCatalogContext(connection))).rejects.toThrow( + "Update Cursor CLI", + ); + expect(connection.selectionHistory).toEqual(["claude-haiku-4-5"]); +}); + +test("discovers thinking models when the session default has no thinking selector", async () => { + const connection = new CursorCatalogConnection(); + const context = createCatalogContext(connection, [modelOption]); + + const models = await resolveCursorCatalogModels(context); + + expect(models[1].thinkingOptions?.map((option) => option.id)).toEqual([ + "low", + "medium", + "high", + "xhigh", + ]); + expect(connection.selectionHistory).toEqual(["claude-haiku-4-5"]); +}); + +test("uses the extension's complete catalog even when session/new lists only one model", async () => { + const connection = new CursorCatalogConnection(); + const context = createCatalogContext(connection); + context.models = context.models.slice(0, 1); + + const models = await resolveCursorCatalogModels(context); + + expect(models.map((model) => model.id)).toEqual(["claude-haiku-4-5", "grok-4.6"]); + expect(connection.selectionHistory).toEqual(["claude-haiku-4-5"]); +}); + +test("does not copy session thinking options onto a model without a selector", async () => { + const connection = new CursorCatalogConnection(); + connection.catalog = { + models: [{ value: "composer-2", name: "Composer", configOptions: [] }], + }; + + const models = await resolveCursorCatalogModels(createCatalogContext(connection)); + + expect(models).toEqual([ + { + provider: "acp", + id: "composer-2", + label: "Composer", + isDefault: false, + thinkingOptions: undefined, + defaultThinkingOptionId: undefined, + }, + ]); +}); + +test("keeps an empty extension catalog empty", async () => { + const connection = new CursorCatalogConnection(); + connection.catalog = { models: [] }; + + await expect(resolveCursorCatalogModels(createCatalogContext(connection))).resolves.toEqual([]); +}); + +test.each([ + {}, + { models: [{ value: "grok-4.6", name: "Grok 4.6" }] }, + { + models: [ + { + value: "grok-4.6", + name: "Grok 4.6", + configOptions: [{ ...effortThinking, currentValue: 1 }], + }, + ], + }, +])("rejects malformed extension responses: %j", async (catalog) => { + const connection = new CursorCatalogConnection(); + connection.catalog = catalog; + + await expect(resolveCursorCatalogModels(createCatalogContext(connection))).rejects.toBeInstanceOf( + ZodError, + ); + expect(connection.selectionHistory).toEqual(["claude-haiku-4-5"]); +}); + +test("propagates extension failures without changing preferences or returning stale options", async () => { + const connection = new CursorCatalogConnection(); + const error = new Error("Connection closed"); + connection.error = error; + + await expect(resolveCursorCatalogModels(createCatalogContext(connection))).rejects.toBe(error); + expect(connection.selectionHistory).toEqual(["claude-haiku-4-5"]); +}); diff --git a/packages/server/src/server/agent/providers/kimi-acp-agent.test.ts b/packages/server/src/server/agent/providers/kimi-acp-agent.test.ts index ce296726a1..b388fdb01a 100644 --- a/packages/server/src/server/agent/providers/kimi-acp-agent.test.ts +++ b/packages/server/src/server/agent/providers/kimi-acp-agent.test.ts @@ -156,8 +156,13 @@ describe("KimiACPAgentClient per-model thinking options", () => { expect(setSessionConfigOption).not.toHaveBeenCalled(); }); - test("skips per-model probing when the provider has no thinking picker", async () => { - const setSessionConfigOption = vi.fn(); + test("probes each model when session/new has no thought_level so a later model can still expose thinking options", async () => { + const setSessionConfigOption = vi.fn(async ({ value }: { value: string }) => ({ + configOptions: + value === "kimi-k3" + ? [modelConfigOption(value), effortThinkingConfigOption()] + : [modelConfigOption(value)], + })); const client = createKimiClient( async () => @@ -174,16 +179,28 @@ describe("KimiACPAgentClient per-model thinking options", () => { }) as unknown as SpawnedACPProcess, ); - await client.fetchCatalog({ + const catalog = await client.fetchCatalog({ scope: "workspace", cwd: "/tmp/acp-kimi-no-thinking", force: false, }); - expect(setSessionConfigOption).not.toHaveBeenCalled(); + expect(setSessionConfigOption).toHaveBeenCalledTimes(2); + + const kimiForCoding = catalog.models.find((model) => model.id === "kimi-for-coding"); + const kimiK3 = catalog.models.find((model) => model.id === "kimi-k3"); + expect(kimiForCoding?.thinkingOptions).toBeUndefined(); + expect(kimiForCoding?.defaultThinkingOptionId).toBeUndefined(); + expect(kimiK3?.thinkingOptions).toEqual([ + expect.objectContaining({ id: "off", isDefault: false }), + expect.objectContaining({ id: "low", isDefault: false }), + expect.objectContaining({ id: "medium", isDefault: true }), + expect.objectContaining({ id: "high", isDefault: false }), + ]); + expect(kimiK3?.defaultThinkingOptionId).toBe("medium"); }); - test("keeps a model's default thinking options when its probe fails", async () => { + test("omits thinking options when a model's probe fails instead of keeping another model's list", async () => { const setSessionConfigOption = vi.fn(async ({ value }: { value: string }) => { if (value === "kimi-k3") { throw new Error("probe rejected model switch"); @@ -212,10 +229,13 @@ describe("KimiACPAgentClient per-model thinking options", () => { force: false, }); + const kimiForCoding = catalog.models.find((model) => model.id === "kimi-for-coding"); const kimiK3 = catalog.models.find((model) => model.id === "kimi-k3"); - expect(kimiK3?.thinkingOptions).toEqual([ + expect(kimiForCoding?.thinkingOptions).toEqual([ expect.objectContaining({ id: "off", isDefault: true }), expect.objectContaining({ id: "on", isDefault: false }), ]); + expect(kimiK3?.thinkingOptions).toBeUndefined(); + expect(kimiK3?.defaultThinkingOptionId).toBeUndefined(); }); }); diff --git a/packages/server/src/server/agent/providers/kimi-acp-agent.ts b/packages/server/src/server/agent/providers/kimi-acp-agent.ts index e8ff7ce252..50effdf33a 100644 --- a/packages/server/src/server/agent/providers/kimi-acp-agent.ts +++ b/packages/server/src/server/agent/providers/kimi-acp-agent.ts @@ -18,23 +18,8 @@ interface KimiACPAgentClientOptions { providerParams?: unknown; } -/** - * Kimi reports different thinking-effort levels per model (a boolean on/off toggle for one - * model, a multi-level select for another), but only exposes the *currently selected* - * model's levels through `configOptions` — the model list itself carries no per-model - * effort metadata. `deriveModelDefinitionsFromACP` can only see whichever model the probe - * session defaulted to, so every other model would otherwise inherit that one model's - * thinking options. - * - * Reuse the single catalog probe session to switch through each candidate model in turn and - * read back its real thinking options, rather than spawning a probe per model. Skipped - * entirely when the provider reports one model or no thinking picker, so a misbehaving ACP - * that only advertises a single model pays no extra round trips. - * - * This lives on the Kimi client, not the base ACP adapter: only Kimi needs the extra - * `setSessionConfigOption` round trips, so no other ACP provider risks a slow or - * nonconforming agent stalling its catalog probe on model switching. - */ +// Kimi exposes thinking options only for the selected model. Keep its model-switching +// discovery here: other providers can supply a read-only catalog instead. export async function resolveKimiCatalogModels({ connection, sessionId, @@ -49,7 +34,7 @@ export async function resolveKimiCatalogModels({ return models; } const modelOption = findSelectConfigOption({ configOptions, category: "model" }); - if (!modelOption || !findSelectConfigOption({ configOptions, category: "thought_level" })) { + if (!modelOption) { return models; } @@ -72,11 +57,24 @@ export async function resolveKimiCatalogModels({ thinkingOptions.find((option) => option.isDefault)?.id ?? undefined, }); } catch (error) { + const errorMessage = toDiagnosticErrorMessage(error); + if (model.isDefault) { + logger.warn( + { modelId: model.id, error: errorMessage }, + `${provider} catalog probe could not refresh thinking options for current model "${model.id}"; keeping session options`, + ); + resolved.push(model); + continue; + } logger.warn( - { modelId: model.id, error: toDiagnosticErrorMessage(error) }, - `${provider} catalog probe could not resolve thinking options for model "${model.id}"; keeping its default options`, + { modelId: model.id, error: errorMessage }, + `${provider} catalog probe could not resolve thinking options for model "${model.id}"; omitting thinking options`, ); - resolved.push(model); + resolved.push({ + ...model, + thinkingOptions: undefined, + defaultThinkingOptionId: undefined, + }); } } return resolved; From 03a54a171da5b08c039e4e4c86b66480cf9e43da Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Sat, 12 Sep 2026 08:50:16 +0200 Subject: [PATCH 27/46] Keep older daemons usable and let clients choose timeline recovery (#4737) * fix(subscriptions): preserve older-host workflows and explicit timeline recovery Keep established app and SDK workflows available on older daemons without emulating independent directory filters. Restore live timeline membership without choosing the consumer's history recovery policy. Preserve daemon filesystem paths separately from legacy workspace IDs. * fix(client): keep legacy workspace replies scoped to the requested page --- docs/plugins.md | 6 +- docs/protocol-compatibility.md | 19 +- .../owned-subscriptions-old-daemon.spec.ts | 118 +++- .../app/src/runtime/directory-sync/index.ts | 9 +- packages/app/src/runtime/host-runtime.test.ts | 12 +- packages/app/src/runtime/host-runtime.ts | 11 - packages/cli/src/commands/agent/attach.ts | 5 +- packages/cli/src/commands/agent/logs.ts | 8 +- .../client/examples/events-and-timeline.ts | 4 +- packages/client/src/connection.test.ts | 622 ++++++++++++++---- packages/client/src/connection/index.ts | 123 +++- .../src/connection/legacy-workspaces.ts | 176 +++++ packages/client/src/connection/legacy.ts | 282 ++++++++ packages/client/src/connection/owned.ts | 2 +- packages/client/src/daemon-client.test.ts | 18 +- packages/client/src/daemon-client.ts | 64 +- packages/client/src/index.test.ts | 42 +- packages/client/src/index.ts | 60 +- .../client/src/terminal-stream-router.test.ts | 17 + packages/client/src/terminal-stream-router.ts | 57 +- .../client/src/timeline-subscription/index.ts | 79 +-- .../owned-subscriptions-client.e2e.test.ts | 26 +- .../server/owned-subscriptions.e2e.test.ts | 83 ++- .../terminal-session-controller.test.ts | 56 +- .../terminal/terminal-session-controller.ts | 17 +- public-docs/sdk/events.md | 31 +- public-docs/sdk/reference.md | 8 +- 27 files changed, 1560 insertions(+), 395 deletions(-) create mode 100644 packages/client/src/connection/legacy-workspaces.ts create mode 100644 packages/client/src/connection/legacy.ts diff --git a/docs/plugins.md b/docs/plugins.md index dfd4d6dde3..6cf231932a 100644 --- a/docs/plugins.md +++ b/docs/plugins.md @@ -356,8 +356,10 @@ query, and toast providers inside the surface content, including overflow pages 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 +`subscription` handle. Plain `list()` and agent/workspace directory `.subscribe(handler)` listeners create no daemon +demand. Provider and project `subscribe()` calls establish their own demand. On capable daemons, each +list-and-subscribe call has its own server ID, even for the same query. Older daemons retain +[shared delivery behavior](protocol-compatibility.md#owned-observations). 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. diff --git a/docs/protocol-compatibility.md b/docs/protocol-compatibility.md index 089bf1a3e8..eab220aa1d 100644 --- a/docs/protocol-compatibility.md +++ b/docs/protocol-compatibility.md @@ -46,15 +46,26 @@ The app, CLI, and plugins inherit those defaults; they supply only host resource automation, or explicit overrides. A schema accepting a message does not establish support for its delivery semantics. -Connecting creates no timeline or event demand. Client subscriptions own their network membership, +On capable daemons, connecting creates no timeline or event demand. Client subscriptions own their network membership, 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. +`owned_subscriptions` and `server_info.features.ownedSubscriptions` negotiate the source-owned +contract described in [architecture](architecture.md#websocket-protocol). The client selects delivery +behavior once at its connection boundary. App workflows use the same observation interface on both. + +With an older daemon, the client uses the existing connection and legacy RPCs. Directory subscriptions +remain shared and last-query-wins. Local handle IDs identify listeners; they do not promise independent +server filters. Timeline and event membership retain their existing shared behavior. Releasing a handle +detaches its listener and uses the old unsubscribe operation where one exists. Broadcast-only hosts keep +broadcasting; readiness there means local attachment, not a daemon acknowledgement. No extra sockets +or multiplexing emulation are introduced. + +Preserve established workflows when changing delivery internals. Independent filters and quiet +connections require a capable daemon; opening the app, reading history and using terminals do not. +Pre-registry workspace grouping and legacy event normalization belong inside the client boundary. 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 diff --git a/packages/app/e2e/browser/owned-subscriptions-old-daemon.spec.ts b/packages/app/e2e/browser/owned-subscriptions-old-daemon.spec.ts index 0f0109ee3c..1bd1f549cf 100644 --- a/packages/app/e2e/browser/owned-subscriptions-old-daemon.spec.ts +++ b/packages/app/e2e/browser/owned-subscriptions-old-daemon.spec.ts @@ -1,14 +1,42 @@ import { randomUUID } from "node:crypto"; +import { submitMessage, expectComposerEditable } from "../support/helpers/composer"; +import { + focusTerminalSurface, + typeInTerminal, + getTerminalBufferText, +} from "../support/helpers/terminal-perf"; +import { buildHostWorkspaceRoute } from "../../src/utils/host-routes"; import { metroTest as test, expect } from "../support/fixtures"; import { buildCreateAgentPreferences, buildSeededHost } from "../support/helpers/daemon-registry"; import { startIsolatedHostDaemon } from "../support/helpers/isolated-host-daemon"; -import { buildAgentRoute } from "../support/helpers/mock-agent"; +import type { MockAgentWorkspace } from "../support/helpers/mock-agent"; import { seedWorkspace } from "../support/helpers/seed-client"; +import { + expectTimelinePromptVisible, + holdOlderHistoryPages, + openAgentTimeline, + scrollThroughOlderHistoryPages, +} from "../support/helpers/timeline-pagination"; -test("requires a host update before observing a published 0.2.5 daemon", async ({ page }) => { +test("does not repeat an assistant block when the current app paginates a published 0.2.5 daemon", async ({ + page, +}) => { test.setTimeout(120_000); const serverId = `srv_old_pagination_${randomUUID().replaceAll("-", "").slice(0, 12)}`; const daemon = await startIsolatedHostDaemon(serverId, { publishedVersion: "0.2.5" }); + const observationSockets = new Set(); + page.on("websocket", (socket) => { + socket.on("framesent", ({ payload }) => { + if (typeof payload !== "string") return; + const frame = JSON.parse(payload); + if ( + frame.type === "session" && + frame.message.type === "fetch_agents_request" && + frame.message.subscribe + ) + observationSockets.add(socket); + }); + }); const workspace = await seedWorkspace({ repoPrefix: "timeline-old-daemon-pagination-", port: daemon.port, @@ -21,16 +49,32 @@ test("requires a host update before observing a published 0.2.5 daemon", async ( modeId: "load-test", model: "ten-second-stream", }); - const requests: unknown[] = []; - page.on("websocket", (socket) => - socket.on("framesent", ({ payload }) => { - if (typeof payload !== "string") return; - const frame = JSON.parse(payload); - if (frame.type === "session" && frame.message.type !== "ping") requests.push(frame.message); - }), - ); + const agent: MockAgentWorkspace = { + agentId: createdAgent.id, + workspaceId: workspace.workspaceId, + cwd: workspace.repoPath, + client: workspace.client, + cleanup: workspace.cleanup, + }; try { + for (let index = 0; index < 40; index += 1) { + await agent.client.sendAgentMessage( + agent.agentId, + `timeline-pagination-older-turn-${index}: emit 1 coalesced agent stream updates`, + ); + await agent.client.waitForFinish(agent.agentId, 15_000); + } + await agent.client.sendAgentMessage(agent.agentId, "build a realistic long mock timeline"); + await agent.client.waitForFinish(agent.agentId, 20_000); + for (let index = 0; index < 20; index += 1) { + await agent.client.sendAgentMessage( + agent.agentId, + `timeline-pagination-turn-${index}: emit 1 coalesced agent stream updates`, + ); + await agent.client.waitForFinish(agent.agentId, 15_000); + } + const host = buildSeededHost({ serverId, endpoint: `127.0.0.1:${daemon.port}`, @@ -45,20 +89,50 @@ test("requires a host update before observing a published 0.2.5 daemon", async ( { seededHost: host, preferences: buildCreateAgentPreferences() }, ); - await page.goto(buildAgentRoute(workspace.workspaceId, createdAgent.id, serverId)); - const updateHost = page.getByText("Update the host to use this version of Paseo.", { - exact: true, + const history = await holdOlderHistoryPages(page, agent, daemon.port); + await openAgentTimeline(page, agent, serverId); + await expectTimelinePromptVisible( + page, + "timeline-pagination-turn-19: emit 1 coalesced agent stream updates", + ); + await scrollThroughOlderHistoryPages(page, 3, history); + + history.expectRepeatedEntries(); + await history.expectOwnedTextRendered("Now I have a clearer picture."); + expect(observationSockets.size).toBe(1); + + await test.step("send a live turn after reconnecting the old daemon", async () => { + await daemon.restart(); + await expect.poll(() => observationSockets.size, { timeout: 30_000 }).toBe(2); + await expectComposerEditable(page); + await submitMessage( + page, + "compatibility-after-reconnect: emit 1 coalesced agent stream updates", + ); + await expectTimelinePromptVisible( + page, + "compatibility-after-reconnect: emit 1 coalesced agent stream updates", + ); + await agent.client.waitForFinish(agent.agentId, 20_000); + await page.screenshot({ path: test.info().outputPath("old-daemon-live-reconnect.png") }); + }); + + await test.step("open a terminal and receive its output", async () => { + const terminal = await agent.client.createTerminal(agent.cwd, "Compatibility terminal"); + expect(terminal.error).toBeNull(); + const terminalId = terminal.terminal!.id; + const route = buildHostWorkspaceRoute(serverId, workspace.workspaceId); + await page.goto(`${route}?open=${encodeURIComponent(`terminal:${terminalId}`)}`); + await focusTerminalSurface(page); + await typeInTerminal(page, "printf 'compatibility-%s\\n' terminal-output\n"); + await expect + .poll(() => getTerminalBufferText(page), { timeout: 15_000 }) + .toContain("compatibility-terminal-output"); + await page.screenshot({ path: test.info().outputPath("old-daemon-terminal.png") }); + await agent.client.killTerminal(terminalId); }); - await expect(updateHost).toHaveCount(1); - await expect(updateHost).toBeVisible(); - await expect(page.getByRole("button", { name: "Manage host", exact: true })).toBeVisible(); - expect(requests).toEqual([]); - await page.reload(); - await expect(updateHost).toHaveCount(1); - await expect(updateHost).toBeVisible(); - expect(requests).toEqual([]); } finally { - await workspace.cleanup(); + await agent.cleanup(); await daemon.close(); } }); diff --git a/packages/app/src/runtime/directory-sync/index.ts b/packages/app/src/runtime/directory-sync/index.ts index 9d457f63b2..51e8b37397 100644 --- a/packages/app/src/runtime/directory-sync/index.ts +++ b/packages/app/src/runtime/directory-sync/index.ts @@ -487,13 +487,8 @@ export class DirectorySync { try { await this.waitForSessionMetadata(client, source); const serverInfo = useSessionStore.getState().sessions[this.serverId]?.serverInfo; - if (serverInfo?.features?.workspaceMultiplicity !== true) { - const deltas = this.workspaceTransactions.fail(transaction); - if (deltas) for (const delta of deltas) this.applyWorkspaceDelta(delta); - return; - } - const supportsProjectList = serverInfo.features?.projectList === true; - const supportsDirectorySync = serverInfo.features?.directorySync === true; + const supportsProjectList = serverInfo?.features?.projectList === true; + const supportsDirectorySync = serverInfo?.features?.directorySync === true; if (supportsProjectList) { await this.fetchProjectSnapshot(client, source, transaction, supportsDirectorySync); } diff --git a/packages/app/src/runtime/host-runtime.test.ts b/packages/app/src/runtime/host-runtime.test.ts index 52686df9c6..fbaac8b7df 100644 --- a/packages/app/src/runtime/host-runtime.test.ts +++ b/packages/app/src/runtime/host-runtime.test.ts @@ -570,7 +570,7 @@ class BrowserClientLifecycle { } describe("HostRuntimeController", () => { - it("gates an old host before publishing the app client or mounting observations", async () => { + it("publishes an old host and mounts observations through the client interface", async () => { const host = makeHost(); const client = new FakeDaemonClient(); client.ownedSubscriptions = false; @@ -595,12 +595,12 @@ describe("HostRuntimeController", () => { 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.", + connectionStatus: "online", + client, + lastError: null, }); - expect(mounts).toBe(0); - expect(publishedClients.every((value) => value === null)).toBe(true); + expect(mounts).toBe(1); + expect(publishedClients).toContain(client); expect(client.fetchAgentsCalls).toEqual([]); stop(); await controller.stop(); diff --git a/packages/app/src/runtime/host-runtime.ts b/packages/app/src/runtime/host-runtime.ts index 6f4d4bd6ad..416cff911e 100644 --- a/packages/app/src/runtime/host-runtime.ts +++ b/packages/app/src/runtime/host-runtime.ts @@ -1273,24 +1273,13 @@ export class HostRuntimeController { /* 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), diff --git a/packages/cli/src/commands/agent/attach.ts b/packages/cli/src/commands/agent/attach.ts index 36cb8e4fc0..522905c033 100644 --- a/packages/cli/src/commands/agent/attach.ts +++ b/packages/cli/src/commands/agent/attach.ts @@ -145,9 +145,8 @@ 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.subscription_restored") { + console.log("\n[Reconnected; live output resumed. Events may have been missed.]"); } else if (message.type === "agent.timeline.error") { console.error(`Timeline observation stopped: ${message.payload.error}`); } else { diff --git a/packages/cli/src/commands/agent/logs.ts b/packages/cli/src/commands/agent/logs.ts index d32ab549a8..0226ed2b06 100644 --- a/packages/cli/src/commands/agent/logs.ts +++ b/packages/cli/src/commands/agent/logs.ts @@ -203,12 +203,8 @@ async function runFollowMode( 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)); + if (message.type === "agent.timeline.subscription_restored") { + console.log("\n[Reconnected; live output resumed. Events may have been missed.]"); return; } if (message.payload.event.type === "timeline") { diff --git a/packages/client/examples/events-and-timeline.ts b/packages/client/examples/events-and-timeline.ts index a56a826677..f0269f87b9 100644 --- a/packages/client/examples/events-and-timeline.ts +++ b/packages/client/examples/events-and-timeline.ts @@ -30,8 +30,8 @@ export async function subscribeToEvents( }); const unsubscribeTimeline = client.agents.ref(agentId).timeline.subscribe((event) => { - if (event.event.type === "snapshot") { - console.log("Replace recent history", event.event.page.epoch, event.event.page.entries); + if (event.event.type === "subscription_restored") { + console.log("Live subscription restored; request any missed history explicitly."); } else if (event.event.type === "error") { console.error(event.event.error); } diff --git a/packages/client/src/connection.test.ts b/packages/client/src/connection.test.ts index 5a87b64e9c..a3d12e48f6 100644 --- a/packages/client/src/connection.test.ts +++ b/packages/client/src/connection.test.ts @@ -6,6 +6,10 @@ function connection( options: { acknowledgeSubscriptions?: boolean; ownedSubscriptions?: boolean; + ownedCapability?: boolean; + workspaceMultiplicity?: boolean; + broadcasts?: boolean; + browserHost?: { hostKind: string; supportedCommands: string[] }; acknowledgeTimelineReads?: boolean; } = {}, ) { @@ -40,8 +44,9 @@ function connection( version: null, features: { ...(options.ownedSubscriptions === false ? {} : { ownedSubscriptions: true }), - selectiveAgentTimeline: true, - explicitEventSubscriptions: true, + workspaceMultiplicity: options.workspaceMultiplicity ?? true, + selectiveAgentTimeline: !options.broadcasts, + explicitEventSubscriptions: !options.broadcasts, }, }, }, @@ -112,6 +117,12 @@ function connection( const client = new DaemonClient({ url: "ws://test", clientId: "test", + capabilities: { + [CLIENT_CAPS.browserHost]: options.browserHost, + ...(options.ownedCapability === undefined + ? {} + : { [CLIENT_CAPS.ownedSubscriptions]: options.ownedCapability }), + }, transportFactory: () => transport, reconnect: { enabled: false }, }); @@ -382,19 +393,30 @@ test("failed terminal bootstrap reports the domain error without reconnecting or } }); -test("an old host produces an update-host error without sending a legacy subscription", async () => { +test("older hosts share event demand on one connection and release only their own listeners", 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); + const first = h.client.observeEvents(["project.update"]); + const second = h.client.observeEvents(["project.update"]); + await Promise.all([first.ready, second.ready]); + const received: string[] = []; + first.subscribe({ snapshot: () => {}, update: () => received.push("first") }); + second.subscribe({ snapshot: () => {}, update: () => received.push("second") }); + h.receive({ type: "project.update", payload: { kind: "remove", projectId: "project" } }); + expect(received).toEqual(["first", "second"]); + await first.release(); + h.receive({ type: "project.update", payload: { kind: "remove", projectId: "project" } }); + expect(received).toEqual(["first", "second", "second"]); + expect(h.sent.at(-1)?.message?.events).toEqual(["project.update"]); + await second.release(); + expect(h.sent.at(-1)?.message?.events).toEqual([]); + expect(h.sent.filter((frame) => frame.type === "hello")).toHaveLength(1); + expect( + h.sent.filter((frame) => frame.message?.type === "subscription.release.request"), + ).toEqual([]); } finally { await h.client.close(); } @@ -474,9 +496,13 @@ function timelinePage(requestId: string | undefined, epoch: string, seq: number) }; } -test("timeline recovery snapshots precede live updates for independent owners and survive epoch changes", async () => { +test.each([ + { name: "modern", ownedSubscriptions: true, broadcasts: false }, + { name: "legacy selective", ownedSubscriptions: false, broadcasts: false }, + { name: "legacy broadcast", ownedSubscriptions: false, broadcasts: true }, +])("$name timelines restore live delivery without requesting history", async (mode) => { const { createPaseoApi } = await import("./index"); - const h = connection({ acknowledgeTimelineReads: false }); + const h = connection(mode); const api = createPaseoApi(h.client); const received: import("./index").PaseoAgentTimelineEvent[][] = [[], [], []]; try { @@ -487,118 +513,154 @@ test("timeline recovery snapshots precede live updates for independent owners an 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: { + expect(received).toEqual([[], [], []]); + for (let cycle = 0; cycle < 2; cycle++) { + const oldIds = owners.map((owner) => owner.subscriptionId); + h.disconnect(); + await owners[2].release(); + const reconnected = h.client.connect(); + h.open(); + await reconnected; + await expect.poll(() => owners[0].subscriptionId).not.toBe(oldIds[0]); + expect( + h.sent.filter((frame) => frame.message?.type === "fetch_agent_timeline_request"), + ).toEqual([]); + for (const index of [0, 1]) { + expect(owners[index].subscriptionId).not.toBeNull(); + expect(received[index].at(-1)).toEqual({ 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 }); + event: { type: "subscription_restored" }, + }); + } + const live = { + agentId: "agent", + timestamp: new Date(0).toISOString(), + event: { type: "turn_completed", provider: "codex" }, + }; + if (mode.ownedSubscriptions) { + for (const owner of owners.slice(0, 2)) + h.receive({ + type: "agent_stream", + payload: { ...live, subscriptionId: owner.subscriptionId }, + }); + } else h.receive({ type: "agent_stream", payload: live }); + for (const events of received.slice(0, 2)) + expect(events.map((update) => update.event.type)).toEqual( + Array.from({ length: cycle + 1 }, () => [ + "subscription_restored", + "turn_completed", + ]).flat(), + ); + expect(received[2]).toEqual([]); } - 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 () => { +test("a consumer chooses its recovery cursor and a failed read leaves live delivery active", async () => { + const { createPaseoApi } = await import("./index"); const h = connection({ acknowledgeTimelineReads: false }); - const messages: unknown[] = []; + const api = createPaseoApi(h.client); + const received: import("./index").PaseoAgentTimelineEvent[] = []; + let read: Promise | undefined; try { const connected = h.client.connect(); h.open(); await connected; - const owner = h.client.subscribeAgentTimeline("agent", (message) => messages.push(message)); + const timeline = api.agents.ref("agent").timeline; + const owner = timeline.subscribe((message) => { + received.push(message); + if (message.event.type === "subscription_restored") { + read = timeline.refetch({ + direction: "after", + cursor: { epoch: "cached", seq: 42 }, + limit: 7, + }); + void read.catch(() => {}); + } + }); 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++) + await expect.poll(() => read !== undefined).toBe(true); + const requests = h.sent.filter( + (frame) => frame.message?.type === "fetch_agent_timeline_request", + ); + expect(requests).toHaveLength(1); + expect(requests[0].message).toMatchObject({ + direction: "after", + cursor: { epoch: "cached", seq: 42 }, + limit: 7, + }); + // A slow consumer-owned history read must not buffer or filter live events. + for (let seq = 43; seq < 173; seq++) h.receive({ type: "agent_stream", payload: { agentId: "agent", subscriptionId: owner.subscriptionId, seq, - epoch: "old", + epoch: "cached", timestamp: new Date(0).toISOString(), - event: { - type: "timeline", - provider: "codex", - item: { type: "assistant_message", text: "piece" }, - }, + event: { type: "turn_completed", provider: "codex" }, }, }); - h.receive(timelinePage(reads()[0].message?.requestId, "old", 1)); - await expect.poll(() => reads().length).toBe(2); - expect(messages).toEqual([]); + expect(received).toHaveLength(131); 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" } } }, + expect(received.at(-1)?.event).toEqual({ type: "replacement", epoch: "new" }); + const page = timelinePage(requests[0].message?.requestId, "cached", 42); + h.receive({ ...page, payload: { ...page.payload, error: "History unavailable" } }); + await expect(read).rejects.toThrow("History unavailable"); + expect(owner.subscriptionId).not.toBeNull(); + expect(received.some((message) => message.event.type === "error")).toBe(false); + expect( + h.sent.filter((frame) => frame.message?.type === "fetch_agent_timeline_request"), + ).toHaveLength(1); + await owner.release(); + } finally { + await api.dispose(); + await h.client.close(); + } +}); + +test("subscription-restored notification waits for the new membership acknowledgement", async () => { + const h = connection({ acknowledgeSubscriptions: false }); + const received: unknown[] = []; + const acknowledge = (id: string) => + h.receive({ + type: "agent.timeline.set_subscription.response", + payload: { + agentIds: ["agent"], + requestId: h.sent.at(-1)?.message?.requestId, + subscriptionId: id, + }, + }); + try { + const connected = h.client.connect(); + h.open(); + await connected; + const owner = h.client.subscribeAgentTimeline("agent", (message) => received.push(message)); + acknowledge("first"); + await owner.ready; + h.disconnect(); + const reconnected = h.client.connect(); + h.open(); + await reconnected; + expect(received).toEqual([]); + acknowledge("second"); + expect(received).toEqual([ + { + type: "agent.timeline.subscription_restored", + payload: { agentId: "agent", subscriptionId: "second" }, + }, ]); await owner.release(); } finally { @@ -606,31 +668,361 @@ test("timeline recovery replaces an obsolete read after bounded overflow and epo } }); -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" }, +function legacyAgent(input: { + id: string; + cwd: string; + status?: "idle" | "running"; + updatedAt?: string; + projectRoot?: string; +}) { + const updatedAt = input.updatedAt ?? "2026-06-18T10:00:00.000Z"; + return { + agent: { + id: input.id, + provider: "mock", + cwd: input.cwd, + model: null, + createdAt: updatedAt, + updatedAt, + lastUserMessageAt: null, + status: input.status ?? "idle", + capabilities: { + supportsStreaming: true, + supportsSessionPersistence: true, + supportsDynamicModes: true, + supportsMcpServers: true, + supportsReasoningStream: true, + supportsToolInvocations: true, }, + currentModeId: null, + availableModes: [], + pendingPermissions: [], + persistence: null, + title: null, + labels: {}, + }, + project: { + projectKey: "/repo", + projectName: "repo", + workspaceName: "app", + checkout: { + cwd: input.cwd, + isGit: true, + currentBranch: "main", + remoteUrl: "git@example.com:repo/app.git", + worktreeRoot: input.cwd, + isPaseoOwnedWorktree: false, + mainRepoRoot: input.projectRoot ?? "/repo", + }, + }, + }; +} + +test.each([ + { cwd: "/repo/app", id: "/repo/app", root: "/repo" }, + { cwd: "C:\\repo\\app", id: "C:/repo/app", root: "C:\\repo" }, + { cwd: "C:\\", id: "C:", root: "C:\\" }, + { cwd: "C:/", id: "C:", root: "C:/" }, + { cwd: "\\\\server\\share\\", id: "//server/share", root: "\\\\server\\share\\" }, + { cwd: "\\\\?\\C:\\repo\\app", id: "//?/C:/repo/app", root: "\\\\?\\C:\\repo" }, + { cwd: "/repo/trailing space ", id: "/repo/trailing space", root: "/repo/ " }, + { cwd: "/repo/back\\slash", id: "/repo/back/slash", root: "/repo" }, +])( + "old workspaces preserve daemon filesystem paths ($cwd) and stable IDs", + async ({ cwd, id, root }) => { + const h = connection({ ownedSubscriptions: false, workspaceMultiplicity: false }); + try { + const connecting = h.client.connect(); + h.open(); + await connecting; + const workspaces = h.client.observeWorkspaces(); + const request = h.sent.at(-1)!.message!; + expect(request.type).toBe("fetch_agents_request"); + h.receive({ + type: "fetch_agents_response", + payload: { + requestId: request.requestId, + entries: [legacyAgent({ id: "agent", cwd, projectRoot: root, status: "idle" })], + pageInfo: { hasMore: false, nextCursor: null, prevCursor: null }, + }, + }); + expect((await workspaces.ready).entries).toEqual([ + expect.objectContaining({ + id, + workspaceDirectory: cwd, + projectRootPath: root, + projectId: "/repo", + status: "done", + }), + ]); + const updates: unknown[] = []; + workspaces.subscribe({ snapshot: () => {}, update: (message) => updates.push(message) }); + const changed = legacyAgent({ id: "agent", cwd, projectRoot: root, status: "running" }); + h.receive({ type: "agent_update", payload: { kind: "upsert", ...changed } }); + expect(updates).toEqual([ + expect.objectContaining({ + type: "workspace_update", + payload: expect.objectContaining({ + kind: "upsert", + workspace: expect.objectContaining({ + id, + workspaceDirectory: cwd, + projectRootPath: root, + status: "running", + }), + }), + }), + ]); + const fetching = h.client.fetchAgent("agent"); + const detail = h.sent.at(-1)!.message!; + h.receive({ + type: "fetch_agent_response", + payload: { requestId: detail.requestId, ...changed, error: null }, + }); + expect((await fetching).agent?.workspaceId).toBe(id); + expect(h.sent.filter((frame) => frame.type === "hello")).toHaveLength(1); + await workspaces.release(); + } finally { + await h.client.close(); + } + }, +); + +test("legacy workspace pages contain only that page's groups and retain earlier live state", async () => { + const h = connection({ ownedSubscriptions: false, workspaceMultiplicity: false }); + try { + const connecting = h.client.connect(); + h.open(); + await connecting; + const workspaces = h.client.observeWorkspaces(); + h.receive({ + type: "fetch_agents_response", + payload: { + requestId: h.sent.at(-1)!.message!.requestId, + entries: [legacyAgent({ id: "first", cwd: "/first", status: "running" })], + pageInfo: { hasMore: true, nextCursor: "next-page", prevCursor: null }, + }, + }); + const first = await workspaces.ready; + const next = h.client.fetchWorkspaces({ + page: { cursor: first.pageInfo.nextCursor!, limit: 1 }, + }); + h.receive({ + type: "fetch_agents_response", + payload: { + requestId: h.sent.at(-1)!.message!.requestId, + entries: [legacyAgent({ id: "second", cwd: "/second" })], + pageInfo: { hasMore: false, nextCursor: null, prevCursor: "previous-page" }, + }, + }); + const second = await next; + expect([...first.entries, ...second.entries].map((workspace) => workspace.id)).toEqual([ + "/first", + "/second", ]); + const updates: unknown[] = []; + workspaces.subscribe({ snapshot: () => {}, update: (message) => updates.push(message) }); + // Earlier pages must remain in the aggregate: this idle sibling cannot + // replace the running status of the first workspace. + h.receive({ + type: "agent_update", + payload: { kind: "upsert", ...legacyAgent({ id: "sibling", cwd: "/first" }) }, + }); + expect(updates).toEqual([]); + h.receive({ type: "agent_update", payload: { kind: "remove", agentId: "first" } }); + expect(updates).toEqual([ + expect.objectContaining({ + type: "workspace_update", + payload: expect.objectContaining({ + kind: "upsert", + workspace: expect.objectContaining({ id: "/first", status: "done" }), + }), + }), + ]); + await workspaces.release(); + } finally { + await h.client.close(); + } +}); + +test("old attention stream events reach the current notification interface", async () => { + const h = connection({ ownedSubscriptions: false }); + try { + const connecting = h.client.connect(); + h.open(); + await connecting; + const observation = h.client.observeEvents(["agent_attention_required"]); + await observation.ready; + const received: unknown[] = []; + observation.subscribe({ snapshot: () => {}, update: (message) => received.push(message) }); + h.receive({ + type: "agent_stream", + payload: { + agentId: "agent", + timestamp: "2026-09-11T00:00:00Z", + event: { + type: "attention_required", + provider: "mock", + reason: "finished", + timestamp: "2026-09-11T00:00:00Z", + shouldNotify: true, + }, + }, + }); + expect(received).toEqual([ + { + type: "agent_attention_required", + payload: { + agentId: "agent", + reason: "finished", + timestamp: "2026-09-11T00:00:00Z", + shouldNotify: true, + subscriptionId: observation.subscriptionId, + }, + }, + ]); + await observation.release(); + } finally { + await h.client.close(); + } +}); + +test("broadcast-only hosts expose local timeline readiness without sending an unsupported RPC", async () => { + const h = connection({ ownedSubscriptions: false, broadcasts: true }); + try { + const connected = h.client.connect(); + h.open(); + await connected; + const observation = h.client.observeTimeline(["agent"]); + expect(await observation.ready).toEqual({ + agentIds: ["agent"], + requestId: expect.any(String), + subscriptionId: observation.subscriptionId, + }); + expect(h.sent).toHaveLength(1); + await observation.release(); + expect(h.sent).toHaveLength(1); + } finally { + await h.client.close(); + } +}); + +test("old browser hosting requires the registration sent in hello", async () => { + const h = connection({ ownedSubscriptions: false }); + try { + const connected = h.client.connect(); + h.open(); + await connected; + const observation = h.client.registerBrowserHost({ + hostKind: "test", + supportedCommands: ["list_tabs"], + }); + await expect(observation.ready).rejects.toThrow("browser_host"); + expect(h.sent).toHaveLength(1); + } finally { + await h.client.close(); + } +}); + +test("old timeline observers retain shared membership and restore surviving interests", async () => { + const h = connection({ ownedSubscriptions: false }); + try { + const connected = h.client.connect(); + h.open(); + await connected; + const app = h.client.observeTimeline(["app"]); + const plugin = h.client.observeTimeline(["plugin"]); + await Promise.all([app.ready, plugin.ready]); + expect(h.sent.at(-1)?.message?.agentIds).toEqual(["app", "plugin"]); + await plugin.release(); + expect(h.sent.at(-1)?.message?.agentIds).toEqual(["app"]); + h.disconnect(); + const reconnecting = h.client.connect(); + h.open(); + await reconnecting; + await expect.poll(() => h.sent.at(-1)?.message?.agentIds).toEqual(["app"]); + await app.release(); + expect(h.sent.at(-1)?.message?.agentIds).toEqual([]); + expect(h.sent.some((frame) => frame.message?.type === "subscription.release.request")).toBe( + false, + ); + } finally { + await h.client.close(); + } +}); + +test("old browser hosts attach to their hello registration without another connection", async () => { + const registration = { + hostKind: "desktop app", + supportedCommands: ["list_tabs"] as ["list_tabs"], + }; + const h = connection({ ownedSubscriptions: false, browserHost: registration }); + try { + const connected = h.client.connect(); + h.open(); + await connected; + const observation = h.client.registerBrowserHost(registration); + expect(await observation.ready).toEqual({ + requestId: expect.any(String), + subscriptionId: observation.subscriptionId, + }); + await observation.release(); + expect(h.sent).toHaveLength(1); + } finally { + await h.client.close(); + } +}); + +test("delivery follows the negotiated client capability as well as the server feature", async () => { + const h = connection({ ownedCapability: false }); + try { + const connected = h.client.connect(); + h.open(); + await connected; + const observation = h.client.observeEvents(["project.update"]); + expect((await observation.ready).subscriptionId).toMatch(/^legacy:/); + await observation.release(); + expect(h.sent.at(-1)?.message).toMatchObject({ + type: "session.events.set_subscription.request", + events: [], + }); + } finally { + await h.client.close(); + } +}); + +test("subscribeFile returns its initial version without calling the change callback", async () => { + const h = connection(); + try { + const connected = h.client.connect(); + h.open(); + await connected; + const changes: unknown[] = []; + const initial = { status: "missing" as const, cwd: "/repo", path: "file.txt" }; + const subscribing = h.client.subscribeFile({ cwd: "/repo", path: "file.txt" }, (value) => + changes.push(value), + ); + h.receive({ + type: "fs.file.subscribe.response", + payload: { + requestId: h.sent.at(-1)!.message!.requestId, + subscriptionId: "file-owner", + initial, + }, + }); + const file = await subscribing; + expect(file.initial).toEqual(initial); + expect(changes).toEqual([]); + const version = { + status: "ready", + cwd: "/repo", + path: "file.txt", + size: 1, + modifiedAt: "2026-09-11T00:00:00Z", + }; + h.receive({ type: "fs.file.update", payload: { subscriptionId: "file-owner", version } }); + expect(changes).toEqual([version]); + await file.unsubscribe(); } finally { await h.client.close(); } diff --git a/packages/client/src/connection/index.ts b/packages/client/src/connection/index.ts index 8d36083c99..c0681852d7 100644 --- a/packages/client/src/connection/index.ts +++ b/packages/client/src/connection/index.ts @@ -1,6 +1,127 @@ export { OwnedSubscriptions, type OwnedSubscription, type SubscriptionObserver } from "./owned.js"; +import { + OwnedSubscriptions, + type OwnedSubscription, + type OwnedSubscriptionsHost, +} from "./owned.js"; +import { LegacySubscriptions, type ObservationRequest } from "./legacy.js"; +import { + type SessionInboundMessage, + type SessionOutboundMessage, + type ServerInfoStatusPayload, +} from "@getpaseo/protocol/messages"; import { CLIENT_CAPS, type ClientCapability } from "@getpaseo/protocol/client-capabilities"; +export class ConnectionSubscriptions extends OwnedSubscriptions { + private legacy: LegacySubscriptions | null = null; + + constructor( + private readonly connection: OwnedSubscriptionsHost & { + send(message: ObservationRequest): Promise; + }, + ) { + super({ + ...connection, + release: async (id) => { + if (!this.legacy) return connection.release(id); + const message = this.legacy.release(id); + if (message) await connection.send(message); + }, + }); + } + + override restore( + info?: ServerInfoStatusPayload, + capabilities?: Partial>, + ): void { + if (info) { + const clientCapabilities = capabilities ?? DEFAULT_CLIENT_CAPABILITIES; + const owned = + info.features?.ownedSubscriptions === true && + clientCapabilities[CLIENT_CAPS.ownedSubscriptions] === true; + this.legacy = null; + if (!owned) { + this.legacy = new LegacySubscriptions( + info, + this.connection.send, + capabilities?.[CLIENT_CAPS.browserHost], + ); + } + } + super.restore(); + } + + override disconnected(): void { + super.disconnected(); + this.legacy = null; + } + + observeRequest( + query: ObservationRequest, + request: (query: ObservationRequest, accept: (snapshot: T) => void) => Promise, + signal?: AbortSignal, + failed?: () => void, + ): OwnedSubscription { + return super.observe( + async (accept) => { + const legacy = this.legacy; + if (!legacy) return request(query, accept); + const interest = legacy.start(query); + const snapshot = (payload: T): T & { subscriptionId: string } => ({ + ...payload, + subscriptionId: interest.id, + }); + try { + const message = legacy.request(interest); + if (!message) { + // Broadcast-era hosts have no acknowledgement. Readiness means the + // local listener is attached; no server membership is being claimed. + const value = snapshot({ + requestId: interest.id, + ...(query.type === "agent.timeline.set_subscription.request" + ? { agentIds: query.agentIds } + : {}), + } as T); + accept(value); + return value; + } + return snapshot(await request(message, (payload) => accept(snapshot(payload)))); + } catch (error) { + const release = legacy.release(interest.id); + if (release && this.legacy === legacy) + await this.connection.send(release).catch(this.connection.failed); + throw error; + } + }, + signal, + failed, + ); + } + + override owns(message: SessionOutboundMessage): boolean { + return this.legacy ? this.legacy.owns(message) : super.owns(message); + } + + override receive(message: SessionOutboundMessage): void { + if (this.legacy) this.legacy.receive(message, (update) => super.receive(update)); + else super.receive(message); + } + + normalize(message: SessionOutboundMessage): SessionOutboundMessage { + return this.legacy?.normalize(message) ?? message; + } + + prepareRequest(message: SessionInboundMessage) { + return ( + this.legacy?.prepareRequest(message) ?? { + message, + receive: (value: SessionOutboundMessage) => value, + finish: async () => {}, + } + ); + } +} + // 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 = { @@ -21,7 +142,7 @@ export const DEFAULT_CLIENT_CAPABILITIES = { [CLIENT_CAPS.explicitEventSubscriptions]: true, } satisfies Record, true>; -/** Calling releases demand; ready acknowledges the initial daemon membership. */ +/** Calling releases demand; ready waits for membership, or local attachment on broadcast hosts. */ export type TimelineSubscription = (() => void) & { readonly ready: Promise; readonly subscriptionId: string | null; diff --git a/packages/client/src/connection/legacy-workspaces.ts b/packages/client/src/connection/legacy-workspaces.ts new file mode 100644 index 0000000000..2cf90423b5 --- /dev/null +++ b/packages/client/src/connection/legacy-workspaces.ts @@ -0,0 +1,176 @@ +import { AgentSnapshotPayloadSchema } from "@getpaseo/protocol/messages"; +import type { AgentSnapshotPayload, SessionOutboundMessage } from "@getpaseo/protocol/messages"; +import { + deriveAgentStateBucket, + getWorkspaceStateBucketPriority, +} from "@getpaseo/protocol/agent-state-bucket"; + +type AgentEntry = Extract< + SessionOutboundMessage, + { type: "fetch_agents_response" } +>["payload"]["entries"][number]; +type Workspace = Extract< + SessionOutboundMessage, + { type: "fetch_workspaces_response" } +>["payload"]["entries"][number]; +type WorkspaceUpdate = Extract; + +// Preserve the pre-registry app's identity format. This is an opaque ID, never +// a filesystem path: converting C:\ to C: changes a drive root into a relative path. +function legacyWorkspaceId(value: string): string { + return value.trim().replace(/\\/g, "/").replace(/\/+$/, "") || "/"; +} + +// COMPAT(legacyWorkspaceDaemon): restored in v0.8.0; remove after 2027-03-11 once daemon floor >= v0.1.97. +// The pre-registry app grouped agents by checkout path. Keep that representation +// at the client edge, including live updates, so app workflows need no version branch. +export class LegacyWorkspaces { + private readonly agents = new Map(); + + normalize(message: SessionOutboundMessage): SessionOutboundMessage { + const stamp = (agent: AgentSnapshotPayload): AgentSnapshotPayload => ({ + ...agent, + workspaceId: agent.workspaceId ?? legacyWorkspaceId(agent.cwd), + }); + if (message.type === "fetch_agents_response") + return { + ...message, + payload: { + ...message.payload, + entries: message.payload.entries.map((entry) => ({ + ...entry, + agent: stamp(entry.agent), + })), + }, + }; + if (message.type === "agent_update" && message.payload.kind === "upsert") + return { ...message, payload: { ...message.payload, agent: stamp(message.payload.agent) } }; + if ( + (message.type === "fetch_agent_response" || + message.type === "fetch_agent_timeline_response" || + message.type === "cancel_agent_response") && + message.payload.agent + ) { + return { + ...message, + payload: { ...message.payload, agent: stamp(message.payload.agent) }, + } as SessionOutboundMessage; + } + if ( + message.type === "status" && + (message.payload.status === "agent_created" || message.payload.status === "agent_resumed") + ) { + return { + ...message, + payload: { + ...message.payload, + agent: stamp(AgentSnapshotPayloadSchema.parse(message.payload.agent)), + }, + }; + } + return message; + } + + read(entries: AgentEntry[], reset: boolean): Workspace[] { + if (reset) this.agents.clear(); + for (const entry of entries) this.agents.set(entry.agent.id, entry); + const pageIds = new Set(entries.map(workspaceId)); + return [...this.workspaces().values()].filter((workspace) => pageIds.has(workspace.id)); + } + + update(message: SessionOutboundMessage): WorkspaceUpdate[] { + if (message.type !== "agent_update") return []; + const before = this.workspaces(); + const update = message.payload; + if (update.kind === "remove") this.agents.delete(update.agentId); + else { + const project = update.project ?? this.agents.get(update.agent.id)?.project; + if (update.agent.archivedAt) this.agents.delete(update.agent.id); + else if (project) this.agents.set(update.agent.id, { agent: update.agent, project }); + } + const after = this.workspaces(); + const changes: WorkspaceUpdate[] = []; + for (const [id, workspace] of after) { + if (JSON.stringify(before.get(id)) !== JSON.stringify(workspace)) + changes.push({ type: "workspace_update", payload: { kind: "upsert", workspace } }); + } + for (const id of before.keys()) + if (!after.has(id)) + changes.push({ type: "workspace_update", payload: { kind: "remove", id } }); + return changes; + } + + private workspaces(): Map { + const workspaces = new Map(); + for (const entry of this.agents.values()) { + const { agent, project } = entry; + const { checkout } = project; + const id = workspaceId(entry); + const status = deriveAgentStateBucket({ + status: agent.status, + pendingPermissionCount: agent.pendingPermissions.length, + requiresAttention: agent.requiresAttention, + attentionReason: agent.attentionReason, + }); + const existing = workspaces.get(id); + if ( + existing && + getWorkspaceStateBucketPriority(existing.status) <= getWorkspaceStateBucketPriority(status) + ) + continue; + workspaces.set(id, { + id, + projectId: project.projectKey, + projectDisplayName: project.projectName, + projectCustomName: null, + projectRootPath: checkout.mainRepoRoot ?? checkout.worktreeRoot ?? checkout.cwd, + workspaceDirectory: checkout.cwd, + projectKind: checkout.isGit ? "git" : "non_git", + workspaceKind: workspaceKind(checkout), + name: workspaceName(entry, id), + title: null, + status, + statusEnteredAt: agent.attentionTimestamp ?? agent.updatedAt, + activityAt: agent.updatedAt, + archivingAt: null, + diffStat: null, + scripts: [], + gitRuntime: gitRuntime(checkout), + githubRuntime: null, + project, + }); + } + return workspaces; + } +} + +function workspaceId(entry: AgentEntry): string { + return entry.agent.workspaceId ?? legacyWorkspaceId(entry.project.checkout.cwd); +} + +function workspaceKind(checkout: AgentEntry["project"]["checkout"]): Workspace["workspaceKind"] { + if (!checkout.isGit) return "directory"; + if (checkout.isPaseoOwnedWorktree) return "worktree"; + return "checkout"; +} + +function workspaceName(entry: AgentEntry, id: string): string { + const name = entry.project.workspaceName?.trim(); + if (name) return name; + const branch = entry.project.checkout.currentBranch?.trim(); + if (branch && branch !== "HEAD") return branch; + return id.slice(id.lastIndexOf("/") + 1); +} + +function gitRuntime(checkout: AgentEntry["project"]["checkout"]): Workspace["gitRuntime"] { + if (!checkout.isGit) return null; + return { + currentBranch: checkout.currentBranch, + remoteUrl: checkout.remoteUrl, + isPaseoOwnedWorktree: checkout.isPaseoOwnedWorktree, + isDirty: null, + aheadBehind: null, + aheadOfOrigin: null, + behindOfOrigin: null, + }; +} diff --git a/packages/client/src/connection/legacy.ts b/packages/client/src/connection/legacy.ts new file mode 100644 index 0000000000..5dec96e428 --- /dev/null +++ b/packages/client/src/connection/legacy.ts @@ -0,0 +1,282 @@ +import { SessionInboundMessageSchema } from "@getpaseo/protocol/messages"; +import { LegacyWorkspaces } from "./legacy-workspaces.js"; +import { BrowserAutomationHostCapabilitySchema } from "@getpaseo/protocol/browser-automation/capabilities"; +import type { + ServerInfoStatusPayload, + SessionInboundMessage, + SessionOutboundMessage, +} from "@getpaseo/protocol/messages"; + +export type ObservationRequest = { type: SessionInboundMessage["type"] } & Record; +interface Interest { + id: string; + query: ObservationRequest; +} + +// COMPAT(ownedSubscriptions): added in v0.8.0; remove after 2027-03-11 once daemon floor >= v0.8.0. +// Legacy directories retain their connection-wide, last-query-wins semantics. +// IDs below identify local listeners, not independent server subscriptions. +export class LegacySubscriptions { + private readonly interests = new Map(); + + private readonly workspaces: LegacyWorkspaces | null; + constructor( + private readonly info: ServerInfoStatusPayload, + private readonly send: (message: ObservationRequest) => Promise, + private readonly browserHost: unknown, + ) { + this.workspaces = info.features?.workspaceMultiplicity === true ? null : new LegacyWorkspaces(); + } + + normalize(message: SessionOutboundMessage): SessionOutboundMessage { + return this.workspaces?.normalize(message) ?? message; + } + + prepareRequest(message: SessionInboundMessage): { + message: SessionInboundMessage; + receive(message: SessionOutboundMessage): SessionOutboundMessage; + finish(): Promise; + } { + const identity = { + message, + receive: (value: SessionOutboundMessage) => value, + finish: async () => {}, + }; + const workspaces = this.workspaces; + if (workspaces && message.type === "fetch_workspaces_request") { + return { + ...identity, + message: SessionInboundMessageSchema.parse({ + type: "fetch_agents_request", + requestId: message.requestId, + scope: "active", + sort: [{ key: "updated_at", direction: "desc" }], + page: message.page, + subscribe: message.subscribe, + }), + receive: (value) => { + if (value.type !== "fetch_agents_response") return value; + if (value.payload.requestId !== message.requestId) return value; + return { + type: "fetch_workspaces_response", + payload: { + ...value.payload, + entries: workspaces.read(value.payload.entries, !message.page?.cursor), + emptyProjects: [], + }, + }; + }, + }; + } + if (message.type === "subscribe_terminals_request") { + return { + ...identity, + receive: (value) => { + if (value.type !== "terminals_changed" || value.payload.cwd !== message.cwd) return value; + return { ...value, payload: { ...value.payload, requestId: message.requestId } }; + }, + }; + } + if (message.type === "workspace.label.list.request" && !message.subscribe) { + return { + ...identity, + message: { ...message, subscribe: { subscriptionId: `legacy:${crypto.randomUUID()}` } }, + }; + } + if (message.type === "checkout.diff.get.request") { + const subscriptionId = `legacy:${crypto.randomUUID()}`; + return { + message: SessionInboundMessageSchema.parse({ + ...message, + type: "subscribe_checkout_diff_request", + subscriptionId, + }), + receive: (value) => { + if (value.type !== "subscribe_checkout_diff_response") return value; + if (value.payload.requestId !== message.requestId) return value; + return { ...value, type: "checkout.diff.get.response" }; + }, + finish: () => this.send({ type: "unsubscribe_checkout_diff_request", subscriptionId }), + }; + } + return identity; + } + + start(query: ObservationRequest): Interest { + const interest = { id: `legacy:${crypto.randomUUID()}`, query }; + this.interests.set(interest.id, interest); + return interest; + } + + request({ id, query }: Interest): ObservationRequest | null { + switch (query.type) { + case "fetch_agents_request": + case "fetch_workspaces_request": + case "workspace.label.list.request": + return { ...query, subscribe: { subscriptionId: id } }; + case "fs.file.subscribe.request": + case "subscribe_checkout_diff_request": + return { ...query, subscriptionId: id }; + case "agent.timeline.set_subscription.request": + if (!this.info.features?.selectiveAgentTimeline) return null; + return { ...query, agentIds: this.members(query.type, "agentIds") }; + case "session.events.set_subscription.request": + if (!this.info.features?.explicitEventSubscriptions) return null; + return { + ...query, + events: this.members(query.type, "events").filter(isLegacyEvent), + }; + case "browser.host.register.request": + if (!matchesBrowserRegistration(this.browserHost, query)) { + throw new Error( + "This daemon requires browser_host registration in the client's hello capabilities", + ); + } + return null; // Older hosts register in hello. + default: + return query; + } + } + + private members(type: ObservationRequest["type"], field: string): string[] { + const members = new Set(); + for (const interest of this.interests.values()) { + if (interest.query.type !== type) continue; + for (const value of interest.query[field] as string[]) members.add(value); + } + return [...members].sort(); + } + + release(id: string): ObservationRequest | null { + const interest = this.interests.get(id); + if (!interest) return null; + this.interests.delete(id); + const { query } = interest; + switch (query.type) { + case "agent.timeline.set_subscription.request": + case "session.events.set_subscription.request": + return this.request(interest); + case "fs.file.subscribe.request": + return { type: "fs.file.unsubscribe.request", subscriptionId: id }; + case "subscribe_checkout_diff_request": + return { type: "unsubscribe_checkout_diff_request", subscriptionId: id }; + case "subscribe_terminal_request": + if ( + [...this.interests.values()].some( + (item) => item.query.type === query.type && item.query.terminalId === query.terminalId, + ) + ) + return null; + return { type: "unsubscribe_terminal_request", terminalId: query.terminalId }; + case "subscribe_terminals_request": + if ( + [...this.interests.values()].some( + (item) => + item.query.type === query.type && + item.query.cwd === query.cwd && + item.query.workspaceId === query.workspaceId, + ) + ) + return null; + return { + type: "unsubscribe_terminals_request", + cwd: query.cwd, + workspaceId: query.workspaceId, + }; + default: + // Directory slots have no release RPC. Detach the local listener. + return null; + } + } + + owns(message: SessionOutboundMessage): boolean { + return [...this.interests.values()].some((interest) => this.matches(interest, message)); + } + + receive( + message: SessionOutboundMessage, + deliver: (message: SessionOutboundMessage) => void, + ): void { + const updates: SessionOutboundMessage[] = [ + message, + ...(this.workspaces?.update(message) ?? []), + ]; + if (message.type === "agent_stream" && message.payload.event.type === "attention_required") { + const { agentId, event } = message.payload; + updates.push({ + type: "agent_attention_required", + payload: { + agentId, + reason: event.reason, + timestamp: event.timestamp, + shouldNotify: event.shouldNotify, + ...(event.notification ? { notification: event.notification } : {}), + }, + }); + } + for (const update of updates) + for (const interest of this.interests.values()) { + if (!this.matches(interest, update)) continue; + // Normalize at the connection edge; all consumers receive the same handle shape. + deliver( + ("payload" in update + ? { ...update, payload: { ...update.payload, subscriptionId: interest.id } } + : { ...update, subscriptionId: interest.id }) as SessionOutboundMessage, + ); + } + } + + private matches({ id, query }: Interest, message: SessionOutboundMessage): boolean { + switch (query.type) { + case "fetch_agents_request": + return message.type === "agent_update"; + case "fetch_workspaces_request": + return message.type === "workspace_update"; + case "workspace.label.list.request": + return message.type === "workspace.label.update"; + case "fs.file.subscribe.request": + return message.type === "fs.file.update" && message.payload.subscriptionId === id; + case "subscribe_checkout_diff_request": + return message.type === "checkout_diff_update" && message.payload.subscriptionId === id; + case "subscribe_terminals_request": + return message.type === "terminals_changed" && message.payload.cwd === query.cwd; + case "subscribe_terminal_request": + return ( + message.type === "terminal_stream_exit" && message.payload.terminalId === query.terminalId + ); + case "agent.timeline.set_subscription.request": + return ( + (message.type === "agent_stream" || message.type === "agent.timeline.replacement") && + (query.agentIds as string[]).includes(message.payload.agentId) + ); + case "session.events.set_subscription.request": + return (query.events as string[]).includes( + message.type === "status" ? `status.${message.payload.status}` : message.type, + ); + case "browser.host.register.request": + return message.type === "browser.automation.execute.request"; + default: + return false; + } + } +} + +function isLegacyEvent(event: string): boolean { + return [ + "project.update", + "providers_snapshot_update", + "agent_attention_required", + "agent_permission_request", + "agent_permission_resolved", + ].includes(event); +} + +function matchesBrowserRegistration(advertised: unknown, request: ObservationRequest): boolean { + const host = BrowserAutomationHostCapabilitySchema.safeParse(advertised); + if (!host.success) return false; + if (host.data.hostKind !== request.hostKind) return false; + for (const command of request.supportedCommands as string[]) { + if (!host.data.supportedCommands.some((supported) => supported === command)) return false; + } + return true; +} diff --git a/packages/client/src/connection/owned.ts b/packages/client/src/connection/owned.ts index 8aa3c94b17..56bf6b98f0 100644 --- a/packages/client/src/connection/owned.ts +++ b/packages/client/src/connection/owned.ts @@ -8,7 +8,7 @@ export interface SubscriptionObserver { error?(error: unknown): void; } -/** A stable local lifetime. The host ID changes when the transport reconnects. */ +/** A stable local lifetime. IDs change on reconnect; legacy IDs identify local listeners only. */ export interface OwnedSubscription { readonly subscriptionId: string | null; readonly ready: Promise>; diff --git a/packages/client/src/daemon-client.test.ts b/packages/client/src/daemon-client.test.ts index bf41e06961..7ecf6130b8 100644 --- a/packages/client/src/daemon-client.test.ts +++ b/packages/client/src/daemon-client.test.ts @@ -328,7 +328,7 @@ test("Hub management requires daemon support before dispatching requests", async expect(mock.sent).toEqual([]); }); -test("owned timeline observation requires the host feature", async () => { +test("timeline observation consumes broadcasts from a host without selective delivery", async () => { const mock = createMockTransport(); const client = new DaemonClient({ url: "ws://test", @@ -341,7 +341,21 @@ test("owned timeline observation requires the host feature", async () => { mock.triggerOpen({ features: {} }); await connecting; const observation = client.observeTimeline(["agent"]); - await expect(observation.ready).rejects.toThrow("Update the host"); + await expect(observation.ready).resolves.toMatchObject({ agentIds: ["agent"] }); + const updates: unknown[] = []; + observation.subscribe({ snapshot: () => {}, update: (message) => updates.push(message) }); + mock.triggerMessage( + wrapSessionMessage({ + type: "agent_stream", + payload: { + agentId: "agent", + timestamp: "2026-09-11T00:00:00Z", + event: { type: "turn_completed", provider: "codex" }, + }, + }), + ); + expect(updates).toEqual([expect.objectContaining({ type: "agent_stream" })]); + await observation.release(); expect(mock.sent).toEqual([]); }); diff --git a/packages/client/src/daemon-client.ts b/packages/client/src/daemon-client.ts index 042541f5ca..e012c7c3df 100644 --- a/packages/client/src/daemon-client.ts +++ b/packages/client/src/daemon-client.ts @@ -1,7 +1,7 @@ import { subscribeTimeline, type TimelineMessage } from "./timeline-subscription/index.js"; import { ProviderSnapshotUpdates } from "./provider-snapshots/index.js"; import { - OwnedSubscriptions, + ConnectionSubscriptions, type OwnedSubscription, DEFAULT_CLIENT_CAPABILITIES, type TimelineSubscription, @@ -1094,7 +1094,11 @@ export class DaemonClient { emit: (message) => this.deliverSessionMessage(message), failed: (error) => this.logger.error({ err: error }, "Failed to resolve provider snapshot"), }); - private readonly owned = new OwnedSubscriptions({ + private readonly owned = new ConnectionSubscriptions({ + send: (message) => + this.sendSessionMessageOrThrow( + SessionInboundMessageSchema.parse({ ...message, requestId: this.createRequestId() }), + ), release: async (subscriptionId) => { if (!this.isConnected) return; try { @@ -1731,6 +1735,7 @@ export class DaemonClient { select: (msg: SessionOutboundMessage) => T | null; options?: { skipQueue?: boolean }; }): Promise { + const wire = this.owned.prepareRequest(params.message); const timeout = params.timeout ?? DEFAULT_SESSION_RPC_TIMEOUT_MS; const { promise, cancel } = this.waitForWithCancel>( (msg) => { @@ -1745,7 +1750,7 @@ export class DaemonClient { }), }; } - const value = params.select(msg); + const value = params.select(wire.receive(msg)); if (value === null) { return null; } @@ -1756,7 +1761,7 @@ export class DaemonClient { ); try { - await this.sendSessionMessageOrThrow(params.message); + await this.sendSessionMessageOrThrow(wire.message); } catch (error) { const err = error instanceof Error ? error : new Error(String(error)); cancel(err); @@ -1764,11 +1769,13 @@ export class DaemonClient { throw err; } - const result = await promise; - if (result.kind === "error") { - throw result.error; + try { + const result = await promise; + if (result.kind === "error") throw result.error; + return result.value; + } finally { + await wire.finish(); } - return result.value; } private async sendCorrelatedRequest< @@ -2098,23 +2105,18 @@ export class DaemonClient { // Agent RPCs (requestId-correlated) // ============================================================================ - private requireOwnedSubscriptions(): void { - if (this.lastServerInfoMessage?.features?.ownedSubscriptions !== true) - throw new Error("Update the host to use independent subscriptions."); - } - private observe( responseType: T, message: { type: SessionInboundMessage["type"] } & Record, options?: { requestId?: string; timeout?: number; signal?: AbortSignal }, ): OwnedSubscription> { let resetSource = false; - return this.owned.observe>( - async (accept) => { + return this.owned.observeRequest>( + message, + async (query, accept) => { resetSource = false; - this.requireOwnedSubscriptions(); const requestId = this.createRequestId(options?.requestId); - const request = SessionInboundMessageSchema.parse({ ...message, requestId }); + const request = SessionInboundMessageSchema.parse({ ...query, requestId }); try { return await this.sendCorrelatedRequest>({ message: request, @@ -3177,17 +3179,8 @@ export class DaemonClient { agentId: string, handler: (message: TimelineMessage) => void, ): TimelineSubscription { - 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"), + return subscribeTimeline(agentId, this.observeTimeline([agentId]), handler, (error) => + this.logger.error({ err: error }, "Timeline observation failed"), ); } @@ -3900,7 +3893,6 @@ export class DaemonClient { compare: { mode: "uncommitted" | "base"; baseRef?: string; ignoreWhitespace?: boolean }, requestId?: string, ): Promise { - this.requireOwnedSubscriptions(); return this.sendCorrelatedSessionRequest({ message: { type: "checkout.diff.get.request", @@ -4550,8 +4542,13 @@ export class DaemonClient { unsubscribe: () => Promise; }> { const subscription = this.observeFile(input); + let initial = true; subscription.subscribe({ - snapshot: (snapshot) => onUpdate(snapshot.initial), + snapshot: (snapshot) => { + // The initial version is returned below. Subsequent snapshots repair reconnects. + if (!initial) onUpdate(snapshot.initial); + initial = false; + }, update: (message) => { if (message.type === "fs.file.update") onUpdate(message.payload.version); }, @@ -6142,6 +6139,7 @@ export class DaemonClient { this.lastErrorValue = reason.trim(); } + this.owned.disconnected(); this.providerSnapshotUpdates.clear(); // Clear all pending waiters and queued sends since the connection was lost @@ -6242,6 +6240,7 @@ export class DaemonClient { } private handleSessionMessage(msg: SessionOutboundMessage): void { + msg = this.owned.normalize(msg); if ( msg.type === "providers_snapshot_update" && this.config.providerSnapshots !== "wire" && @@ -6269,7 +6268,10 @@ export class DaemonClient { this.reconnectAttempt = 0; this.updateConnectionState({ status: "connected" }, { event: "HELLO_SERVER_INFO" }); this.startLivenessHeartbeat(); - this.owned.restore(); + this.owned.restore(serverInfo, { + ...DEFAULT_CLIENT_CAPABILITIES, + ...this.config.capabilities, + }); this.flushPendingSendQueue(); this.resolveConnect(); } diff --git a/packages/client/src/index.test.ts b/packages/client/src/index.test.ts index 508eba4c61..9aed42febc 100644 --- a/packages/client/src/index.test.ts +++ b/packages/client/src/index.test.ts @@ -102,9 +102,9 @@ async function connectClient( ownedSubscriptions: true, }, ): Promise<{ client: PaseoClient; ws: FakeWebSocket }> { - vi.stubGlobal("WebSocket", FakeWebSocket); const client = createPaseoClient({ url: "ws://daemon.test", + webSocketFactory: (url) => new FakeWebSocket(url), reconnect: { enabled: false }, }); @@ -377,13 +377,15 @@ 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); }); + expect(parseSentSessionMessage(ws.sent.at(-1))).toMatchObject({ + type: "session.events.set_subscription.request", + events: ["project.update"], + }); + acknowledgeObservation(ws, "projects-sdk"); ws.message( sessionMessage({ @@ -1367,15 +1369,17 @@ test("provider actions delegate to existing provider RPCs and local snapshot upd ], }); - const observation = client.observeEvents(["providers_snapshot_update"]); - acknowledgeObservation(ws, "provider-updates"); - await observation.ready; const snapshotUpdates: string[] = []; const snapshotModelDefaults: Array = []; const unsubscribe = client.providers.subscribe((update) => { snapshotUpdates.push(update.generatedAt); snapshotModelDefaults.push(update.entries[0]?.models?.[0]?.defaultThinkingOptionId); }); + expect(parseSentSessionMessage(ws.sent.at(-1))).toMatchObject({ + type: "session.events.set_subscription.request", + events: ["providers_snapshot_update"], + }); + acknowledgeObservation(ws, "provider-updates"); ws.message( sessionMessage({ type: "providers_snapshot_update", @@ -1408,15 +1412,25 @@ test("provider actions delegate to existing provider RPCs and local snapshot upd await client.close(); }); -test("waitForReady requires owned subscriptions from the host", async () => { +test("waitForReady reads an older host on the existing connection", 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 use independent subscriptions.", + const waiting = client.providers.waitForReady({ cwd: "/repo/./sdk" }); + await expect + .poll(() => parseSentSessionMessage(ws.sent.at(-1)).type) + .toBe("get_providers_snapshot_request"); + const request = parseSentSessionMessage(ws.sent.at(-1)); + ws.message( + sessionMessage({ + type: "get_providers_snapshot_response", + payload: { + requestId: request.requestId, + entries: [], + generatedAt: "2026-09-11T00:00:00.000Z", + }, + }), ); - expect(ws.sent).toHaveLength(sentBeforeWait); - + await expect(waiting).resolves.toMatchObject({ entries: [] }); + expect(FakeWebSocket.instances).toHaveLength(1); await client.close(); }); diff --git a/packages/client/src/index.ts b/packages/client/src/index.ts index fb67a4228a..8485b063af 100644 --- a/packages/client/src/index.ts +++ b/packages/client/src/index.ts @@ -303,7 +303,7 @@ export type PaseoAgentTimelineEvent = | { agentId: string; subscriptionId: string; - event: { type: "snapshot"; reason: "reconnect"; page: FetchAgentTimelinePayload }; + event: { type: "subscription_restored" }; } | { agentId: string; event: { type: "error"; error: string } }; @@ -318,10 +318,10 @@ export interface PaseoAgentTimelineHandle { */ refetch(options?: PaseoAgentTimelineRefetchOptions): Promise; /** - * 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. + * Delivers live events only. After reconnect, subscription_restored precedes + * subsequent updates. History may have been missed; use refetch() to request + * the range you need. No history is fetched automatically. A replacement event + * invalidates the previous epoch. Subscription errors release this observation. * Await the returned unsubscribe function's `ready` promise before starting * work that must be observed. It rejects if establishment fails. */ @@ -520,8 +520,6 @@ export function createPaseoApi( const handles = new Set<{ release(): Promise }>(); const agentListeners = new Set(); const workspaceListeners = new Set(); - const projectListeners = new Set(); - const providerListeners = new Set<(update: PaseoProviderSnapshotUpdate) => void>(); const lifetime = new AbortController(); const own = }>(create: () => T): T => { if (lifetime.signal.aborted) throw new Error("Paseo API is disposed"); @@ -598,8 +596,6 @@ export function createPaseoApi( 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(); @@ -619,18 +615,20 @@ export function createPaseoApi( 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; + const observeEvents: DaemonClient["observeEvents"] = (events, options) => + own(() => daemonClient.observeEvents(events, options)); + + const subscribeEvent = ( + event: "project.update" | "providers_snapshot_update", + update: (message: SessionOutboundMessage) => void, + ): (() => void) => { + const observation = observeEvents([event]); + observation.subscribe({ snapshot: () => {}, update }); + return () => { + void observation + .release() + .catch((error) => console.error("Event subscription cleanup failed", error)); + }; }; function listWorkspaces(options: PaseoWorkspaceListOptions & { subscribe: {} }): Promise< @@ -686,11 +684,9 @@ export function createPaseoApi( projects: { list: (options) => daemonClient.listProjects(options), subscribe: (handler) => { - if (lifetime.signal.aborted) throw new Error("Paseo API is disposed"); - projectListeners.add(handler); - return () => { - projectListeners.delete(handler); - }; + return subscribeEvent("project.update", (message) => { + if (message.type === "project.update") handler(message.payload); + }); }, }, workspaces: { @@ -735,11 +731,9 @@ export function createPaseoApi( diagnostic: (provider, options) => daemonClient.getProviderDiagnostic(provider, options), listUsage: (options) => listProviderUsage(daemonClient, options), subscribe: (handler) => { - if (lifetime.signal.aborted) throw new Error("Paseo API is disposed"); - providerListeners.add(handler); - return () => { - providerListeners.delete(handler); - }; + return subscribeEvent("providers_snapshot_update", (message) => { + if (message.type === "providers_snapshot_update") handler(message.payload); + }); }, }, config: { @@ -865,11 +859,11 @@ function createAgentHandleFactory( switch (message.type) { case "agent_stream": return handler(message.payload); - case "agent.timeline.snapshot": + case "agent.timeline.subscription_restored": return handler({ agentId: id, subscriptionId: message.payload.subscriptionId, - event: { type: "snapshot", reason: "reconnect", page: message.payload.page }, + event: { type: "subscription_restored" }, }); case "agent.timeline.error": return handler({ diff --git a/packages/client/src/terminal-stream-router.test.ts b/packages/client/src/terminal-stream-router.test.ts index 88b79efc29..7b9e598410 100644 --- a/packages/client/src/terminal-stream-router.test.ts +++ b/packages/client/src/terminal-stream-router.test.ts @@ -44,3 +44,20 @@ test("two registrations for one terminal retain independent slots and release", expect(a).toHaveLength(1); expect(b).toHaveLength(2); }); + +test("legacy listeners share a terminal slot without releasing each other", () => { + const router = new TerminalStreamRouter(); + const received: string[] = []; + const release = router.attach("first", "terminal", 1, () => received.push("first")); + router.attach("second", "terminal", 1, () => received.push("second")); + const frame = { + opcode: TerminalStreamOpcode.Output, + slot: 1, + payload: new TextEncoder().encode("bytes"), + }; + router.handleFrame(frame); + expect(received).toEqual(["first", "second"]); + release(); + router.handleFrame(frame); + expect(received).toEqual(["first", "second", "second"]); +}); diff --git a/packages/client/src/terminal-stream-router.ts b/packages/client/src/terminal-stream-router.ts index fa4d5e7613..a2c9575450 100644 --- a/packages/client/src/terminal-stream-router.ts +++ b/packages/client/src/terminal-stream-router.ts @@ -14,11 +14,11 @@ export type TerminalStreamEvent = { terminalId: string; subscriptionId: string } export class TerminalStreamRouter { private readonly slots = new Map< number, - { + Set<{ subscriptionId: string; terminalId: string; receive: (event: TerminalStreamEvent) => void; - } + }> >(); private readonly listeners = new Set<(event: TerminalStreamEvent) => void>(); @@ -37,9 +37,12 @@ export class TerminalStreamRouter { receive: (event: TerminalStreamEvent) => void, ): () => void { const registration = { subscriptionId, terminalId, receive }; - this.slots.set(slot, registration); + const registrations = this.slots.get(slot) ?? new Set(); + registrations.add(registration); + this.slots.set(slot, registrations); return () => { - if (this.slots.get(slot) === registration) this.slots.delete(slot); + registrations.delete(registration); + if (registrations.size === 0) this.slots.delete(slot); }; } @@ -48,28 +51,30 @@ export class TerminalStreamRouter { } handleFrame(frame: TerminalStreamFrame): void { - 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; - event = { ...identity, type: "snapshot", state }; - } else return; - registration.receive(event); - for (const listener of this.listeners) { - try { - listener(event); - } catch { - /* Passive diagnostics cannot interrupt delivery. */ + const registrations = this.slots.get(frame.slot); + if (!registrations) return; + for (const registration of registrations) { + 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; + event = { ...identity, type: "snapshot", state }; + } else return; + registration.receive(event); + for (const listener of this.listeners) { + try { + listener(event); + } catch { + /* Passive diagnostics cannot interrupt delivery. */ + } } } } diff --git a/packages/client/src/timeline-subscription/index.ts b/packages/client/src/timeline-subscription/index.ts index 6671b26707..9293a0a739 100644 --- a/packages/client/src/timeline-subscription/index.ts +++ b/packages/client/src/timeline-subscription/index.ts @@ -1,5 +1,4 @@ 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< @@ -7,36 +6,24 @@ type TimelineUpdate = Extract< { type: "agent_stream" | "agent.timeline.replacement" } >; -/** Local SDK recovery messages; these are not additional wire RPCs. */ +/** Local SDK lifecycle 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.subscription_restored"; + payload: { agentId: string; subscriptionId: string }; } | { 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. */ +/** Restores live delivery; the consumer owns history reads and gap recovery. */ export function subscribeTimeline( agentId: string, observation: OwnedSubscription<{ agentIds: string[] }>, - readSnapshot: () => Promise, handler: (message: TimelineMessage) => void, reportError: (error: unknown) => void, ): TimelineSubscription { let established = false; let released = false; - interface Recovery { - id: string; - revision: number; - updates: TimelineUpdate[]; - replacement?: Extract; - } - let recovery: Recovery | null = null; - const current = (state: Recovery) => - !released && recovery === state && observation.subscriptionId === state.id; const notify = (message: TimelineMessage) => { if (released) return; try { @@ -47,7 +34,6 @@ export function subscribeTimeline( }; const release = () => { released = true; - recovery = null; return observation.release(); }; const stop = () => { @@ -61,65 +47,20 @@ export function subscribeTimeline( }); 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); + notify({ + type: "agent.timeline.subscription_restored", + payload: { agentId, subscriptionId }, + }); }, 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); - } + if (message.type === "agent_stream" || message.type === "agent.timeline.replacement") + notify(message); }, error: fail, }); diff --git a/packages/server/src/server/owned-subscriptions-client.e2e.test.ts b/packages/server/src/server/owned-subscriptions-client.e2e.test.ts index 1502f39047..623fa23ac6 100644 --- a/packages/server/src/server/owned-subscriptions-client.e2e.test.ts +++ b/packages/server/src/server/owned-subscriptions-client.e2e.test.ts @@ -104,13 +104,13 @@ test("public SDK scope cancellation during bootstrap releases the returned ID", } }); -test("a surviving public SDK timeline recovers persistent rows written during its socket outage", async () => { +test("a restored public SDK timeline leaves missed history to the consumer", async () => { const daemon = await createTestPaseoDaemon({ mcpEnabled: false }); const sockets: WebSocket[] = []; const makeClient = (observer: boolean) => new PublicDaemonClient({ url: `ws://127.0.0.1:${daemon.port}/ws`, - clientId: observer ? "r5-observer" : "r5-actor", + clientId: observer ? "timeline-observer" : "timeline-actor", appVersion: "0.8.0", reconnect: { enabled: false }, webSocketFactory: (url) => { @@ -136,28 +136,32 @@ test("a surviving public SDK timeline recovers persistent rows written during it const handle = api.agents.ref(agent.id); const subscription = handle.timeline.subscribe((event) => received.push(event)); await subscription.ready; - await actor.sendMessage(agent.id, "before-outage-r5"); + await actor.sendMessage(agent.id, "before-outage"); await actor.waitForFinish(agent.id); - await expect.poll(() => JSON.stringify(received)).toContain("before-outage-r5"); + await expect.poll(() => JSON.stringify(received)).toContain("before-outage"); + const cached = await handle.timeline.refetch(); + expect(cached.endCursor).not.toBeNull(); const oldId = subscription.subscriptionId; sockets[0]!.terminate(); await expect.poll(() => observer.getConnectionState().status).not.toBe("connected"); - await actor.sendMessage(agent.id, "during-outage-r5"); + await actor.sendMessage(agent.id, "during-outage"); await actor.waitForFinish(agent.id); await observer.connect(); await expect .poll(() => subscription.subscriptionId) .toSatisfy((id: unknown) => typeof id === "string" && id !== oldId); - await actor.sendMessage(agent.id, "after-outage-r5"); + await expect.poll(() => JSON.stringify(received)).toContain("subscription_restored"); + expect(JSON.stringify(received)).not.toContain("during-outage"); + await actor.sendMessage(agent.id, "after-outage"); await actor.waitForFinish(agent.id); - await expect.poll(() => JSON.stringify(received)).toContain("after-outage-r5"); + await expect.poll(() => JSON.stringify(received)).toContain("after-outage"); const history = await handle.timeline.refetch({ - direction: "before", - limit: 100, + direction: "after", + cursor: cached.endCursor!, projection: "projected", }); - expect(JSON.stringify(history)).toContain("during-outage-r5"); - expect(JSON.stringify(received)).toContain("during-outage-r5"); + expect(JSON.stringify(history)).toContain("during-outage"); + expect(JSON.stringify(received)).not.toContain("during-outage"); } finally { await api.dispose(); await observer.close(); diff --git a/packages/server/src/server/owned-subscriptions.e2e.test.ts b/packages/server/src/server/owned-subscriptions.e2e.test.ts index 0ae518fae8..68437daf70 100644 --- a/packages/server/src/server/owned-subscriptions.e2e.test.ts +++ b/packages/server/src/server/owned-subscriptions.e2e.test.ts @@ -1,4 +1,5 @@ import { MockLoadTestAgentClient } from "./agent/providers/mock-load-test-agent.js"; +import { createPaseoApi } from "@getpaseo/client"; import { execFileSync } from "node:child_process"; import { mkdir, writeFile } from "node:fs/promises"; import path from "node:path"; @@ -952,7 +953,7 @@ test("browser capability advertisement is passive and explicit hosts own command } }); -test("adding public SDK listeners does not create server observation demand", async () => { +test("adding raw client listeners does not create server observation demand", async () => { const daemon = await createTestPaseoDaemon({ mcpEnabled: false }); const client = new DaemonClient({ url: `ws://127.0.0.1:${daemon.port}/ws`, @@ -1672,3 +1673,83 @@ test("mark unread replies remain source-owned while directory observers receive await daemon.close(); } }); + +test("legacy event subscribers retain notifications without the new notifications flag", async () => { + const daemon = await createTestPaseoDaemon({ mcpEnabled: false }); + const admin = new DaemonClient({ url: `ws://127.0.0.1:${daemon.port}/ws`, appVersion: "0.8.0" }); + let legacy: SubscriptionPeer | undefined; + try { + await admin.connect(); + const workspace = ( + await admin.createWorkspace({ source: { kind: "directory", path: daemon.staticDir } }) + ).workspace!; + const agent = await admin.createAgent({ + provider: "codex", + cwd: daemon.staticDir, + workspaceId: workspace.id, + }); + legacy = await SubscriptionPeer.connect(daemon.port, "legacy-notifications", { + owned_subscriptions: false, + selective_agent_timeline: true, + explicit_event_subscriptions: true, + }); + await legacy.request({ + type: "fetch_agents_request", + requestId: "directory", + subscribe: { subscriptionId: "directory" }, + }); + await legacy.request({ + type: "session.events.set_subscription.request", + requestId: "events", + events: ["agent_attention_required"], + }); + legacy.send({ + type: "client_heartbeat", + deviceType: "web", + focusedAgentId: null, + lastActivityAt: new Date().toISOString(), + appVisible: true, + }); + await legacy.request({ type: "ping", requestId: "barrier", clientSentAt: 1 }); + const peer = legacy; + await admin.sendMessage(agent.id, "Finish a legacy notification turn"); + await expect + .poll( + () => + peer.frames.flatMap((frame) => + frame.type === "session" && frame.message.type === "agent_attention_required" + ? [frame.message.payload.shouldNotify] + : [], + ), + { timeout: 10_000 }, + ) + .toEqual([true]); + } finally { + legacy?.close(); + await admin.close(); + await daemon.close(); + } +}); + +test("public project subscriptions request updates and release their producer demand", async () => { + const daemon = await createTestPaseoDaemon({ mcpEnabled: false }); + const client = new DaemonClient({ url: `ws://127.0.0.1:${daemon.port}/ws` }); + const api = createPaseoApi(client); + try { + await client.connect(); + const updates: unknown[] = []; + const unsubscribe = api.projects.subscribe((update) => updates.push(update)); + await client.createWorkspace({ source: { kind: "directory", path: daemon.staticDir } }); + await expect + .poll(() => updates) + .toEqual(expect.arrayContaining([expect.objectContaining({ kind: "upsert" })])); + unsubscribe(); + await expect + .poll(async () => (await client.collectDiagnostics()).diagnostic) + .toContain("Registrations: 0"); + } finally { + await api.dispose(); + await client.close(); + await daemon.close(); + } +}); diff --git a/packages/server/src/terminal/terminal-session-controller.test.ts b/packages/server/src/terminal/terminal-session-controller.test.ts index 301292edeb..b438af8381 100644 --- a/packages/server/src/terminal/terminal-session-controller.test.ts +++ b/packages/server/src/terminal/terminal-session-controller.test.ts @@ -1143,7 +1143,7 @@ describe("terminal output before natural exit", () => { }); describe("terminal snapshot failure", () => { - test.each(["initial", "legacy initial", "backpressure"])( + test.each(["initial", "backpressure"])( "releases only the failed observer after %s rejection", async (phase) => { vi.useFakeTimers(); @@ -1155,17 +1155,13 @@ describe("terminal snapshot failure", () => { }, phase === "backpressure"); try { const source = {}; - const failed = await f.subscribe(source, phase !== "legacy initial", "failed"); + const failed = await f.subscribe(source, true, "failed"); await vi.advanceTimersByTimeAsync(0); if (phase === "backpressure") { f.output("x".repeat(300 * 1024), 1); await vi.advanceTimersByTimeAsync(5); } - const healthy = await f.subscribe( - phase === "legacy initial" ? {} : source, - phase !== "legacy initial", - "healthy", - ); + const healthy = await f.subscribe(source, true, "healthy"); await vi.advanceTimersByTimeAsync(0); f.output("SIBLING-ALIVE", 2); await vi.advanceTimersByTimeAsync(5); @@ -1182,7 +1178,7 @@ describe("terminal snapshot failure", () => { type: "terminal_stream_exit", payload: { terminalId: "exit-terminal", - ...(phase === "legacy initial" ? {} : { subscriptionId: failed.id }), + subscriptionId: failed.id, error: "Terminal worker request timed out: getTerminalState", }, }, @@ -1201,7 +1197,7 @@ describe("terminal snapshot failure", () => { type: "terminal_stream_exit", payload: { terminalId: "exit-terminal", - ...(phase === "legacy initial" ? {} : { subscriptionId: healthy.id }), + subscriptionId: healthy.id, }, }); expect(f.delivery.registrationCount).toBe(0); @@ -1238,3 +1234,45 @@ describe("terminal snapshot failure", () => { } }); }); + +test.each([false, true])( + "legacy snapshot failure retries without reporting a live PTY exited (backpressure=%s)", + async (backpressure) => { + vi.useFakeTimers(); + let reads = 0; + const f = exitFixture(async () => { + if (++reads === (backpressure ? 2 : 1)) throw new Error("Snapshot unavailable"); + return { state: terminalState("recovered"), revision: 0 }; + }, backpressure); + try { + await f.subscribe({}, false, "legacy-recovery"); + await vi.advanceTimersByTimeAsync(0); + if (backpressure) { + f.output("x".repeat(300 * 1024), 1); + await vi.advanceTimersByTimeAsync(5); + } + expect(f.frames.filter((row) => row.message?.type === "terminal_stream_exit")).toEqual([]); + f.output("RECOVERED-OUTPUT", 2); + for (const listener of f.outputs) listener({ type: "snapshotReady", revision: 2 }); + await vi.advanceTimersByTimeAsync(5); + expect(reads).toBe(backpressure ? 3 : 2); + expect(f.frames.some((row) => row.binary?.opcode === TerminalStreamOpcode.Snapshot)).toBe( + true, + ); + f.exit(); + await vi.advanceTimersByTimeAsync(0); + expect(f.frames.filter((row) => row.message?.type === "terminal_stream_exit")).toHaveLength( + 1, + ); + expect(f.frames.at(-1)?.message).toEqual({ + type: "terminal_stream_exit", + payload: { terminalId: "exit-terminal" }, + }); + expect(f.delivery.registrationCount).toBe(0); + expect(f.outputs.size + f.exits.size).toBe(0); + } finally { + await f.delivery.close(); + vi.useRealTimers(); + } + }, +); diff --git a/packages/server/src/terminal/terminal-session-controller.ts b/packages/server/src/terminal/terminal-session-controller.ts index 9925ed1846..4f46dbc88c 100644 --- a/packages/server/src/terminal/terminal-session-controller.ts +++ b/packages/server/src/terminal/terminal-session-controller.ts @@ -66,6 +66,7 @@ interface ActiveTerminalStream { unsubscribe: () => void; needsSnapshot: boolean; snapshotInFlight: boolean; + retrySnapshotErrors: boolean; readyRevision?: number; restore?: TerminalRestoreOptions; bufferedOutputs: BufferedTerminalOutput[]; @@ -706,7 +707,10 @@ export class TerminalSessionController { }); } - const slot = this.bindActiveStream(session, owner, { restore: msg.restore }); + const slot = this.bindActiveStream(session, owner, { + restore: msg.restore, + retrySnapshotErrors: !ownership.isModern(owner.source), + }); if (slot === null) { await owner.release(); this.sessionLogger.warn( @@ -852,7 +856,7 @@ export class TerminalSessionController { private bindActiveStream( terminal: TerminalSession, owner: OwnedSubscription, - options?: { restore?: TerminalRestoreOptions }, + options: { restore?: TerminalRestoreOptions; retrySnapshotErrors: boolean }, ): number | null { if (!this.hasBinaryChannel()) { return null; @@ -870,6 +874,7 @@ export class TerminalSessionController { unsubscribe: () => {}, needsSnapshot: true, snapshotInFlight: false, + retrySnapshotErrors: options.retrySnapshotErrors, exiting: false, readyRevision: undefined, restore: options?.restore, @@ -1010,6 +1015,14 @@ export class TerminalSessionController { ); // Natural completion owns the buffered final bytes and waits for this read. if (activeStream.exiting) return; + // COMPAT(terminalSnapshotErrors): restored in v0.8.0; remove after 2027-03-11 once client floor >= v0.8.0. + // Old clients interpret every stream exit as PTY exit. Preserve their + // attached stream after failure. Another snapshot notification can retry; + // this does not schedule recovery. Source teardown still releases the stream. + if (activeStream.retrySnapshotErrors) { + activeStream.needsSnapshot = true; + return; + } activeStream.owner.emit({ type: "terminal_stream_exit", payload: { diff --git a/public-docs/sdk/events.md b/public-docs/sdk/events.md index 03f69907fa..9762323dce 100644 --- a/public-docs/sdk/events.md +++ b/public-docs/sdk/events.md @@ -8,7 +8,7 @@ category: TypeScript SDK # SDK events -Use an owned subscription to fetch a snapshot and follow its changes. Connecting, plain reads, and local listeners do not start observation. Each observation gets a new server-issued ID, even when its filter matches another observation. +Use an owned subscription to fetch a snapshot and follow its changes. On capable daemons, connecting, plain reads, and local directory listeners do not start observation. Each observation gets a new server-issued ID, even when its filter matches another observation. ## Follow one agent's status @@ -47,10 +47,9 @@ await directory.subscription.release(); ```ts const unsubscribe = agent.timeline.subscribe((update) => { const { event } = update; - if (event.type === "snapshot") { - // Replace your recent history view; these are complete projected entries, - // not additional live message fragments. - console.log("Reconnected", event.page.epoch, event.page.entries); + if (event.type === "subscription_restored") { + // Live delivery has resumed. Choose whether to fetch missed history. + console.log("Reconnected; events may have been missed"); return; } if (event.type === "error") { @@ -72,11 +71,13 @@ const unsubscribe = agent.timeline.subscribe((update) => { }); ``` -Await `unsubscribe.ready` before starting work whose events you need to observe. It acknowledges the initial live subscription; initial history is a separate [read](#fetch-timeline-history). Call `unsubscribe()` to release demand, or await `unsubscribe.release()` for teardown. +Await `unsubscribe.ready` before starting work whose events you need to observe. It waits for the initial live subscription (local listener attachment on broadcast-only hosts); initial history is a separate [read](#fetch-timeline-history). Call `unsubscribe()` to release demand, or await `unsubscribe.release()` for teardown. -After reconnect, the same handle receives `{ agentId, subscriptionId, event: { type: "snapshot", reason: "reconnect", page } }` before subsequent updates. The host assigns a fresh subscription ID. `page` is the latest 100 projected history entries, with the same epoch, cursors and paging flags as `timeline.refetch()`. Replace your recent view with this page; do not concatenate it with earlier message fragments. If `hasOlder` is true, fetch older pages with `page.startCursor`. Compare epochs before retaining any previously loaded history: a reconnect may preserve or replace the epoch. A live `replacement` event still means the previous epoch is invalid. +After reconnect, the same handle receives `{ agentId, subscriptionId, event: { type: "subscription_restored" } }` before subsequent live updates. This is a local SDK notification after membership acknowledgement (local attachment on broadcast-only hosts). The subscription gets a fresh ID. No history is fetched automatically, and missed events are not replayed. -Persisted history is recovered; transient events such as an offline turn-completion notification are not replayed. Recovery buffers at most 128 current-connection updates. Overflow or a concurrent replacement discards the obsolete buffer and reads a fresh page. A recovery read failure delivers `{ agentId, event: { type: "error", error } }` and releases that observation; establish a new subscription when ready to retry. Release suppresses pending recovery callbacks. Each same-agent subscription recovers independently. +Choose recovery for your consumer: continue live, request a recent page, or call `timeline.refetch({ direction: "after", cursor: { epoch, seq } })` using your saved position. Live delivery continues while your read is pending; buffer or reconcile those events with the returned page by epoch and sequence. Follow `hasNewer` and `endCursor` to read further pages when needed. A live `replacement` invalidates the previous epoch. A failed explicit history read rejects that read and leaves the live subscription active. + +Subscription establishment failures deliver `{ agentId, event: { type: "error", error } }` and release the observation. Establish a new subscription when ready to retry. Releasing a subscription stops its callbacks and prevents restoration after reconnect. Assistant messages can arrive in pieces. Concatenate their text when you need a complete message, or use `run()` and read `lastMessage` when you only need the final reply. @@ -118,7 +119,6 @@ await directory.subscription.release(); ## Follow provider catalog changes ```ts -const observation = client.observeEvents(["providers_snapshot_update"]); const unsubscribe = client.providers.subscribe((update) => { const ready = update.entries.filter((entry) => entry.status === "ready"); console.log( @@ -126,14 +126,19 @@ const unsubscribe = client.providers.subscribe((update) => { ready.map((entry) => entry.provider), ); }); -await observation.ready; console.log(await client.providers.snapshot()); // When this view closes: unsubscribe(); -await observation.release(); ``` -Project listeners work the same way: request `client.observeEvents(["project.update"])` before relying on `client.projects.subscribe()`. For an initial project cache, buffer updates while awaiting `client.projects.list()`, then apply them after the snapshot. +Provider and project `subscribe()` calls request their own updates and release that demand on unsubscribe. For an initial project cache, buffer updates while awaiting `client.projects.list()`, then apply them after the snapshot. -Call `client.close()` when the application no longer needs the connection. Observation requires a host that advertises independent subscriptions; an older host returns an update-host error. Plain reads remain available. +Call `client.close()` when the application no longer needs the connection. + +## Older daemons + +The same methods use the existing connection and legacy delivery behavior. Old directory subscriptions +share a server slot: a later filtered observation replaces that slot's filter. Handles have local IDs +and separate callbacks, but independent server filters require an updated daemon. Releasing a handle +detaches its callbacks; old daemons may continue sending broadcasts. diff --git a/public-docs/sdk/reference.md b/public-docs/sdk/reference.md index 960404c58a..620f398691 100644 --- a/public-docs/sdk/reference.md +++ b/public-docs/sdk/reference.md @@ -55,7 +55,7 @@ Create a new client after `close()`. | `ref(agentOrId)` | `PaseoAgentHandle` | Creates a local handle without fetching. | | `subscribe(handler)` | Unsubscribe function | Local listener for this API instance. Requires an owned `list({ subscribe: {} })` observation. | -`list({ subscribe: {} })` also returns a server-issued `subscriptionId` and an owned `subscription`. Its `subscribe({ snapshot, update, error? })` callbacks receive the snapshot before scoped wire updates; `release()` ends that observation. Plain lists create no observation. The same contract applies to workspace lists. See [events](./events.md). +`list({ subscribe: {} })` also returns a `subscriptionId` and an owned `subscription`. Its `subscribe({ snapshot, update, error? })` callbacks receive the snapshot before scoped wire updates; `release()` ends that observation. Capable daemons assign the ID and keep observations independent. Older daemons use local IDs and their established shared delivery behavior. Plain lists create no observation. The same contract applies to workspace lists. See [events](./events.md). Creation options include `config`, `cwd`, `parent`, `title`, `prompt`, `env`, `outputSchema`, `images`, `attachments`, `git`, `worktree`, `autoArchive`, and `labels`. @@ -110,14 +110,14 @@ Creation options include `config`, `cwd`, `parent`, `title`, `prompt`, `env`, `o `agent.timeline.refetch(options?)` fetches a page. Options are `direction`, `cursor`, `limit`, `projection`, and `requestId`. -`agent.timeline.subscribe(handler)` establishes network demand for this agent and restores it after reconnect. Its unsubscribe function releases that demand; await `unsubscribe.ready` for initial daemon acknowledgement before starting work. Initial delivery is live-only. Reconnect delivers a bounded projected history snapshot before later updates; a live replacement invalidates the previous epoch. See the callback shapes, paging and failure behavior in [timeline events](./events.md#follow-timeline-events). +`agent.timeline.subscribe(handler)` establishes network demand for this agent and restores it after reconnect. Its unsubscribe function releases that demand; await `unsubscribe.ready` for initial daemon acknowledgement before starting work. Delivery is live-only. Reconnect emits a local `subscription_restored` event; request missed history explicitly with `refetch()`. A live replacement invalidates the previous epoch. See the callback shapes, paging and failure behavior in [timeline events](./events.md#follow-timeline-events). ## `client.projects` | Method | Result | Behavior | | -------------------- | ------------------------ | ---------------------------------------------------------------------------------------------- | | `list(options?)` | `PaseoProjectListResult` | Lists every registered project, including projects with no active workspaces. | -| `subscribe(handler)` | Unsubscribe function | Local listener. Requires `observeEvents(["project.update"])`; `list()` supplies initial state. | +| `subscribe(handler)` | Unsubscribe function | Requests future project updates; unsubscribe releases demand. `list()` supplies initial state. | See [events](./events.md#follow-provider-catalog-changes) for explicit event observation and cleanup. @@ -187,7 +187,7 @@ Use `workspace.terminals.create(options?)` and `workspace.terminals.list(options | `listFeatures(draftConfig)` | Features result | Discovers features for the current draft provider configuration. | | `diagnostic(provider)` | Diagnostic result | Returns human-readable setup diagnostics. | | `listUsage(options?)` | `PaseoProviderUsageResult` | Returns normalized subscription windows, balances, and provider details. Rejects with an update-host error when unsupported. Options: `requestId`. | -| `subscribe(handler)` | Unsubscribe function | Local listener. Requires `observeEvents(["providers_snapshot_update"])`. | +| `subscribe(handler)` | Unsubscribe function | Requests future catalog updates; unsubscribe releases demand. | ## `client.config` From 0d7760e68b5d3aa9d4f0e4384e231af32cde4a91 Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Sat, 12 Sep 2026 09:33:26 +0200 Subject: [PATCH 28/46] fix(agents): keep rejected plans collapsed in conversation order (#4756) Keep plan proposals at their original timeline position through approval, rejection, and cancellation. Make plan cards collapsible while retaining readable history after follow-up prompts and daemon restart. --- docs/timeline-sync.md | 5 + .../e2e/browser/permission-steer.real.spec.ts | 199 +++++++++++++----- .../browser/plan-approval.claude.real.spec.ts | 164 +++++++++++++++ packages/app/src/agent-stream/view.tsx | 1 + packages/app/src/components/message.tsx | 1 + packages/app/src/components/plan-card.tsx | 131 ++++++++---- packages/app/src/i18n/resources/ar.ts | 4 + packages/app/src/i18n/resources/en.ts | 4 + packages/app/src/i18n/resources/es.ts | 4 + packages/app/src/i18n/resources/fr.ts | 4 + packages/app/src/i18n/resources/ja.ts | 4 + packages/app/src/i18n/resources/ko.ts | 4 + packages/app/src/i18n/resources/pt-BR.ts | 4 + packages/app/src/i18n/resources/ru.ts | 4 + packages/app/src/i18n/resources/zh-CN.ts | 4 + .../detail-level/projection.test.ts | 22 ++ .../src/tool-calls/detail-level/projection.ts | 29 ++- packages/app/src/tool-calls/presentation.ts | 11 + packages/app/src/types/stream.test.ts | 45 ++++ packages/app/src/types/stream.ts | 4 - .../server/agent/providers/claude/agent.ts | 43 ++-- .../providers/claude/tool-call-mapper.test.ts | 35 +++ .../providers/claude/tool-call-mapper.ts | 24 +++ ...odex-app-server-agent.process-exit.test.ts | 82 ++++++++ .../providers/codex-app-server-agent.test.ts | 30 ++- .../agent/providers/codex-app-server-agent.ts | 71 ++++--- 26 files changed, 772 insertions(+), 161 deletions(-) create mode 100644 packages/app/e2e/browser/plan-approval.claude.real.spec.ts diff --git a/docs/timeline-sync.md b/docs/timeline-sync.md index 02c5723162..1c3b1e4593 100644 --- a/docs/timeline-sync.md +++ b/docs/timeline-sync.md @@ -88,6 +88,11 @@ The installed tail carries `hasOlder`, so history skipped by a replacement remai ordinary backward pagination. A backward page is accepted only when it is adjacent to the current history start; a response requested from a pre-replacement range is stale and is discarded. +A plan approval keeps the original proposal's tool-call identity through resolution and provider +history replay. The pending approval UI can hide that tool from presentation, but the client model +must retain its position. Creating a new history card on rejection places it after the prompt that +rejected it; changing steer-event ordering would also put new assistant output before that prompt. + ## Client replica lifetime The session projection remains host-scoped for as long as the host is registered. The viewed-timeline diff --git a/packages/app/e2e/browser/permission-steer.real.spec.ts b/packages/app/e2e/browser/permission-steer.real.spec.ts index 802ae763ac..e88027cd6c 100644 --- a/packages/app/e2e/browser/permission-steer.real.spec.ts +++ b/packages/app/e2e/browser/permission-steer.real.spec.ts @@ -1,4 +1,5 @@ import { mkdtempSync, realpathSync } from "node:fs"; +import type { Page, TestInfo } from "@playwright/test"; import { tmpdir } from "node:os"; import path from "node:path"; import { expect, test } from "../support/fixtures"; @@ -18,7 +19,7 @@ const scenarios = [ }, { provider: "codex" as const, - providerConfig: { featureValues: { plan_mode: true } }, + providerConfig: { model: "gpt-5.6-sol", featureValues: { plan_mode: true } }, planPrompt: "Produce a concise implementation plan with exactly these steps: Inspect permission steering; Implement permission steering; Verify permission steering. Do not implement it.", planText: "Inspect permission steering", @@ -27,57 +28,151 @@ const scenarios = [ }, ]; -test.describe("composer steer supersedes plan approval", () => { - for (const scenario of scenarios) { - test(`${scenario.provider} keeps the rejected plan in the timeline`, async ({ +async function readConversationOrder(page: Page, prompt: string, reply: string) { + const plan = page.getByTestId("timeline-plan-card"); + const question = page.getByTestId("user-message").filter({ hasText: prompt }); + const answer = page.getByTestId("assistant-message").filter({ hasText: reply }); + const boxes = await Promise.all([ + plan.boundingBox(), + question.boundingBox(), + answer.boundingBox(), + ]); + return boxes + .map((box, index) => { + if (!box) throw new Error("Expected the plan, follow-up, and answer to be rendered"); + return { role: ["plan", "question", "answer"][index], y: box.y }; + }) + .sort((a, b) => a.y - b.y) + .map((item) => item.role); +} + +async function togglePlan( + page: Page, + testId: string, + name: string, + expanded: boolean, +): Promise { + const toggle = page.getByTestId(testId).getByRole("button", { name, exact: true }); + await toggle.click(); + await expect(toggle).toHaveAttribute("aria-expanded", String(expanded)); +} + +async function expectRejectedPlanCollapsed(page: Page): Promise { + await expect( + page + .getByTestId("timeline-plan-card") + .getByRole("button", { name: "Rejected plan", exact: true }), + ).toHaveAttribute("aria-expanded", "false"); +} + +async function reopenConversation(page: Page, reply: string): Promise { + await page.reload(); + await expect(page.getByTestId("timeline-plan-card")).toBeVisible({ timeout: 30_000 }); + await expect(page.getByTestId("assistant-message").filter({ hasText: reply })).toBeVisible(); +} + +type PlanScenario = (typeof scenarios)[number]; +interface PlanSteer { + page: Page; + scenario: PlanScenario; + testInfo: TestInfo; +} + +async function withPlanSteer(context: PlanSteer, journey: () => Promise): Promise { + const { page, scenario } = context; + const cwd = realpathSync( + mkdtempSync(path.join(tmpdir(), `paseo-permission-steer-${scenario.provider}-`)), + ); + let handle: AgentHandle | undefined; + try { + handle = await launchAgent({ page, - }, testInfo) => { - test.setTimeout(420_000); - const cwd = realpathSync( - mkdtempSync(path.join(tmpdir(), `paseo-permission-steer-${scenario.provider}-`)), - ); - let handle: AgentHandle | undefined; - - try { - handle = await launchAgent({ - page, - provider: scenario.provider, - cwd, - mode: "full-access", - providerConfig: scenario.providerConfig, - }); - await submitMessage(page, scenario.planPrompt); - await waitForPermissionPrompt(page, 180_000); - - const pendingPlan = page.getByTestId("permission-plan-card"); - await expect(pendingPlan).toContainText(scenario.planText); - const pendingScreenshot = testInfo.outputPath(`${scenario.provider}-pending-plan.png`); - await page.screenshot({ path: pendingScreenshot }); - await testInfo.attach(`${scenario.provider} pending plan`, { - path: pendingScreenshot, - contentType: "image/png", - }); - - await submitMessage(page, scenario.steerPrompt); - await expect(pendingPlan).toHaveCount(0, { timeout: 30_000 }); - const rejectedPlan = page.getByTestId("timeline-plan-card"); - await expect(rejectedPlan).toBeVisible({ timeout: 30_000 }); - await expect(rejectedPlan).toContainText(scenario.planText); - await expect( - page.getByTestId("assistant-message").filter({ hasText: scenario.reply }), - ).toBeVisible({ - timeout: 180_000, - }); - - const rejectedScreenshot = testInfo.outputPath(`${scenario.provider}-rejected-plan.png`); - await page.screenshot({ path: rejectedScreenshot }); - await testInfo.attach(`${scenario.provider} rejected plan`, { - path: rejectedScreenshot, - contentType: "image/png", - }); - } finally { - await cleanupRewindFlow({ handle, cwd }); - } + provider: scenario.provider, + cwd, + mode: "full-access", + providerConfig: scenario.providerConfig, }); + await journey(); + } finally { + await cleanupRewindFlow({ handle, cwd }); } -}); +} + +async function reviewCollapsibleProposal({ page, scenario, testInfo }: PlanSteer): Promise { + await submitMessage(page, scenario.planPrompt); + await waitForPermissionPrompt(page, 180_000); + + const pendingPlan = page.getByTestId("permission-plan-card"); + await expect(pendingPlan).toContainText(scenario.planText); + await togglePlan(page, "permission-plan-card", "Plan", false); + await expect(page.getByTestId("permission-request-deny")).toBeVisible(); + await togglePlan(page, "permission-plan-card", "Plan", true); + const pendingScreenshot = testInfo.outputPath(`${scenario.provider}-pending-plan.png`); + await page.screenshot({ path: pendingScreenshot }); + await testInfo.attach(`${scenario.provider} pending plan`, { + path: pendingScreenshot, + contentType: "image/png", + }); +} + +async function rejectWithFollowUp({ page, scenario, testInfo }: PlanSteer): Promise { + await submitMessage(page, scenario.steerPrompt); + await expect(page.getByTestId("permission-plan-card")).toHaveCount(0, { timeout: 30_000 }); + const rejectedPlan = page.getByTestId("timeline-plan-card"); + await expect(rejectedPlan).toBeVisible({ timeout: 30_000 }); + await expectRejectedPlanCollapsed(page); + await expect( + page.getByTestId("assistant-message").filter({ hasText: scenario.reply }), + ).toBeVisible({ + timeout: 180_000, + }); + + const rejectedScreenshot = testInfo.outputPath(`${scenario.provider}-rejected-plan.png`); + await page.screenshot({ path: rejectedScreenshot }); + await testInfo.attach(`${scenario.provider} rejected plan`, { + path: rejectedScreenshot, + contentType: "image/png", + }); + await expect( + page.getByTestId("user-message").filter({ hasText: scenario.steerPrompt }), + ).toBeVisible(); + expect + .soft(await readConversationOrder(page, scenario.steerPrompt, scenario.reply)) + .toEqual(["plan", "question", "answer"]); +} + +async function rereadRejectedPlan({ page, scenario, testInfo }: PlanSteer): Promise { + const rejectedPlan = page.getByTestId("timeline-plan-card"); + await togglePlan(page, "timeline-plan-card", "Rejected plan", true); + await expect(rejectedPlan).toContainText(scenario.planText); + await rejectedPlan.screenshot({ + path: testInfo.outputPath(`${scenario.provider}-expanded-rejected-plan.png`), + }); + await togglePlan(page, "timeline-plan-card", "Rejected plan", false); +} + +async function restoreConversationOrder({ page, scenario }: PlanSteer): Promise { + await reopenConversation(page, scenario.reply); + await expectRejectedPlanCollapsed(page); + expect(await readConversationOrder(page, scenario.steerPrompt, scenario.reply)).toEqual([ + "plan", + "question", + "answer", + ]); +} + +for (const scenario of scenarios) { + test(`${scenario.provider} keeps the rejected plan in the timeline`, async ({ + page, + }, testInfo) => { + test.setTimeout(420_000); + const context = { page, scenario, testInfo }; + await withPlanSteer(context, async () => { + await test.step("Review a collapsible proposal", () => reviewCollapsibleProposal(context)); + await test.step("Reject with a follow-up below the plan", () => rejectWithFollowUp(context)); + await test.step("Reopen the rejected plan", () => rereadRejectedPlan(context)); + await test.step("Keep conversation order after reload", () => + restoreConversationOrder(context)); + }); + }); +} diff --git a/packages/app/e2e/browser/plan-approval.claude.real.spec.ts b/packages/app/e2e/browser/plan-approval.claude.real.spec.ts new file mode 100644 index 0000000000..2994c63caf --- /dev/null +++ b/packages/app/e2e/browser/plan-approval.claude.real.spec.ts @@ -0,0 +1,164 @@ +import { mkdtempSync, readFileSync, realpathSync } from "node:fs"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import type { Page, TestInfo } from "@playwright/test"; +import { expect, test } from "../support/fixtures"; +import { submitMessage } from "../support/helpers/composer"; +import { + allowPermission, + denyPermission, + waitForPermissionPrompt, +} from "../support/helpers/permissions"; +import { + assertComposerIdle, + cleanupRewindFlow, + launchAgent, + type AgentHandle, +} from "../support/helpers/rewind-flow"; +import { connectDaemonClient } from "../support/helpers/daemon-client-loader"; +import { getE2EDaemonPort } from "../support/helpers/daemon-port"; + +const PLAN_PROMPT = + "Propose a concise plan to append one line Hello to README.md. Present it for approval using ExitPlanMode. Do not ask questions. Only implement after approval. If the plan is rejected, reply PLAN_REJECTED and stop without proposing another plan. After implementing an approved plan, reply PLAN_IMPLEMENTED."; + +async function requestPlan(page: Page, prompt: string): Promise { + await submitMessage(page, prompt); + await waitForPermissionPrompt(page, 180_000); + await expect(page.getByTestId("permission-plan-card")).toContainText("README.md"); +} + +function planWithOutcome(page: Page, label: string) { + return page + .getByTestId("timeline-plan-card") + .filter({ has: page.getByRole("button", { name: label, exact: true }) }); +} + +async function expectPlanOutcome(page: Page, label: string, expanded: boolean): Promise { + const card = planWithOutcome(page, label); + await expect(card).toHaveCount(1); + await expect(card.getByRole("button", { name: label, exact: true })).toHaveAttribute( + "aria-expanded", + String(expanded), + ); +} + +async function toggleRecordedPlan(page: Page, label: string, expanded: boolean): Promise { + await planWithOutcome(page, label).getByRole("button", { name: label, exact: true }).click(); + await expectPlanOutcome(page, label, expanded); +} + +async function expectClaudeReply(page: Page, marker: string): Promise { + await expect(page.getByTestId("assistant-message").filter({ hasText: marker })).toBeVisible({ + timeout: 180_000, + }); + await assertComposerIdle({ page }); +} + +async function restartIsolatedDaemon(): Promise { + const port = Number(getE2EDaemonPort()); + expect([6767, 6768]).not.toContain(port); + const client = await connectDaemonClient<{ + connect(): Promise; + close(): Promise; + getDaemonStatus(): Promise<{ pid: number }>; + restartServer(reason: string): Promise; + }>({ port, clientIdPrefix: "plan-history-restart" }); + try { + const before = (await client.getDaemonStatus()).pid; + await client.restartServer("Verify plan history replay in the isolated Playwright daemon"); + await expect + .poll( + async () => { + try { + return (await client.getDaemonStatus()).pid; + } catch { + return before; + } + }, + { timeout: 60_000 }, + ) + .not.toBe(before); + } finally { + await client.close(); + } +} + +async function expectRecordedPlans(page: Page): Promise { + await expect(page.getByTestId("timeline-plan-card")).toHaveCount(2, { timeout: 60_000 }); + await expectPlanOutcome(page, "Rejected plan", false); + await expectPlanOutcome(page, "Approved plan", true); +} + +interface PlanReview { + page: Page; + cwd: string; + testInfo: TestInfo; +} + +async function withPlanReview( + page: Page, + testInfo: TestInfo, + review: (context: PlanReview) => Promise, +): Promise { + const cwd = realpathSync(mkdtempSync(path.join(tmpdir(), "paseo-plan-lifecycle-"))); + let handle: AgentHandle | undefined; + try { + handle = await launchAgent({ + page, + provider: "claude", + cwd, + mode: "full-access", + providerConfig: { model: "haiku", modeId: "plan" }, + }); + await review({ page, cwd, testInfo }); + } finally { + await cleanupRewindFlow({ handle, cwd }); + } +} + +async function rejectPlanWithoutImplementing({ page, cwd, testInfo }: PlanReview): Promise { + await requestPlan(page, PLAN_PROMPT); + await denyPermission(page); + await expectClaudeReply(page, "PLAN_REJECTED"); + await expectPlanOutcome(page, "Rejected plan", false); + await toggleRecordedPlan(page, "Rejected plan", true); + await expect(planWithOutcome(page, "Rejected plan")).toContainText("README.md"); + await page.screenshot({ path: testInfo.outputPath("button-rejected-plan-expanded.png") }); + await toggleRecordedPlan(page, "Rejected plan", false); + expect(readFileSync(path.join(cwd, "README.md"), "utf8")).not.toContain("Hello"); +} + +async function approveFreshPlanAndImplement({ page, cwd, testInfo }: PlanReview): Promise { + await requestPlan(page, `Propose the plan again for a fresh approval. ${PLAN_PROMPT}`); + await allowPermission(page); + await expectClaudeReply(page, "PLAN_IMPLEMENTED"); + expect(readFileSync(path.join(cwd, "README.md"), "utf8")).toContain("Hello"); + await expectRecordedPlans(page); + await toggleRecordedPlan(page, "Approved plan", false); + await toggleRecordedPlan(page, "Approved plan", true); + await page.screenshot({ path: testInfo.outputPath("approved-plan.png") }); +} + +async function restoreReadablePlanHistory({ page, testInfo }: PlanReview): Promise { + await restartIsolatedDaemon(); + await page.reload(); + await expectRecordedPlans(page); + await toggleRecordedPlan(page, "Rejected plan", true); + await expect(planWithOutcome(page, "Rejected plan")).toContainText("README.md"); + await toggleRecordedPlan(page, "Rejected plan", false); + await page.screenshot({ path: testInfo.outputPath("restored-plans.png") }); +} + +test("Claude button rejection and approval preserve collapsible plans after daemon restart", async ({ + page, +}, testInfo) => { + test.setTimeout(420_000); + await withPlanReview(page, testInfo, async (review) => { + await test.step("Reject a plan without implementing it", () => + rejectPlanWithoutImplementing(review)); + await test.step("Approve and implement a fresh plan", () => + approveFreshPlanAndImplement(review)); + await test.step("Restore readable plans after daemon restart", () => + restoreReadablePlanHistory(review)); + }); +}); diff --git a/packages/app/src/agent-stream/view.tsx b/packages/app/src/agent-stream/view.tsx index eb68675580..117132199e 100644 --- a/packages/app/src/agent-stream/view.tsx +++ b/packages/app/src/agent-stream/view.tsx @@ -1565,6 +1565,7 @@ function PermissionRequestCard({ title={title} description={description} text={planMarkdown} + outcome="pending" footer={footer} testID="permission-plan-card" disableOuterSpacing diff --git a/packages/app/src/components/message.tsx b/packages/app/src/components/message.tsx index a8a56dee89..99ae316d95 100644 --- a/packages/app/src/components/message.tsx +++ b/packages/app/src/components/message.tsx @@ -3167,6 +3167,7 @@ export const ToolCall = memo(function ToolCall({ return ( diff --git a/packages/app/src/components/plan-card.tsx b/packages/app/src/components/plan-card.tsx index 2ac00f1b7e..52c70683fb 100644 --- a/packages/app/src/components/plan-card.tsx +++ b/packages/app/src/components/plan-card.tsx @@ -1,9 +1,19 @@ -import { useMemo, type ReactNode } from "react"; -import { Text, View, type StyleProp, type TextStyle, type ViewStyle } from "react-native"; -import Markdown, { type ASTNode } from "react-native-markdown-display"; -import { StyleSheet, useUnistyles } from "react-native-unistyles"; +import { useCallback, useMemo, useState, type ReactNode } from "react"; +import { + Pressable, + Text, + View, + type StyleProp, + type TextStyle, + type ViewStyle, +} from "react-native"; +import { type ASTNode } from "react-native-markdown-display"; +import { StyleSheet, withUnistyles } from "react-native-unistyles"; import { useTranslation } from "react-i18next"; -import { createMarkdownStyles } from "@/styles/markdown-styles"; +import { MarkdownRenderer } from "@/components/markdown/renderer"; +import { ChevronRight } from "lucide-react-native"; +import { isWeb } from "@/constants/platform"; +import type { Theme } from "@/styles/theme"; import { getMarkdownListMarker } from "@/utils/markdown-list"; import { createMarkdownParser } from "@/utils/markdown-parser"; @@ -180,54 +190,81 @@ function createPlanMarkdownRules() { }; } -export function PlanCard({ - title, - description, - text, - footer, - disableOuterSpacing = false, - testID, -}: { +export type PlanOutcome = "pending" | "approved" | "rejected" | "canceled"; + +interface PlanCardProps { title?: string; description?: string; text: string; + outcome?: PlanOutcome; footer?: ReactNode; disableOuterSpacing?: boolean; testID?: string; -}) { - const { theme } = useUnistyles(); - const { t } = useTranslation(); - const markdownStyles = createMarkdownStyles(theme); - const markdownRules = createPlanMarkdownRules(); - const resolvedTitle = title ?? t("agentStream.permission.plan"); +} +const ThemedChevron = withUnistyles(ChevronRight); +const chevronColor = (theme: Theme) => ({ color: theme.colors.foregroundMuted }); +const markdownRules = createPlanMarkdownRules(); + +export function PlanCard(props: PlanCardProps) { + // A resolution starts its own presentation state; subsequent taps stay local. + return ; +} + +function PlanCardContent({ + title, + description, + text, + outcome, + footer, + disableOuterSpacing = false, + testID, +}: PlanCardProps) { + const { t } = useTranslation(); + const [expanded, setExpanded] = useState(outcome !== "rejected" && outcome !== "canceled"); + const labels = { + pending: title ?? t("agentStream.permission.plan"), + rejected: t("agentStream.permission.rejectedPlan"), + approved: t("agentStream.permission.approvedPlan"), + canceled: t("agentStream.permission.canceledPlan"), + }; + const resolvedTitle = labels[outcome ?? "pending"]; + const accessibilityState = useMemo(() => ({ expanded }), [expanded]); + const webExpandedState = useMemo( + () => (isWeb ? ({ "aria-expanded": expanded } as const) : null), + [expanded], + ); + const toggleExpanded = useCallback(() => setExpanded((value) => !value), []); const containerStyle = useMemo( - () => [ - styles.container, - disableOuterSpacing && styles.containerCompact, - { - backgroundColor: theme.colors.surface1, - borderColor: theme.colors.border, - }, - ], - [disableOuterSpacing, theme.colors.surface1, theme.colors.border], + () => [styles.container, disableOuterSpacing && styles.containerCompact], + [disableOuterSpacing], ); - const titleStyle = useMemo( - () => [styles.title, { color: theme.colors.foreground }], - [theme.colors.foreground], - ); - const descriptionStyle = useMemo( - () => [styles.description, { color: theme.colors.foregroundMuted }], - [theme.colors.foregroundMuted], + const chevronStyle = useMemo( + () => [styles.chevron, expanded && styles.chevronExpanded], + [expanded], ); return ( - {resolvedTitle} - {description ? {description} : null} - - {text} - + + + + + {resolvedTitle} + + {expanded ? ( + + {description ? {description} : null} + + + ) : null} {footer ? {footer} : null} ); @@ -239,16 +276,30 @@ const styles = StyleSheet.create((theme) => ({ padding: theme.spacing[3], borderRadius: theme.spacing[2], borderWidth: 1, + backgroundColor: theme.colors.surface1, + borderColor: theme.colors.border, gap: theme.spacing[2], }, containerCompact: { marginVertical: 0, }, + header: { + flexDirection: "row", + alignItems: "center", + gap: theme.spacing[2], + minHeight: 24, + }, + chevron: {}, + chevronExpanded: { transform: [{ rotate: "90deg" }] }, + body: { gap: theme.spacing[2] }, title: { + color: theme.colors.foreground, + flexShrink: 1, fontSize: theme.fontSize.base, lineHeight: 22, }, description: { + color: theme.colors.foregroundMuted, fontSize: theme.fontSize.base, lineHeight: 20, }, diff --git a/packages/app/src/i18n/resources/ar.ts b/packages/app/src/i18n/resources/ar.ts index 728374a242..90683d5ae9 100644 --- a/packages/app/src/i18n/resources/ar.ts +++ b/packages/app/src/i18n/resources/ar.ts @@ -209,6 +209,10 @@ export const ar: TranslationResources = { historyLoadFailed: "تعذر تحميل سجل الوكيل", messageCapped: "تم اقتطاع هذه الرسالة ({{bytes}} بايت).", permission: { + rejectedPlan: "خطة مرفوضة", + approvedPlan: "خطة معتمدة", + canceledPlan: "خطة ملغاة", + plan: "يخطط", required: "الإذن مطلوب", deny: "ينكر", diff --git a/packages/app/src/i18n/resources/en.ts b/packages/app/src/i18n/resources/en.ts index 3046971400..3ebc7e5b4b 100644 --- a/packages/app/src/i18n/resources/en.ts +++ b/packages/app/src/i18n/resources/en.ts @@ -206,6 +206,10 @@ export const en = { historyLoadFailed: "Couldn't load agent history", messageCapped: "This message was capped ({{bytes}} bytes).", permission: { + rejectedPlan: "Rejected plan", + approvedPlan: "Approved plan", + canceledPlan: "Canceled plan", + plan: "Plan", required: "Permission Required", deny: "Deny", diff --git a/packages/app/src/i18n/resources/es.ts b/packages/app/src/i18n/resources/es.ts index 0eb0e3b092..83955b2ed7 100644 --- a/packages/app/src/i18n/resources/es.ts +++ b/packages/app/src/i18n/resources/es.ts @@ -209,6 +209,10 @@ export const es: TranslationResources = { historyLoadFailed: "No se pudo cargar el historial del agente", messageCapped: "Este mensaje fue truncado ({{bytes}} bytes).", permission: { + rejectedPlan: "Plan rechazado", + approvedPlan: "Plan aprobado", + canceledPlan: "Plan cancelado", + plan: "Plan", required: "Permiso requerido", deny: "Denegar", diff --git a/packages/app/src/i18n/resources/fr.ts b/packages/app/src/i18n/resources/fr.ts index e46c5701ec..9eedd70af4 100644 --- a/packages/app/src/i18n/resources/fr.ts +++ b/packages/app/src/i18n/resources/fr.ts @@ -211,6 +211,10 @@ export const fr: TranslationResources = { historyLoadFailed: "Impossible de charger l’historique de l’agent", messageCapped: "Ce message a été tronqué ({{bytes}} octets).", permission: { + rejectedPlan: "Plan refusé", + approvedPlan: "Plan approuvé", + canceledPlan: "Plan annulé", + plan: "Plan", required: "Autorisation requise", deny: "Refuser", diff --git a/packages/app/src/i18n/resources/ja.ts b/packages/app/src/i18n/resources/ja.ts index 09d32471a3..f70f133412 100644 --- a/packages/app/src/i18n/resources/ja.ts +++ b/packages/app/src/i18n/resources/ja.ts @@ -209,6 +209,10 @@ export const ja: TranslationResources = { historyLoadFailed: "エージェントの履歴を読み込めませんでした", messageCapped: "このメッセージは上限で切り詰められました({{bytes}}バイト)。", permission: { + rejectedPlan: "却下されたプラン", + approvedPlan: "承認されたプラン", + canceledPlan: "キャンセルされたプラン", + plan: "プラン", required: "権限が必要です", deny: "拒否", diff --git a/packages/app/src/i18n/resources/ko.ts b/packages/app/src/i18n/resources/ko.ts index 8d9ca884b0..78ec33c3b4 100644 --- a/packages/app/src/i18n/resources/ko.ts +++ b/packages/app/src/i18n/resources/ko.ts @@ -209,6 +209,10 @@ export const ko: TranslationResources = { historyLoadFailed: "에이전트 기록을 로드할 수 없습니다.", messageCapped: "이 메시지는 길이 제한으로 잘렸습니다({{bytes}}바이트).", permission: { + rejectedPlan: "거부된 계획", + approvedPlan: "승인된 계획", + canceledPlan: "취소된 계획", + plan: "계획", required: "권한 필요", deny: "거부", diff --git a/packages/app/src/i18n/resources/pt-BR.ts b/packages/app/src/i18n/resources/pt-BR.ts index 75dcd62990..84c4352e11 100644 --- a/packages/app/src/i18n/resources/pt-BR.ts +++ b/packages/app/src/i18n/resources/pt-BR.ts @@ -209,6 +209,10 @@ export const ptBR: TranslationResources = { historyLoadFailed: "Não foi possível carregar o histórico do agente", messageCapped: "Esta mensagem foi truncada ({{bytes}} bytes).", permission: { + rejectedPlan: "Plano rejeitado", + approvedPlan: "Plano aprovado", + canceledPlan: "Plano cancelado", + plan: "Plano", required: "Permissão necessária", deny: "Negar", diff --git a/packages/app/src/i18n/resources/ru.ts b/packages/app/src/i18n/resources/ru.ts index 5cd4942ef4..9fdefa4a1e 100644 --- a/packages/app/src/i18n/resources/ru.ts +++ b/packages/app/src/i18n/resources/ru.ts @@ -209,6 +209,10 @@ export const ru: TranslationResources = { historyLoadFailed: "Не удалось загрузить историю агента", messageCapped: "Это сообщение было обрезано ({{bytes}} байт).", permission: { + rejectedPlan: "Отклонённый план", + approvedPlan: "Одобренный план", + canceledPlan: "Отменённый план", + plan: "План", required: "Требуется разрешение", deny: "Отклонить", diff --git a/packages/app/src/i18n/resources/zh-CN.ts b/packages/app/src/i18n/resources/zh-CN.ts index 6c908d90ad..299aec11fc 100644 --- a/packages/app/src/i18n/resources/zh-CN.ts +++ b/packages/app/src/i18n/resources/zh-CN.ts @@ -209,6 +209,10 @@ export const zhCN: TranslationResources = { historyLoadFailed: "无法加载智能体历史记录", messageCapped: "此消息已被截断({{bytes}} 字节)。", permission: { + rejectedPlan: "已拒绝的计划", + approvedPlan: "已批准的计划", + canceledPlan: "已取消的计划", + plan: "Plan", required: "需要权限", deny: "拒绝", diff --git a/packages/app/src/tool-calls/detail-level/projection.test.ts b/packages/app/src/tool-calls/detail-level/projection.test.ts index c0e32c3124..dabf87b73d 100644 --- a/packages/app/src/tool-calls/detail-level/projection.test.ts +++ b/packages/app/src/tool-calls/detail-level/projection.test.ts @@ -63,6 +63,28 @@ function project(input: { } describe("tool call detail-level projection", () => { + it.each(["detailed", "overview"] as const)( + "keeps pending approval tools out of %s presentation without removing their canonical position", + (level) => { + const pending = toolCall( + "1", + { type: "plan", text: "Ship it" }, + { name: "ExitPlanMode", status: "running" }, + ); + const followUp = { + kind: "user_message", + id: "question", + text: "What about tests?", + timestamp: new Date(2), + } satisfies StreamItem; + const tail = [pending, followUp]; + expect(project({ level, tail }).tail).toEqual([followUp]); + expect(tail).toEqual([pending, followUp]); + const rejected = toolCall("1", { type: "plan", text: "Ship it" }, { name: "plan_approval" }); + expect(project({ level, tail: [rejected, followUp] }).tail).toEqual([rejected, followUp]); + }, + ); + it("passes detailed timelines through without grouping work", () => { const tail = [toolCall("1", { type: "shell", command: "one" })]; const head = [toolCall("2", { type: "shell", command: "two" })]; diff --git a/packages/app/src/tool-calls/detail-level/projection.ts b/packages/app/src/tool-calls/detail-level/projection.ts index 1740e8500b..5dc1067914 100644 --- a/packages/app/src/tool-calls/detail-level/projection.ts +++ b/packages/app/src/tool-calls/detail-level/projection.ts @@ -20,6 +20,24 @@ export interface ToolCallDetailProjection extends GroupedToolCalls(); +// Approval UI owns pending plan presentation. Retain the canonical tool in the +// stream model so resolving it can reveal a card at its original position. +const visibleItemsCache = new WeakMap(); +function visibleToolCallItems(items: StreamItem[]): StreamItem[] { + const cached = visibleItemsCache.get(items); + if (cached) return cached; + const visible = items.filter((item) => { + if (item.kind !== "tool_call" || item.payload.source !== "agent") return true; + const data = item.payload.data; + return ( + data.name !== "ExitPlanMode" && !(data.name === "plan_approval" && data.status === "running") + ); + }); + const result = visible.length === items.length ? items : visible; + visibleItemsCache.set(items, result); + return result; +} + export function prepareToolCallHistory( level: ToolCallDetailLevel, tail: StreamItem[], @@ -29,7 +47,10 @@ export function prepareToolCallHistory( } return { mode: "overview", - grouped: prepareGroupedHistory({ tail, buildGroup: buildOverviewGroup }), + grouped: prepareGroupedHistory({ + tail: visibleToolCallItems(tail), + buildGroup: buildOverviewGroup, + }), }; } @@ -42,8 +63,8 @@ export function projectToolCallDetailLevel(input: { }): ToolCallDetailProjection { if (input.level === "detailed") { return { - tail: input.tail, - head: input.head, + tail: visibleToolCallItems(input.tail), + head: visibleToolCallItems(input.head), groupsByHostId: EMPTY_TOOL_CALL_GROUPS, historyGroupUpdatesByHostId: EMPTY_TOOL_CALL_GROUPS, }; @@ -53,7 +74,7 @@ export function projectToolCallDetailLevel(input: { } return groupLiveToolCalls({ history: input.preparedHistory.grouped, - head: input.head, + head: visibleToolCallItems(input.head), isTurnActive: input.isTurnActive, buildGroup: buildOverviewGroup, }); diff --git a/packages/app/src/tool-calls/presentation.ts b/packages/app/src/tool-calls/presentation.ts index e43aacf771..3db13ccbbd 100644 --- a/packages/app/src/tool-calls/presentation.ts +++ b/packages/app/src/tool-calls/presentation.ts @@ -1,3 +1,4 @@ +import type { PlanOutcome } from "@/components/plan-card"; import type { ComponentType } from "react"; import type { ToolCallDetail } from "@getpaseo/protocol/agent-types"; import type { ToolCallDisplayInput } from "@/utils/tool-call-display"; @@ -31,6 +32,7 @@ export interface ToolCallPresentation { canOpenDetails: boolean; openFilePath: string | null; isPlan: boolean; + planOutcome?: PlanOutcome; } export type ToolCallIconResolver = ( @@ -75,5 +77,14 @@ export function buildToolCallPresentation( canOpenDetails: hasDetails || isLoadingDetails, openFilePath: extractToolCallFilePath(input.detail), isPlan: input.detail?.type === "plan", + planOutcome: input.detail?.type === "plan" ? resolvePlanOutcome(input) : undefined, }; } + +function resolvePlanOutcome(input: BuildToolCallPresentationInput): PlanOutcome | undefined { + if (input.status === "canceled") return "canceled"; + if (input.metadata?.approved === false) return "rejected"; + if (input.metadata?.approved === true) return "approved"; + if (input.status === "running" || input.status === "executing") return "pending"; + return undefined; +} diff --git a/packages/app/src/types/stream.test.ts b/packages/app/src/types/stream.test.ts index cd05fd0826..49fdf7daf6 100644 --- a/packages/app/src/types/stream.test.ts +++ b/packages/app/src/types/stream.test.ts @@ -23,6 +23,51 @@ import { timelineItemIdentity } from "@getpaseo/protocol/timeline-identity"; type CanonicalToolStatus = "running" | "completed" | "failed" | "canceled"; +it("updates a resolved Claude plan at its proposal position across a follow-up", () => { + const proposal = { + type: "timeline", + provider: "claude", + turnId: "turn-1", + item: { + type: "tool_call", + callId: "plan-1", + name: "ExitPlanMode", + status: "running", + error: null, + detail: { type: "plan", text: "Ship it" }, + }, + } satisfies AgentStreamEventPayload; + const pending = reduceStreamUpdate([], proposal, new Date(1)); + const steered = reduceStreamUpdate( + pending, + { + type: "timeline", + provider: "claude", + turnId: "turn-1", + item: { type: "user_message", text: "What about tests?", messageId: "question" }, + }, + new Date(2), + ); + const resolved = reduceStreamUpdate( + steered, + { + ...proposal, + item: { + ...proposal.item, + name: "plan_approval", + status: "completed", + metadata: { approved: false }, + }, + }, + new Date(3), + ); + expect(resolved.map((item) => item.kind)).toEqual(["tool_call", "user_message"]); + expect(resolved[0]?.id).toBe(pending[0]?.id); + expect(resolved[0]).toMatchObject({ + payload: { data: { name: "plan_approval", metadata: { approved: false } } }, + }); +}); + describe("plugin timeline rows", () => { it("uses the protocol identity format for stream tool and plugin rows", () => { const tool = { diff --git a/packages/app/src/types/stream.ts b/packages/app/src/types/stream.ts index 4ebd0972db..1e8a32f06f 100644 --- a/packages/app/src/types/stream.ts +++ b/packages/app/src/types/stream.ts @@ -1399,10 +1399,6 @@ function reduceTimelineToolCall( .trim() .replace(/[.\s-]+/g, "_") .toLowerCase(); - if (event.provider === "claude" && normalizedToolName === "exitplanmode") { - return state; - } - if ( event.provider === "claude" && (normalizedToolName === "todowrite" || normalizedToolName === "todo_write") diff --git a/packages/server/src/server/agent/providers/claude/agent.ts b/packages/server/src/server/agent/providers/claude/agent.ts index 4adbf0ce1c..496dfe2687 100644 --- a/packages/server/src/server/agent/providers/claude/agent.ts +++ b/packages/server/src/server/agent/providers/claude/agent.ts @@ -2515,9 +2515,8 @@ class ClaudeAgentSession implements AgentSession { } /** - * A denied request is the only record the transcript gets. Plans especially: - * the pending card is the only place the plan text lives, so losing it means - * the user can no longer read what they just declined. + * Settle the original call immediately, before waiting for the SDK tool result. + * Plan resolution must use the native call ID so it cannot move past a follow-up. */ private recordDeniedPermissionTimeline( request: AgentPermissionRequest, @@ -2538,28 +2537,24 @@ class ClaudeAgentSession implements AgentSession { return; } if (request.kind === "plan") { - let planText: string | null = null; - if (typeof request.metadata?.planText === "string") { - planText = request.metadata.planText; - } else if (typeof request.input?.plan === "string") { - planText = request.input.plan; - } - if (!planText) return; - this.pushToolCall({ - type: "tool_call", - name: "plan_approval", - callId: request.id, - status: "completed", - error: null, - detail: { type: "plan", text: planText }, - metadata: { - approved: false, - actionId: response.selectedActionId ?? "reject", - }, - }); + this.pushToolCall( + mapClaudeFailedToolCall({ + name: "ExitPlanMode", + callId: this.planToolCallId(request), + input: request.input, + error: response.message ?? "Permission denied", + metadata: { actionId: response.selectedActionId ?? "reject" }, + }), + ); } } + private planToolCallId(request: AgentPermissionRequest): string { + return typeof request.metadata?.toolUseId === "string" + ? request.metadata.toolUseId + : request.id; + } + private resolveDeniedPermission( request: AgentPermissionRequest, response: Extract, @@ -2610,8 +2605,8 @@ class ClaudeAgentSession implements AgentSession { await this.setMode(targetMode); this.pushToolCall( mapClaudeCompletedToolCall({ - name: "plan_approval", - callId: pending.request.id, + name: "ExitPlanMode", + callId: this.planToolCallId(pending.request), input: pending.request.input ?? null, output: { approved: true, diff --git a/packages/server/src/server/agent/providers/claude/tool-call-mapper.test.ts b/packages/server/src/server/agent/providers/claude/tool-call-mapper.test.ts index 0522d0cd50..f0b3ea2af1 100644 --- a/packages/server/src/server/agent/providers/claude/tool-call-mapper.test.ts +++ b/packages/server/src/server/agent/providers/claude/tool-call-mapper.test.ts @@ -1,6 +1,7 @@ import { describe, expect, it } from "vitest"; import { + mapClaudeCanceledToolCall, mapClaudeCompletedToolCall, mapClaudeFailedToolCall, mapClaudeRunningToolCall, @@ -15,6 +16,40 @@ function expectMapped(item: T | null): T { } describe("claude tool-call mapper", () => { + it("preserves a plan's original tool identity and outcome for live and replayed results", () => { + const proposal = { name: "ExitPlanMode", callId: "plan-tool-1", input: { plan: "Ship it" } }; + const pending = expectMapped(mapClaudeRunningToolCall(proposal)); + const rejected = expectMapped( + mapClaudeFailedToolCall({ ...proposal, error: "Denied by user" }), + ); + const approved = expectMapped(mapClaudeCompletedToolCall(proposal)); + const canceled = expectMapped(mapClaudeCanceledToolCall(proposal)); + expect(canceled).toMatchObject({ + callId: proposal.callId, + name: "plan_approval", + status: "canceled", + detail: { type: "plan", text: "Ship it" }, + }); + expect(pending).toMatchObject({ + callId: "plan-tool-1", + detail: { type: "plan", text: "Ship it" }, + }); + expect(rejected).toMatchObject({ + callId: pending.callId, + name: "plan_approval", + status: "completed", + error: null, + detail: pending.detail, + metadata: { approved: false }, + }); + expect(approved).toMatchObject({ + callId: pending.callId, + name: "plan_approval", + status: "completed", + detail: pending.detail, + metadata: { approved: true }, + }); + }); it("maps running shell calls with canonical fields", () => { const item = expectMapped( mapClaudeRunningToolCall({ diff --git a/packages/server/src/server/agent/providers/claude/tool-call-mapper.ts b/packages/server/src/server/agent/providers/claude/tool-call-mapper.ts index cc8ed529f0..896cdc962a 100644 --- a/packages/server/src/server/agent/providers/claude/tool-call-mapper.ts +++ b/packages/server/src/server/agent/providers/claude/tool-call-mapper.ts @@ -12,6 +12,8 @@ interface MapperParams { metadata?: Record; } +const ClaudePlanInputSchema = z.object({ plan: z.string() }); + const ClaudeToolCallStatusSchema = z.enum(["running", "completed", "failed", "canceled"]); type ClaudeToolCallStatus = z.infer; @@ -113,6 +115,28 @@ function mapClaudeToolCall( } const trimmedName = raw.name.trim(); + if (trimmedName === "ExitPlanMode") { + const plan = ClaudePlanInputSchema.safeParse(raw.input); + if (plan.success) { + // The permission callback and transcript tool_result describe the same call. + // Keep its native identity so resolution updates the proposal's original position. + return { + type: "tool_call", + callId, + // Older clients suppress the pending native call while showing its approval card. + name: status === "running" ? trimmedName : "plan_approval", + status: status === "failed" ? "completed" : status, + error: null, + detail: { type: "plan", text: plan.data.plan }, + metadata: { + ...raw.metadata, + ...(status === "completed" || status === "failed" + ? { approved: status === "completed" } + : {}), + }, + }; + } + } const toolKind = resolveClaudeToolKind(trimmedName); const name = toolKind === "speak" ? "speak" : trimmedName; const input = raw.input ?? null; diff --git a/packages/server/src/server/agent/providers/codex-app-server-agent.process-exit.test.ts b/packages/server/src/server/agent/providers/codex-app-server-agent.process-exit.test.ts index 63a45be061..9cfeca60d3 100644 --- a/packages/server/src/server/agent/providers/codex-app-server-agent.process-exit.test.ts +++ b/packages/server/src/server/agent/providers/codex-app-server-agent.process-exit.test.ts @@ -11,6 +11,7 @@ import type { AgentLaunchContext, AgentSession, AgentSessionConfig, + AgentStreamEvent, } from "../agent-sdk-types.js"; import { CodexAppServerAgentClient, CodexAppServerAgentSession } from "./codex-app-server-agent.js"; import { @@ -20,6 +21,87 @@ import { const logger = createTestLogger(); +interface PendingPlanLifecycle { + close(): Promise; + crashDuringActiveTurn(): Promise; + expectCanceledPlan(): void; +} + +async function withPendingPlan( + runScenario: (plan: PendingPlanLifecycle) => Promise, +): Promise { + const workdir = mkdtempSync(join(tmpdir(), "codex-plan-cancel-")); + const appServer = createFakeCodexAppServer(); + const client = new ProcessExitCodexClient([appServer]); + const session = await client.createSession({ + provider: "codex", + cwd: workdir, + modeId: "auto", + model: "gpt-5.4", + featureValues: { plan_mode: true }, + }); + const events: AgentStreamEvent[] = []; + session.subscribe((event) => events.push(event)); + try { + const run = session.run("make a plan"); + await appServer.waitForTurnStart(); + appServer.startsTurn({ threadId: "thread-1", turnId: "plan-turn" }); + appServer.updatesPlan({ threadId: "thread-1", steps: ["Inspect README"] }); + appServer.completeTurn(); + await run; + const proposal = events.find( + (event) => + event.type === "timeline" && + event.item.type === "tool_call" && + event.item.name === "plan_approval", + ); + expect(proposal).toBeDefined(); + const [request] = session.getPendingPermissions?.() ?? []; + expect(request).toBeDefined(); + await runScenario({ + close: () => session.close(), + async crashDuringActiveTurn() { + appServer.startsTurn({ threadId: "thread-1", turnId: "autonomous-turn" }); + await expect + .poll(() => events.filter((event) => event.type === "turn_started").length) + .toBe(2); + appServer.child.emit("exit", 17, null); + await expect.poll(() => events.some((event) => event.type === "turn_failed")).toBe(true); + }, + expectCanceledPlan() { + expect(session.getPendingPermissions?.()).toEqual([]); + expect(events).toContainEqual({ + ...proposal, + item: expect.objectContaining({ + type: "tool_call", + name: "plan_approval", + callId: request?.id, + status: "canceled", + detail: { type: "plan", text: "- Inspect README" }, + }), + }); + }, + }); + } finally { + await session.close(); + rmSync(workdir, { recursive: true, force: true }); + } +} + +test("closing a session retains a canceled pending plan", async () => { + await withPendingPlan(async (plan) => { + await plan.close(); + plan.expectCanceledPlan(); + }); +}); + +test("an active provider crash retains a canceled pending plan", async () => { + await withPendingPlan(async (plan) => { + await plan.crashDuringActiveTurn(); + plan.expectCanceledPlan(); + }); +}); + class ProcessExitCodexClient extends CodexAppServerAgentClient implements AgentClient { constructor(private readonly appServers: FakeCodexAppServer[]) { super(logger); diff --git a/packages/server/src/server/agent/providers/codex-app-server-agent.test.ts b/packages/server/src/server/agent/providers/codex-app-server-agent.test.ts index b86418814d..1b1ff3738d 100644 --- a/packages/server/src/server/agent/providers/codex-app-server-agent.test.ts +++ b/packages/server/src/server/agent/providers/codex-app-server-agent.test.ts @@ -5023,14 +5023,22 @@ describe("Codex app-server provider", () => { turn: { status: "completed", error: null }, }); - expect( - events.some( - (event) => - event.type === "timeline" && - event.item.type === "tool_call" && - event.item.detail.type === "plan", - ), - ).toBe(false); + expect(events.at(-3)).toEqual({ + type: "timeline", + provider: "codex", + turnId: "test-turn", + item: { + type: "tool_call", + callId: session.getPendingPermissions()[0]?.id, + name: "plan_approval", + status: "running", + error: null, + detail: { + type: "plan", + text: "- Inspect the existing auth flow\n- Implement the button behavior", + }, + }, + }); expect(events.at(-2)).toEqual({ type: "permission_requested", provider: "codex", @@ -5065,7 +5073,7 @@ describe("Codex app-server provider", () => { }); }); - test("does not emit Codex plan thread items as timeline cards while plan approval is pending", () => { + test("does not complete Codex plan timeline cards while plan approval is pending", () => { const session = createSession({ featureValues: { plan_mode: true, fast_mode: true }, }); @@ -5091,6 +5099,7 @@ describe("Codex app-server provider", () => { type: "timeline", item: expect.objectContaining({ type: "tool_call", + status: "completed", detail: expect.objectContaining({ type: "plan" }), }), }), @@ -6073,7 +6082,8 @@ describe("Codex denied plan approvals", () => { (event) => event.type === "timeline" && event.item.type === "tool_call" && - event.item.name === "plan_approval", + event.item.name === "plan_approval" && + event.item.status === "completed", ); } diff --git a/packages/server/src/server/agent/providers/codex-app-server-agent.ts b/packages/server/src/server/agent/providers/codex-app-server-agent.ts index 5e2cb3795a..8bf9ede447 100644 --- a/packages/server/src/server/agent/providers/codex-app-server-agent.ts +++ b/packages/server/src/server/agent/providers/codex-app-server-agent.ts @@ -3286,7 +3286,7 @@ interface CodexPendingPermissionHandler { resolve: (value: unknown) => void; kind: "command" | "file" | "question" | "mcp_elicitation" | "plan"; questions?: CodexQuestionPrompt[]; - planText?: string; + plan?: { text: string; turnId: string | undefined }; } interface ConsumedRootCompaction { @@ -3745,7 +3745,20 @@ export class CodexAppServerAgentSession implements AgentSession { this.pendingPermissionHandlers.set(requestId, { resolve: () => undefined, kind: "plan", - planText, + plan: { text: planText, turnId: this.activeForegroundTurnId ?? undefined }, + }); + // Reserve the proposal's place before a later prompt can resolve it. + this.emitEvent({ + type: "timeline", + provider: CODEX_PROVIDER, + item: { + type: "tool_call", + callId: requestId, + name: "plan_approval", + status: "running", + error: null, + detail: { type: "plan", text: planText }, + }, }); this.emitEvent({ type: "permission_requested", provider: CODEX_PROVIDER, request }); } @@ -4617,7 +4630,7 @@ export class CodexAppServerAgentSession implements AgentSession { let followUpPrompt: string | undefined; if (response.behavior === "allow") { followUpPrompt = this.preparePlanImplementation({ - planText: pending.planText ?? pendingRequest?.metadata?.planText, + planText: pending.plan?.text ?? pendingRequest?.metadata?.planText, }); } @@ -4649,29 +4662,7 @@ export class CodexAppServerAgentSession implements AgentSession { } private resolvePlanPermission(requestId: string, resolution: AgentPermissionResponse): void { - if (resolution.behavior === "deny") { - // Every route into a denial lands here — the response handler, a new - // prompt, and an accepted steer — so the transcript record belongs here - // rather than in handlePlanPermissionResponse. - const planText = - this.pendingPermissionHandlers.get(requestId)?.planText ?? - this.pendingPermissions.get(requestId)?.metadata?.planText; - if (typeof planText === "string") { - this.emitEvent({ - type: "timeline", - provider: CODEX_PROVIDER, - item: { - type: "tool_call", - callId: requestId, - name: "plan_approval", - status: "completed", - error: null, - detail: { type: "plan", text: planText }, - metadata: { approved: false }, - }, - }); - } - } + this.recordPlanOutcome(requestId, resolution.behavior === "allow" ? "approved" : "rejected"); this.pendingPermissionHandlers.delete(requestId); this.pendingPermissions.delete(requestId); this.resolvedPermissionRequests.add(requestId); @@ -4683,6 +4674,29 @@ export class CodexAppServerAgentSession implements AgentSession { }); } + private recordPlanOutcome( + requestId: string, + outcome: "approved" | "rejected" | "canceled", + ): void { + const plan = this.pendingPermissionHandlers.get(requestId)?.plan; + if (plan) { + this.emitEvent({ + type: "timeline", + provider: CODEX_PROVIDER, + turnId: plan.turnId, + item: { + type: "tool_call", + callId: requestId, + name: "plan_approval", + status: outcome === "canceled" ? "canceled" : "completed", + error: null, + detail: { type: "plan", text: plan.text }, + metadata: outcome === "canceled" ? {} : { approved: outcome === "approved" }, + }, + }); + } + } + private emitDeniedToolCallTimelineEvent(params: { requestId: string; response: Extract; @@ -4851,6 +4865,9 @@ export class CodexAppServerAgentSession implements AgentSession { if (options?.preservePlanApprovals && pending.kind === "plan") { continue; } + if (pending.kind === "plan") { + this.recordPlanOutcome(requestId, "canceled"); + } pending.resolve({ decision: "cancel" }); this.pendingPermissionHandlers.delete(requestId); this.pendingPermissions.delete(requestId); @@ -5172,7 +5189,7 @@ export class CodexAppServerAgentSession implements AgentSession { } private notifySubscribers(event: AgentStreamEvent): void { - const turnId = this.activeForegroundTurnId; + const turnId = getAgentStreamEventTurnId(event) ?? this.activeForegroundTurnId; const tagged = turnId ? { ...event, turnId } : event; this.logger.trace( { From d1b705a0cd91617a5707fae25d80cb0be3057950 Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Sat, 12 Sep 2026 09:50:25 +0200 Subject: [PATCH 29/46] fix(app): restore Android clipboard image pasting (#4758) Decode clipboard image data URLs locally because native fetch rejects them. Add native-runtime and byte-integrity regression coverage, and clarify empty-clipboard guidance. --- packages/app/e2e/mobile/clipboard-image.mjs | 96 +++++++++++++++++++ .../local-file-attachment-store.test.ts | 73 ++++++++++++-- .../local-file-attachment-store.ts | 9 +- packages/app/src/i18n/resources/ar.ts | 2 +- packages/app/src/i18n/resources/en.ts | 2 +- packages/app/src/i18n/resources/es.ts | 3 +- packages/app/src/i18n/resources/fr.ts | 3 +- packages/app/src/i18n/resources/ja.ts | 3 +- packages/app/src/i18n/resources/ko.ts | 2 +- packages/app/src/i18n/resources/pt-BR.ts | 2 +- packages/app/src/i18n/resources/ru.ts | 3 +- packages/app/src/i18n/resources/zh-CN.ts | 2 +- 12 files changed, 180 insertions(+), 20 deletions(-) create mode 100644 packages/app/e2e/mobile/clipboard-image.mjs diff --git a/packages/app/e2e/mobile/clipboard-image.mjs b/packages/app/e2e/mobile/clipboard-image.mjs new file mode 100644 index 0000000000..2a93f5ce20 --- /dev/null +++ b/packages/app/e2e/mobile/clipboard-image.mjs @@ -0,0 +1,96 @@ +// Run against a native dev app loaded from this checkout: +// node packages/app/e2e/mobile/clipboard-image.mjs +// Exercises real native attachment persistence; Node's fetch(data:) hides this regression. +import assert from "node:assert/strict"; +import { readFile } from "node:fs/promises"; +import { setTimeout as delay } from "node:timers/promises"; +import { WebSocket } from "ws"; + +const [, , metroPort, appId] = process.argv; +assert(metroPort && appId, "Pass the Metro port and native app ID"); +const targets = await fetch(`http://127.0.0.1:${Number(metroPort)}/json/list`).then((r) => + r.json(), +); +const target = targets.find((candidate) => candidate.appId === appId); +assert(target, `Missing Hermes target for ${appId}`); +const png = await readFile(new URL("../../assets/images/icon.png", import.meta.url)); +const base64 = png.toString("base64"); +const socket = new WebSocket(target.webSocketDebuggerUrl); +await new Promise((resolve, reject) => { + socket.once("open", resolve); + socket.once("error", reject); +}); + +let nextId = 0; +function evaluate(expression) { + return new Promise((resolve, reject) => { + const id = ++nextId; + const timeout = setTimeout(() => { + socket.off("message", onMessage); + reject(new Error("Hermes evaluation timed out")); + }, 10000); + function onMessage(message) { + const response = JSON.parse(message.toString()); + if (response.id !== id) return; + clearTimeout(timeout); + socket.off("message", onMessage); + if (response.error || response.result.exceptionDetails) { + reject(new Error(JSON.stringify(response.error ?? response.result.exceptionDetails))); + } else { + resolve(response.result.result.value); + } + } + socket.on("message", onMessage); + socket.send( + JSON.stringify({ + id, + method: "Runtime.evaluate", + params: { expression, returnByValue: true }, + }), + ); + }); +} + +async function waitForNativeClipboardImage() { + const deadline = Date.now() + 15000; + do { + const result = await evaluate("globalThis.__clipboardImageTest"); + if (result) return result; + await delay(100); + } while (Date.now() < deadline); + assert.fail("Native image persistence timed out"); +} + +try { + // Metro's debugger module table locates the same public service used by the composer. + // Hermes does not await promises through Runtime.evaluate, so collect the async result. + await evaluate(`(() => { + const entry = Array.from(__r.getModules().entries()).find( + ([, module]) => module.verboseName === "src/attachments/service.ts" + ); + if (!entry) throw new Error("Open the composer before running this test"); + const service = __r(entry[0]); + globalThis.__clipboardImageTest = null; + service.persistAttachmentFromDataUrl({ + dataUrl: ${JSON.stringify(`data:image/png;base64,${base64}`)}, + fileName: "clipboard.png" + }).then(attachment => { + return Promise.all([ + service.encodeAttachmentsForSend([attachment]), + service.resolveAttachmentPreviewUrl(attachment) + ]).then(([encoded, preview]) => ({ attachment, encoded, preview })) + .finally(() => service.deleteAttachments([attachment])); + }).then(result => { globalThis.__clipboardImageTest = result; }) + .catch(error => { globalThis.__clipboardImageTest = { error: String(error) }; }); + })()`); + const result = await waitForNativeClipboardImage(); + assert.equal(result.error, undefined, result.error); + assert.equal(result.attachment.storageType, "native-file"); + assert.equal(result.attachment.byteSize, png.length); + assert.equal(result.attachment.fileName, "clipboard.png"); + assert.match(result.preview, /^file:\/\//); + assert.deepEqual(result.encoded, [{ data: base64, mimeType: "image/png" }]); + console.log(`PASS: native clipboard PNG persisted, previewed, and encoded (${png.length} bytes)`); +} finally { + await evaluate("delete globalThis.__clipboardImageTest").finally(() => socket.close()); +} diff --git a/packages/app/src/attachments/local-file-attachment-store.test.ts b/packages/app/src/attachments/local-file-attachment-store.test.ts index 48f974fa1a..82774aa723 100644 --- a/packages/app/src/attachments/local-file-attachment-store.test.ts +++ b/packages/app/src/attachments/local-file-attachment-store.test.ts @@ -2,16 +2,75 @@ import { describe, expect, it } from "vitest"; import { createLocalFileAttachmentStore } from "./local-file-attachment-store"; import { createTestAttachmentFileSystem } from "./test-attachment-file-system"; +function createStore() { + const fileSystem = createTestAttachmentFileSystem(); + const store = createLocalFileAttachmentStore({ + storageType: "native-file", + baseDirectoryName: "preview-assets", + fileSystem, + resolvePreviewUrl: async (attachment) => `file://${attachment.storageKey}`, + }); + return { fileSystem, store }; +} + describe("local file attachment store", () => { - it("writes raw byte sources directly to the managed file path", async () => { - const fileSystem = createTestAttachmentFileSystem(); - const store = createLocalFileAttachmentStore({ - storageType: "native-file", - baseDirectoryName: "preview-assets", - fileSystem, - resolvePreviewUrl: async (attachment) => `file://${attachment.storageKey}`, + it.each([ + "data:image/png;base64,AAECAwQF+/8=", + "data:image/png;charset=utf-8;base64,AAEC\nAwQF+/8=", + ])("preserves binary image bytes from %s", async (dataUrl) => { + const { fileSystem, store } = createStore(); + const attachment = await store.save({ + id: "clipboard", + fileName: "clipboard.png", + source: { kind: "data_url", dataUrl }, }); + expect(attachment).toMatchObject({ + mimeType: "image/png", + fileName: "clipboard.png", + byteSize: 8, + storageType: "native-file", + storageKey: "/cache/preview-assets/clipboard.png", + }); + expect(fileSystem.files.get("file:///cache/preview-assets/clipboard.png")).toEqual( + new Uint8Array([0, 1, 2, 3, 4, 5, 251, 255]), + ); + await expect(store.encodeBase64({ attachment })).resolves.toBe("AAECAwQF+/8="); + await expect(store.resolvePreviewUrl({ attachment })).resolves.toBe( + "file:///cache/preview-assets/clipboard.png", + ); + }); + + it.each([ + "not-a-data-url", + "data:image/png,hello", + "data:image/png;base64,", + "data:image/png;base64,%%%%", + "data:image/png;base64,A", + ])("rejects malformed image data without writing a file: %s", async (dataUrl) => { + const { fileSystem, store } = createStore(); + await expect(store.save({ source: { kind: "data_url", dataUrl } })).rejects.toThrow(); + expect(fileSystem.files.size).toBe(0); + }); + + it("copies file URI images without altering the source", async () => { + const { fileSystem, store } = createStore(); + const bytes = new Uint8Array([0, 1, 254, 255]); + fileSystem.setFile("file:///keyboard/image.png", bytes); + const attachment = await store.save({ + id: "keyboard", + mimeType: "image/png", + source: { kind: "file_uri", uri: "file:///keyboard/image.png" }, + }); + + expect(fileSystem.files.get("file:///cache/preview-assets/keyboard.png")).toEqual(bytes); + expect(fileSystem.files.get("file:///keyboard/image.png")).toEqual(bytes); + expect(attachment.byteSize).toBe(bytes.length); + }); + + it("writes raw byte sources directly to the managed file path", async () => { + const { fileSystem, store } = createStore(); + const attachment = await store.save({ id: "preview_8_test", mimeType: "image/png", diff --git a/packages/app/src/attachments/local-file-attachment-store.ts b/packages/app/src/attachments/local-file-attachment-store.ts index b9e76a86e6..54b6347fd4 100644 --- a/packages/app/src/attachments/local-file-attachment-store.ts +++ b/packages/app/src/attachments/local-file-attachment-store.ts @@ -44,9 +44,10 @@ async function ensureDirectory(fileSystem: AttachmentFileSystem, uri: string): P await fileSystem.makeDirectory(uri, { intermediates: true }); } -async function dataUrlToBytes(dataUrl: string): Promise { - const response = await fetch(dataUrl); - return new Uint8Array(await response.arrayBuffer()); +function dataUrlToBytes(dataUrl: string): Uint8Array { + // React Native's fetch cannot read data URLs. Decode the embedded bytes locally. + const { base64 } = parseDataUrl(dataUrl); + return Uint8Array.from(atob(base64), (character) => character.charCodeAt(0)); } async function blobToBytes(blob: Blob): Promise { @@ -70,7 +71,7 @@ async function writeFromSource(input: { let bytes: Uint8Array; if (input.source.kind === "data_url") { - bytes = await dataUrlToBytes(input.source.dataUrl); + bytes = dataUrlToBytes(input.source.dataUrl); } else if (input.source.kind === "blob") { bytes = await blobToBytes(input.source.blob); } else { diff --git a/packages/app/src/i18n/resources/ar.ts b/packages/app/src/i18n/resources/ar.ts index 90683d5ae9..f0e080ea66 100644 --- a/packages/app/src/i18n/resources/ar.ts +++ b/packages/app/src/i18n/resources/ar.ts @@ -154,7 +154,7 @@ export const ar: TranslationResources = { initialPromptRequired: "مطلوب موجه الأولي", alreadyLoading: "جارٍ التحميل بالفعل", uploadFailed: "Failed to upload file", - noClipboardImage: "لا توجد صورة في الحافظة", + noClipboardImage: "لا توجد صورة في الحافظة الحالية. جرّب اللصق من لوحة المفاتيح.", pasteImageFailed: "تعذر لصق الصورة", fileTooLarge: "{{fileName}} is too large (max {{size}})", }, diff --git a/packages/app/src/i18n/resources/en.ts b/packages/app/src/i18n/resources/en.ts index 3ebc7e5b4b..b186b013e0 100644 --- a/packages/app/src/i18n/resources/en.ts +++ b/packages/app/src/i18n/resources/en.ts @@ -151,7 +151,7 @@ export const en = { initialPromptRequired: "Initial prompt is required", alreadyLoading: "Already loading", uploadFailed: "Failed to upload file", - noClipboardImage: "No image in clipboard", + noClipboardImage: "No image in the current clipboard. Try pasting from your keyboard.", pasteImageFailed: "Failed to paste image", fileTooLarge: "{{fileName}} is too large (max {{size}})", }, diff --git a/packages/app/src/i18n/resources/es.ts b/packages/app/src/i18n/resources/es.ts index 83955b2ed7..35ae22fb11 100644 --- a/packages/app/src/i18n/resources/es.ts +++ b/packages/app/src/i18n/resources/es.ts @@ -154,7 +154,8 @@ export const es: TranslationResources = { initialPromptRequired: "Se requiere aviso inicial", alreadyLoading: "Ya cargando", uploadFailed: "Failed to upload file", - noClipboardImage: "No hay ninguna imagen en el portapapeles", + noClipboardImage: + "No hay ninguna imagen en el portapapeles actual. Prueba a pegar desde el teclado.", pasteImageFailed: "No se pudo pegar la imagen", fileTooLarge: "{{fileName}} is too large (max {{size}})", }, diff --git a/packages/app/src/i18n/resources/fr.ts b/packages/app/src/i18n/resources/fr.ts index 9eedd70af4..da62e2d85e 100644 --- a/packages/app/src/i18n/resources/fr.ts +++ b/packages/app/src/i18n/resources/fr.ts @@ -156,7 +156,8 @@ export const fr: TranslationResources = { initialPromptRequired: "Une invite initiale est requise", alreadyLoading: "Déjà en cours de chargement", uploadFailed: "Failed to upload file", - noClipboardImage: "Aucune image dans le presse-papiers", + noClipboardImage: + "Aucune image dans le presse-papiers actuel. Essayez de coller depuis votre clavier.", pasteImageFailed: "Impossible de coller l’image", fileTooLarge: "{{fileName}} is too large (max {{size}})", }, diff --git a/packages/app/src/i18n/resources/ja.ts b/packages/app/src/i18n/resources/ja.ts index f70f133412..f07938d044 100644 --- a/packages/app/src/i18n/resources/ja.ts +++ b/packages/app/src/i18n/resources/ja.ts @@ -154,7 +154,8 @@ export const ja: TranslationResources = { initialPromptRequired: "初期プロンプトが必要です", alreadyLoading: "すでに読み込み中です", uploadFailed: "ファイルのアップロードに失敗しました", - noClipboardImage: "クリップボードに画像がありません", + noClipboardImage: + "現在のクリップボードに画像がありません。キーボードから貼り付けてみてください。", pasteImageFailed: "画像を貼り付けられませんでした", fileTooLarge: "{{fileName}}が大きすぎます(最大{{size}})", }, diff --git a/packages/app/src/i18n/resources/ko.ts b/packages/app/src/i18n/resources/ko.ts index 78ec33c3b4..b45f44a63b 100644 --- a/packages/app/src/i18n/resources/ko.ts +++ b/packages/app/src/i18n/resources/ko.ts @@ -154,7 +154,7 @@ export const ko: TranslationResources = { initialPromptRequired: "초기 프롬프트가 필요합니다", alreadyLoading: "이미 불러오는 중입니다", uploadFailed: "파일을 업로드하지 못했습니다", - noClipboardImage: "클립보드에 이미지가 없습니다.", + noClipboardImage: "현재 클립보드에 이미지가 없습니다. 키보드에서 붙여넣기를 시도해 보세요.", pasteImageFailed: "이미지를 붙여넣지 못했습니다.", fileTooLarge: "{{fileName}}이(가) 너무 큽니다 (최대 {{size}})", }, diff --git a/packages/app/src/i18n/resources/pt-BR.ts b/packages/app/src/i18n/resources/pt-BR.ts index 84c4352e11..f9e1f030f7 100644 --- a/packages/app/src/i18n/resources/pt-BR.ts +++ b/packages/app/src/i18n/resources/pt-BR.ts @@ -154,7 +154,7 @@ export const ptBR: TranslationResources = { initialPromptRequired: "O prompt inicial é obrigatório", alreadyLoading: "Já está carregando", uploadFailed: "Falha ao enviar arquivo", - noClipboardImage: "Não há imagem na área de transferência", + noClipboardImage: "Não há imagem na área de transferência atual. Tente colar pelo teclado.", pasteImageFailed: "Falha ao colar a imagem", fileTooLarge: "{{fileName}} é grande demais (máximo {{size}})", }, diff --git a/packages/app/src/i18n/resources/ru.ts b/packages/app/src/i18n/resources/ru.ts index 9fdefa4a1e..370a2b95d5 100644 --- a/packages/app/src/i18n/resources/ru.ts +++ b/packages/app/src/i18n/resources/ru.ts @@ -154,7 +154,8 @@ export const ru: TranslationResources = { initialPromptRequired: "Необходим инициализирующий промпт", alreadyLoading: "Уже загружается", uploadFailed: "Ошибка загрузки файла", - noClipboardImage: "В буфере обмена нет изображения", + noClipboardImage: + "В текущем буфере обмена нет изображения. Попробуйте вставить его с клавиатуры.", pasteImageFailed: "Не удалось вставить изображение", fileTooLarge: "Файл {{fileName}} слишком большой (максимальный размер: {{size}})", }, diff --git a/packages/app/src/i18n/resources/zh-CN.ts b/packages/app/src/i18n/resources/zh-CN.ts index 299aec11fc..210e8724d0 100644 --- a/packages/app/src/i18n/resources/zh-CN.ts +++ b/packages/app/src/i18n/resources/zh-CN.ts @@ -154,7 +154,7 @@ export const zhCN: TranslationResources = { initialPromptRequired: "初始 prompt 必填", alreadyLoading: "正在加载", uploadFailed: "Failed to upload file", - noClipboardImage: "剪贴板中没有图片", + noClipboardImage: "当前剪贴板中没有图片。请尝试通过键盘粘贴。", pasteImageFailed: "无法粘贴图片", fileTooLarge: "{{fileName}} is too large (max {{size}})", }, From d1a53255fcd559bccd95f3f9e0b6ba85dc11d420 Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Mon, 14 Sep 2026 12:25:29 +0200 Subject: [PATCH 30/46] fix(app): keep growing composers below the header (#4824) * fix(app): keep growing composers below the header Native keyboard translation left the dock at full height, so intrinsic composer growth could extend behind the header. Reserve the keyboard destination at motion boundaries and bound the composer to its stationary viewport while preserving native input scrolling and animation. * docs: reuse existing test suites and automation --- CLAUDE.md | 1 + docs/floating-panels.md | 11 ++ docs/mobile-testing.md | 7 + .../e2e/mobile/composer-keyboard/android.sh | 25 +++ .../assert-composer-keyboard.mjs | 37 +++- .../app/src/composer/draft/workspace-tab.tsx | 129 +++++++------- packages/app/src/composer/index.tsx | 3 +- .../src/composer/viewport/capacity.test.ts | 35 ++++ .../app/src/composer/viewport/capacity.ts | 13 ++ .../src/composer/viewport/index.native.tsx | 66 ++++++++ packages/app/src/composer/viewport/index.tsx | 18 ++ .../app/src/hooks/keyboard-shift-context.ts | 2 + .../app/src/hooks/keyboard-shift-policy.ts | 10 ++ .../hooks/use-keyboard-shift-style.test.ts | 23 +++ .../app/src/hooks/use-keyboard-shift-style.ts | 33 +++- packages/app/src/panels/agent-panel.tsx | 9 +- .../app/src/screens/new-workspace-screen.tsx | 159 ++++++++++-------- 17 files changed, 440 insertions(+), 141 deletions(-) create mode 100644 packages/app/src/composer/viewport/capacity.test.ts create mode 100644 packages/app/src/composer/viewport/capacity.ts create mode 100644 packages/app/src/composer/viewport/index.native.tsx create mode 100644 packages/app/src/composer/viewport/index.tsx diff --git a/CLAUDE.md b/CLAUDE.md index b6ecb7ef7e..b1f8a294ed 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -125,6 +125,7 @@ and updating `next`, integrating it after a release, and releasing a hotfix from - If you must run a broad suite, pipe output to a file and read it afterward: `npx vitest run --bail=1 > /tmp/test-output.txt 2>&1` then read the file. - Never re-run a test suite that another agent already ran and reported green — trust the result. - For full suite verification, push to CI and check GitHub Actions instead. +- Add tests to existing suites and reuse their npm scripts and CI jobs instead of creating feature-specific ones. - **Always run typecheck and lint after every change.** - **Build workspace packages before diagnosing cross-package type errors.** This repo consumes generated declarations across workspaces. If typecheck fails in a package that depends on another workspace, rebuild the owning stack first so `dist` declarations are current: - `npm run build:client` — rebuild protocol and client declarations. diff --git a/docs/floating-panels.md b/docs/floating-panels.md index 69e8f2b105..616288fbf3 100644 --- a/docs/floating-panels.md +++ b/docs/floating-panels.md @@ -154,6 +154,17 @@ layout. Preserve the translated dock's history scroll range with a far-end content inset on the inverted stream list. Update that inset only when keyboard motion settles; never drive it per frame. +The translated dock's height is not the composer's available height. Bound the +composer against the stationary space below the header through +`composer/viewport`, including its controls and attachments. Reserve the +keyboard destination at move start and release space at move end on the UI +thread. Waiting for a settled JS update lets a long draft disappear behind the +header during opening. This is a boundary-time layout change, not an animated +layout prop. Keep the input's native intrinsic sizing and internal scrolling. +New workspace also needs its setup fields to scroll when the form exceeds this +space. The Android regression must check both the header and IME boundaries; +checking only the bottom controls allowed this regression through previously. + Move the stream and composer together through `KeyboardDock`. Do not translate them independently. diff --git a/docs/mobile-testing.md b/docs/mobile-testing.md index b341a2d80e..b51033d484 100644 --- a/docs/mobile-testing.md +++ b/docs/mobile-testing.md @@ -47,6 +47,13 @@ focused input identity and IME hide/show events. It saves screenshots and logs u `.dev/agent-device-artifacts/terminal-keyboard-android`. Set `PASEO_TERMINAL_KEYBOARD_APP_ID=sh.paseo` to test an installed production build. It never submits a chat message. +`npm run test:e2e:composer-keyboard:android` covers composer growth in chat and +New workspace, plus keyboard and control interactions. Configure its daemon, +Metro, and device through the `PASEO_COMPOSER_KEYBOARD_*` variables in +`packages/app/e2e/mobile/composer-keyboard/android.sh`. Use a software keyboard; +the headless input helper cannot verify the +[visible composer constraints](floating-panels.md#gotcha-3--keyboard-layout-and-portal-anchors). + When replay diverges, read its ranked selector suggestions. Edit the script deliberately and rerun it from the beginning. `--update` is retained for compatibility but no longer rewrites scripts. ## Maestro compatibility diff --git a/packages/app/e2e/mobile/composer-keyboard/android.sh b/packages/app/e2e/mobile/composer-keyboard/android.sh index c6739b02b1..86554ff998 100755 --- a/packages/app/e2e/mobile/composer-keyboard/android.sh +++ b/packages/app/e2e/mobile/composer-keyboard/android.sh @@ -275,6 +275,11 @@ node "${ASSERT}" xml-above-y \ "${ARTIFACTS_DIR}/long-draft-keyboard-open.xml" \ "Add attachment" \ "$(read_ime_top)" +display_density="$(adb shell wm density | awk '/density:/ { density = $NF } END { print density }')" +node "${ASSERT}" xml-composer-contained \ + "${ARTIFACTS_DIR}/long-draft-keyboard-open.xml" \ + "$(read_ime_top)" \ + "${display_density}" adb shell ime set "${HELPER_IME}" >/dev/null ad fill 'editable=true' "${MESSAGE}" --settle open_gboard "${input_x}" "${input_y}" @@ -337,4 +342,24 @@ node "${ASSERT}" same-region \ 1400 \ 430 +ad press 'id="menu-button"' --settle +ad press 'id="sidebar-global-new-workspace"' --settle +ad wait 'id="workspace-create-submit"' 10000 +adb shell ime set "${HELPER_IME}" >/dev/null +ad fill 'editable=true' "${LONG_MESSAGE}" --settle +snapshot_json "${ARTIFACTS_DIR}/new-workspace-long-draft.json" +read -r new_input_x new_input_y new_input_height < <( + node "${ASSERT}" rect "${ARTIFACTS_DIR}/new-workspace-long-draft.json" "editable" +) +# The editor shrinks when the keyboard opens. Its bottom stays anchored, while +# its old center can move into the setup fields after the height constraint. +new_input_y="$((new_input_y + new_input_height / 2 - 8))" +open_gboard "${new_input_x}" "${new_input_y}" +capture_ui_xml "${ARTIFACTS_DIR}/new-workspace-long-draft-keyboard-open.xml" +capture_screen "${ARTIFACTS_DIR}/new-workspace-long-draft-keyboard-open.png" +node "${ASSERT}" xml-composer-contained \ + "${ARTIFACTS_DIR}/new-workspace-long-draft-keyboard-open.xml" \ + "$(read_ime_top)" \ + "${display_density}" + echo "Composer keyboard invariants passed" diff --git a/packages/app/e2e/mobile/composer-keyboard/assert-composer-keyboard.mjs b/packages/app/e2e/mobile/composer-keyboard/assert-composer-keyboard.mjs index 3aaff5b2e0..255d442fde 100644 --- a/packages/app/e2e/mobile/composer-keyboard/assert-composer-keyboard.mjs +++ b/packages/app/e2e/mobile/composer-keyboard/assert-composer-keyboard.mjs @@ -4,11 +4,44 @@ import sharp from "sharp"; const [, , command, ...args] = process.argv; -if (command === "rect") { +if (command === "xml-composer-contained") { + const [snapshotPath, imeTopArgument, densityArgument] = args; + const snapshot = await fs.readFile(snapshotPath, "utf8"); + const boundsFor = (id) => { + const node = snapshot.match(new RegExp(`]*resource-id="${id}"[^>]*>`)); + if (!node) throw new Error(`Missing node: ${id}`); + const bounds = node[0].match(/bounds="\[(-?\d+),(-?\d+)\]\[(-?\d+),(-?\d+)\]"/); + if (!bounds) throw new Error(`Missing bounds: ${id}`); + return { top: Number(bounds[2]), bottom: Number(bounds[4]) }; + }; + const viewport = boundsFor("composer-viewport"); + const content = boundsFor("composer-viewport-content"); + const composer = boundsFor("message-input-root"); + const controls = boundsFor("message-input-attach-button"); + const clearance = (5 * Number(densityArgument)) / 160; + const minimumTop = viewport.top + clearance; + // Android rounds layout units to physical pixels; allow one pixel of rounding. + if (content.top < minimumTop - 1 || composer.top < minimumTop - 1) { + throw new Error( + `Composer overlaps header: content=${content.top}, editor=${composer.top}, minimum=${minimumTop}`, + ); + } + if (composer.bottom > Number(imeTopArgument) || controls.bottom > Number(imeTopArgument)) { + throw new Error( + `Composer extends behind keyboard: editor=${composer.bottom}, controls=${controls.bottom}, keyboard=${imeTopArgument}`, + ); + } + process.stdout.write( + `Composer contained: top=${composer.top}, header=${viewport.top}, bottom=${composer.bottom}, keyboard=${imeTopArgument}\n`, + ); +} else if (command === "rect") { const [snapshotPath, identifier] = args; const snapshot = JSON.parse(await fs.readFile(snapshotPath, "utf8")); const node = snapshot.data.nodes.find( - (candidate) => candidate.identifier === identifier || candidate.label === identifier, + (candidate) => + candidate.identifier === identifier || + candidate.label === identifier || + (identifier === "editable" && candidate.type.endsWith("EditText")), ); if (!node) throw new Error(`Missing node: ${identifier}`); const { x, y, width, height } = node.rect; diff --git a/packages/app/src/composer/draft/workspace-tab.tsx b/packages/app/src/composer/draft/workspace-tab.tsx index 4dca9df166..742380789f 100644 --- a/packages/app/src/composer/draft/workspace-tab.tsx +++ b/packages/app/src/composer/draft/workspace-tab.tsx @@ -4,6 +4,7 @@ import { useTranslation } from "react-i18next"; import { StyleSheet } from "react-native-unistyles"; import { useSafeAreaInsets } from "react-native-safe-area-context"; import { KeyboardTranslateView } from "@/components/keyboard-translate-view"; +import { ComposerViewport, ComposerViewportContent } from "@/composer/viewport"; import { useContainerWidthBelow } from "@/hooks/use-container-width"; import invariant from "tiny-invariant"; import { Composer } from "@/composer"; @@ -621,75 +622,83 @@ export function WorkspaceDraftAgentTab({ [composerState.agentControls, handleDropdownCloseFocus, isSubmitting], ); return ( - - - {isSubmitting && draftAgent ? ( - - + + + {isSubmitting && draftAgent ? ( + + + + ) : ( + + + {formErrorMessage ? ( + + {formErrorMessage} + + ) : null} + + + )} + + + + + {importPillPress ? ( + + + + + + ) : null} + - - ) : ( - - - {formErrorMessage ? ( - - {formErrorMessage} - - ) : null} - - - )} - - - - {importPillPress ? ( - - - - - - ) : null} - - - + + + + ); } const animatedStaticStyles = RNStyleSheet.create({ inputAreaWrapper: { width: "100%", + flexShrink: 1, }, }); diff --git a/packages/app/src/composer/index.tsx b/packages/app/src/composer/index.tsx index 84de49a3b9..fa2f994fc9 100644 --- a/packages/app/src/composer/index.tsx +++ b/packages/app/src/composer/index.tsx @@ -2407,8 +2407,7 @@ function ComposerContentImpl({ const animatedStaticStyles = RNStyleSheet.create({ container: { flexDirection: "column", - // KeyboardDock reduces the available column height with bottom padding. - // Keep the composer's constraint chain shrinkable down to its scrolling input. + // Propagate the viewport's height constraint down to the scrolling input. flexShrink: 1, position: "relative", }, diff --git a/packages/app/src/composer/viewport/capacity.test.ts b/packages/app/src/composer/viewport/capacity.test.ts new file mode 100644 index 0000000000..04b82e04ea --- /dev/null +++ b/packages/app/src/composer/viewport/capacity.test.ts @@ -0,0 +1,35 @@ +import { describe, expect, it } from "vitest"; +import { resolveComposerCapacity } from "./capacity"; + +describe("composer viewport", () => { + it("leaves five points below the header for a bottom-anchored composer", () => { + const height = resolveComposerCapacity({ + height: 582, + bottomInset: 24, + keyboardShift: 308, + centered: false, + }); + expect(height).toBe(245); + expect(582 - 24 - 308 - height).toBe(5); + }); + + it("keeps a centered tablet form below the header after translation", () => { + const height = resolveComposerCapacity({ + height: 1000, + bottomInset: 80, + keyboardShift: 300, + centered: true, + }); + expect(height).toBe(310); + expect((1000 - 80 - height) / 2 - 300).toBe(5); + }); + + it("releases space after closing and responds to rotation instead of using window height", () => { + expect( + resolveComposerCapacity({ height: 582, bottomInset: 24, keyboardShift: 0, centered: false }), + ).toBe(553); + expect( + resolveComposerCapacity({ height: 300, bottomInset: 0, keyboardShift: 200, centered: false }), + ).toBe(95); + }); +}); diff --git a/packages/app/src/composer/viewport/capacity.ts b/packages/app/src/composer/viewport/capacity.ts new file mode 100644 index 0000000000..7e80b22964 --- /dev/null +++ b/packages/app/src/composer/viewport/capacity.ts @@ -0,0 +1,13 @@ +export function resolveComposerCapacity(input: { + height: number; + bottomInset: number; + keyboardShift: number; + centered: boolean; +}): number { + "worklet"; + // A centered form grows upward by half its height. Reserve both halves so + // translating it still leaves five layout points below the header. + const clearance = input.keyboardShift + 5; + const reservedSpace = input.centered ? clearance * 2 : clearance; + return Math.max(0, input.height - input.bottomInset - reservedSpace); +} diff --git a/packages/app/src/composer/viewport/index.native.tsx b/packages/app/src/composer/viewport/index.native.tsx new file mode 100644 index 0000000000..0ee128a0f8 --- /dev/null +++ b/packages/app/src/composer/viewport/index.native.tsx @@ -0,0 +1,66 @@ +import { createContext, useCallback, useContext } from "react"; +import { View, type LayoutChangeEvent, type ViewProps } from "react-native"; +import Animated, { + useAnimatedStyle, + useSharedValue, + useDerivedValue, + type SharedValue, +} from "react-native-reanimated"; +import { useKeyboardShift } from "@/hooks/keyboard-shift-context"; +import { resolveComposerCapacity } from "./capacity"; + +const ViewportCapacity = createContext | null>(null); + +interface ComposerViewportProps extends ViewProps { + bottomInset?: number; + centered?: boolean; +} + +/** Measure the stationary space below the header, outside keyboard translation. */ +export function ComposerViewport({ + children, + onLayout, + bottomInset = 0, + centered = false, + ...props +}: ComposerViewportProps) { + const measuredHeight = useSharedValue(0); + const { layoutShift } = useKeyboardShift(); + const capacity = useDerivedValue(() => { + if (measuredHeight.value === 0) return undefined; + return resolveComposerCapacity({ + height: measuredHeight.value, + bottomInset, + keyboardShift: layoutShift.value, + centered, + }); + }); + const measureViewport = useCallback( + (event: LayoutChangeEvent) => { + measuredHeight.value = event.nativeEvent.layout.height; + onLayout?.(event); + }, + [measuredHeight, onLayout], + ); + + return ( + + {children} + + ); +} + +/** Bound the complete composer (including its controls), not just the text input. */ +export function ComposerViewportContent({ style, ...props }: ViewProps) { + const capacity = useContext(ViewportCapacity); + if (!capacity) throw new Error("ComposerViewportContent requires ComposerViewport"); + const constraint = useAnimatedStyle(() => ({ + // Only viewport changes and keyboard start/end events trigger layout. Motion + // stays in KeyboardTranslateView and never changes this constraint per frame. + maxHeight: capacity.value, + })); + + return ( + + ); +} diff --git a/packages/app/src/composer/viewport/index.tsx b/packages/app/src/composer/viewport/index.tsx new file mode 100644 index 0000000000..bc2846a8bd --- /dev/null +++ b/packages/app/src/composer/viewport/index.tsx @@ -0,0 +1,18 @@ +import { View, type ViewProps } from "react-native"; + +interface ComposerViewportProps extends ViewProps { + bottomInset?: number; + centered?: boolean; +} + +export function ComposerViewport({ + bottomInset: _bottomInset, + centered: _centered, + ...props +}: ComposerViewportProps) { + return ; +} + +export function ComposerViewportContent(props: ViewProps) { + return ; +} diff --git a/packages/app/src/hooks/keyboard-shift-context.ts b/packages/app/src/hooks/keyboard-shift-context.ts index 9d41f13224..dcb0b4cf64 100644 --- a/packages/app/src/hooks/keyboard-shift-context.ts +++ b/packages/app/src/hooks/keyboard-shift-context.ts @@ -3,6 +3,8 @@ import type { SharedValue } from "react-native-reanimated"; export interface KeyboardShiftContextValue { shift: SharedValue; + /** Space reserved at keyboard boundaries, never updated by animation frames. */ + layoutShift: SharedValue; isMoving: SharedValue; bottomInset: SharedValue; } diff --git a/packages/app/src/hooks/keyboard-shift-policy.ts b/packages/app/src/hooks/keyboard-shift-policy.ts index 19c021a26c..97435027fa 100644 --- a/packages/app/src/hooks/keyboard-shift-policy.ts +++ b/packages/app/src/hooks/keyboard-shift-policy.ts @@ -1,5 +1,15 @@ export const DEFAULT_IOS_KEYBOARD_INSET_MIN_HEIGHT = 120; +/** Reserve both endpoints during motion so content cannot grow into a closing IME. */ +export function reserveKeyboardLayoutShift(input: { + current: number; + target: number; + phase: "start" | "end"; +}): number { + "worklet"; + return input.phase === "start" ? Math.max(input.current, input.target) : input.target; +} + export function resolveStreamKeyboardInset(input: { platform: "android" | "ios"; settledShift: number; diff --git a/packages/app/src/hooks/use-keyboard-shift-style.test.ts b/packages/app/src/hooks/use-keyboard-shift-style.test.ts index bada7dd74c..c85f26dde4 100644 --- a/packages/app/src/hooks/use-keyboard-shift-style.test.ts +++ b/packages/app/src/hooks/use-keyboard-shift-style.test.ts @@ -3,9 +3,32 @@ import { resolveStreamKeyboardInset, shouldReconcileHiddenKeyboardEnd, resolveKeyboardShift, + reserveKeyboardLayoutShift, shouldUseCompactExplorerKeyboardPadding, } from "./keyboard-shift-policy"; +describe("keyboard layout reservation", () => { + it("reserves the opening destination before motion and releases it only after closing", () => { + const opened = reserveKeyboardLayoutShift({ current: 0, target: 308, phase: "start" }); + expect(opened).toBe(308); + const closing = reserveKeyboardLayoutShift({ current: opened, target: 0, phase: "start" }); + expect(closing).toBe(308); + expect(reserveKeyboardLayoutShift({ current: closing, target: 0, phase: "end" })).toBe(0); + }); + + it("keeps enough space when a resize or reversal interrupts keyboard motion", () => { + const resizing = reserveKeyboardLayoutShift({ current: 308, target: 250, phase: "start" }); + expect(resizing).toBe(308); + const reversing = reserveKeyboardLayoutShift({ + current: resizing, + target: 340, + phase: "start", + }); + expect(reversing).toBe(340); + expect(reserveKeyboardLayoutShift({ current: reversing, target: 340, phase: "end" })).toBe(340); + }); +}); + describe("resolveStreamKeyboardInset", () => { it("uses the native scroll inset on iOS without changing content size", () => { expect(resolveStreamKeyboardInset({ platform: "ios", settledShift: 311 })).toEqual({ diff --git a/packages/app/src/hooks/use-keyboard-shift-style.ts b/packages/app/src/hooks/use-keyboard-shift-style.ts index 126ab9e9b3..2bd57d105c 100644 --- a/packages/app/src/hooks/use-keyboard-shift-style.ts +++ b/packages/app/src/hooks/use-keyboard-shift-style.ts @@ -17,6 +17,7 @@ import { scheduleOnRN } from "react-native-worklets"; import { DEFAULT_IOS_KEYBOARD_INSET_MIN_HEIGHT, resolveKeyboardShift, + reserveKeyboardLayoutShift, shouldReconcileHiddenKeyboardEnd, } from "@/hooks/keyboard-shift-policy"; import { @@ -33,6 +34,7 @@ export function KeyboardShiftProvider({ children }: { children: ReactNode }) { const bottomInset = useSharedValue(insets.bottom); const isIos = Platform.OS === "ios"; const isMoving = useSharedValue(false); + const layoutShift = useSharedValue(0); const [settledShift, setSettledShift] = useState(0); const publishSettledShift = useCallback((nextShift: number) => { setSettledShift((currentShift) => (currentShift === nextShift ? currentShift : nextShift)); @@ -55,12 +57,36 @@ export function KeyboardShiftProvider({ children }: { children: ReactNode }) { useGenericKeyboardHandler( { - onStart: () => { + onStart: (event) => { "worklet"; + const target = resolveKeyboardShift({ + rawKeyboardHeight: event.height, + keyboardProgress: event.height > 0 ? 1 : 0, + bottomInset: bottomInset.value, + isIos, + iosMinHeight: DEFAULT_IOS_KEYBOARD_INSET_MIN_HEIGHT, + }); + layoutShift.value = reserveKeyboardLayoutShift({ + current: layoutShift.value, + target, + phase: "start", + }); isMoving.value = true; }, onEnd: (event) => { "worklet"; + const target = resolveKeyboardShift({ + rawKeyboardHeight: event.height, + keyboardProgress: event.progress, + bottomInset: bottomInset.value, + isIos, + iosMinHeight: DEFAULT_IOS_KEYBOARD_INSET_MIN_HEIGHT, + }); + layoutShift.value = reserveKeyboardLayoutShift({ + current: layoutShift.value, + target, + phase: "end", + }); if (isIos && shouldReconcileHiddenKeyboardEnd(event)) { keyboardHeight.value = 0; keyboardProgress.value = 0; @@ -68,7 +94,7 @@ export function KeyboardShiftProvider({ children }: { children: ReactNode }) { isMoving.value = false; }, }, - [isIos, isMoving, keyboardHeight, keyboardProgress], + [bottomInset, isIos, isMoving, keyboardHeight, keyboardProgress, layoutShift], ); useAnimatedReaction( @@ -84,10 +110,11 @@ export function KeyboardShiftProvider({ children }: { children: ReactNode }) { const value = useMemo( () => ({ shift, + layoutShift, isMoving, bottomInset, }), - [bottomInset, isMoving, shift], + [bottomInset, isMoving, layoutShift, shift], ); return createElement( diff --git a/packages/app/src/panels/agent-panel.tsx b/packages/app/src/panels/agent-panel.tsx index 84f86bf745..5f26342542 100644 --- a/packages/app/src/panels/agent-panel.tsx +++ b/packages/app/src/panels/agent-panel.tsx @@ -24,6 +24,7 @@ import { useStoreWithEqualityFn } from "zustand/traditional"; import { AgentStreamView, type AgentStreamViewHandle } from "@/agent-stream/view"; import { ArchivedAgentCallout } from "@/components/archived-agent-callout"; import { KeyboardDock } from "@/components/keyboard-dock"; +import { ComposerViewport, ComposerViewportContent } from "@/composer/viewport"; import { FileDropZone } from "@/components/file-drop/file-drop-zone"; import { useRetainedPanelActive } from "@/components/retained-panel"; import { Composer } from "@/composer"; @@ -1359,7 +1360,7 @@ const ChatAgentReadyContent = memo(function ChatAgentReadyContent({ setText={agentInputDraft.replaceText} onRewindComplete={handleRewindComplete} > - + {contentContainer} @@ -1386,7 +1387,9 @@ const ChatAgentReadyContent = memo(function ChatAgentReadyContent({ ) : null} - {composerSection} + + {composerSection} + {showHistorySyncOverlay ? ( @@ -1404,7 +1407,7 @@ const ChatAgentReadyContent = memo(function ChatAgentReadyContent({ {t("agentPanel.states.archivingSubtitle")} ) : null} - + ); }); diff --git a/packages/app/src/screens/new-workspace-screen.tsx b/packages/app/src/screens/new-workspace-screen.tsx index 7f7b6b13a8..a9baaf007d 100644 --- a/packages/app/src/screens/new-workspace-screen.tsx +++ b/packages/app/src/screens/new-workspace-screen.tsx @@ -12,6 +12,8 @@ import { useQuery, useQueryClient } from "@tanstack/react-query"; import { ChevronDown, Folder, FolderPlus, GitBranch, GitPullRequest } from "lucide-react-native"; import { Composer } from "@/composer"; import { KeyboardTranslateView } from "@/components/keyboard-translate-view"; +import { ComposerViewport, ComposerViewportContent } from "@/composer/viewport"; +import { ScrollView } from "@/components/ui/scroll-view"; import { FileDropZone } from "@/components/file-drop/file-drop-zone"; import { resolveComposerAttachmentSubmitFormat, @@ -745,7 +747,7 @@ function getContentStyle(input: { isCompact: boolean; insetBottom: number }) { if (input.isCompact) { return [styles.content, styles.contentCompact, { paddingBottom: input.insetBottom }]; } - return [styles.content, styles.contentCentered]; + return [styles.content, styles.contentCentered, { paddingBottom: input.insetBottom }]; } function normalizeBranchDetails( @@ -2282,9 +2284,13 @@ export function NewWorkspaceScreen({ ], ); + const contentBottomInset = useMemo( + () => (isCompact ? insets.bottom : HEADER_INNER_HEIGHT + theme.spacing[6]), + [isCompact, insets.bottom, theme.spacing], + ); const contentStyle = useMemo( - () => getContentStyle({ isCompact, insetBottom: insets.bottom }), - [isCompact, insets.bottom], + () => getContentStyle({ isCompact, insetBottom: contentBottomInset }), + [isCompact, contentBottomInset], ); const agentControlsWithDisabled = useMemo( @@ -2370,85 +2376,97 @@ export function NewWorkspaceScreen({ return ( - + - - {t("newWorkspace.title")} - - {formStack} - {isTerminalLaunch ? ( - - ) : ( - - )} - {errorMessage ? {errorMessage} : null} + + + + {t("newWorkspace.title")} + + {formStack} + + {isTerminalLaunch ? ( + + ) : ( + + )} + {errorMessage ? {errorMessage} : null} + - + ); } const animatedStaticStyles = RNStyleSheet.create({ centered: { + flexShrink: 1, width: "100%", maxWidth: MAX_CONTENT_WIDTH, }, + form: { + flexShrink: 1, + }, + setup: { + flexGrow: 0, + flexShrink: 1, + }, }); const styles = StyleSheet.create((theme) => ({ @@ -2464,7 +2482,6 @@ const styles = StyleSheet.create((theme) => ({ }, contentCentered: { justifyContent: "center", - paddingBottom: HEADER_INNER_HEIGHT + theme.spacing[6], }, contentCompact: { justifyContent: "flex-end", From cdc669fde6deb54fee6d4ad6e1f3756cd8726421 Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Mon, 14 Sep 2026 16:51:10 +0200 Subject: [PATCH 31/46] fix(agents): prevent OOM from cumulative tool history (#4838) * fix(agents): prevent OOM from cumulative tool history Repeated progress updates retained complete historical copies of growing tool output. Retain projected items on ingestion and serve them for every history fetch, preserving source sequence coverage for catch-up. Keep intermediate payloads only in streaming; confine compatibility degradation to child transcripts and overwritten fork checkpoints. * test(client): expect projected subagent capability in hello * fix(app): report unexpected child history failures Keep connection failures recoverable while forwarding unexpected fetch and reconciliation errors to diagnostics. Name observer arguments to keep server and agent identities explicit. * test(agents): assert projected history after rewind * docs(protocol): tag projected child compatibility gates --- docs/timeline-sync.md | 25 +- .../src/panels/provider-subagent-panel.tsx | 36 ++- .../app/src/subagents/provider-store.test.ts | 272 +++++++++++++++++- packages/app/src/subagents/provider-store.ts | 256 ++++++++++------- packages/client/src/connection/index.ts | 1 + packages/client/src/daemon-client.test.ts | 1 + packages/client/src/daemon-client.ts | 4 + packages/protocol/src/client-capabilities.ts | 2 + packages/protocol/src/messages.ts | 15 + .../src/server/agent/activity-curator.test.ts | 76 +++-- .../src/server/agent/activity-curator.ts | 17 +- .../agent-manager-stream-coalescing.test.ts | 94 +++--- .../src/server/agent/agent-manager.test.ts | 49 ++-- .../server/src/server/agent/agent-manager.ts | 6 +- .../agent/agent-timeline-store-types.ts | 7 +- .../server/agent/agent-timeline-store.test.ts | 128 ++++++++- .../src/server/agent/agent-timeline-store.ts | 269 ++++------------- .../agent/projected-timeline-contract.test.ts | 201 +++++++++++++ .../agent/provider-subagents/store.test.ts | 9 +- .../server/agent/provider-subagents/store.ts | 26 +- .../src/server/agent/rewind/rewind.test.ts | 16 +- .../src/server/agent/timeline-projection.ts | 132 ++++++--- .../timeline-reconnect-contract.e2e.test.ts | 50 +++- .../daemon-e2e/timeline-window.e2e.test.ts | 29 +- packages/server/src/server/session.ts | 180 ++++-------- .../server/src/server/websocket-server.ts | 2 + 26 files changed, 1190 insertions(+), 713 deletions(-) create mode 100644 packages/server/src/server/agent/projected-timeline-contract.test.ts diff --git a/docs/timeline-sync.md b/docs/timeline-sync.md index 1c3b1e4593..740a040d90 100644 --- a/docs/timeline-sync.md +++ b/docs/timeline-sync.md @@ -5,8 +5,10 @@ Agent chat delivery has two paths: 1. **Live stream** — `agent_stream` WebSocket messages for immediacy. These may be delta-shaped lifecycle updates. 2. **Authoritative history** — `fetch_agent_timeline_request` for correctness. This always returns full projected timeline items, never lifecycle deltas. -The daemon keeps canonical rows only for its runtime. Provider history is the durable transcript -authority and repopulates those rows when an agent resumes. +The daemon retains projected items in memory. Each source event advances the stream sequence, +then replaces the previous tool state or merges into the current text item. Intermediate payloads +are never retained for history or catch-up. Provider history is the durable transcript authority +and rebuilds the projection when an agent resumes. The invariants are: @@ -67,7 +69,10 @@ Provider message IDs are not guaranteed for every displayed item. Paseo-generate Actions that address a point in chat history, such as Fork, use the daemon timeline `epoch` plus the projected item's `seqEnd`. The app carries that position on the rendered assistant item for both live and fetched history. When adjacent projected chunks merge, the merged item retains the newer chunk's position. -The daemon validates that the epoch is current and the exact source sequence still exists before slicing rows. It slices before projection so later lifecycle updates cannot leak into the selected context. +The daemon validates the epoch and locates the projected item at the selected position. A fork +includes projected items through that checkpoint. If an item spans the checkpoint and changed +afterward, the daemon refuses that fork with an actionable error: discarded historical payloads +cannot be reconstructed from sequence metadata. Forking the current context remains available. ## Resume behavior @@ -93,6 +98,20 @@ history replay. The pending approval UI can hide that tool from presentation, bu must retain its position. Creating a new history card on rejection places it after the prompt that rejected it; changing steer-event ordering would also put new assistant output before that prompt. +## Provider child history + +Child transcripts use the same projected-page reconciliation as the main conversation. The client +retains rendered items and sequence cursors, never a second cache of source events. Pagination +uses the projected display anchor; live updates advance the source cursor without moving tools. + +Clients advertising `projected_subagent_timeline` receive child streams and projected fetches. +Updated clients require `features.projectedSubagentTimeline` for child history; older hosts show +an update-host notice for that pane. +Older clients retain child names/status and receive an upgrade message when opening a child +conversation. This gate affects only the child transcript; it does not gate the connection, +main conversation, or current-context forks. A legacy `canonical` root request still receives +projected items. + ## Client replica lifetime The session projection remains host-scoped for as long as the host is registered. The viewed-timeline diff --git a/packages/app/src/panels/provider-subagent-panel.tsx b/packages/app/src/panels/provider-subagent-panel.tsx index 80323561ba..8fc18eb0e3 100644 --- a/packages/app/src/panels/provider-subagent-panel.tsx +++ b/packages/app/src/panels/provider-subagent-panel.tsx @@ -21,6 +21,7 @@ import { providerSubagentKey, providerSubagentLifecycleStatus, refreshProviderSubagents, + observeProviderSubagentTimeline, useProviderSubagentStore, } from "@/subagents/provider-store"; import { useTranslation } from "react-i18next"; @@ -126,8 +127,8 @@ function ProviderSubagentPanel() { ); const client = useSessionStore((state) => state.sessions[serverId]?.client ?? null); const serverInfo = useSessionStore((state) => state.sessions[serverId]?.serverInfo ?? null); - // COMPAT(providerSubagents): added in v0.2.11, remove after 2027-01-12. - const supported = serverInfo?.features?.providerSubagents === true; + // COMPAT(projectedSubagentTimeline): added after v0.8.0, remove after 2027-03-14. + const supported = serverInfo?.features?.projectedSubagentTimeline === true; const [isLoadingOlder, setIsLoadingOlder] = useState(false); const isCompact = useIsCompactFormFactor(); const childRows = useSubagentsForParent({ @@ -150,23 +151,28 @@ function ProviderSubagentPanel() { useEffect(() => { if (!client || !supported) return; - void client - .fetchProviderSubagentTimeline(target.parentAgentId, target.subagentId, { - direction: "tail", - limit: TIMELINE_FETCH_PAGE_SIZE, - }) - .then((payload) => { - useProviderSubagentStore.getState().replaceTimeline(serverId, payload); - return undefined; - }) - .catch(() => undefined); - }, [client, serverId, supported, target.parentAgentId, target.subagentId]); + return observeProviderSubagentTimeline({ + client, + serverId, + parentAgentId: target.parentAgentId, + subagentId: target.subagentId, + limit: TIMELINE_FETCH_PAGE_SIZE, + reportError: (error) => { + console.error("[ProviderSubagentTimeline] Failed to refresh child history", { + error, + serverId, + parentAgentId: target.parentAgentId, + subagentId: target.subagentId, + }); + }, + }); + }, [client, supported, serverId, target.parentAgentId, target.subagentId]); const loadOlder = useCallback((): boolean => { if (!client || !supported || isLoadingOlder || !timeline?.hasOlder || !timeline.epoch) { return false; } - const firstSeq = timeline.rows.size ? Math.min(...timeline.rows.keys()) : null; + const firstSeq = timeline.cursor?.startSeq ?? null; if (firstSeq === null) return false; setIsLoadingOlder(true); void client @@ -191,7 +197,7 @@ function ProviderSubagentPanel() { target.subagentId, timeline, ]); - const firstTimelineSeq = timeline?.rows.size ? Math.min(...timeline.rows.keys()) : null; + const firstTimelineSeq = timeline?.cursor?.startSeq ?? null; const progressKey = timeline?.epoch && firstTimelineSeq !== null ? `${timeline.epoch}:${firstTimelineSeq}` : null; const subtitle = descriptor?.subtitle?.trim(); diff --git a/packages/app/src/subagents/provider-store.test.ts b/packages/app/src/subagents/provider-store.test.ts index ea863ea37a..96badfd354 100644 --- a/packages/app/src/subagents/provider-store.test.ts +++ b/packages/app/src/subagents/provider-store.test.ts @@ -1,5 +1,10 @@ +import { DaemonConnectionError } from "@getpaseo/client/internal/daemon-client"; import { afterEach, describe, expect, test } from "vitest"; -import { providerSubagentKey, useProviderSubagentStore } from "./provider-store"; +import { + observeProviderSubagentTimeline, + providerSubagentKey, + useProviderSubagentStore, +} from "./provider-store"; const SERVER_ID = "server-1"; const PARENT_ID = "parent-1"; @@ -41,6 +46,7 @@ describe("provider subagent client store", () => { item: { type: "assistant_message", text: "New live output." }, }); subagents.replaceTimeline(SERVER_ID, { + projection: "projected", requestId: "history-1", parentAgentId: PARENT_ID, subagentId: SUBAGENT_ID, @@ -103,12 +109,12 @@ describe("provider subagent client store", () => { const state = useProviderSubagentStore.getState(); expect(state.descriptors.get(key)?.status).toBe("completed"); expect(state.timelines.get(key)?.head).toEqual([]); - expect(state.timelines.get(key)?.tail).toEqual([ - expect.objectContaining({ - kind: "assistant_message", - text: "Older history.New live output.", - }), - ]); + expect( + state.timelines + .get(key) + ?.tail.map((item) => (item.kind === "assistant_message" ? item.text : "")) + .join(""), + ).toBe("Older history.New live output."); }); test("removes timelines for children no longer returned by the provider", () => { @@ -325,6 +331,7 @@ describe("provider subagent client store", () => { test("merges bounded older pages and tracks whether more history remains", () => { const store = useProviderSubagentStore.getState(); store.replaceTimeline(SERVER_ID, { + projection: "projected", requestId: "tail-page", parentAgentId: PARENT_ID, subagentId: SUBAGENT_ID, @@ -347,6 +354,7 @@ describe("provider subagent client store", () => { error: null, }); store.replaceTimeline(SERVER_ID, { + projection: "projected", requestId: "older-page", parentAgentId: PARENT_ID, subagentId: SUBAGENT_ID, @@ -373,8 +381,8 @@ describe("provider subagent client store", () => { .getState() .timelines.get(providerSubagentKey(SERVER_ID, PARENT_ID, SUBAGENT_ID)); expect(timeline?.hasOlder).toBe(false); - expect([...timeline!.rows.keys()]).toEqual([2, 1]); - expect(timeline?.head).toEqual([ + expect(timeline?.cursor).toMatchObject({ startSeq: 1, endSeq: 2 }); + expect([...(timeline?.tail ?? []), ...(timeline?.head ?? [])]).toEqual([ expect.objectContaining({ kind: "assistant_message", text: "Older output.Recent output." }), ]); }); @@ -382,6 +390,7 @@ describe("provider subagent client store", () => { test("ignores delayed live updates from a stale timeline epoch", () => { const store = useProviderSubagentStore.getState(); store.replaceTimeline(SERVER_ID, { + projection: "projected", requestId: "current-page", parentAgentId: PARENT_ID, subagentId: SUBAGENT_ID, @@ -419,8 +428,8 @@ describe("provider subagent client store", () => { .getState() .timelines.get(providerSubagentKey(SERVER_ID, PARENT_ID, SUBAGENT_ID)); expect(timeline?.epoch).toBe("epoch-current"); - expect([...timeline!.rows.keys()]).toEqual([2]); - expect(timeline?.head).toEqual([ + expect(timeline?.cursor).toMatchObject({ startSeq: 2, endSeq: 2 }); + expect([...(timeline?.tail ?? []), ...(timeline?.head ?? [])]).toEqual([ expect.objectContaining({ kind: "assistant_message", text: "Current output." }), ]); }); @@ -428,6 +437,7 @@ describe("provider subagent client store", () => { test("replaces cached rows with an authoritative tail page after a reconnect gap", () => { const store = useProviderSubagentStore.getState(); store.replaceTimeline(SERVER_ID, { + projection: "projected", requestId: "old-tail", parentAgentId: PARENT_ID, subagentId: SUBAGENT_ID, @@ -450,6 +460,7 @@ describe("provider subagent client store", () => { error: null, }); store.replaceTimeline(SERVER_ID, { + projection: "projected", requestId: "reconnect-tail", parentAgentId: PARENT_ID, subagentId: SUBAGENT_ID, @@ -475,9 +486,244 @@ describe("provider subagent client store", () => { const timeline = useProviderSubagentStore .getState() .timelines.get(providerSubagentKey(SERVER_ID, PARENT_ID, SUBAGENT_ID)); - expect([...timeline!.rows.keys()]).toEqual([401]); - expect(timeline?.head).toEqual([ + expect(timeline?.cursor).toMatchObject({ startSeq: 401, endSeq: 401 }); + expect([...(timeline?.tail ?? []), ...(timeline?.head ?? [])]).toEqual([ expect.objectContaining({ kind: "assistant_message", text: "Current tail output." }), ]); }); }); + +describe("projected child history", () => { + const key = providerSubagentKey(SERVER_ID, PARENT_ID, SUBAGENT_ID); + const timestamp = "2026-09-14T00:00:00.000Z"; + const current = () => useProviderSubagentStore.getState().timelines.get(key)!; + const text = () => + [...current().tail, ...current().head] + .map((item) => (item.kind === "assistant_message" ? item.text : "")) + .join(""); + function stream(seq: number, value: string) { + useProviderSubagentStore.getState().applyUpdate(SERVER_ID, { + kind: "timeline", + parentAgentId: PARENT_ID, + subagentId: SUBAGENT_ID, + provider: "codex", + epoch: "e", + seq, + timestamp, + item: { type: "assistant_message", messageId: "m", text: value }, + }); + } + type Payload = Parameters< + ReturnType["replaceTimeline"] + >[1]; + function response( + value: string, + seqStart: number, + seqEnd: number, + direction: "tail" | "after" = "tail", + ): Payload { + return { + requestId: "r", + parentAgentId: PARENT_ID, + subagentId: SUBAGENT_ID, + provider: "codex", + epoch: "e", + direction, + projection: "projected", + reset: false, + staleCursor: false, + gap: false, + window: { minSeq: 1, maxSeq: seqEnd, nextSeq: seqEnd + 1 }, + startCursor: { epoch: "e", seq: seqStart }, + endCursor: { epoch: "e", seq: seqEnd }, + hasOlder: false, + hasNewer: false, + error: null, + rows: [ + { + seq: seqEnd, + seqStart, + seqEnd, + sourceSeqRanges: [{ startSeq: seqStart, endSeq: seqEnd }], + timestamp, + item: { type: "assistant_message", messageId: "m", text: value }, + }, + ], + }; + } + function page( + value: string, + seqStart: number, + seqEnd: number, + direction: "tail" | "after" = "tail", + ) { + useProviderSubagentStore + .getState() + .replaceTimeline(SERVER_ID, response(value, seqStart, seqEnd, direction)); + } + test("replaces overlapping fetched text and continues live streaming", () => { + page("AB", 1, 2); + stream(3, "C"); + page("ABCD", 1, 4, "after"); + expect(text()).toBe("ABCD"); + stream(5, "E"); + expect(text()).toBe("ABCDE"); + }); + test("reconciles a tail snapshot racing newer live text", () => { + stream(1, "A"); + stream(2, "B"); + stream(3, "C"); + page("AB", 1, 2); + page("ABC", 1, 3); + expect(text()).toBe("ABC"); + expect(current().needsRefresh).toBe(false); + }); + test("recovers a live sequence gap while observed and stops fetching when closed", async () => { + let calls = 0; + const client = { + async fetchProviderSubagentTimeline() { + calls++; + return calls === 1 ? response("A", 1, 1) : response("ABC", 1, 3); + }, + }; + const errors: unknown[] = []; + const stop = observeProviderSubagentTimeline({ + client, + serverId: SERVER_ID, + parentAgentId: PARENT_ID, + subagentId: SUBAGENT_ID, + limit: 100, + reportError: (error) => { + errors.push(error); + }, + }); + try { + await expect.poll(() => current()?.cursor?.endSeq).toBe(1); + stream(3, "C"); + await expect.poll(text).toBe("ABC"); + expect(calls).toBe(2); + expect(current().needsRefresh).toBe(false); + } finally { + stop(); + } + stream(5, "E"); + await Promise.resolve(); + expect(calls).toBe(2); + expect(errors).toEqual([]); + }); + test("retries a disconnected history read on the next stream update", async () => { + let calls = 0; + const errors: unknown[] = []; + const stop = observeProviderSubagentTimeline({ + client: { + async fetchProviderSubagentTimeline() { + calls++; + if (calls === 2) throw new DaemonConnectionError("Connection lost"); + return calls === 1 ? response("A", 1, 1) : response("ABCD", 1, 4); + }, + }, + serverId: SERVER_ID, + parentAgentId: PARENT_ID, + subagentId: SUBAGENT_ID, + limit: 100, + reportError: (error) => { + errors.push(error); + }, + }); + try { + await expect.poll(() => current()?.cursor?.endSeq).toBe(1); + stream(3, "C"); + await expect.poll(() => calls).toBe(2); + expect(current().needsRefresh).toBe(true); + stream(4, "D"); + await expect.poll(text).toBe("ABCD"); + expect(current().needsRefresh).toBe(false); + expect(errors).toEqual([]); + } finally { + stop(); + } + }); + test("reports unexpected child history failures", async () => { + const failure = new TypeError("Invalid child timeline response"); + const errors: unknown[] = []; + const stop = observeProviderSubagentTimeline({ + client: { + async fetchProviderSubagentTimeline() { + throw failure; + }, + }, + serverId: SERVER_ID, + parentAgentId: PARENT_ID, + subagentId: SUBAGENT_ID, + limit: 100, + reportError: (error) => { + errors.push(error); + }, + }); + try { + await expect.poll(() => errors).toEqual([failure]); + } finally { + stop(); + } + }); + test("keeps a completed tool at its original position after fetching its latest update", () => { + const payload = response("Answer", 1, 5); + payload.rows = [ + { + seq: 5, + seqStart: 1, + seqEnd: 5, + sourceSeqRanges: [ + { startSeq: 1, endSeq: 1 }, + { startSeq: 5, endSeq: 5 }, + ], + timestamp, + item: { + type: "tool_call", + callId: "t", + name: "shell", + status: "completed", + error: null, + detail: { type: "plain_text", label: "work" }, + }, + }, + { + seq: 4, + seqStart: 2, + seqEnd: 4, + sourceSeqRanges: [{ startSeq: 2, endSeq: 4 }], + timestamp, + item: { type: "assistant_message", text: "Answer", messageId: "m" }, + }, + ]; + useProviderSubagentStore.getState().replaceTimeline(SERVER_ID, payload); + expect([...current().tail, ...current().head].map((item) => item.kind)).toEqual([ + "tool_call", + "assistant_message", + ]); + }); + test("retains projected display state rather than cumulative tool snapshots", () => { + for (let seq = 1; seq <= 2000; seq++) { + useProviderSubagentStore.getState().applyUpdate(SERVER_ID, { + kind: "timeline", + parentAgentId: PARENT_ID, + subagentId: SUBAGENT_ID, + provider: "codex", + epoch: "e", + seq, + timestamp, + item: { + type: "tool_call", + callId: "tool", + name: "task", + status: "running", + error: null, + detail: { type: "sub_agent", description: "Child", log: "x".repeat(seq * 128) }, + }, + }); + } + expect([...current().tail, ...current().head]).toHaveLength(1); + expect(JSON.stringify(current()).length).toBeLessThan(1_000_000); + expect(current().lastSeq).toBe(2000); + }); +}); diff --git a/packages/app/src/subagents/provider-store.ts b/packages/app/src/subagents/provider-store.ts index 15a9eb9b71..86e857d774 100644 --- a/packages/app/src/subagents/provider-store.ts +++ b/packages/app/src/subagents/provider-store.ts @@ -3,30 +3,25 @@ import type { ProviderSubagentDescriptorPayload, SessionOutboundMessage, } from "@getpaseo/protocol/messages"; -import type { DaemonClient } from "@getpaseo/client/internal/daemon-client"; +import { DaemonConnectionError, type DaemonClient } from "@getpaseo/client/internal/daemon-client"; import { create } from "zustand"; import { applyStreamEvent } from "@/types/stream"; +import { + processTimelineResponse, + processAgentStreamEvent, + type TimelineCursor, +} from "@/timeline/session-stream-reducers"; import type { StreamItem } from "@/types/stream"; import type { AgentLifecycleStatus } from "@getpaseo/protocol/agent-lifecycle"; -type ProviderSubagentTimelineItem = Extract< - Extract["payload"], - { kind: "timeline" } ->["item"]; - -interface ProviderSubagentTimelineRow { - provider: ProviderSubagentDescriptorPayload["provider"]; - item: ProviderSubagentTimelineItem; - timestamp: string; -} - export interface ProviderSubagentTimelineState { tail: StreamItem[]; head: StreamItem[]; epoch: string | null; lastSeq: number; hasOlder: boolean; - rows: Map; + cursor: TimelineCursor | undefined; + needsRefresh: boolean; } interface ProviderSubagentState { @@ -112,7 +107,8 @@ const EMPTY_TIMELINE: ProviderSubagentTimelineState = { epoch: null, lastSeq: 0, hasOlder: false, - rows: new Map(), + cursor: undefined, + needsRefresh: false, }; function providerSubagentTerminalEvent( @@ -130,68 +126,23 @@ function providerSubagentTerminalEvent( return { type: "turn_completed", provider: subagent.provider }; } -function buildTimelineState( - rows: ProviderSubagentTimelineState["rows"], - epoch: string | null, +function settleTimeline( + timeline: ProviderSubagentTimelineState, descriptor?: ProviderSubagentDescriptorPayload, - hasOlder = false, ): ProviderSubagentTimelineState { - let timeline = { tail: [] as StreamItem[], head: [] as StreamItem[] }; - for (const [, row] of [...rows].sort(([left], [right]) => left - right)) { - timeline = applyStreamEvent({ - ...timeline, - event: { type: "timeline", provider: row.provider, item: row.item }, - timestamp: new Date(row.timestamp), - }); - } - const terminalEvent = descriptor ? providerSubagentTerminalEvent(descriptor) : null; - if (terminalEvent && descriptor) { - timeline = applyStreamEvent({ - ...timeline, - event: terminalEvent, - timestamp: new Date(descriptor.updatedAt), - }); - } + const event = descriptor ? providerSubagentTerminalEvent(descriptor) : null; + if (!event || !descriptor) return timeline; return { ...timeline, - epoch, - lastSeq: rows.size ? Math.max(...rows.keys()) : 0, - hasOlder, - rows, + ...applyStreamEvent({ + tail: timeline.tail, + head: timeline.head, + event, + timestamp: new Date(descriptor.updatedAt), + }), }; } -function buildTimelineResponseRows( - existing: ProviderSubagentTimelineState | undefined, - payload: Extract< - SessionOutboundMessage, - { type: "agent.provider_subagents.timeline.get.response" } - >["payload"], - provider: ProviderSubagentDescriptorPayload["provider"], -): ProviderSubagentTimelineState["rows"] { - const rows = new Map(); - for (const row of payload.rows) { - rows.set(row.seq, { provider, item: row.item, timestamp: row.timestamp }); - } - if (payload.reset || existing?.epoch !== payload.epoch) { - return rows; - } - if (payload.direction !== "tail") { - return new Map([...existing.rows, ...rows]); - } - - let nextSeq = payload.rows.length - ? Math.max(...payload.rows.map((row) => row.seq)) + 1 - : payload.window.maxSeq + 1; - for (const [seq, row] of [...existing.rows].sort(([left], [right]) => left - right)) { - if (seq < nextSeq) continue; - if (seq !== nextSeq) break; - rows.set(seq, row); - nextSeq += 1; - } - return rows; -} - export const useProviderSubagentStore = create((set) => ({ descriptors: new Map(), timelines: new Map(), @@ -229,10 +180,7 @@ export const useProviderSubagentStore = create((set) => ( const current = timelines.get(key); const previous = state.descriptors.get(key); if (current && previous?.status !== subagent.status) { - timelines.set( - key, - buildTimelineState(current.rows, current.epoch, subagent, current.hasOlder), - ); + timelines.set(key, settleTimeline(current, subagent)); } } return { descriptors, timelines, hiddenFromTrack }; @@ -257,10 +205,7 @@ export const useProviderSubagentStore = create((set) => ( const current = state.timelines.get(key); if (current && previous?.status !== payload.subagent.status) { timelines = new Map(state.timelines); - timelines.set( - key, - buildTimelineState(current.rows, current.epoch, payload.subagent, current.hasOlder), - ); + timelines.set(key, settleTimeline(current, payload.subagent)); } return { descriptors, timelines, hiddenFromTrack }; } @@ -281,30 +226,33 @@ export const useProviderSubagentStore = create((set) => ( if (payload.seq <= current.lastSeq) { return state; } - const rows = new Map(current.rows); - rows.set(payload.seq, { - provider: payload.provider, - item: payload.item, - timestamp: payload.timestamp, - }); - const descriptor = state.descriptors.get(key); - const next = - descriptor && descriptor.status !== "running" - ? buildTimelineState(rows, payload.epoch, descriptor, current.hasOlder) - : applyStreamEvent({ - tail: current.tail, - head: current.head, - event: { type: "timeline", provider: payload.provider, item: payload.item }, - timestamp: new Date(payload.timestamp), - }); - const timelines = new Map(state.timelines); - timelines.set(key, { - ...next, + const next = processAgentStreamEvent({ + currentTail: current.tail, + currentHead: current.head, + currentCursor: current.cursor, + hasAuthoritativeBaseline: current.cursor !== undefined, + event: { type: "timeline", provider: payload.provider, item: payload.item }, + timestamp: new Date(payload.timestamp), + seq: payload.seq, epoch: payload.epoch, - lastSeq: payload.seq, - hasOlder: current.hasOlder, - rows, }); + const timelines = new Map(state.timelines); + timelines.set( + key, + settleTimeline( + { + tail: next.tail, + head: next.head, + epoch: payload.epoch, + lastSeq: payload.seq, + cursor: next.cursor ?? current.cursor, + hasOlder: current.hasOlder, + needsRefresh: + current.needsRefresh || next.sideEffects.some((effect) => effect.type === "catch_up"), + }, + state.descriptors.get(key), + ), + ); return { timelines }; }); }, @@ -316,11 +264,115 @@ export const useProviderSubagentStore = create((set) => ( set((state) => { const key = providerSubagentKey(serverId, payload.parentAgentId, payload.subagentId); const existing = state.timelines.get(key); - const rows = buildTimelineResponseRows(existing, payload, provider); - const descriptor = state.descriptors.get(key); + const current = existing ?? EMPTY_TIMELINE; + // Normalize optional wire metadata at the child timeline boundary. Legacy + // notices are complete singleton items; history always comes from a projected host. + const entries = payload.rows.map((row) => ({ + ...row, + provider, + seqStart: row.seqStart ?? row.seq, + seqEnd: row.seqEnd ?? row.seq, + })); + const result = processTimelineResponse({ + payload: { + ...payload, + agentId: payload.subagentId, + projection: "projected", + startCursor: + payload.startCursor ?? + (entries.length ? { seq: Math.min(...entries.map((entry) => entry.seqStart)) } : null), + endCursor: + payload.endCursor ?? + (entries.length ? { seq: Math.max(...entries.map((entry) => entry.seqEnd)) } : null), + entries, + }, + currentTail: current.tail, + currentHead: current.head, + currentCursor: current.cursor, + isInitializing: current.cursor === undefined, + hasActiveInitDeferred: current.cursor === undefined, + initRequestDirection: "tail", + sendingClientMessageIds: [], + }); const timelines = new Map(state.timelines); - timelines.set(key, buildTimelineState(rows, payload.epoch, descriptor, payload.hasOlder)); + timelines.set( + key, + settleTimeline( + { + tail: result.tail, + head: result.head, + epoch: payload.epoch, + lastSeq: + payload.reset || current.epoch !== payload.epoch + ? (result.cursor?.endSeq ?? 0) + : Math.max(current.lastSeq, result.cursor?.endSeq ?? 0), + cursor: result.cursor ?? undefined, + hasOlder: + result.older === "unchanged" ? current.hasOlder : result.older === "available", + needsRefresh: + result.sideEffects.some((effect) => effect.type === "catch_up") && + (payload.hasNewer || current.lastSeq > (result.cursor?.endSeq ?? 0)), + }, + state.descriptors.get(key), + ), + ); return { timelines }; }); }, })); + +/** Owns child history bootstrap and recovery while a pane observes the child. */ +export function observeProviderSubagentTimeline({ + client, + serverId, + parentAgentId, + subagentId, + limit, + reportError, +}: { + client: Pick; + serverId: string; + parentAgentId: string; + subagentId: string; + limit: number; + reportError: (error: unknown) => void; +}): () => void { + const key = providerSubagentKey(serverId, parentAgentId, subagentId); + let active = true; + let fetching = false; + const refresh = async () => { + if (!active || fetching) return; + fetching = true; + let succeeded = false; + try { + const payload = await client.fetchProviderSubagentTimeline(parentAgentId, subagentId, { + direction: "tail", + limit, + }); + if (active) useProviderSubagentStore.getState().replaceTimeline(serverId, payload); + succeeded = true; + } catch (error) { + // A later stream update or reopening the pane retries a disconnected read. + if (!(error instanceof DaemonConnectionError)) throw error; + } finally { + fetching = false; + if ( + succeeded && + active && + useProviderSubagentStore.getState().timelines.get(key)?.needsRefresh + ) + requestRefresh(); + } + }; + const requestRefresh = () => { + void refresh().catch(reportError); + }; + const unsubscribe = useProviderSubagentStore.subscribe((state) => { + if (state.timelines.get(key)?.needsRefresh) requestRefresh(); + }); + requestRefresh(); + return () => { + active = false; + unsubscribe(); + }; +} diff --git a/packages/client/src/connection/index.ts b/packages/client/src/connection/index.ts index c0681852d7..e1d1761774 100644 --- a/packages/client/src/connection/index.ts +++ b/packages/client/src/connection/index.ts @@ -132,6 +132,7 @@ export const DEFAULT_CLIENT_CAPABILITIES = { [CLIENT_CAPS.customModeIcons]: true, [CLIENT_CAPS.terminalReflowableSnapshot]: true, [CLIENT_CAPS.providerSubagents]: true, + [CLIENT_CAPS.projectedSubagentTimeline]: true, [CLIENT_CAPS.projectUpdates]: true, [CLIENT_CAPS.compactProviderSnapshots]: true, [CLIENT_CAPS.providerSnapshotReferences]: true, diff --git a/packages/client/src/daemon-client.test.ts b/packages/client/src/daemon-client.test.ts index 7ecf6130b8..866f5bba22 100644 --- a/packages/client/src/daemon-client.test.ts +++ b/packages/client/src/daemon-client.test.ts @@ -740,6 +740,7 @@ test("advertises client capabilities in hello", async () => { custom_mode_icons: true, project_updates: true, provider_subagents: true, + projected_subagent_timeline: true, reasoning_merge_enum: true, terminal_reflowable_snapshot: true, timeline_notifications: true, diff --git a/packages/client/src/daemon-client.ts b/packages/client/src/daemon-client.ts index e012c7c3df..5c2e15d906 100644 --- a/packages/client/src/daemon-client.ts +++ b/packages/client/src/daemon-client.ts @@ -3137,6 +3137,10 @@ export class DaemonClient { subagentId: string, options: FetchProviderSubagentTimelineOptions = {}, ): Promise { + // COMPAT(projectedSubagentTimeline): added after v0.8.0, remove after 2027-03-14. + if (this.lastServerInfoMessage?.features?.projectedSubagentTimeline !== true) { + throw new Error("Update the host to view subagent conversations."); + } const requestId = this.createRequestId(options.requestId); const message = SessionInboundMessageSchema.parse({ type: "agent.provider_subagents.timeline.get.request", diff --git a/packages/protocol/src/client-capabilities.ts b/packages/protocol/src/client-capabilities.ts index d7f5da1b7c..68488f4d66 100644 --- a/packages/protocol/src/client-capabilities.ts +++ b/packages/protocol/src/client-capabilities.ts @@ -24,6 +24,8 @@ export const CLIENT_CAPS = { // COMPAT(providerSubagents): added in v0.1.107. The daemon emits provider-owned // child descriptors and timelines only to clients that understand the new messages. providerSubagents: "provider_subagents", + // COMPAT(projectedSubagentTimeline): added after v0.8.0, remove gates after 2027-03-14; retain wire capability. + projectedSubagentTimeline: "projected_subagent_timeline", // COMPAT(projectUpdates): added in v0.1.109, remove gate after 2027-01-15. projectUpdates: "project_updates", // COMPAT(compactProviderSnapshots): added in v0.2.X. Capable clients receive diff --git a/packages/protocol/src/messages.ts b/packages/protocol/src/messages.ts index b8d0e56cc2..0b6c1b1882 100644 --- a/packages/protocol/src/messages.ts +++ b/packages/protocol/src/messages.ts @@ -3579,6 +3579,8 @@ export const ServerInfoStatusPayloadSchema = z agentForkContextCursor: z.boolean().optional(), // COMPAT(providerSubagents): added in v0.1.107, remove gate after 2027-01-12. providerSubagents: z.boolean().optional(), + // COMPAT(projectedSubagentTimeline): added after v0.8.0, remove gates after 2027-03-14; retain wire field. + projectedSubagentTimeline: z.boolean().optional(), // COMPAT(providerSubagentNesting): added in v0.7, remove gate after 2027-03-04. providerSubagentNesting: z.boolean().optional(), // COMPAT(workspacePinning): added in v0.1.107, remove gate after 2027-01-12. @@ -4608,6 +4610,9 @@ export const ProviderSubagentTimelineResponseMessageSchema = z.object({ provider: AgentProviderSchema.nullable(), direction: z.enum(["tail", "before", "after"]), epoch: z.string(), + projection: z.literal("projected").optional(), + startCursor: AgentTimelineCursorSchema.nullable().optional(), + endCursor: AgentTimelineCursorSchema.nullable().optional(), reset: z.boolean(), staleCursor: z.boolean(), gap: z.boolean(), @@ -4623,6 +4628,16 @@ export const ProviderSubagentTimelineResponseMessageSchema = z.object({ item: AgentTimelineItemPayloadSchema, timestamp: z.string(), seq: z.number().int().nonnegative(), + seqStart: z.number().int().nonnegative().optional(), + seqEnd: z.number().int().nonnegative().optional(), + sourceSeqRanges: z + .array( + z.object({ + startSeq: z.number().int().nonnegative(), + endSeq: z.number().int().nonnegative(), + }), + ) + .optional(), }), ), error: z.string().nullable(), diff --git a/packages/server/src/server/agent/activity-curator.test.ts b/packages/server/src/server/agent/activity-curator.test.ts index 7256a0669d..15ad9db90e 100644 --- a/packages/server/src/server/agent/activity-curator.test.ts +++ b/packages/server/src/server/agent/activity-curator.test.ts @@ -354,46 +354,44 @@ second line'`, expect(result.attachment.text).toContain("[Assistant] Done."); }); - it("selects the fork boundary before collapsing later tool updates", () => { - const result = buildAgentForkContextAttachment({ - boundaryMessageId: "assistant-1", - rows: [ - row(1, { type: "user_message", text: "Run it", messageId: "user-1" }), - row( - 2, - toolCallItem({ - callId: "terminal-1", - name: "terminal", - status: "running", - detail: { - type: "plain_text", - label: "before boundary", - }, + it("rejects a checkpoint whose projected tool state changed later", () => { + expect(() => + buildAgentForkContextAttachment({ + boundaryMessageId: "assistant-1", + rows: [ + row(1, { type: "user_message", text: "Run it", messageId: "user-1" }), + row( + 2, + toolCallItem({ + callId: "terminal-1", + name: "terminal", + status: "running", + detail: { + type: "plain_text", + label: "before boundary", + }, + }), + ), + row(3, { + type: "assistant_message", + text: "Partial result.", + messageId: "assistant-1", }), - ), - row(3, { - type: "assistant_message", - text: "Partial result.", - messageId: "assistant-1", - }), - row( - 4, - toolCallItem({ - callId: "terminal-1", - name: "terminal", - status: "completed", - detail: { - type: "plain_text", - label: "after boundary", - }, - }), - ), - ], - }); - - expect(result.attachment.text).toContain("[Terminal] before boundary"); - expect(result.attachment.text).toContain("[Assistant] Partial result."); - expect(result.attachment.text).not.toContain("after boundary"); + row( + 4, + toolCallItem({ + callId: "terminal-1", + name: "terminal", + status: "completed", + detail: { + type: "plain_text", + label: "after boundary", + }, + }), + ), + ], + }), + ).toThrow("Fork from a later completed response"); }); it("selects a synthetic assistant error by its timeline cursor", () => { diff --git a/packages/server/src/server/agent/activity-curator.ts b/packages/server/src/server/agent/activity-curator.ts index da3318c1a3..3bcab550e7 100644 --- a/packages/server/src/server/agent/activity-curator.ts +++ b/packages/server/src/server/agent/activity-curator.ts @@ -230,12 +230,12 @@ function selectForkContextRows(input: { boundaryCursor: { epoch: string; seq: number } | null; boundaryMessageId: string | null; } { + const projectedRows = projectTimelineRows({ rows: input.rows, mode: "projected" }); const boundaryCursor = input.cursorBoundary?.cursor ?? null; const boundaryMessageId = input.boundaryMessageId?.trim() || null; if (!boundaryCursor && !boundaryMessageId) { - const projected = projectTimelineRows({ rows: input.rows, mode: "projected" }); return { - items: projected.map((entry) => entry.item), + items: projectedRows.map((entry) => entry.item), boundaryCursor: null, boundaryMessageId: null, }; @@ -248,8 +248,8 @@ function selectForkContextRows(input: { throw new Error("Selected timeline position is no longer available."); } const boundaryIndex = boundaryCursor - ? input.rows.findIndex((row) => row.seq === boundaryCursor.seq) - : input.rows.findLastIndex( + ? projectedRows.findIndex((row) => row.seqEnd === boundaryCursor.seq) + : projectedRows.findLastIndex( (row) => row.item.type === "assistant_message" && row.item.messageId === boundaryMessageId, ); if (boundaryIndex < 0) { @@ -259,8 +259,13 @@ function selectForkContextRows(input: { : "Selected assistant message is no longer available.", ); } - const selectedRows = input.rows.slice(0, boundaryIndex + 1); - const projected = projectTimelineRows({ rows: selectedRows, mode: "projected" }); + const boundarySeq = projectedRows[boundaryIndex].seqEnd; + if (projectedRows.some((row) => row.seqStart <= boundarySeq && row.seqEnd > boundarySeq)) { + throw new Error( + "This checkpoint changed after it was created. Fork from a later completed response instead.", + ); + } + const projected = projectedRows.filter((row) => row.seqEnd <= boundarySeq); return { items: projected.map((entry) => entry.item), diff --git a/packages/server/src/server/agent/agent-manager-stream-coalescing.test.ts b/packages/server/src/server/agent/agent-manager-stream-coalescing.test.ts index d3fb7c4b58..9dc194fa11 100644 --- a/packages/server/src/server/agent/agent-manager-stream-coalescing.test.ts +++ b/packages/server/src/server/agent/agent-manager-stream-coalescing.test.ts @@ -366,7 +366,16 @@ function getProjectedTimelineItems(rows: AgentTimelineRow[]): AgentTimelineItem[ } function expectContiguousRowSeqs(rows: AgentTimelineRow[], expected: number[]): void { - expect(rows.map((row) => row.seq)).toEqual(expected); + const projected = projectTimelineRows({ rows, mode: "projected" }); + const covered = projected.flatMap((row) => + row.sourceSeqRanges.flatMap((range) => + Array.from( + { length: range.endSeq - range.startSeq + 1 }, + (_, index) => range.startSeq + index, + ), + ), + ); + expect([...new Set(covered)].sort((a, b) => a - b)).toEqual(expected); } function expectContiguousLiveSeqs(events: AgentManagerEvent[], expected: number[]): void { @@ -524,11 +533,10 @@ describe("target coalesced behavior", () => { const rows = await harness.manager.getTimelineRows(agentId); const events = getTimelineStreamEvents(harness.events, agentId); - expect(rows).toHaveLength(2); + expect(rows).toHaveLength(1); expect(events).toHaveLength(2); expect(getTimelineItems(rows)).toEqual([ - { type: "assistant_message", text: "x" }, - { type: "assistant_message", text: "x".repeat(999) }, + { type: "assistant_message", text: "x".repeat(1000) }, ]); expect(events.map((event) => (event.type === "agent_stream" ? event.event : null))).toEqual([ { @@ -567,10 +575,7 @@ describe("target coalesced behavior", () => { const rows = await harness.manager.getTimelineRows(agentId); const events = getTimelineStreamEvents(harness.events, agentId); - expect(getTimelineItems(rows)).toEqual([ - { type: "reasoning", text: "r" }, - { type: "reasoning", text: "r".repeat(99) }, - ]); + expect(getTimelineItems(rows)).toEqual([{ type: "reasoning", text: "r".repeat(100) }]); expect(events).toHaveLength(2); expectContiguousRowSeqs(rows, [1, 2]); expectContiguousLiveSeqs(events, [1, 2]); @@ -604,8 +609,7 @@ describe("target coalesced behavior", () => { await vi.advanceTimersByTimeAsync(COALESCE_WINDOW_MS); expect(getTimelineItems(await harness.manager.getTimelineRows(agentId))).toEqual([ - { type: "assistant_message", text: "a" }, - { type: "assistant_message", text: "a".repeat(499) }, + { type: "assistant_message", text: "a".repeat(500) }, runningToolCall, ]); expectContiguousLiveSeqs(getTimelineStreamEvents(harness.events, agentId), [1, 2, 3]); @@ -617,15 +621,14 @@ describe("target coalesced behavior", () => { // The "b" burst arrives inside the window that just flushed, so it gets no // leading flush of its own and coalesces whole. await vi.advanceTimersByTimeAsync(BEFORE_COALESCE_WINDOW_MS); - expect(await harness.manager.getTimelineRows(agentId)).toHaveLength(3); + expect(await harness.manager.getTimelineRows(agentId)).toHaveLength(2); await vi.advanceTimersByTimeAsync(1); const rows = await harness.manager.getTimelineRows(agentId); const events = getTimelineStreamEvents(harness.events, agentId); expect(getTimelineItems(rows)).toEqual([ - { type: "assistant_message", text: "a" }, - { type: "assistant_message", text: "a".repeat(499) }, + { type: "assistant_message", text: "a".repeat(500) }, runningToolCall, { type: "assistant_message", text: "b".repeat(500) }, ]); @@ -665,10 +668,7 @@ describe("target coalesced behavior", () => { const rows = await harness.manager.getTimelineRows(agentId); const events = getTimelineStreamEvents(harness.events, agentId); - expect(getTimelineItems(rows)).toEqual([ - toolCall({ output: "chunk-0" }), - toolCall({ output: "chunk-199" }), - ]); + expect(getTimelineItems(rows)).toEqual([toolCall({ output: "chunk-199" })]); expect(events).toHaveLength(2); expectContiguousRowSeqs(rows, [1, 2]); expectContiguousLiveSeqs(events, [1, 2]); @@ -695,9 +695,8 @@ describe("target coalesced behavior", () => { const events = getTimelineStreamEvents(harness.events, agentId); expect(getTimelineItems(rows)).toEqual([ - toolCall({ callId: "tool-1", output: "one-a" }), - toolCall({ callId: "tool-2", output: "two-b" }), toolCall({ callId: "tool-1", output: "one-b" }), + toolCall({ callId: "tool-2", output: "two-b" }), ]); expect(events).toHaveLength(3); expectContiguousRowSeqs(rows, [1, 2, 3]); @@ -876,14 +875,8 @@ describe("target coalesced behavior", () => { const firstEvents = getTimelineStreamEvents(harness.events, first.agentId); const secondEvents = getTimelineStreamEvents(harness.events, second.agentId); - expect(getTimelineItems(firstRows)).toEqual([ - { type: "assistant_message", text: "a" }, - { type: "assistant_message", text: "a" }, - ]); - expect(getTimelineItems(secondRows)).toEqual([ - { type: "assistant_message", text: "b" }, - { type: "assistant_message", text: "b" }, - ]); + expect(getTimelineItems(firstRows)).toEqual([{ type: "assistant_message", text: "aa" }]); + expect(getTimelineItems(secondRows)).toEqual([{ type: "assistant_message", text: "bb" }]); expect(firstEvents).toHaveLength(2); expect(secondEvents).toHaveLength(2); expectContiguousRowSeqs(firstRows, [1, 2]); @@ -916,10 +909,7 @@ describe("target coalesced behavior", () => { const rows = await harness.manager.getTimelineRows(agentId); const events = getTimelineStreamEvents(harness.events, agentId); - expect(getTimelineItems(rows)).toEqual([ - { type: "assistant_message", text: "codex" }, - { type: "assistant_message", text: "claude" }, - ]); + expect(getTimelineItems(rows)).toEqual([{ type: "assistant_message", text: "codexclaude" }]); expect(events.map((event) => (event.type === "agent_stream" ? event.event : null))).toEqual([ { type: "timeline", provider: "codex", item: { type: "assistant_message", text: "codex" } }, { @@ -991,10 +981,7 @@ describe("target coalesced behavior", () => { const events = getTimelineStreamEvents(harness.events, agentId); // The empty chunk is dropped before the coalescer, so " " leads the burst. - expect(getTimelineItems(rows)).toEqual([ - { type: "assistant_message", text: " " }, - { type: "assistant_message", text: "\n\tdone" }, - ]); + expect(getTimelineItems(rows)).toEqual([{ type: "assistant_message", text: " \n\tdone" }]); expect(events).toHaveLength(2); expectContiguousRowSeqs(rows, [1, 2]); expectContiguousLiveSeqs(events, [1, 2]); @@ -1189,9 +1176,7 @@ describe("target coalesced behavior", () => { const timelineEvents = getTimelineStreamEvents(idempotencyHarness.events, agentId); expect(getTimelineItems(rows)).toEqual([ - { type: "assistant_message", text: "a" }, - { type: "assistant_message", text: "bc" }, - TOOL_CALL, + { type: "assistant_message", text: "abc" }, TOOL_CALL, ]); expect(timelineEvents).toHaveLength(4); @@ -1243,8 +1228,7 @@ describe("target coalesced behavior", () => { const rowsAfterSecondFlush = await reentryHarness.manager.getTimelineRows(agentId); const timelineEvents = getTimelineStreamEvents(reentryHarness.events, agentId); expect(getTimelineItems(rowsAfterSecondFlush)).toEqual([ - { type: "assistant_message", text: "A" }, - { type: "assistant_message", text: "B" }, + { type: "assistant_message", text: "AB" }, ]); expect(timelineEvents).toHaveLength(2); expectContiguousRowSeqs(rowsAfterSecondFlush, [1, 2]); @@ -1274,20 +1258,19 @@ describe("target coalesced behavior", () => { const rows = await harness.manager.getTimelineRows(agentId); const events = getTimelineStreamEvents(harness.events, agentId); expect(getTimelineItems(rows)).toEqual([ - { type: "assistant_message", text: "flush" }, - { type: "assistant_message", text: "-pending" }, + { type: "assistant_message", text: "flush-pending" }, ]); expect(events).toHaveLength(2); await vi.advanceTimersByTimeAsync(100); - expect(await harness.manager.getTimelineRows(agentId)).toHaveLength(2); + expect(await harness.manager.getTimelineRows(agentId)).toHaveLength(1); expect(getTimelineStreamEvents(harness.events, agentId)).toHaveLength(2); } finally { harness.cleanup(); } }); - test("history hydration is uncoalesced and immediate", async () => { + test("history hydration projects immediately without waiting for a streaming window", async () => { vi.useFakeTimers(); const harness = createHarness(); try { @@ -1298,20 +1281,16 @@ describe("target coalesced behavior", () => { const rows = await harness.manager.getTimelineRows(agentId); expect(getTimelineItems(rows)).toEqual([ - { type: "assistant_message", text: "a1" }, - { type: "assistant_message", text: "a2" }, - { type: "reasoning", text: "r1" }, - { type: "reasoning", text: "r2" }, + { type: "assistant_message", text: "a1a2" }, + { type: "reasoning", text: "r1r2" }, ]); expectContiguousRowSeqs(rows, [1, 2, 3, 4]); expect(getTimelineStreamEvents(harness.events, agentId)).toHaveLength(0); await vi.advanceTimersByTimeAsync(100); expect(getTimelineItems(await harness.manager.getTimelineRows(agentId))).toEqual([ - { type: "assistant_message", text: "a1" }, - { type: "assistant_message", text: "a2" }, - { type: "reasoning", text: "r1" }, - { type: "reasoning", text: "r2" }, + { type: "assistant_message", text: "a1a2" }, + { type: "reasoning", text: "r1r2" }, ]); } finally { harness.cleanup(); @@ -1362,10 +1341,7 @@ describe("target coalesced behavior", () => { const rows = await harness.manager.getTimelineRows(agentId); const streamEvents = getStreamEvents(harness.events, agentId); - expect(getTimelineItems(rows)).toEqual([ - { type: "assistant_message", text: "hello " }, - { type: "assistant_message", text: "world" }, - ]); + expect(getTimelineItems(rows)).toEqual([{ type: "assistant_message", text: "hello world" }]); expect(getProjectedTimelineItems(rows)).toEqual([ { type: "assistant_message", text: "hello world" }, ]); @@ -1417,8 +1393,7 @@ describe("target coalesced behavior", () => { const rows = await harness.manager.getTimelineRows(agentId); expect(getTimelineItems(rows)).toEqual([ - { type: "assistant_message", text: "a1" }, - { type: "assistant_message", text: "a2" }, + { type: "assistant_message", text: "a1a2" }, { type: "reasoning", text: "r1r2" }, { type: "assistant_message", text: "b1b2" }, ]); @@ -1434,8 +1409,7 @@ describe("target coalesced behavior", () => { await vi.advanceTimersByTimeAsync(100); expect(getTimelineItems(await harness.manager.getTimelineRows(agentId))).toEqual([ - { type: "assistant_message", text: "a1" }, - { type: "assistant_message", text: "a2" }, + { type: "assistant_message", text: "a1a2" }, { type: "reasoning", text: "r1r2" }, { type: "assistant_message", text: "b1b2" }, ]); diff --git a/packages/server/src/server/agent/agent-manager.test.ts b/packages/server/src/server/agent/agent-manager.test.ts index 3ea6413561..2abdf65040 100644 --- a/packages/server/src/server/agent/agent-manager.test.ts +++ b/packages/server/src/server/agent/agent-manager.test.ts @@ -5386,14 +5386,17 @@ test("fetchTimeline returns a bounded reset window when cursor epoch is stale", await manager.appendTimelineItem(snapshot.id, { type: "assistant_message", + messageId: "one", text: "one", }); await manager.appendTimelineItem(snapshot.id, { type: "assistant_message", + messageId: "two", text: "two", }); await manager.appendTimelineItem(snapshot.id, { type: "assistant_message", + messageId: "three", text: "three", }); @@ -5467,21 +5470,14 @@ test("getTimelineRows falls back to the in-memory timeline when no durable store }); await expect(manager.getTimelineRows(snapshot.id)).resolves.toEqual([ - { - seq: 1, - timestamp: expect.any(String), - item: { - type: "assistant_message", - text: "row one", - }, - }, { seq: 2, + seqStart: 1, + seqEnd: 2, + sourceSeqRanges: [{ startSeq: 1, endSeq: 2 }], + collapsed: ["assistant_merge"], timestamp: expect.any(String), - item: { - type: "assistant_message", - text: "row two", - }, + item: { type: "assistant_message", text: "row onerow two" }, }, ]); }); @@ -5543,7 +5539,7 @@ test("getAgent does not expose committed history internals once manager owns the expect(fetched.rows.map((row) => row.seq)).toEqual([1, 2]); }); -test("coalesces assistant chunks and persists the canonical row", async () => { +test("streams coalesced assistant chunks and retains the projected message", async () => { const workdir = mkdtempSync(join(tmpdir(), "agent-manager-provisional-timeline-")); const storagePath = join(workdir, "agents"); const storage = new AgentStorage(storagePath, logger); @@ -5600,8 +5596,8 @@ test("coalesces assistant chunks and persists the canonical row", async () => { } // The coalescer flushes the first chunk on the leading edge, so "final " ships - // as its own row and "reply" follows on the trailing window. Clients read the - // projected timeline, which merges the two back into one assistant message. + // as its own event and "reply" follows on the trailing window. History retains + // their complete projected assistant message. const assistantTimelineEvents = streamEvents.filter( (event) => event.itemType === "assistant_message", ); @@ -5624,18 +5620,15 @@ test("coalesces assistant chunks and persists the canonical row", async () => { expect(manager.getTimeline(snapshot.id)).toEqual([ { type: "assistant_message", - text: "final ", - }, - { - type: "assistant_message", - text: "reply", + text: "final reply", }, ]); const fetched = await manager.fetchTimeline(snapshot.id, { direction: "tail", limit: 0, }); - expect(fetched.rows).toHaveLength(2); + expect(fetched.rows).toHaveLength(1); + expect(fetched.rows[0]).toMatchObject({ seqStart: 1, seqEnd: 2 }); expect(assistantTimelineEvents[0]?.epoch).toBe(fetched.epoch); expect(projectTimelineRows({ rows: fetched.rows, mode: "projected" }).map((e) => e.item)).toEqual( [ @@ -5671,28 +5664,34 @@ test("fetchTimeline supports older-history pagination with before seq", async () await manager.appendTimelineItem(snapshot.id, { type: "assistant_message", + messageId: "first", text: "first", }); await manager.appendTimelineItem(snapshot.id, { type: "assistant_message", + messageId: "second", text: "second", }); await manager.appendTimelineItem(snapshot.id, { type: "assistant_message", + messageId: "third", text: "third", }); await manager.appendTimelineItem(snapshot.id, { type: "assistant_message", + messageId: "fourth", text: "fourth", }); await manager.appendTimelineItem(snapshot.id, { type: "assistant_message", + messageId: "fifth", text: "fifth", }); const result = await manager.fetchTimeline(snapshot.id, { direction: "before", cursor: { + epoch: manager.fetchTimeline(snapshot.id).epoch, seq: 5, }, limit: 2, @@ -5729,14 +5728,17 @@ test("does not trim committed history", async () => { await manager.appendTimelineItem(snapshot.id, { type: "assistant_message", + messageId: "first", text: "first", }); await manager.appendTimelineItem(snapshot.id, { type: "assistant_message", + messageId: "second", text: "second", }); await manager.appendTimelineItem(snapshot.id, { type: "assistant_message", + messageId: "third", text: "third", }); @@ -5834,8 +5836,7 @@ test("hydrateTimeline preserves assistant chunk, reasoning, and tool timeline hi await manager.hydrateTimelineFromProvider(snapshot.id, { force: true }); expect(manager.getTimeline(snapshot.id)).toEqual([ - { type: "assistant_message", text: "chunk one " }, - { type: "assistant_message", text: "chunk two" }, + { type: "assistant_message", text: "chunk one chunk two" }, { type: "reasoning", text: "internal" }, { type: "tool_call", @@ -10305,7 +10306,7 @@ test("canonical submitted prompt keeps wire identity while rewind resolves provi ]); const timeline = manager.fetchTimeline(snapshot.id, { direction: "tail", limit: 20 }).rows; - expect(timeline).toEqual([ + expect(timeline).toMatchObject([ { seq: 1, timestamp: expect.any(String), diff --git a/packages/server/src/server/agent/agent-manager.ts b/packages/server/src/server/agent/agent-manager.ts index adbb25bf34..5232b33a17 100644 --- a/packages/server/src/server/agent/agent-manager.ts +++ b/packages/server/src/server/agent/agent-manager.ts @@ -1,3 +1,4 @@ +import { projectTimelineRows } from "./timeline-projection.js"; import type { PluginLifecycle } from "../plugins/lifecycle/index.js"; import { describeHookAgent, publishAgentStream } from "../plugins/lifecycle/index.js"; import type { PluginSessionOpenRequest } from "@getpaseo/plugin/server"; @@ -1154,7 +1155,10 @@ export class AgentManager { async getTimelineRows(id: string): Promise { this.requireAgent(id); if (this.durableTimelineStore) { - return await this.durableTimelineStore.getCommittedRows(id); + return projectTimelineRows({ + rows: await this.durableTimelineStore.getCommittedRows(id), + mode: "projected", + }).map((entry) => Object.assign({ seq: entry.seqEnd }, entry)); } return this.timelineStore.getRows(id); } diff --git a/packages/server/src/server/agent/agent-timeline-store-types.ts b/packages/server/src/server/agent/agent-timeline-store-types.ts index d73f52299c..639b24a904 100644 --- a/packages/server/src/server/agent/agent-timeline-store-types.ts +++ b/packages/server/src/server/agent/agent-timeline-store-types.ts @@ -1,3 +1,4 @@ +import type { ProjectedTimelineRow } from "./timeline-projection.js"; import type { AgentTimelineItem } from "./agent-sdk-types.js"; export interface AgentTimelineRow { @@ -19,7 +20,7 @@ export interface AgentTimelineFetchOptions { direction?: AgentTimelineFetchDirection; cursor?: AgentTimelineCursor; /** - * Number of canonical rows to return. + * Number of projected items to return. * - undefined: store default * - 0: all rows in the selected window */ @@ -41,7 +42,9 @@ export interface AgentTimelineFetchResult { window: AgentTimelineWindow; hasOlder: boolean; hasNewer: boolean; - rows: AgentTimelineRow[]; + startSeq: number | null; + endSeq: number | null; + rows: ProjectedTimelineRow[]; } export interface AgentTimelineStore { diff --git a/packages/server/src/server/agent/agent-timeline-store.test.ts b/packages/server/src/server/agent/agent-timeline-store.test.ts index 2f801e0271..895aec2684 100644 --- a/packages/server/src/server/agent/agent-timeline-store.test.ts +++ b/packages/server/src/server/agent/agent-timeline-store.test.ts @@ -11,17 +11,17 @@ describe("InMemoryAgentTimelineStore", () => { { seq: 5, timestamp: "2026-01-01T00:00:00.000Z", - item: { type: "assistant_message", text: "five" }, + item: { type: "assistant_message", text: "five", messageId: "five" }, }, { seq: 6, timestamp: "2026-01-01T00:00:01.000Z", - item: { type: "assistant_message", text: "six" }, + item: { type: "assistant_message", text: "six", messageId: "six" }, }, { seq: 7, timestamp: "2026-01-01T00:00:02.000Z", - item: { type: "assistant_message", text: "seven" }, + item: { type: "assistant_message", text: "seven", messageId: "seven" }, }, ], }); @@ -32,7 +32,7 @@ describe("InMemoryAgentTimelineStore", () => { limit: 2, }); - expect(result).toEqual({ + expect(result).toMatchObject({ epoch: "epoch-1", direction: "before", reset: false, @@ -45,12 +45,12 @@ describe("InMemoryAgentTimelineStore", () => { { seq: 6, timestamp: "2026-01-01T00:00:01.000Z", - item: { type: "assistant_message", text: "six" }, + item: { type: "assistant_message", text: "six", messageId: "six" }, }, { seq: 7, timestamp: "2026-01-01T00:00:02.000Z", - item: { type: "assistant_message", text: "seven" }, + item: { type: "assistant_message", text: "seven", messageId: "seven" }, }, ], }); @@ -65,17 +65,17 @@ describe("InMemoryAgentTimelineStore", () => { { seq: 5, timestamp: "2026-01-01T00:00:00.000Z", - item: { type: "assistant_message", text: "five" }, + item: { type: "assistant_message", text: "five", messageId: "five" }, }, { seq: 6, timestamp: "2026-01-01T00:00:01.000Z", - item: { type: "assistant_message", text: "six" }, + item: { type: "assistant_message", text: "six", messageId: "six" }, }, { seq: 7, timestamp: "2026-01-01T00:00:02.000Z", - item: { type: "assistant_message", text: "seven" }, + item: { type: "assistant_message", text: "seven", messageId: "seven" }, }, ], }); @@ -86,7 +86,7 @@ describe("InMemoryAgentTimelineStore", () => { limit: 1, }); - expect(result).toEqual({ + expect(result).toMatchObject({ epoch: "epoch-1", direction: "after", reset: true, @@ -99,9 +99,115 @@ describe("InMemoryAgentTimelineStore", () => { { seq: 7, timestamp: "2026-01-01T00:00:02.000Z", - item: { type: "assistant_message", text: "seven" }, + item: { type: "assistant_message", text: "seven", messageId: "seven" }, }, ], }); }); }); + +describe("projected timeline retention", () => { + it("retains one full tool state while streaming every source update", () => { + const store = new InMemoryAgentTimelineStore(); + store.initialize("agent"); + for (let seq = 1; seq <= 2000; seq++) { + const item = { + type: "tool_call" as const, + callId: "child", + name: "task", + status: "running" as const, + error: null, + detail: { type: "plain_text" as const, label: "Child", text: "x".repeat(seq * 128) }, + }; + expect(store.append("agent", item)).toMatchObject({ seq, item }); + } + const result = store.fetch("agent", { limit: 0 }); + expect(result.rows).toHaveLength(1); + expect(JSON.stringify(result.rows).length).toBeLessThan(260_000); + expect(result.window.maxSeq).toBe(2000); + }); + + it("catches up a mid-message cursor with the complete projected message", () => { + const store = new InMemoryAgentTimelineStore(); + store.initialize("agent", { epoch: "e" }); + store.append("agent", { type: "assistant_message", messageId: "m", text: "A" }); + store.append("agent", { type: "assistant_message", messageId: "m", text: "B" }); + expect( + store.fetch("agent", { direction: "after", cursor: { epoch: "e", seq: 1 } }).rows, + ).toMatchObject([ + { + item: { text: "AB" }, + seqStart: 1, + seqEnd: 2, + sourceSeqRanges: [{ startSeq: 1, endSeq: 2 }], + }, + ]); + }); +}); + +describe("projected sequence ownership", () => { + it("preserves fetched coverage when another source chunk arrives", () => { + const store = new InMemoryAgentTimelineStore(); + store.initialize("a"); + store.append("a", { type: "assistant_message", text: "A" }); + const before = store.fetch("a"); + store.append("a", { type: "assistant_message", text: "B" }); + expect(before.rows[0]).toMatchObject({ + item: { text: "A" }, + seqEnd: 1, + sourceSeqRanges: [{ startSeq: 1, endSeq: 1 }], + }); + expect(store.fetch("a").rows[0]).toMatchObject({ + item: { text: "AB" }, + seqEnd: 2, + sourceSeqRanges: [{ startSeq: 1, endSeq: 2 }], + }); + }); + it("preserves source positions when seeding a projected history whose last update is an earlier tool", () => { + const store = new InMemoryAgentTimelineStore(); + store.initialize("a"); + const tool = { + type: "tool_call" as const, + callId: "t", + name: "shell", + error: null, + detail: { type: "plain_text" as const, label: "work" }, + }; + store.append("a", { ...tool, status: "running" }); + store.append("a", { type: "assistant_message", text: "Answer" }); + store.append("a", { ...tool, status: "completed" }); + store.initialize("b", { rows: store.getRows("a") }); + expect(store.append("b", { type: "user_message", text: "next" }).seq).toBe(4); + expect(store.fetch("b").rows.map((row) => row.seqStart)).toEqual([1, 2, 4]); + }); + it("returns the last projected assistant message without joining different messages", () => { + const store = new InMemoryAgentTimelineStore(); + store.initialize("a"); + store.append("a", { type: "assistant_message", messageId: "first", text: "First" }); + store.append("a", { type: "assistant_message", messageId: "second", text: "Sec" }); + store.append("a", { type: "assistant_message", messageId: "second", text: "ond" }); + expect(store.getLastAssistantMessage("a")).toBe("Second"); + }); +}); + +it("includes transitive tool updates in a contiguous projected tail", () => { + const store = new InMemoryAgentTimelineStore(); + store.initialize("a"); + const tool = (callId: string, status: "running" | "completed") => ({ + type: "tool_call" as const, + callId, + name: "shell", + status, + error: null, + detail: { type: "plain_text" as const, label: "work" }, + }); + store.append("a", tool("first", "running")); + store.append("a", tool("second", "running")); + store.append("a", { type: "user_message", text: "Continue" }); + store.append("a", tool("first", "completed")); + store.append("a", { type: "assistant_message", text: "Answer" }); + store.append("a", tool("second", "completed")); + const tail = store.fetch("a", { limit: 1 }); + expect(tail.rows.map((row) => row.seqStart)).toEqual([1, 2, 3, 5]); + expect(tail).toMatchObject({ startSeq: 1, endSeq: 6, hasOlder: false }); +}); diff --git a/packages/server/src/server/agent/agent-timeline-store.ts b/packages/server/src/server/agent/agent-timeline-store.ts index e57080711a..6a37e11ef0 100644 --- a/packages/server/src/server/agent/agent-timeline-store.ts +++ b/packages/server/src/server/agent/agent-timeline-store.ts @@ -1,4 +1,9 @@ import { randomUUID } from "node:crypto"; +import { + TimelineProjection, + selectProjectedTimelinePage, + type ProjectedTimelineRow, +} from "./timeline-projection.js"; import type { AgentTimelineItem } from "./agent-sdk-types.js"; import type { AgentTimelineFetchOptions, @@ -16,125 +21,15 @@ export interface SeedAgentTimelineOptions { interface AgentTimelineState { epoch: string; - rows: AgentTimelineRow[]; + projection: TimelineProjection; + minSeq: number; nextSeq: number; } - const DEFAULT_TIMELINE_FETCH_LIMIT = 200; - -function cloneRow(row: AgentTimelineRow): AgentTimelineRow { +function cloneRow(row: T): T { return { ...row }; } -interface FetchContext { - state: AgentTimelineState; - direction: NonNullable; - limit: number; - selectAll: boolean; - cursor: AgentTimelineFetchOptions["cursor"]; - minSeq: number; - maxSeq: number; - window: { minSeq: number; maxSeq: number; nextSeq: number }; -} - -function fetchTail(ctx: FetchContext): AgentTimelineFetchResult { - const { state, direction, limit, selectAll, minSeq, window } = ctx; - const selected = - selectAll || limit >= state.rows.length - ? state.rows - : state.rows.slice(state.rows.length - limit); - return { - epoch: state.epoch, - direction, - reset: false, - staleCursor: false, - gap: false, - window, - hasOlder: selected.length > 0 && selected[0].seq > minSeq, - hasNewer: false, - rows: selected.map(cloneRow), - }; -} - -function fetchAfter(ctx: FetchContext): AgentTimelineFetchResult { - const { state, direction, limit, selectAll, cursor, minSeq, maxSeq, window } = ctx; - const baseSeq = cursor?.seq ?? 0; - const startIdx = state.rows.findIndex((row) => row.seq > baseSeq); - if (startIdx < 0) { - return { - epoch: state.epoch, - direction, - reset: false, - staleCursor: false, - gap: false, - window, - hasOlder: baseSeq >= minSeq, - hasNewer: false, - rows: [], - }; - } - - const selected = selectAll - ? state.rows.slice(startIdx) - : state.rows.slice(startIdx, startIdx + limit); - const lastSelected = selected[selected.length - 1]; - return { - epoch: state.epoch, - direction, - reset: false, - staleCursor: false, - gap: false, - window, - hasOlder: selected[0].seq > minSeq, - hasNewer: lastSelected !== null && lastSelected !== undefined && lastSelected.seq < maxSeq, - rows: selected.map(cloneRow), - }; -} - -function fetchBefore(ctx: FetchContext): AgentTimelineFetchResult { - const { state, direction, limit, selectAll, cursor, minSeq, window } = ctx; - const beforeSeq = cursor?.seq ?? state.nextSeq; - const endExclusive = state.rows.findIndex((row) => row.seq >= beforeSeq); - const boundedRows = endExclusive < 0 ? state.rows : state.rows.slice(0, endExclusive); - const selected = - selectAll || limit >= boundedRows.length - ? boundedRows - : boundedRows.slice(boundedRows.length - limit); - return { - epoch: state.epoch, - direction, - reset: false, - staleCursor: false, - gap: false, - window, - hasOlder: selected.length > 0 && selected[0].seq > minSeq, - hasNewer: endExclusive >= 0, - rows: selected.map(cloneRow), - }; -} - -function fetchReset( - ctx: FetchContext, - flags: { staleCursor: boolean; gap: boolean }, -): AgentTimelineFetchResult { - const { state, direction, limit, selectAll, minSeq, window } = ctx; - const rows = - selectAll || limit >= state.rows.length - ? state.rows.map(cloneRow) - : state.rows.slice(state.rows.length - limit).map(cloneRow); - return { - epoch: state.epoch, - direction, - reset: true, - staleCursor: flags.staleCursor, - gap: flags.gap, - window, - hasOlder: rows.length > 0 && rows[0].seq > minSeq, - hasNewer: false, - rows, - }; -} - export class InMemoryAgentTimelineStore { private readonly states = new Map(); @@ -147,10 +42,13 @@ export class InMemoryAgentTimelineStore { const rows = options?.rows?.length ? options.rows.map(cloneRow) : this.buildRowsFromItems(options?.items ?? [], options?.nextSeq ?? 1, timestamp); - const nextSeq = options?.nextSeq ?? (rows.length ? rows[rows.length - 1].seq + 1 : 1); + const nextSeq = rows.reduce((next, row) => Math.max(next, row.seq + 1), options?.nextSeq ?? 1); + const projection = new TimelineProjection(); + for (const row of rows) projection.append(row); this.states.set(agentId, { epoch: options?.epoch ?? randomUUID(), - rows, + projection, + minSeq: projection.getRows()[0]?.seqStart ?? 0, nextSeq, }); } @@ -160,19 +58,23 @@ export class InMemoryAgentTimelineStore { } getItems(agentId: string): AgentTimelineItem[] { - return this.requireState(agentId).rows.map((row) => row.item); + return this.requireState(agentId) + .projection.getRows() + .map((row) => row.item); } - getRows(agentId: string): AgentTimelineRow[] { - return this.requireState(agentId).rows.map(cloneRow); + getRows(agentId: string): ProjectedTimelineRow[] { + return this.requireState(agentId).projection.getRows().map(cloneRow); } getSubmittedUserMessage(agentId: string, clientMessageId: string): AgentTimelineRow | null { - const row = this.requireState(agentId).rows.find( - (candidate) => - candidate.item.type === "user_message" && - candidate.item.clientMessageId === clientMessageId, - ); + const row = this.requireState(agentId) + .projection.getRows() + .find( + (candidate) => + candidate.item.type === "user_message" && + candidate.item.clientMessageId === clientMessageId, + ); return row ? cloneRow(row) : null; } @@ -181,19 +83,10 @@ export class InMemoryAgentTimelineStore { clientMessageId: string, providerMessageId: string, ): AgentTimelineRow | null { - const state = this.requireState(agentId); - const index = state.rows.findIndex( - (candidate) => - candidate.item.type === "user_message" && - candidate.item.clientMessageId === clientMessageId, + return this.requireState(agentId).projection.enrichSubmittedUserMessage( + clientMessageId, + providerMessageId, ); - const row = state.rows[index]; - if (!row || row.item.type !== "user_message") { - return null; - } - const enriched: AgentTimelineRow = { ...row, providerMessageId }; - state.rows[index] = enriched; - return cloneRow(enriched); } getEpoch(agentId: string): string { @@ -203,62 +96,37 @@ export class InMemoryAgentTimelineStore { fetch(agentId: string, options?: AgentTimelineFetchOptions): AgentTimelineFetchResult { const state = this.requireState(agentId); const direction = options?.direction ?? "tail"; - const requestedLimit = options?.limit; - const limit = - requestedLimit === undefined - ? DEFAULT_TIMELINE_FETCH_LIMIT - : Math.max(0, Math.floor(requestedLimit)); const cursor = options?.cursor; - const minSeq = state.rows.length ? state.rows[0].seq : 0; - const maxSeq = state.rows.length ? state.rows[state.rows.length - 1].seq : 0; - const selectAll = limit === 0; - - const window = { - minSeq, - maxSeq, - nextSeq: state.nextSeq, - }; - - const ctx: FetchContext = { - state, + const rows = state.projection.getRows(); + const window = { minSeq: state.minSeq, maxSeq: state.nextSeq - 1, nextSeq: state.nextSeq }; + const staleCursor = cursor !== undefined && cursor.epoch !== state.epoch; + const gap = + !staleCursor && + direction === "after" && + cursor !== undefined && + rows.length > 0 && + cursor.seq < state.minSeq - 1; + const reset = staleCursor || gap; + const page = selectProjectedTimelinePage({ + rows, + bounds: window, + direction: reset ? "tail" : direction, + cursorSeq: cursor?.seq, + limit: options?.limit ?? DEFAULT_TIMELINE_FETCH_LIMIT, + }); + return { + epoch: state.epoch, direction, - limit, - selectAll, - cursor, - minSeq, - maxSeq, + reset, + staleCursor, + gap, window, + hasOlder: page.hasOlder, + hasNewer: page.hasNewer, + startSeq: page.startSeq, + endSeq: page.endSeq, + rows: page.entries.map((entry) => Object.assign({ seq: entry.seqEnd }, entry)), }; - - if (cursor && typeof cursor.epoch === "string" && cursor.epoch !== state.epoch) { - return fetchReset(ctx, { staleCursor: true, gap: false }); - } - - if (direction === "after" && cursor && state.rows.length > 0 && cursor.seq < minSeq - 1) { - return fetchReset(ctx, { staleCursor: false, gap: true }); - } - - if (state.rows.length === 0) { - return { - epoch: state.epoch, - direction, - reset: false, - staleCursor: false, - gap: false, - window, - hasOlder: false, - hasNewer: false, - rows: [], - }; - } - - if (direction === "tail") { - return fetchTail(ctx); - } - if (direction === "after") { - return fetchAfter(ctx); - } - return fetchBefore(ctx); } append( @@ -275,34 +143,21 @@ export class InMemoryAgentTimelineStore { ...(options?.providerMessageId ? { providerMessageId: options.providerMessageId } : {}), }; state.nextSeq += 1; - state.rows.push(row); + if (state.minSeq === 0) state.minSeq = row.seq; + state.projection.append(row); return cloneRow(row); } getLastItem(agentId: string): AgentTimelineItem | null { const state = this.requireState(agentId); - return state.rows[state.rows.length - 1]?.item ?? null; + return state.projection.getRows().find((row) => row.seqEnd === state.nextSeq - 1)?.item ?? null; } getLastAssistantMessage(agentId: string): string | null { - const rows = this.requireState(agentId).rows; - const chunks: string[] = []; - for (let i = rows.length - 1; i >= 0; i -= 1) { - const item = rows[i].item; - if (item.type !== "assistant_message") { - if (chunks.length > 0) { - break; - } - continue; - } - chunks.push(item.text); - } - - if (chunks.length === 0) { - return null; - } - - return chunks.toReversed().join(""); + const row = this.requireState(agentId) + .projection.getRows() + .findLast((candidate) => candidate.item.type === "assistant_message"); + return row?.item.type === "assistant_message" ? row.item.text : null; } private requireState(agentId: string): AgentTimelineState { diff --git a/packages/server/src/server/agent/projected-timeline-contract.test.ts b/packages/server/src/server/agent/projected-timeline-contract.test.ts new file mode 100644 index 0000000000..ee76fe50a7 --- /dev/null +++ b/packages/server/src/server/agent/projected-timeline-contract.test.ts @@ -0,0 +1,201 @@ +import { mkdtemp, rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { expect, test } from "vitest"; +import pino from "pino"; +import { CLIENT_CAPS } from "@getpaseo/protocol/client-capabilities"; +import { createDaemonTestContext } from "../test-utils/daemon-test-context.js"; +import { DaemonClient } from "../test-utils/daemon-client.js"; +import { createMessageCollector } from "../test-utils/message-collector.js"; +import { CodexAppServerAgentSession } from "./providers/codex-app-server-agent.js"; +import { createFakeCodexAppServer } from "./providers/codex/test-utils/fake-app-server.js"; +import type { AgentClient } from "./agent-sdk-types.js"; + +test("projects Codex child history and confines old-client degradation to the child transcript", async () => { + const cwd = await mkdtemp(join(tmpdir(), "paseo-projected-contract-")); + const app = createFakeCodexAppServer(); + const session = new CodexAppServerAgentSession( + { provider: "codex", cwd }, + null, + pino({ level: "silent" }), + async () => app.child, + ); + await session.connect(); + const provider: AgentClient = { + provider: "codex", + capabilities: session.capabilities, + isAvailable: async () => true, + createSession: async () => session, + resumeSession: async () => session, + fetchCatalog: async () => ({ models: [], modes: [] }), + }; + const ctx = await createDaemonTestContext({ + pluginsEnabled: false, + relayEnabled: false, + agentClients: { codex: provider }, + }); + const legacy = new DaemonClient({ + url: `ws://127.0.0.1:${ctx.daemon.port}/ws`, + capabilities: { [CLIENT_CAPS.projectedSubagentTimeline]: false }, + }); + await legacy.connect(); + const messages = createMessageCollector(legacy); + const observation = legacy.observeEvents(["agent.provider_subagents.update"]); + const unsubscribe = observation.subscribe({ snapshot() {}, update() {} }); + await observation.ready; + try { + const agent = await ctx.client.createAgent({ + provider: "codex", + cwd, + title: "Projected contract", + }); + app.startsTurn({ threadId: "thread-1" }); + app.startsSubAgent({ callId: "spawn-child", threadId: "child-thread", agentPath: "child" }); + for (const delta of ["A", "B", "C"]) { + app.child.stdout.write( + JSON.stringify({ + method: "item/agentMessage/delta", + params: { threadId: "child-thread", itemId: "message-1", delta }, + }) + "\n", + ); + } + await expect + .poll(() => ctx.daemon.daemon.agentManager.listProviderSubagents(agent.id).length) + .toBe(1); + await expect + .poll( + () => + ctx.daemon.daemon.agentManager.fetchProviderSubagentTimeline(agent.id, "child-thread") + .window.maxSeq, + ) + .toBe(3); + const child = await ctx.client.fetchProviderSubagentTimeline(agent.id, "child-thread", { + limit: 1, + }); + expect(child.projection).toBe("projected"); + expect(child.rows).toMatchObject([{ seqStart: 1, seqEnd: 3, item: { text: "ABC" } }]); + const catchUp = await ctx.client.fetchProviderSubagentTimeline(agent.id, "child-thread", { + direction: "after", + cursor: { epoch: child.epoch, seq: 1 }, + }); + expect(catchUp.rows).toMatchObject([{ item: { text: "ABC" } }]); + const oldChild = await legacy.fetchProviderSubagentTimeline(agent.id, "child-thread"); + expect(oldChild.rows).toMatchObject([ + { item: { type: "assistant_message", text: expect.stringContaining("upgrade") } }, + ]); + expect((await legacy.listProviderSubagents(agent.id)).subagents).toHaveLength(1); + expect( + messages.messages.some( + (message) => + message.type === "agent.provider_subagents.update" && message.payload.kind === "upsert", + ), + ).toBe(true); + expect( + messages.messages.some( + (message) => + message.type === "agent.provider_subagents.update" && message.payload.kind === "timeline", + ), + ).toBe(false); + const root = await legacy.fetchAgentTimeline(agent.id, { projection: "canonical", limit: 0 }); + expect(root.error).toBeNull(); + expect(root.projection).toBe("projected"); + expect(root.entries).toHaveLength(1); + expect((await legacy.buildAgentForkContext(agent.id)).attachment.text).toContain( + "[Child] child", + ); + app.assertNoErrors(); + } finally { + unsubscribe(); + messages.unsubscribe(); + await legacy.close(); + await ctx.cleanup(); + await rm(cwd, { recursive: true, force: true }); + } +}, 30_000); + +test("resuming Codex restores child descriptors and complete messages without historical deltas", async () => { + const app = createFakeCodexAppServer({ + "thread/read": (params) => { + const { threadId } = params as { threadId: string }; + return { + thread: { + id: threadId, + turns: [ + { + id: "turn", + items: + threadId === "root" + ? [ + { + type: "subAgentActivity", + id: "spawn", + kind: "started", + agentThreadId: "child", + agentPath: "explore", + }, + ] + : [{ type: "agentMessage", id: "message", text: "Complete child answer" }], + }, + ], + }, + }; + }, + }); + const session = new CodexAppServerAgentSession( + { provider: "codex", cwd: tmpdir() }, + { sessionId: "root" }, + pino({ level: "silent" }), + async () => app.child, + ); + try { + await session.connect(); + const children = []; + for await (const event of session.streamHistory()) { + if (event.type === "provider_subagent") children.push(event.event); + } + expect(children).toMatchObject([ + { type: "upsert", id: "child", status: "completed" }, + { + type: "timeline", + id: "child", + item: { type: "assistant_message", text: "Complete child answer" }, + }, + ]); + app.assertNoErrors(); + } finally { + await session.close(); + } +}); + +test("retains only the latest cumulative Pi tool progress payload", async () => { + const { parseToolArgs, parseToolResult, mapToolDetail } = + await import("./providers/pi/tool-call-mapper.js"); + const { InMemoryAgentTimelineStore } = await import("./agent-timeline-store.js"); + const store = new InMemoryAgentTimelineStore(); + store.initialize("agent"); + const tool = parseToolArgs("custom_progress", {}); + const messages: string[] = []; + for (let index = 0; index < 20; index++) { + messages.push(`Update ${index}: ${"x".repeat(2048)}`); + const result = parseToolResult({ + content: [{ type: "text", text: "running" }], + details: { results: [{ messages: [...messages] }] }, + }); + store.append("agent", { + type: "tool_call", + callId: "same-call", + name: "custom_progress", + status: "running", + error: null, + detail: mapToolDetail(tool, result), + }); + } + const history = store.fetch("agent", { limit: 0 }); + expect(history.rows).toHaveLength(1); + expect(history.rows[0]).toMatchObject({ + seqStart: 1, + seqEnd: 20, + item: { detail: { output: { details: { results: [{ messages }] } } } }, + }); + expect(JSON.stringify(history.rows).length).toBeLessThan(50_000); +}); diff --git a/packages/server/src/server/agent/provider-subagents/store.test.ts b/packages/server/src/server/agent/provider-subagents/store.test.ts index ba1f3b5270..dc71c0f5c6 100644 --- a/packages/server/src/server/agent/provider-subagents/store.test.ts +++ b/packages/server/src/server/agent/provider-subagents/store.test.ts @@ -45,7 +45,7 @@ describe("ProviderSubagentStore", () => { updatedAt: "2026-07-12T10:00:02.000Z", }), ]); - expect(subagents.fetchTimeline("parent-a", "child-1").rows).toEqual([ + expect(subagents.fetchTimeline("parent-a", "child-1").rows).toMatchObject([ { seq: 1, timestamp: "2026-07-12T10:00:01.000Z", @@ -98,8 +98,11 @@ describe("ProviderSubagentStore", () => { direction: "tail", limit: 1, }); - expect(page.rows).toHaveLength(101); - expect(page.rows[0]?.seq).toBe(1); + expect(page.rows).toHaveLength(1); + expect(page.rows[0]?.seqStart).toBe(1); + expect(page.rows[0]?.item).toMatchObject({ + text: Array.from({ length: 101 }, (_, i) => String(i)).join(""), + }); expect(page.rows.at(-1)?.seq).toBe(101); expect(page.hasOlder).toBe(false); }); diff --git a/packages/server/src/server/agent/provider-subagents/store.ts b/packages/server/src/server/agent/provider-subagents/store.ts index d9a298e790..b04246054a 100644 --- a/packages/server/src/server/agent/provider-subagents/store.ts +++ b/packages/server/src/server/agent/provider-subagents/store.ts @@ -6,7 +6,6 @@ import type { AgentTimelineFetchResult, AgentTimelineRow, } from "../agent-timeline-store-types.js"; -import { selectTimelineWindowByProjectedLimit } from "../timeline-projection.js"; export type ProviderSubagentStatus = "running" | "completed" | "failed" | "canceled"; @@ -151,30 +150,7 @@ export class ProviderSubagentStore { subagentId: string, options?: AgentTimelineFetchOptions, ): AgentTimelineFetchResult { - const direction = options?.direction ?? "tail"; - const limit = options?.limit === undefined ? 200 : Math.max(0, Math.floor(options.limit)); - const timeline = this.timelines.fetch(storeKey(parentAgentId, subagentId), { - ...options, - limit: 0, - }); - if (limit === 0 || timeline.rows.length === 0) { - return timeline; - } - const selected = selectTimelineWindowByProjectedLimit({ - rows: timeline.rows, - direction: timeline.reset ? "tail" : direction, - limit, - }); - const firstRow = selected.selectedRows[0]; - const lastRow = selected.selectedRows[selected.selectedRows.length - 1]; - return { - ...timeline, - rows: selected.selectedRows, - hasOlder: - timeline.hasOlder || (firstRow !== undefined && firstRow.seq > timeline.window.minSeq), - hasNewer: - timeline.hasNewer || (lastRow !== undefined && lastRow.seq < timeline.window.maxSeq), - }; + return this.timelines.fetch(storeKey(parentAgentId, subagentId), options); } deleteParent(parentAgentId: string): ProviderSubagentStoreEvent[] { diff --git a/packages/server/src/server/agent/rewind/rewind.test.ts b/packages/server/src/server/agent/rewind/rewind.test.ts index 7040c42e3e..df5c242251 100644 --- a/packages/server/src/server/agent/rewind/rewind.test.ts +++ b/packages/server/src/server/agent/rewind/rewind.test.ts @@ -82,10 +82,11 @@ describe("AgentManager rewind", () => { test("replaces the canonical epoch without replaying reconstructed parent rows", async () => { const { manager, session, agentId } = await createRewindHarness(); - session.history = Array.from({ length: 250 }, (_, index) => ({ + const history = Array.from({ length: 250 }, (_, index) => ({ type: "assistant_message" as const, text: `rewound ${index}`, })); + session.history = history; const epochBefore = manager.fetchTimeline(agentId, { limit: 0 }).epoch; const events: string[] = []; const unsubscribe = manager.subscribe((event) => events.push(event.type), { @@ -97,7 +98,18 @@ describe("AgentManager rewind", () => { const replacement = manager.fetchTimeline(agentId, { limit: 0 }); expect(replacement.epoch).not.toBe(epochBefore); - expect(replacement.rows).toHaveLength(250); + expect(replacement.rows).toEqual([ + expect.objectContaining({ + seq: 250, + seqStart: 1, + seqEnd: 250, + sourceSeqRanges: [{ startSeq: 1, endSeq: 250 }], + item: { + type: "assistant_message", + text: history.map((item) => item.text).join(""), + }, + }), + ]); expect(events.filter((type) => type === "agent_stream")).toEqual([]); }); diff --git a/packages/server/src/server/agent/timeline-projection.ts b/packages/server/src/server/agent/timeline-projection.ts index cbcde7944e..d8128a6a2b 100644 --- a/packages/server/src/server/agent/timeline-projection.ts +++ b/packages/server/src/server/agent/timeline-projection.ts @@ -1,7 +1,8 @@ import type { AgentTimelineItem, ToolCallDetail } from "./agent-sdk-types.js"; -import type { AgentTimelineRow } from "./agent-manager.js"; import { timelineItemIdentity } from "@getpaseo/protocol/timeline-identity"; +import type { AgentTimelineRow } from "./agent-timeline-store-types.js"; + export type TimelineProjectionMode = "canonical" | "projected"; export interface TimelineSeqRange { @@ -19,6 +20,7 @@ export type TimelineLimitDirection = "tail" | "before" | "after"; export interface TimelineProjectionEntry { item: AgentTimelineItem; turnId?: string; + providerMessageId?: string; timestamp: string; seqStart: number; seqEnd: number; @@ -54,7 +56,7 @@ function appendSeqToRanges(ranges: TimelineSeqRange[], seq: number): TimelineSeq } if (seq <= last.endSeq + 1) { - last.endSeq = Math.max(last.endSeq, seq); + next[next.length - 1] = { ...last, endSeq: Math.max(last.endSeq, seq) }; return next; } @@ -114,15 +116,19 @@ function mergeToolCallItems( } function makeCanonicalEntries(rows: readonly AgentTimelineRow[]): WorkingEntry[] { - return rows.map((row) => ({ - item: row.item, - ...(row.turnId ? { turnId: row.turnId } : {}), - timestamp: row.timestamp, - seqStart: row.seq, - seqEnd: row.seq, - sourceSeqRanges: [{ startSeq: row.seq, endSeq: row.seq }], - collapsed: [], - })); + return rows.map((row) => { + if ("seqStart" in row) return { ...(row as ProjectedTimelineRow) }; + return { + item: row.item, + timestamp: row.timestamp, + ...(row.turnId ? { turnId: row.turnId } : {}), + ...(row.providerMessageId ? { providerMessageId: row.providerMessageId } : {}), + seqStart: row.seq, + seqEnd: row.seq, + sourceSeqRanges: [{ startSeq: row.seq, endSeq: row.seq }], + collapsed: [], + }; + }); } function mergeIdentityMetadata( @@ -285,6 +291,54 @@ function mergeAssistantChunks(entries: readonly WorkingEntry[]): WorkingEntry[] return output; } +/** A retained item. `seq` is its latest source event, not its display anchor. */ +export type ProjectedTimelineRow = AgentTimelineRow & TimelineProjectionEntry; + +/** Owns projected state; source events are consumed, never retained. */ +export class TimelineProjection { + private readonly rows: ProjectedTimelineRow[] = []; + private readonly identities = new Map(); + + append(row: AgentTimelineRow): void { + const entry = makeCanonicalEntries([row])[0]; + const identity = timelineItemIdentity(row.item); + const index = identity === null ? undefined : this.identities.get(identity); + const existing = index === undefined ? undefined : this.rows[index]; + const merged = existing ? mergeIdentityEntries(existing, entry) : null; + if (merged && index !== undefined) { + this.rows[index] = { ...merged, seq: merged.seqEnd }; + return; + } + const previous = this.rows.at(-1); + const adjacent = previous + ? mergeReasoningChunks(mergeAssistantChunks([previous, entry])) + : [entry]; + if (previous && adjacent.length === 1) { + const mergedText = adjacent[0]; + this.rows[this.rows.length - 1] = { ...mergedText, seq: mergedText.seqEnd }; + return; + } + if (identity !== null) this.identities.set(identity, this.rows.length); + this.rows.push({ ...entry, seq: entry.seqEnd }); + } + + getRows(): ProjectedTimelineRow[] { + return this.rows.map((row) => ({ ...row })); + } + + enrichSubmittedUserMessage( + clientMessageId: string, + providerMessageId: string, + ): ProjectedTimelineRow | null { + const index = this.rows.findIndex( + (row) => row.item.type === "user_message" && row.item.clientMessageId === clientMessageId, + ); + if (index < 0) return null; + this.rows[index] = { ...this.rows[index], providerMessageId }; + return { ...this.rows[index] }; + } +} + export function projectTimelineRows(input: { rows: readonly AgentTimelineRow[]; mode: TimelineProjectionMode; @@ -397,7 +451,11 @@ function getTimelineBounds( if (!first || !last) { return null; } - return { minSeq: first.seq, maxSeq: last.seq }; + const entries = makeCanonicalEntries(rows); + return { + minSeq: entries.reduce((min, e) => Math.min(min, e.seqStart), Infinity), + maxSeq: entries.reduce((max, e) => Math.max(max, e.seqEnd), 0), + }; } function firstSourceSeqInRange( @@ -420,7 +478,6 @@ interface ProjectedEntryCandidate { function selectProjectedEntriesAfter(input: { entries: readonly TimelineProjectionEntry[]; - rows: readonly AgentTimelineRow[]; startSeq: number; maxSeq: number; limit: number; @@ -445,16 +502,10 @@ function selectProjectedEntriesAfter(input: { // Wide projected entries can include future, discontiguous source ranges. The // page cursor advances only through source rows covered without a gap. let endSeq = input.startSeq - 1; - let rangeIndex = 0; - for (const row of input.rows) { - if (row.seq < input.startSeq) continue; - if (row.seq > input.maxSeq || row.seq !== endSeq + 1) break; - while (selectedRanges[rangeIndex] && selectedRanges[rangeIndex].endSeq < row.seq) { - rangeIndex += 1; - } - const range = selectedRanges[rangeIndex]; - if (!range || row.seq < range.startSeq || row.seq > range.endSeq) break; - endSeq = row.seq; + for (const range of selectedRanges) { + if (range.endSeq <= endSeq) continue; + if (range.startSeq > endSeq + 1) break; + endSeq = Math.min(range.endSeq, input.maxSeq); } return { @@ -484,6 +535,27 @@ function selectProjectedEntriesBefore(input: { }; } +function selectProjectedEntriesTail( + projectedAll: TimelineProjectionEntry[], + limit: number, + bounds: { minSeq: number; maxSeq: number }, +): ProjectedTimelinePageSelection { + let start = limit === 0 ? 0 : Math.max(0, projectedAll.length - limit); + // Include earlier display anchors with updates in the selected source window, + // so a tail certifies contiguous coverage even with interleaved tool updates. + for (let i = start - 1; i >= 0; i--) { + if (projectedAll[i].seqEnd >= projectedAll[start].seqStart) start = i; + } + const selected = projectedAll.slice(start); + return { + entries: selected, + startSeq: selected[0]?.seqStart ?? null, + endSeq: bounds.maxSeq, + hasOlder: start > 0, + hasNewer: false, + }; +} + export function selectProjectedTimelinePage(input: { rows: readonly AgentTimelineRow[]; bounds?: { minSeq: number; maxSeq: number }; @@ -535,18 +607,7 @@ export function selectProjectedTimelinePage(input: { } if (input.direction === "tail") { - const selected = selectTimelineWindowByProjectedLimit({ - rows: input.rows, - direction: "tail", - limit, - }); - return { - entries: selected.projectedEntries, - startSeq: selected.minSeq, - endSeq: selected.maxSeq, - hasOlder: selected.minSeq !== null && selected.minSeq > bounds.minSeq, - hasNewer: false, - }; + return selectProjectedEntriesTail(projectedAll, limit, bounds); } if (input.direction === "after") { @@ -554,7 +615,6 @@ export function selectProjectedTimelinePage(input: { const startSeq = Math.max(bounds.minSeq, cursorSeq + 1); const selected = selectProjectedEntriesAfter({ entries: projectedAll, - rows: input.rows, startSeq, maxSeq: bounds.maxSeq, limit, diff --git a/packages/server/src/server/daemon-e2e/timeline-reconnect-contract.e2e.test.ts b/packages/server/src/server/daemon-e2e/timeline-reconnect-contract.e2e.test.ts index 510fb9db5e..ae01a8539d 100644 --- a/packages/server/src/server/daemon-e2e/timeline-reconnect-contract.e2e.test.ts +++ b/packages/server/src/server/daemon-e2e/timeline-reconnect-contract.e2e.test.ts @@ -1,4 +1,4 @@ -import { afterEach, beforeEach, expect, test } from "vitest"; +import { afterEach, beforeEach, expect, onTestFinished, test } from "vitest"; import { mkdtempSync, rmSync } from "node:fs"; import { tmpdir } from "node:os"; import path from "node:path"; @@ -11,6 +11,23 @@ import { import { createMessageCollector } from "../test-utils/message-collector.js"; import type { SessionOutboundMessage } from "../messages.js"; +async function observeTimeline( + client: DaemonClient, + agentId: string, + messages: SessionOutboundMessage[], +): Promise { + const subscription = client.observeTimeline([agentId]); + onTestFinished( + subscription.subscribe({ + snapshot() {}, + update(message) { + messages.push(message); + }, + }), + ); + await subscription.ready; +} + function tmpCwd(): string { return mkdtempSync(path.join(tmpdir(), "daemon-e2e-")); } @@ -32,7 +49,6 @@ async function waitFor( function isLiveAssistantTimeline( message: SessionOutboundMessage, agentId: string, - epoch?: string, text?: string, ): boolean { return ( @@ -41,8 +57,7 @@ function isLiveAssistantTimeline( message.payload.event.type === "timeline" && message.payload.event.item.type === "assistant_message" && message.payload.seq === undefined && - typeof message.payload.epoch === "string" && - (epoch === undefined || message.payload.epoch === epoch) && + message.payload.epoch === undefined && (text === undefined || message.payload.event.item.text === text) ); } @@ -69,6 +84,8 @@ test("reconnect catches up committed rows without replaying a provisional seed", modeId: "full-access", }); + await observeTimeline(ctx.client, agent.id, primaryCollector.messages); + for (let seq = 1; seq <= 120; seq += 1) { await ctx.daemon.daemon.agentManager.appendTimelineItem(agent.id, { type: "assistant_message", @@ -91,7 +108,7 @@ test("reconnect catches up committed rows without replaying a provisional seed", }); await waitFor(() => primaryCollector.messages.some((message) => - isLiveAssistantTimeline(message, agent.id, epoch, "partial before disconnect"), + isLiveAssistantTimeline(message, agent.id, "partial before disconnect"), ), ); @@ -107,6 +124,7 @@ test("reconnect catches up committed rows without replaying a provisional seed", }); await reconnectClient.connect(); const reconnectCollector = createMessageCollector(reconnectClient); + await observeTimeline(reconnectClient, agent.id, reconnectCollector.messages); try { await reconnectClient.fetchAgents({ @@ -114,9 +132,7 @@ test("reconnect catches up committed rows without replaying a provisional seed", }); expect( - reconnectCollector.messages.some((message) => - isLiveAssistantTimeline(message, agent.id, epoch), - ), + reconnectCollector.messages.some((message) => isLiveAssistantTimeline(message, agent.id)), ).toBe(false); const catchUp = await reconnectClient.fetchAgentTimeline(agent.id, { @@ -133,11 +149,14 @@ test("reconnect catches up committed rows without replaying a provisional seed", expect(catchUp.entries).toHaveLength(1); expect(catchUp.startCursor).toEqual({ epoch, seq: 121 }); expect(catchUp.endCursor).toEqual({ epoch, seq: 121 }); - expect(catchUp.entries[0]?.seqStart).toBe(121); + expect(catchUp.projection).toBe("projected"); + expect(catchUp.entries[0]?.seqStart).toBe(1); expect(catchUp.entries[0]?.seqEnd).toBe(121); expect(catchUp.entries[0]?.item).toEqual({ type: "assistant_message", - text: "finalized while disconnected", + text: + Array.from({ length: 120 }, (_, index) => `committed row ${index + 1}`).join("") + + "finalized while disconnected", }); } finally { reconnectCollector.unsubscribe(); @@ -161,6 +180,8 @@ test("reconnect with no new committed rows resumes from future live provisional modeId: "full-access", }); + await observeTimeline(ctx.client, agent.id, primaryCollector.messages); + for (let seq = 1; seq <= 120; seq += 1) { await ctx.daemon.daemon.agentManager.appendTimelineItem(agent.id, { type: "assistant_message", @@ -183,7 +204,7 @@ test("reconnect with no new committed rows resumes from future live provisional }); await waitFor(() => primaryCollector.messages.some((message) => - isLiveAssistantTimeline(message, agent.id, epoch, "partial before disconnect"), + isLiveAssistantTimeline(message, agent.id, "partial before disconnect"), ), ); @@ -194,6 +215,7 @@ test("reconnect with no new committed rows resumes from future live provisional }); await reconnectClient.connect(); const reconnectCollector = createMessageCollector(reconnectClient); + await observeTimeline(reconnectClient, agent.id, reconnectCollector.messages); try { await reconnectClient.fetchAgents({ @@ -201,9 +223,7 @@ test("reconnect with no new committed rows resumes from future live provisional }); expect( - reconnectCollector.messages.some((message) => - isLiveAssistantTimeline(message, agent.id, epoch), - ), + reconnectCollector.messages.some((message) => isLiveAssistantTimeline(message, agent.id)), ).toBe(false); const catchUp = await reconnectClient.fetchAgentTimeline(agent.id, { @@ -228,7 +248,7 @@ test("reconnect with no new committed rows resumes from future live provisional }); await waitFor(() => reconnectCollector.messages.some((message) => - isLiveAssistantTimeline(message, agent.id, epoch, "fresh live after reconnect"), + isLiveAssistantTimeline(message, agent.id, "fresh live after reconnect"), ), ); } finally { diff --git a/packages/server/src/server/daemon-e2e/timeline-window.e2e.test.ts b/packages/server/src/server/daemon-e2e/timeline-window.e2e.test.ts index 1e809314be..78134d7865 100644 --- a/packages/server/src/server/daemon-e2e/timeline-window.e2e.test.ts +++ b/packages/server/src/server/daemon-e2e/timeline-window.e2e.test.ts @@ -178,7 +178,7 @@ describe("daemon E2E - timeline window", () => { } }); - test("canonical after fetch returns only committed rows after the cursor", async () => { + test("legacy canonical requests return full projected items after the cursor", async () => { const cwd = tmpCwd(); try { const agent = await ctx.client.createAgent({ @@ -201,13 +201,16 @@ describe("daemon E2E - timeline window", () => { projection: "canonical", }); - expect(timeline.projection).toBe("canonical"); + expect(timeline.projection).toBe("projected"); expect(timeline.entries).toHaveLength(1); expect(timeline.startCursor?.seq).toBe(250); expect(timeline.endCursor?.seq).toBe(250); - expect(timeline.entries[0]?.seqStart).toBe(250); + expect(timeline.entries[0]?.seqStart).toBe(1); expect(timeline.entries[0]?.seqEnd).toBe(250); - expect(timeline.entries[0]?.sourceSeqRanges).toEqual([{ startSeq: 250, endSeq: 250 }]); + expect(timeline.entries[0]?.sourceSeqRanges).toEqual([ + { startSeq: 1, endSeq: 1 }, + { startSeq: 250, endSeq: 250 }, + ]); expect(timeline.entries[0]?.item.type).toBe("tool_call"); } finally { rmSync(cwd, { recursive: true, force: true }); @@ -301,7 +304,6 @@ describe("daemon E2E - timeline window", () => { }); } - const fetchSpy = vi.spyOn(ctx.daemon.daemon.agentManager, "fetchTimeline"); const timeline = await ctx.client.fetchAgentTimeline(agent.id, { direction: "tail", limit: 100, @@ -311,11 +313,6 @@ describe("daemon E2E - timeline window", () => { expect(timeline.startCursor?.seq).toBe(501); expect(timeline.endCursor?.seq).toBe(600); expect(timeline.hasOlder).toBe(true); - expect( - fetchSpy.mock.calls.some( - ([, options]) => options?.direction === "tail" && options.limit === 0, - ), - ).toBe(false); } finally { rmSync(cwd, { recursive: true, force: true }); } @@ -341,7 +338,6 @@ describe("daemon E2E - timeline window", () => { limit: 1, }).epoch; - const fetchSpy = vi.spyOn(ctx.daemon.daemon.agentManager, "fetchTimeline"); const timeline = await ctx.client.fetchAgentTimeline(agent.id, { direction: "after", cursor: { epoch, seq: 300 }, @@ -352,11 +348,6 @@ describe("daemon E2E - timeline window", () => { expect(timeline.startCursor?.seq).toBe(301); expect(timeline.endCursor?.seq).toBe(400); expect(timeline.hasNewer).toBe(true); - expect( - fetchSpy.mock.calls.some( - ([, options]) => options?.direction === "tail" && options.limit === 0, - ), - ).toBe(false); } finally { rmSync(cwd, { recursive: true, force: true }); } @@ -490,7 +481,6 @@ describe("daemon E2E - timeline window", () => { limit: 1, }).epoch; - const fetchSpy = vi.spyOn(ctx.daemon.daemon.agentManager, "fetchTimeline"); const timeline = await ctx.client.fetchAgentTimeline(agent.id, { direction: "before", cursor: { epoch, seq: 501 }, @@ -501,11 +491,6 @@ describe("daemon E2E - timeline window", () => { expect(timeline.startCursor?.seq).toBe(401); expect(timeline.endCursor?.seq).toBe(500); expect(timeline.hasOlder).toBe(true); - expect( - fetchSpy.mock.calls.some( - ([, options]) => options?.direction === "tail" && options.limit === 0, - ), - ).toBe(false); } finally { rmSync(cwd, { recursive: true, force: true }); } diff --git a/packages/server/src/server/session.ts b/packages/server/src/server/session.ts index 20f4143edf..21aca8bf90 100644 --- a/packages/server/src/server/session.ts +++ b/packages/server/src/server/session.ts @@ -119,12 +119,6 @@ import { } from "./agent/timeline-append.js"; import { assertPluginTimelineDataSize } from "./agent/agent-timeline-content.js"; import { parsePluginClientId } from "./plugins/plugin-session-identity.js"; -import { - projectTimelineRows, - selectProjectedTimelinePage, - type TimelineProjectionEntry, - type TimelineProjectionMode, -} from "./agent/timeline-projection.js"; import { buildAgentForkContextAttachment } from "./agent/activity-curator.js"; import { buildAgentPrompt } from "./agent/prompt-attachments.js"; import type { StructuredGenerationDaemonConfig } from "./agent/structured-generation-providers.js"; @@ -594,15 +588,6 @@ function sessionRequestId(message: SessionInboundMessage): string | null { return null; } -interface AgentTimelineProjectionSelection { - timeline: AgentTimelineFetchResult; - entries: TimelineProjectionEntry[]; - startSeq: number | null; - endSeq: number | null; - hasOlder: boolean; - hasNewer: boolean; -} - type RegistryTransition = "created" | "unarchived" | "existing"; interface ArchivedRecordSnapshot { @@ -1798,6 +1783,17 @@ export class Session { } } + private supportsSubagentTimelineItem( + item: AgentTimelineFetchResult["rows"][number]["item"], + source?: object, + ): boolean { + // COMPAT(projectedSubagentTimeline): added after v0.8.0, remove gate after 2027-03-14. + const supportsProjection = source + ? this.supportsForSource(CLIENT_CAPS.projectedSubagentTimeline, source) + : this.supports(CLIENT_CAPS.projectedSubagentTimeline); + return supportsProjection && this.supportsTimelineItem(item, source); + } + private forwardProviderSubagentUpdate( update: Extract["event"], ): void { @@ -1837,7 +1833,7 @@ export class Session { if (!subscription.events.has("agent.provider_subagents.update")) continue; if ( update.type === "timeline" && - !this.supportsTimelineItem(update.row.item, subscription.owner.source) + !this.supportsSubagentTimelineItem(update.row.item, subscription.owner.source) ) continue; subscription.owner.emit(message); @@ -1846,7 +1842,7 @@ export class Session { if (this.clientSources.size === 0 || !this.onMessageToSource) { if ( this.supports(CLIENT_CAPS.providerSubagents) && - (update.type !== "timeline" || this.supportsTimelineItem(update.row.item)) + (update.type !== "timeline" || this.supportsSubagentTimelineItem(update.row.item)) ) { this.emit(message); } @@ -1859,7 +1855,7 @@ export class Session { !capabilities.has(CLIENT_CAPS.providerSubagents) ) continue; - if (update.type === "timeline" && !this.supportsTimelineItem(update.row.item, source)) + if (update.type === "timeline" && !this.supportsSubagentTimelineItem(update.row.item, source)) continue; this.onMessageToSource(source, message); } @@ -7390,105 +7386,12 @@ export class Session { }); } - private shouldUseFullTimelineForProjectedPage(input: { - timeline: AgentTimelineFetchResult; - pageLimit: number; - }): boolean { - const { timeline } = input; - if (timeline.rows.length === 0) return false; - - if (timeline.rows.some((row) => row.item.type === "tool_call")) return true; - - const firstRow = timeline.rows[0]; - if ( - timeline.hasOlder && - (firstRow?.item.type === "assistant_message" || firstRow?.item.type === "reasoning") - ) { - return true; - } - - const lastRow = timeline.rows.at(-1); - if ( - timeline.hasNewer && - (lastRow?.item.type === "assistant_message" || lastRow?.item.type === "reasoning") - ) { - return true; - } - - if (!timeline.hasNewer || input.pageLimit === 0) return false; - return projectTimelineRows({ rows: timeline.rows, mode: "projected" }).length < input.pageLimit; - } - - private selectCanonicalTimelineProjection(input: { - timeline: AgentTimelineFetchResult; - }): AgentTimelineProjectionSelection { - const entries = projectTimelineRows({ rows: input.timeline.rows, mode: "canonical" }); - return { - timeline: input.timeline, - entries, - startSeq: entries[0]?.seqStart ?? null, - endSeq: entries[entries.length - 1]?.seqEnd ?? null, - hasOlder: input.timeline.hasOlder, - hasNewer: input.timeline.hasNewer, - }; - } - - private selectProjectedTimelineProjection(input: { - agentId: string; - controlTimeline: AgentTimelineFetchResult; - direction: AgentTimelineFetchDirection; - cursor?: AgentTimelineCursor; - pageLimit: number; - fullTimeline?: AgentTimelineFetchResult; - }): AgentTimelineProjectionSelection { - const selectedTimeline = this.shouldUseFullTimelineForProjectedPage({ - timeline: input.controlTimeline, - pageLimit: input.pageLimit, - }) - ? (input.fullTimeline ?? - this.agentManager.fetchTimeline(input.agentId, { direction: "tail", limit: 0 })) - : input.controlTimeline; - const page = selectProjectedTimelinePage({ - rows: selectedTimeline.rows, - bounds: selectedTimeline.window, - direction: input.controlTimeline.reset ? "tail" : input.direction, - ...(input.cursor ? { cursorSeq: input.cursor.seq } : {}), - limit: input.pageLimit, - }); - - return { - timeline: selectedTimeline, - entries: page.entries, - startSeq: page.startSeq, - endSeq: page.endSeq, - hasOlder: - page.hasOlder || (page.startSeq !== null && page.startSeq > selectedTimeline.window.minSeq), - hasNewer: page.hasNewer, - }; - } - - private selectTimelineProjection(input: { - agentId: string; - projection: TimelineProjectionMode; - controlTimeline: AgentTimelineFetchResult; - direction: AgentTimelineFetchDirection; - cursor?: AgentTimelineCursor; - pageLimit: number; - fullTimeline?: AgentTimelineFetchResult; - }): AgentTimelineProjectionSelection { - if (input.projection === "canonical") { - return this.selectCanonicalTimelineProjection({ timeline: input.controlTimeline }); - } - - return this.selectProjectedTimelineProjection(input); - } - private async handleFetchAgentTimelineRequest( msg: Extract, source?: object, ): Promise { const direction: AgentTimelineFetchDirection = msg.direction ?? (msg.cursor ? "after" : "tail"); - const projection: TimelineProjectionMode = msg.projection ?? "projected"; + const projection = "projected" as const; const requestedLimit = msg.limit; const pageLimit = requestedLimit ?? (direction === "after" ? 0 : 200); const cursor: AgentTimelineCursor | undefined = msg.cursor @@ -7511,14 +7414,14 @@ export class Session { cursor, limit: pageLimit, }); - const selectedTimeline = this.selectTimelineProjection({ - agentId: msg.agentId, - projection, - controlTimeline: fetchedControlTimeline, - direction, - ...(cursor ? { cursor } : {}), - pageLimit, - }); + const selectedTimeline = { + timeline: fetchedControlTimeline, + entries: fetchedControlTimeline.rows, + startSeq: fetchedControlTimeline.startSeq, + endSeq: fetchedControlTimeline.endSeq, + hasOlder: fetchedControlTimeline.hasOlder, + hasNewer: fetchedControlTimeline.hasNewer, + }; const startCursor = selectedTimeline.startSeq !== null ? { epoch: selectedTimeline.timeline.epoch, seq: selectedTimeline.startSeq } @@ -7719,6 +7622,10 @@ export class Session { if (!descriptor) { throw new Error("Provider subagent not found"); } + // COMPAT(projectedSubagentTimeline): added after v0.8.0, remove after 2027-03-14. + const supportsProjection = source + ? this.supportsForSource(CLIENT_CAPS.projectedSubagentTimeline, source) + : this.supports(CLIENT_CAPS.projectedSubagentTimeline); const timeline = this.agentManager.fetchProviderSubagentTimeline( msg.parentAgentId, msg.subagentId, @@ -7739,17 +7646,36 @@ export class Session { provider: descriptor.provider, direction, epoch: timeline.epoch, + projection: "projected", + startCursor: + timeline.startSeq === null ? null : { epoch: timeline.epoch, seq: timeline.startSeq }, + endCursor: + timeline.endSeq === null ? null : { epoch: timeline.epoch, seq: timeline.endSeq }, reset: timeline.reset, staleCursor: timeline.staleCursor, gap: timeline.gap, window: timeline.window, - hasOlder: timeline.hasOlder, - hasNewer: timeline.hasNewer, - rows: rows.map((row) => ({ - item: row.item, - timestamp: row.timestamp, - seq: row.seq, - })), + hasOlder: supportsProjection && timeline.hasOlder, + hasNewer: supportsProjection && timeline.hasNewer, + rows: supportsProjection + ? rows.map((row) => ({ + item: row.item, + timestamp: row.timestamp, + seq: row.seqEnd, + seqStart: row.seqStart, + seqEnd: row.seqEnd, + sourceSeqRanges: row.sourceSeqRanges, + })) + : [ + { + seq: timeline.window.maxSeq, + timestamp: new Date().toISOString(), + item: { + type: "assistant_message", + text: "Please upgrade the Paseo app to view this subagent conversation.", + }, + }, + ], error: null, }, }, diff --git a/packages/server/src/server/websocket-server.ts b/packages/server/src/server/websocket-server.ts index 8b0ab3879f..d14094a674 100644 --- a/packages/server/src/server/websocket-server.ts +++ b/packages/server/src/server/websocket-server.ts @@ -1741,6 +1741,8 @@ export class VoiceAssistantWebSocketServer { agentForkContextCursor: true, // COMPAT(providerSubagents): added in v0.1.107, remove gate after 2027-01-12. providerSubagents: true, + // COMPAT(projectedSubagentTimeline): added after v0.8.0, remove gates after 2027-03-14; retain advertisement. + projectedSubagentTimeline: true, // COMPAT(providerSubagentNesting): added in v0.7, remove gate after 2027-03-04. providerSubagentNesting: true, // COMPAT(workspacePinning): added in v0.1.107, remove gate after 2027-01-12. From d63132a37dd49c38af400dbbcd8f8b7ebd519339 Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Mon, 14 Sep 2026 16:55:42 +0200 Subject: [PATCH 32/46] fix(creation): prevent duplicate workspaces and agents on retries (#4442) * fix(creation): prevent duplicate workspaces and agents on retries * test(creation): cover plugin hooks across receipt replays * fix(creation): recover draft retries without accepting partial workspaces * refactor(creation): name receipt inputs and tag compatibility gate * fix(file-observer): track files announced by native change events * fix(creation): keep initial prompts in daemon-owned creation requests * fix(creation): reuse connection-owned progress and recovery * test(creation): adapt handoff gates to combined requests * ci(desktop): avoid xz compression for smoke packages * ci(desktop): pass valid RPM compression through FPM config * refactor(creation): give legacy and modern requests one identity owner * fix(creation): log observer failures without interrupting creation --- .github/workflows/ci.yml | 5 +- docs/architecture.md | 30 ++ docs/hub.md | 4 + ...agent-profile-modeless-preferences.spec.ts | 53 +- .../e2e/browser/creation-idempotency.spec.ts | 130 +++++ ...w-workspace-codex-mode-preferences.spec.ts | 80 +-- packages/app/e2e/support/creation-fixtures.ts | 92 ++++ .../support/helpers/agent-timeline-gate.ts | 27 +- packages/app/e2e/support/helpers/creation.ts | 277 +++++++++++ .../support/helpers/daemon-client-loader.ts | 7 + .../support/helpers/daemon-websocket-gate.ts | 1 + .../helpers/new-workspace-navigation.ts | 9 +- .../app/e2e/support/helpers/new-workspace.ts | 125 ++--- packages/app/e2e/support/helpers/workspace.ts | 7 +- .../src/components/workspace-setup-dialog.tsx | 79 +-- .../app/src/composer/draft/create-flow.ts | 37 +- .../app/src/composer/draft/workspace-tab.tsx | 36 +- packages/app/src/contexts/session-context.tsx | 48 +- .../app/src/screens/new-workspace-screen.tsx | 321 ++++++------ .../app/src/stores/create-flow-store.test.ts | 27 + packages/app/src/stores/create-flow-store.ts | 22 +- .../workspace-draft-submission-store.test.ts | 71 +++ .../workspace-draft-submission-store.ts | 23 +- .../src/stores/workspace-setup-store.test.ts | 2 + .../app/src/stores/workspace-setup-store.ts | 14 +- packages/client/src/creation/index.test.ts | 234 +++++++++ packages/client/src/creation/index.ts | 276 +++++++++++ packages/client/src/daemon-client.test.ts | 166 +++++++ packages/client/src/daemon-client.ts | 121 ++++- packages/client/src/index.ts | 65 ++- packages/protocol/src/messages.ts | 99 +++- .../src/server/agent/create-agent/create.ts | 3 + .../src/server/agent/requests/index.test.ts | 143 ------ .../server/src/server/agent/requests/index.ts | 138 ------ .../authorization/operation-permissions.ts | 6 + .../src/server/creation/creation.e2e.test.ts | 286 +++++++++++ .../server/src/server/creation/index.test.ts | 324 ++++++++++++ packages/server/src/server/creation/index.ts | 351 +++++++++++++ .../internal/native-recursive.test.ts | 127 ++--- .../src/server/message-receipts/index.test.ts | 77 +++ .../src/server/message-receipts/index.ts | 81 +++ .../src/server/paseo-worktree-service.ts | 2 + .../src/server/plugins/lifecycle.e2e.test.ts | 11 +- packages/server/src/server/session.test.ts | 8 +- packages/server/src/server/session.ts | 461 ++++++++++++++---- .../session.workspace-git-watch.test.ts | 8 +- ...on.workspace-resolution-invariants.test.ts | 8 +- .../src/server/session.workspaces.test.ts | 32 +- .../workspace-provisioning-service.ts | 9 +- .../snapshot-mutation-ownership.test.ts | 8 +- .../server/test-utils/fake-agent-client.ts | 2 + .../src/server/test-utils/session-stubs.ts | 28 +- .../server/src/server/websocket-server.ts | 40 +- .../server/src/server/wire-compat.test.ts | 8 +- .../workspace-create-errors.e2e.test.ts | 29 +- 55 files changed, 3702 insertions(+), 976 deletions(-) create mode 100644 packages/app/e2e/browser/creation-idempotency.spec.ts create mode 100644 packages/app/e2e/support/creation-fixtures.ts create mode 100644 packages/app/e2e/support/helpers/creation.ts create mode 100644 packages/app/src/stores/workspace-draft-submission-store.test.ts create mode 100644 packages/client/src/creation/index.test.ts create mode 100644 packages/client/src/creation/index.ts delete mode 100644 packages/server/src/server/agent/requests/index.test.ts delete mode 100644 packages/server/src/server/agent/requests/index.ts create mode 100644 packages/server/src/server/creation/creation.e2e.test.ts create mode 100644 packages/server/src/server/creation/index.test.ts create mode 100644 packages/server/src/server/creation/index.ts create mode 100644 packages/server/src/server/message-receipts/index.test.ts create mode 100644 packages/server/src/server/message-receipts/index.ts diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index b735b2b06c..47ee95f36e 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -251,7 +251,10 @@ jobs: - name: Build Linux desktop artifacts if: ${{ runner.os == 'Linux' }} - run: npm run build:desktop -- --publish never --linux --x64 + # Smoke package contents and installation without spending minutes on xz compression. + run: | + printf '{"extends":"%s/packages/desktop/electron-builder.yml","deb":{"compression":"gz"},"rpm":{"fpm":["--rpm-compression=gzip"]}}' "$GITHUB_WORKSPACE" > "$RUNNER_TEMP/desktop-smoke-builder.json" + npm run build:desktop -- --publish never --linux --x64 --config "$RUNNER_TEMP/desktop-smoke-builder.json" env: EP_GH_IGNORE_TIME: true PASEO_DESKTOP_SMOKE_ARTIFACT_DIR: ${{ runner.temp }}/desktop-smoke diff --git a/docs/architecture.md b/docs/architecture.md index 96d8cdcc56..6d21103cb6 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -319,6 +319,36 @@ Example: adding a new enum value // 4. Gate the new emitted value: session.supports(CLIENT_CAPS.newThing) ? "new_value" : "old_value" ``` +### Creation ownership + +Creation is owned by the daemon across socket lifetimes. A workspace request can include +its initial agent and prompt. Workspace readiness is published before provider startup; +the app keeps its existing disk-ready navigation and observes the remaining creation. +Callbacks do not advance the workflow. Resource reservations in acknowledgement are +identities, not ready workspace records. + +The creation journal (`server/creation/`) owns identity and execution for both legacy and +modern creation RPCs. Requesting progress never selects a different journal. Existing +agent receipts are imported at this boundary; message delivery receipts remain separate. +It keeps cumulative milestones and permanent IDs under an operation kind and idempotency +key. Reconnect subscribes to that key; retries +join active work or reuse committed stages. A persisted resource alone cannot prove +that a provider accepted its initial prompt. Interrupted side effects with no conclusive +receipt return an unknown outcome instead of being repeated. + +`packages/client/src/creation/` owns capability selection and legacy orchestration. +Callers always pass the initial prompt to agent creation. On an older host, the client +adapts keyed creation to the legacy create/send sequence; it cannot continue that +sequence after the client process disappears. Explicitly requested IDs or receipts +that an old host cannot honor produce an unsupported error. Keep these adapters inside +the client package, as an exception to the default no-fallback feature policy. + +Creation executes through the existing Session capabilities. Connection-owned delivery +controls observation only: detaching a socket or cleaning up its Session does not cancel +accepted creation. Updates require an explicit subscription and go only to that socket; +reconnect uses the shared subscription owner. Legacy consumers, including Hub, keep their +existing response contract. + ## Agent lifecycle The lifecycle states are defined in `shared/agent-lifecycle.ts`: diff --git a/docs/hub.md b/docs/hub.md index bf44067b67..8c6493504a 100644 --- a/docs/hub.md +++ b/docs/hub.md @@ -49,6 +49,10 @@ Provider controls remain provider-native; see [providers.md](providers.md). `send_agent_message_request` with a stable `messageId`. Request IDs correlate individual attempts; creation keys and message IDs identify the operation across attempts. A creation key is daemon-wide; a message ID is scoped to its agent. Reusing either with different arguments is a conflict. +`workspace.create.request.idempotencyKey` provides the same creation guarantee for directory and +worktree workspaces; check `server_info.features.workspaceRequestReceipts` before using it. +Workspace creation keys and agent creation keys have separate namespaces. The app keeps these keys +for the lifetime of a draft and reuses the first message ID after failures. The daemon journals the assigned agent ID before creation. Concurrent retries share one operation, and a retry after a lost acknowledgement or restart returns the durable agent without creating a diff --git a/packages/app/e2e/browser/agent-profile-modeless-preferences.spec.ts b/packages/app/e2e/browser/agent-profile-modeless-preferences.spec.ts index 2558d3b427..a10afd2cca 100644 --- a/packages/app/e2e/browser/agent-profile-modeless-preferences.spec.ts +++ b/packages/app/e2e/browser/agent-profile-modeless-preferences.spec.ts @@ -9,7 +9,7 @@ import { seedModelProvider, } from "../support/helpers/agent-profiles"; import { gotoAppShell } from "../support/helpers/app"; -import { daemonWsRoutePattern } from "../support/helpers/daemon-port"; +import { captureWorkspaceAgentRequest } from "../support/helpers/creation"; import { openGlobalNewWorkspaceComposer, selectNewWorkspaceProject, @@ -22,53 +22,6 @@ const CREATE_AGENT_PREFERENCES_KEY = "@paseo:create-agent-preferences"; const MODELESS_PROVIDER = "modeless-profile-e2e"; const MODELESS_MODEL = "pi-profile-model"; -type WebSocketMessage = string | Buffer; - -interface CreateAgentRequestMessage { - type: "create_agent_request"; - config?: { - provider?: unknown; - modeId?: unknown; - featureValues?: unknown; - }; -} - -function getSessionMessage(message: WebSocketMessage): Record | null { - const rawMessage = typeof message === "string" ? message : message.toString("utf8"); - try { - const envelope = JSON.parse(rawMessage) as { type?: unknown; message?: unknown }; - return envelope.type === "session" && envelope.message && typeof envelope.message === "object" - ? (envelope.message as Record) - : null; - } catch { - return null; - } -} - -async function recordAndBlockCreateAgentRequest(page: Page): Promise<{ - waitForRequest(): Promise; -}> { - let resolveRequest: ((message: CreateAgentRequestMessage) => void) | null = null; - const request = new Promise((resolve) => { - resolveRequest = resolve; - }); - - await page.routeWebSocket(daemonWsRoutePattern(), (ws) => { - const server = ws.connectToServer(); - ws.onMessage((message) => { - const sessionMessage = getSessionMessage(message); - if (sessionMessage?.type === "create_agent_request") { - resolveRequest?.(sessionMessage as unknown as CreateAgentRequestMessage); - return; - } - server.send(message); - }); - server.onMessage((message) => ws.send(message)); - }); - - return { waitForRequest: () => request }; -} - async function seedPoisonedModelessPreference(page: Page): Promise { await page.addInitScript( ({ preferencesKey, provider, model }) => { @@ -143,7 +96,7 @@ test.describe("Agent profiles repair modeless provider preferences", () => { ], }); await seedPoisonedModelessPreference(page); - const createAgentRecorder = await recordAndBlockCreateAgentRequest(page); + const createAgentRecorder = await captureWorkspaceAgentRequest(page, { block: true }); try { await gotoAppShell(page); @@ -206,7 +159,7 @@ test.describe("Agent profiles repair modeless provider preferences", () => { }, ], }); - const createAgentRecorder = await recordAndBlockCreateAgentRequest(page); + const createAgentRecorder = await captureWorkspaceAgentRequest(page, { block: true }); try { await gotoAppShell(page); diff --git a/packages/app/e2e/browser/creation-idempotency.spec.ts b/packages/app/e2e/browser/creation-idempotency.spec.ts new file mode 100644 index 0000000000..39e73dbded --- /dev/null +++ b/packages/app/e2e/browser/creation-idempotency.spec.ts @@ -0,0 +1,130 @@ +import type { createCreationScenario } from "../support/helpers/creation"; +import { expect } from "../support/fixtures"; +import { test } from "../support/creation-fixtures"; + +for (const isolation of ["local", "worktree"] as const) { + test(`repeated Create clicks before a render create only one ${isolation} workspace`, async ({ + creation, + }) => { + await creation.openWorkspaceForm(isolation); + await creation.submitRepeatedly("Create"); + await creation.expectOneCreatedWorkspace(); + }); +} + +test("repeated Send clicks before a render create only one agent", async ({ creation }) => { + await creation.openAgentDraft(); + await creation.submitRepeatedly("Send message", "Start exactly one agent for this prompt."); + await creation.expectPromptVisible(); + await creation.expectAgentCount(1); +}); + +test("retrying the app's agent creation returns the same agent for every attempt", async ({ + creation, + agentRetries, +}) => { + await creation.openAgentDraft(); + await creation.submitPrompt("Start exactly one agent for this prompt."); + expect(new Set(await agentRetries.completedAgentIds()).size).toBe(1); + await creation.expectAgentCount(1); +}); + +test("the first prompt still names an agent created with a receipt", async ({ creation }) => { + await creation.openAgentDraft(); + await creation.submitPrompt("Name this new agent from its first prompt."); + await creation.expectPromptVisible(); + await creation.expectAgentTitle("Name this new agent from its first prompt."); +}); + +test("reconnecting after a lost creation response recovers the agent and initial message", async ({ + creation, + promptRetry, +}) => { + await creation.openAgentDraft(); + promptRetry.holdAcknowledgement(); + await creation.submitPrompt("Deliver this initial prompt once."); + await promptRetry.waitForDeliveredPrompt(); + await promptRetry.disconnectAndReconnect(); + await promptRetry.expectSameAgentAndMessage(); + await creation.expectAgentCount(1); +}); + +test("a remounted draft reconciles its original creation after reconnect", async ({ + creation, + promptRetry, +}) => { + await creation.openAgentDraft(); + promptRetry.holdAcknowledgement(); + await creation.submitPrompt("Deliver this initial prompt once."); + await promptRetry.waitForDeliveredPrompt(); + await creation.evictAndReturnToDraft(); + await creation.expectPromptVisible("Deliver this initial prompt once."); + await promptRetry.disconnectAndReconnect(); + await promptRetry.expectSameAgentAndMessage(); + await creation.expectAgentCount(1); +}); + +test("repeated new-workspace prompt submissions create one workspace and one agent", async ({ + creation, +}) => { + await creation.openWorkspaceForm("local"); + await creation.submitRepeatedly("Create", "Start one agent in one new workspace."); + await creation.expectPromptVisible(); + await creation.expectOneCreatedWorkspace(); + await creation.expectAgentCount(1); +}); + +test("separate drafts can intentionally create two agents in the same workspace", async ({ + creation, + delayedCreation, +}) => { + await creation.openAgentDraft(); + await creation.submitPrompt("Start the first agent."); + await creation.expectPromptVisible("Start the first agent."); + await delayedCreation.waitForDelayedCreatedStatus(); + await creation.startAnotherDraft(); + await creation.submitPrompt("Start the second agent."); + await creation.expectPromptVisible("Start the second agent."); + await delayedCreation.waitForDelayedCreatedStatus(); + delayedCreation.release(); + await creation.expectAgentCount(2); +}); + +test("new workspace navigation and optimistic prompt precede agent initialization", async ({ + creation, + startup, + delayedCreation, +}) => { + await creation.openWorkspaceForm("worktree"); + await creation.submitPrompt("Show this prompt while the agent is starting.", "Create"); + await creation.expectAgentStillStarting(); + delayedCreation.expectSingleWorkspaceIntent(); + delayedCreation.release(); + await startup.release(); + await creation.expectOneCreatedWorkspace(); + await creation.expectAgentCount(1); +}); + +for (const scenario of [ + { suffix: "", prepare: async () => {} }, + { + suffix: " after remount", + prepare: async (creation: Awaited>) => + creation.evictAndReturnToDraft(), + }, +]) { + test(`retrying failed agent initialization preserves its workspace${scenario.suffix}`, async ({ + creation, + startup, + }) => { + await creation.openWorkspaceForm("local"); + await creation.submitPrompt("Retry this workspace and agent together.", "Create"); + await creation.expectAgentStillStarting(); + await startup.fail(); + await creation.expectStartupFailure(); + await creation.expectOneCreatedWorkspace(); + await scenario.prepare(creation); + await creation.submitPrompt("Retry this workspace and agent together."); + await creation.expectAgentCount(1); + }); +} diff --git a/packages/app/e2e/browser/new-workspace-codex-mode-preferences.spec.ts b/packages/app/e2e/browser/new-workspace-codex-mode-preferences.spec.ts index 327666ecb8..956340db74 100644 --- a/packages/app/e2e/browser/new-workspace-codex-mode-preferences.spec.ts +++ b/packages/app/e2e/browser/new-workspace-codex-mode-preferences.spec.ts @@ -1,9 +1,10 @@ -import { expect, test, type Page } from "../support/fixtures"; +import { expect, type Page } from "../support/fixtures"; +import { test } from "../support/creation-fixtures"; import type { DaemonClient } from "@getpaseo/client/internal/daemon-client"; import { connectDaemonClient } from "../support/helpers/daemon-client-loader"; import type { FormPreferences } from "@/create-agent-preferences/preferences"; import { gotoAppShell } from "../support/helpers/app"; -import { daemonWsRoutePattern } from "../support/helpers/daemon-port"; +import { captureWorkspaceAgentRequest } from "../support/helpers/creation"; import { openAgentRoute } from "../support/helpers/mock-agent"; import { openGlobalNewWorkspaceComposer, @@ -17,40 +18,6 @@ import { waitForSidebarHydration } from "../support/helpers/workspace-ui"; const CREATE_AGENT_PREFERENCES_KEY = "@paseo:create-agent-preferences"; -type WebSocketMessage = string | Buffer; - -interface CreateAgentRequestMessage { - type: "create_agent_request"; - config?: { - provider?: unknown; - modeId?: unknown; - }; -} - -function parseWebSocketJson(message: WebSocketMessage): unknown { - const rawMessage = typeof message === "string" ? message : message.toString("utf8"); - try { - return JSON.parse(rawMessage); - } catch { - return null; - } -} - -function getSessionMessage(message: WebSocketMessage): Record | null { - const envelope = parseWebSocketJson(message); - if (!envelope || typeof envelope !== "object") { - return null; - } - const maybeEnvelope = envelope as { type?: unknown; message?: unknown }; - if (maybeEnvelope.type !== "session" || !maybeEnvelope.message) { - return null; - } - if (typeof maybeEnvelope.message !== "object") { - return null; - } - return maybeEnvelope.message as Record; -} - async function seedCodexDefaultPermissionPreferences(page: Page, cwd: string): Promise { const client = await connectDaemonClient({ clientIdPrefix: "codex-mode-preferences", @@ -142,44 +109,15 @@ async function expectThinkingOptionsFit(page: Page): Promise { await expect(page.getByTestId("combobox-desktop-container")).toHaveCount(0, { timeout: 5_000 }); } -async function recordAndBlockCreateAgentRequests(page: Page): Promise<{ - waitForCreateAgentRequest(): Promise; -}> { - let resolveRequest: ((message: CreateAgentRequestMessage) => void) | null = null; - const createAgentSeen = new Promise((resolve) => { - resolveRequest = resolve; - }); - - await page.routeWebSocket(daemonWsRoutePattern(), (ws) => { - const server = ws.connectToServer(); - - ws.onMessage((message) => { - const sessionMessage = getSessionMessage(message); - if (sessionMessage?.type === "create_agent_request") { - resolveRequest?.(sessionMessage as unknown as CreateAgentRequestMessage); - return; - } - server.send(message); - }); - - server.onMessage((message) => { - ws.send(message); - }); - }); - - return { - waitForCreateAgentRequest: () => createAgentSeen, - }; -} - test.describe("New workspace Codex mode preferences", () => { test.describe.configure({ timeout: 240_000 }); test("keeps Full Access as the global Codex mode after the workspace draft auto-submit handoff", async ({ page, + startup, }) => { const seeded = await seedWorkspace({ repoPrefix: "codex-mode-preferences-" }); - const createAgentRecorder = await recordAndBlockCreateAgentRequests(page); + const createAgentRecorder = await captureWorkspaceAgentRequest(page, { block: false }); try { await seedCodexDefaultPermissionPreferences(page, seeded.repoPath); await gotoAppShell(page); @@ -200,8 +138,12 @@ test.describe("New workspace Codex mode preferences", () => { ).toBeVisible(); await submitNewWorkspacePrompt(page, "Keep Codex full access selected globally."); - const createAgentRequest = await createAgentRecorder.waitForCreateAgentRequest(); + const createAgentRequest = await createAgentRecorder.waitForRequest(); + await expect(page).toHaveURL(/\/workspace\//); + await expect( + page.getByText("Keep Codex full access selected globally.", { exact: true }).first(), + ).toBeVisible(); expect(createAgentRequest.config).toMatchObject({ provider: "codex", modeId: "full-access", @@ -209,6 +151,8 @@ test.describe("New workspace Codex mode preferences", () => { await expect .poll(() => readCodexModePreference(page), { timeout: 10_000 }) .toBe("full-access"); + await startup.fail(); + await expect(page.getByText(/Creation startup failed for test/).first()).toBeVisible(); } finally { await seeded.cleanup(); } diff --git a/packages/app/e2e/support/creation-fixtures.ts b/packages/app/e2e/support/creation-fixtures.ts new file mode 100644 index 0000000000..67a9528b05 --- /dev/null +++ b/packages/app/e2e/support/creation-fixtures.ts @@ -0,0 +1,92 @@ +import { writeFile, rm } from "node:fs/promises"; +import path from "node:path"; +import { copyPluginExample } from "./helpers/plugin-fixture"; +import { connectNewWorkspaceDaemonClient } from "./helpers/new-workspace"; +import { test as base } from "./fixtures"; +import { + createCreationScenario, + createPromptRetryScenario, + retryNextAgentCreation, +} from "./helpers/creation"; +import { installDaemonWebSocketGate } from "./helpers/daemon-websocket-gate"; +import { delayBrowserAgentCreatedStatus } from "./helpers/new-workspace"; + +export const test = base.extend<{ + startup: { release(): Promise; fail(): Promise }; + creation: Awaited>; + promptRetry: ReturnType; + agentRetries: Awaited>; + delayedCreation: Awaited>; +}>({ + startup: async ({ page }, provide) => { + void page; + const client = await connectNewWorkspaceDaemonClient(); + const plugin = await copyPluginExample("lifecycle-logger"); + const gate = path.join(plugin.directory, "startup.txt"); + const previous = await client.getDaemonConfig(); + try { + await writeFile(gate, "hold"); + await writeFile( + path.join(plugin.directory, "index.server.ts"), + ` +import { readFile, rm } from "node:fs/promises"; +export default function contribute(server) { + return server.before("agent.session_open", async ({ request }) => { + if (request.reason !== "create") return request; + const gate = ${JSON.stringify(gate)}; + let command = await readFile(gate, "utf8").catch(() => "release"); + while (command === "hold") { + await new Promise(resolve => setTimeout(resolve, 20)); + command = await readFile(gate, "utf8").catch(() => "release"); + } + if (command === "fail") { + await rm(gate, { force: true }); + throw new Error("Creation startup failed for test"); + } + return request; + }); +} +`, + ); + await client.patchDaemonConfig({ pluginsEnabled: true }); + await client.installDirectoryPlugin(plugin.directory); + await provide({ + release: () => rm(gate, { force: true }), + fail: () => writeFile(gate, "fail"), + }); + } finally { + await rm(gate, { force: true }); + await client.removePlugin("lifecycle-logger"); + await client.patchDaemonConfig({ pluginsEnabled: previous.config.pluginsEnabled }); + await plugin.cleanup(); + await client.close(); + } + }, + creation: async ({ page }, provide) => { + const creation = await createCreationScenario(page); + try { + await provide(creation); + } finally { + await creation.cleanup(); + } + }, + promptRetry: async ({ page }, provide) => { + const gate = await installDaemonWebSocketGate(page); + try { + await provide(createPromptRetryScenario(page, gate)); + } finally { + gate.restore(); + } + }, + agentRetries: async ({ page }, provide) => { + await provide(await retryNextAgentCreation(page)); + }, + delayedCreation: async ({ page }, provide) => { + const delayed = await delayBrowserAgentCreatedStatus(page); + try { + await provide(delayed); + } finally { + delayed.release(); + } + }, +}); diff --git a/packages/app/e2e/support/helpers/agent-timeline-gate.ts b/packages/app/e2e/support/helpers/agent-timeline-gate.ts index 91b6867cf0..690a3067ae 100644 --- a/packages/app/e2e/support/helpers/agent-timeline-gate.ts +++ b/packages/app/e2e/support/helpers/agent-timeline-gate.ts @@ -1,4 +1,5 @@ import type { Page } from "@playwright/test"; +import { loadSessionMessageReaders } from "./new-workspace"; import { daemonWsRoutePattern, wsRoutePatternForPort } from "./daemon-port"; type WebSocketMessage = string | Buffer; @@ -321,6 +322,7 @@ export async function holdRewindCompletion( export async function delayCreatedAgentInitialTailResponse( page: Page, ): Promise { + const frames = await loadSessionMessageReaders(); let createdAgentId: string | null = null; let releaseRequested = false; let delayedResponseSeen = false; @@ -350,19 +352,24 @@ export async function delayCreatedAgentInitialTailResponse( }); server.onMessage((message) => { - const sessionMessage = getSessionMessage(message); - const payload = sessionMessage ? getPayload(sessionMessage) : null; - if (sessionMessage?.type === "status" && payload?.status === "agent_created") { - const agentId = payload.agentId; - if (typeof agentId === "string") { - createdAgentId = agentId; - resolveCreatedAgent?.(agentId); - } + const sessionMessage = frames.server(message); + let createdId: unknown; + if (sessionMessage?.type === "agent.create.response") { + createdId = sessionMessage.payload.agent?.id; + } else if ( + sessionMessage?.type === "status" && + sessionMessage.payload.status === "agent_created" + ) { + createdId = sessionMessage.payload.agentId; + } + if (typeof createdId === "string") { + createdAgentId = createdId; + resolveCreatedAgent?.(createdId); } if (sessionMessage?.type === "fetch_agent_timeline_response") { - const agentId = payload?.agentId; - const direction = payload?.direction; + const agentId = sessionMessage.payload.agentId; + const direction = sessionMessage.payload.direction; if ( !delayedResponseSeen && typeof agentId === "string" && diff --git a/packages/app/e2e/support/helpers/creation.ts b/packages/app/e2e/support/helpers/creation.ts new file mode 100644 index 0000000000..c29bbeeeeb --- /dev/null +++ b/packages/app/e2e/support/helpers/creation.ts @@ -0,0 +1,277 @@ +import type { WorkspaceCreateRequest } from "@getpaseo/protocol/messages"; +import { expect, type Page } from "@playwright/test"; +import { daemonWsRoutePattern } from "./daemon-port"; +import { gotoAppShell } from "./app"; +import { gotoWorkspace } from "./launcher"; +import { fillComposerDraft } from "./composer"; +import { createAgentTabFromMenu } from "./workspace-tabs"; +import { + openNewWorkspaceComposer, + selectWorkspaceIsolation, + loadSessionMessageReaders, +} from "./new-workspace"; +import { seedWorkspace } from "./seed-client"; +import { + waitForSidebarHydration, + switchWorkspaceViaSidebar, + workspaceDeckEntryLocator, +} from "./workspace-ui"; +import { getServerId } from "./server-id"; +import { WORKSPACE_DECK_MAX_MOUNTED_WORKSPACES } from "@/screens/workspace/workspace-deck-retention"; +import type { installDaemonWebSocketGate } from "./daemon-websocket-gate"; + +/** Capture the submitted agent options; optionally stop provisioning for wire-only assertions. */ +export async function captureWorkspaceAgentRequest(page: Page, options: { block: boolean }) { + const frames = await loadSessionMessageReaders(); + type AgentIntent = NonNullable; + let resolveRequest!: (agent: AgentIntent) => void; + const request = new Promise((resolve) => { + resolveRequest = resolve; + }); + await page.routeWebSocket(daemonWsRoutePattern(), (ws) => { + const server = ws.connectToServer(); + ws.onMessage((frame) => { + const message = frames.client(frame); + if (message?.type === "workspace.create.request" && message.agent) { + resolveRequest(message.agent); + if (options.block) return; + } + server.send(frame); + }); + server.onMessage((frame) => ws.send(frame)); + }); + return { waitForRequest: () => request }; +} + +export async function pressSubmitBeforeTheNextRender(page: Page, name: string): Promise { + const create = page.getByRole("button", { name, exact: true }); + await expect(create).toBeEnabled(); + // Dispatch the queued clicks in one JS task, before pending state can paint. + // Exercise DOM events rather than calling the app's submit handler directly. + await create.evaluate((button) => { + for (let click = 0; click < 3; click++) { + button.dispatchEvent(new MouseEvent("click", { bubbles: true })); + } + }); +} + +export async function observeCreationRequests(page: Page) { + const frames = await loadSessionMessageReaders(); + const pending = new Set(); + page.on("websocket", (socket) => { + socket.on("framesent", ({ payload }) => { + const request = frames.client(payload); + if ( + request?.type === "workspace.create.request" || + request?.type === "agent.create.request" + ) { + pending.add(request.requestId); + } + }); + socket.on("framereceived", ({ payload }) => { + const response = frames.server(payload); + if ( + response?.type === "workspace.create.response" || + response?.type === "agent.create.response" || + (response?.type === "status" && + (response.payload.status === "agent_created" || + response.payload.status === "agent_create_failed")) + ) { + const requestId = response.payload.requestId; + if (typeof requestId === "string") pending.delete(requestId); + } + }); + }); + return { + async settled() { + await expect.poll(() => pending.size).toBe(0); + }, + }; +} + +export async function retryNextAgentCreation(page: Page) { + const frames = await loadSessionMessageReaders(); + const retryIds = new Set(); + const results: Array<{ status: string; agentId?: string }> = []; + let repeated = false; + await page.routeWebSocket(daemonWsRoutePattern(), (browser) => { + const server = browser.connectToServer(); + browser.onMessage((frame) => { + const request = frames.client(frame); + if (!repeated && request?.type === "agent.create.request") { + repeated = true; + for (let attempt = 1; attempt <= 3; attempt++) { + const requestId = attempt === 1 ? request.requestId : `${request.requestId}-${attempt}`; + retryIds.add(requestId); + // Keep the app's operation key and payload; only RPC correlation changes. + server.send(JSON.stringify({ type: "session", message: { ...request, requestId } })); + } + return; + } + server.send(frame); + }); + server.onMessage((frame) => { + const response = frames.server(frame); + if ( + response?.type === "agent.create.response" && + retryIds.delete(response.payload.requestId) + ) { + results.push({ + status: response.payload.error ? "agent_create_failed" : "agent_created", + agentId: response.payload.agent?.id, + }); + } + browser.send(frame); + }); + }); + return { + async completedAgentIds() { + await expect.poll(() => results.length).toBe(3); + expect(results.map((result) => result.status)).toEqual([ + "agent_created", + "agent_created", + "agent_created", + ]); + return results.map((result) => result.agentId); + }, + }; +} + +export async function createCreationScenario(page: Page) { + const requests = await observeCreationRequests(page); + const project = await seedWorkspace({ repoPrefix: "creation-idempotency-" }); + let workspaceId = project.workspaceId; + return { + cleanup: project.cleanup, + async openWorkspaceForm(isolation: "local" | "worktree") { + await gotoAppShell(page); + await waitForSidebarHydration(page); + await openNewWorkspaceComposer(page, project); + await selectWorkspaceIsolation(page, isolation); + }, + async openAgentDraft() { + await gotoWorkspace(page, project.workspaceId); + await createAgentTabFromMenu(page); + }, + async startAnotherDraft() { + await createAgentTabFromMenu(page); + }, + async submitPrompt(prompt: string, button = "Send message") { + await fillComposerDraft(page, prompt); + await page.getByRole("button", { name: button, exact: true }).click(); + }, + async submitRepeatedly(button: string, prompt?: string) { + if (prompt) await fillComposerDraft(page, prompt); + await pressSubmitBeforeTheNextRender(page, button); + }, + async expectPromptVisible(prompt?: string) { + const rows = page.getByTestId("user-message"); + await expect(prompt ? rows.filter({ hasText: prompt }) : rows.first()).toBeVisible(); + }, + async expectWorkspaceReadyBeforeAgentCompletion() { + await expect(page).toHaveURL(/\/workspace\//); + await expect(page.getByTestId("user-message").first()).toBeVisible(); + await expect( + page + .locator('[data-testid^="workspace-tab-draft_"][aria-selected="true"]') + .filter({ visible: true }), + ).toBeVisible(); + }, + async expectAgentStillStarting() { + await this.expectWorkspaceReadyBeforeAgentCompletion(); + expect((await project.client.fetchAgents()).entries).toHaveLength(0); + }, + async expectStartupFailure() { + await expect(page.getByText(/Creation startup failed for test/).first()).toBeVisible(); + }, + async expectOneCreatedWorkspace() { + await expect(page).toHaveURL(/\/workspace\//); + await requests.settled(); + const workspaces = (await project.client.fetchWorkspaces()).entries.filter( + (workspace) => + workspace.projectId === project.projectId && workspace.id !== project.workspaceId, + ); + expect(workspaces).toHaveLength(1); + workspaceId = workspaces[0]!.id; + }, + async expectAgentCount(count: number) { + await requests.settled(); + await expect + .poll( + async () => + (await project.client.fetchAgents()).entries.filter( + ({ agent }) => agent.workspaceId === workspaceId, + ).length, + ) + .toBe(count); + }, + async expectAgentTitle(title: string) { + await expect + .poll( + async () => + (await project.client.fetchAgents()).entries.find( + ({ agent }) => agent.workspaceId === workspaceId, + )?.agent.title, + ) + .toBe(title); + }, + async evictAndReturnToDraft() { + const draft = page + .locator('[data-testid^="workspace-tab-draft_"][aria-selected="true"]') + .filter({ visible: true }) + .first(); + const draftTestId = await draft.getAttribute("data-testid"); + expect(draftTestId).not.toBeNull(); + for (let index = 0; index < WORKSPACE_DECK_MAX_MOUNTED_WORKSPACES; index++) { + const result = await project.client.createWorkspace({ + source: { kind: "directory", path: project.repoPath }, + }); + if (!result.workspace) throw new Error(result.error ?? "Failed to seed eviction workspace"); + await switchWorkspaceViaSidebar({ + page, + serverId: getServerId(), + workspaceId: result.workspace.id, + }); + } + await expect(workspaceDeckEntryLocator(page, getServerId(), workspaceId)).toHaveCount(0); + await switchWorkspaceViaSidebar({ page, serverId: getServerId(), workspaceId }); + await page + .getByTestId(draftTestId!) + .filter({ visible: true }) + .click({ position: { x: 12, y: 13 } }); + }, + }; +} + +export function createPromptRetryScenario( + page: Page, + gate: Awaited>, +) { + let firstMessage: ReturnType[number] | undefined; + return { + holdAcknowledgement() { + gate.holdNextServerMessage("agent.create.response"); + }, + async waitForDeliveredPrompt() { + await gate.waitForHeldServerMessage("agent.create.response"); + firstMessage = gate.getClientRequests("agent.create.request").at(-1); + }, + async disconnectAndReconnect() { + const fetches = gate.getClientRequestCount("fetch_agents_request"); + await gate.drop(); + gate.restore(); + await expect + .poll(() => gate.getClientRequestCount("fetch_agents_request")) + .toBeGreaterThan(fetches); + }, + async expectSameAgentAndMessage() { + expect(gate.getClientRequestCount("agent.create.request")).toBe(1); + await expect + .poll(() => gate.getClientRequestCount("creation.subscribe.request")) + .toBeGreaterThan(0); + expect(gate.getClientRequests("agent.create.request").at(-1)).toEqual(firstMessage); + expect(gate.getClientRequestCount("send_agent_message_request")).toBe(0); + await expect(page.getByTestId("user-message").filter({ visible: true })).toHaveCount(1); + }, + }; +} diff --git a/packages/app/e2e/support/helpers/daemon-client-loader.ts b/packages/app/e2e/support/helpers/daemon-client-loader.ts index b36bbae32a..eee8cdaffb 100644 --- a/packages/app/e2e/support/helpers/daemon-client-loader.ts +++ b/packages/app/e2e/support/helpers/daemon-client-loader.ts @@ -64,3 +64,10 @@ function loadAppVersion(): string { } return packageJson.version; } + +export async function loadProtocolSchemas(): Promise { + const moduleUrl = pathToFileURL( + path.resolve(__dirname, "../../../../../packages/protocol/dist/messages.js"), + ).href; + return import(moduleUrl); +} diff --git a/packages/app/e2e/support/helpers/daemon-websocket-gate.ts b/packages/app/e2e/support/helpers/daemon-websocket-gate.ts index 2f3d6b22c6..7c9e212c93 100644 --- a/packages/app/e2e/support/helpers/daemon-websocket-gate.ts +++ b/packages/app/e2e/support/helpers/daemon-websocket-gate.ts @@ -21,6 +21,7 @@ interface ClientRequest { mode?: unknown; path?: unknown; agentId?: unknown; + messageId?: unknown; text?: unknown; activeTurnBehavior?: unknown; } diff --git a/packages/app/e2e/support/helpers/new-workspace-navigation.ts b/packages/app/e2e/support/helpers/new-workspace-navigation.ts index 6e6220c581..1b3106bdf7 100644 --- a/packages/app/e2e/support/helpers/new-workspace-navigation.ts +++ b/packages/app/e2e/support/helpers/new-workspace-navigation.ts @@ -134,15 +134,20 @@ export async function verifyDelayedWorkspaceCreation( ); if (!upload) throw new Error("The create request did not contain the submitted file"); expect(await readFile(upload.path)).toEqual(CONTEXT.buffer); + const agents = await client.fetchAgents(); + expect( + agents.entries.find((entry) => entry.agent.workspaceId === created.id)?.agent, + ).toMatchObject({ + cwd: created.workspaceDirectory, + }); expect(delay.agentRequests[0]).toMatchObject({ - workspaceId: created.id, initialPrompt: PROMPT, config: { provider: "mock", model: "ten-second-stream", modeId: "load-test", thinkingOptionId: "low", - cwd: created.workspaceDirectory, + cwd: repo.path, }, attachments: [ expect.objectContaining({ diff --git a/packages/app/e2e/support/helpers/new-workspace.ts b/packages/app/e2e/support/helpers/new-workspace.ts index 5cebc309b1..c6695a268d 100644 --- a/packages/app/e2e/support/helpers/new-workspace.ts +++ b/packages/app/e2e/support/helpers/new-workspace.ts @@ -1,8 +1,8 @@ import { expect, type BrowserContext, type Page } from "@playwright/test"; -import type { CreateAgentRequestMessage } from "@getpaseo/protocol/messages"; +import type { CreateAgentRequestMessage, SessionInboundMessage } from "@getpaseo/protocol/messages"; import type { DaemonClient as InternalDaemonClient } from "@getpaseo/client/internal/daemon-client"; import { decodeWorkspaceIdFromPathSegment } from "@/utils/host-routes"; -import { connectDaemonClient } from "./daemon-client-loader"; +import { connectDaemonClient, loadProtocolSchemas } from "./daemon-client-loader"; import { daemonWsRoutePattern } from "./daemon-port"; import { projectEquivalenceViewKey } from "./project-view-key"; import { expectWorkspaceHeader } from "./workspace-ui"; @@ -535,37 +535,35 @@ function parseWebSocketJson(message: WebSocketMessage): unknown { } } -function getSessionMessage(message: WebSocketMessage): Record | null { - const envelope = parseWebSocketJson(message); - if (!envelope || typeof envelope !== "object") { - return null; - } - const maybeEnvelope = envelope as { type?: unknown; message?: unknown }; - if (maybeEnvelope.type !== "session" || !maybeEnvelope.message) { - return null; - } - if (typeof maybeEnvelope.message !== "object") { - return null; - } - return maybeEnvelope.message as Record; -} - -function getStringField(input: Record, key: string): string | null { - const value = input[key]; - return typeof value === "string" ? value : null; +export async function loadSessionMessageReaders() { + // Use the same ESM module instance as the dynamically loaded daemon client. + const { WSInboundMessageSchema, WSOutboundMessageSchema } = await loadProtocolSchemas(); + return { + client(message: WebSocketMessage) { + const parsed = WSInboundMessageSchema.safeParse(parseWebSocketJson(message)); + return parsed.success && parsed.data.type === "session" ? parsed.data.message : null; + }, + server(message: WebSocketMessage) { + const parsed = WSOutboundMessageSchema.safeParse(parseWebSocketJson(message)); + return parsed.success && parsed.data.type === "session" ? parsed.data.message : null; + }, + }; } export interface AgentCreatedDelayControl { release(): void; waitForCreateRequest(): Promise; waitForDelayedCreatedStatus(): Promise; + expectSingleWorkspaceIntent(): void; } export async function delayBrowserAgentCreatedStatus( page: Page, ): Promise { + const frames = await loadSessionMessageReaders(); const daemonPortPattern = daemonWsRoutePattern(); const createRequestIds = new Set(); + const creationRequests: SessionInboundMessage[] = []; const delayedForwards: Array<() => void> = []; let releaseRequested = false; let resolveCreateRequest: (() => void) | null = null; @@ -581,26 +579,32 @@ export async function delayBrowserAgentCreatedStatus( const server = ws.connectToServer(); ws.onMessage((message) => { - const sessionMessage = getSessionMessage(message); - if (sessionMessage?.type === "create_agent_request") { - const requestId = getStringField(sessionMessage, "requestId"); - if (requestId) { - createRequestIds.add(requestId); - resolveCreateRequest?.(); - } + const sessionMessage = frames.client(message); + if (sessionMessage?.type === "send_agent_message_request") + creationRequests.push(sessionMessage); + if ( + sessionMessage?.type === "create_agent_request" || + sessionMessage?.type === "agent.create.request" || + sessionMessage?.type === "workspace.create.request" + ) { + creationRequests.push(sessionMessage); + createRequestIds.add(sessionMessage.requestId); + resolveCreateRequest?.(); } server.send(message); }); server.onMessage((message) => { - const sessionMessage = getSessionMessage(message); - const payload = - sessionMessage?.type === "status" && typeof sessionMessage.payload === "object" - ? (sessionMessage.payload as Record) - : null; - const requestId = payload ? getStringField(payload, "requestId") : null; - - if (payload?.status === "agent_created" && requestId && createRequestIds.has(requestId)) { + const sessionMessage = frames.server(message); + const isCreated = + (sessionMessage?.type === "status" && sessionMessage.payload.status === "agent_created") || + sessionMessage?.type === "agent.create.response" || + sessionMessage?.type === "workspace.create.response"; + if ( + isCreated && + typeof sessionMessage.payload.requestId === "string" && + createRequestIds.has(sessionMessage.payload.requestId) + ) { resolveDelayedCreatedStatus?.(); if (releaseRequested) { ws.send(message); @@ -621,26 +625,41 @@ export async function delayBrowserAgentCreatedStatus( forward(); } }, + + expectSingleWorkspaceIntent() { + expect(creationRequests).toHaveLength(1); + expect(creationRequests[0]).toMatchObject({ + type: "workspace.create.request", + agent: { initialPrompt: expect.any(String) }, + }); + }, waitForCreateRequest: () => createRequestSeen, waitForDelayedCreatedStatus: () => delayedCreatedStatusSeen, }; } +type AgentCreationIntent = Pick< + CreateAgentRequestMessage, + "config" | "initialPrompt" | "attachments" +>; + export interface WorkspaceCreatedDelayControl { - agentRequests: readonly CreateAgentRequestMessage[]; + agentRequests: readonly AgentCreationIntent[]; release(): void; waitForCreateRequest(): Promise; } /** - * Holds `workspace.create.response` so a test can act as the user does while the daemon is still + * Holds workspace readiness events and responses so a test can act while the daemon is still * running `git worktree add` — most importantly, navigate somewhere else. */ export async function delayBrowserWorkspaceCreatedResponse( page: Page, ): Promise { + const frames = await loadSessionMessageReaders(); const daemonPortPattern = daemonWsRoutePattern(); - const agentRequests: CreateAgentRequestMessage[] = []; + const agentRequests: AgentCreationIntent[] = []; + const creationKeys = new Set(); const createRequestIds = new Set(); const delayedForwards: Array<() => void> = []; let releaseRequested = false; @@ -653,29 +672,25 @@ export async function delayBrowserWorkspaceCreatedResponse( const server = ws.connectToServer(); ws.onMessage((message) => { - const sessionMessage = getSessionMessage(message); - if (sessionMessage?.type === "create_agent_request") - agentRequests.push(sessionMessage as CreateAgentRequestMessage); + const sessionMessage = frames.client(message); + if (sessionMessage?.type === "create_agent_request") agentRequests.push(sessionMessage); if (sessionMessage?.type === "workspace.create.request") { - const requestId = getStringField(sessionMessage, "requestId"); - if (requestId) { - createRequestIds.add(requestId); - resolveCreateRequest?.(); - } + createRequestIds.add(sessionMessage.requestId); + if (sessionMessage.idempotencyKey) creationKeys.add(sessionMessage.idempotencyKey); + if (sessionMessage.agent) agentRequests.push(sessionMessage.agent); + resolveCreateRequest?.(); } server.send(message); }); server.onMessage((message) => { - const sessionMessage = getSessionMessage(message); - const payload = - sessionMessage?.type === "workspace.create.response" && - typeof sessionMessage.payload === "object" - ? (sessionMessage.payload as Record) - : null; - const requestId = payload ? getStringField(payload, "requestId") : null; - - if (requestId && createRequestIds.has(requestId) && !releaseRequested) { + const sessionMessage = frames.server(message); + const isCreation = + (sessionMessage?.type === "workspace.create.response" && + createRequestIds.has(sessionMessage.payload.requestId)) || + (sessionMessage?.type === "workspace.create.update" && + creationKeys.has(sessionMessage.payload.idempotencyKey)); + if (isCreation && !releaseRequested) { delayedForwards.push(() => ws.send(message)); return; } diff --git a/packages/app/e2e/support/helpers/workspace.ts b/packages/app/e2e/support/helpers/workspace.ts index 23bfc4bf9b..14b31be926 100644 --- a/packages/app/e2e/support/helpers/workspace.ts +++ b/packages/app/e2e/support/helpers/workspace.ts @@ -18,12 +18,11 @@ export interface TempDirectory { } /** - * The temp root for E2E fixtures. On macOS we resolve symlinks (/tmp → - * /private/tmp) so fixture paths match the daemon's resolved paths; on Windows - * `/tmp` doesn't exist, so fall back to the OS temp dir. + * Honor the OS temp directory (including TMPDIR), resolving symlinks so fixture + * paths match the daemon's resolved paths. */ export async function resolveTempRoot(): Promise { - return process.platform === "win32" ? tmpdir() : await realpath("/tmp"); + return await realpath(tmpdir()); } async function configureRemote(input: { diff --git a/packages/app/src/components/workspace-setup-dialog.tsx b/packages/app/src/components/workspace-setup-dialog.tsx index 968b721eb2..4b2da86ea5 100644 --- a/packages/app/src/components/workspace-setup-dialog.tsx +++ b/packages/app/src/components/workspace-setup-dialog.tsx @@ -1,9 +1,10 @@ +import type { CreateWorkspaceRequestOptions } from "@getpaseo/client/internal/daemon-client"; +import type { AgentSnapshotPayload } from "@getpaseo/protocol/messages"; import { getHostRuntimeStore } from "@/runtime/host-runtime"; import { useCallback, useEffect, useMemo, useState } from "react"; import { Text, View } from "react-native"; import { StyleSheet } from "react-native-unistyles"; import { useTranslation } from "react-i18next"; -import { createNameId } from "mnemonic-id"; import { AdaptiveModalSheet, type SheetHeader } from "@/components/adaptive-modal-sheet"; import { FileDropZone } from "@/components/file-drop/file-drop-zone"; import { Composer } from "@/composer"; @@ -32,7 +33,6 @@ import type { DaemonClient, } from "@getpaseo/client/internal/daemon-client"; import { projectIconPlaceholderLabelFromDisplayName } from "@/utils/project-display-name"; -import { requireWorkspaceDirectory } from "@/utils/workspace-directory"; import { navigateToAgent } from "@/utils/navigate-to-agent"; import { navigateToWorkspace } from "@/stores/navigation-active-workspace-store"; import type { MessagePayload } from "@/composer/types"; @@ -85,20 +85,23 @@ function buildChatDraftComposerArgs({ async function callWorkspaceCreation({ creationMethod, connectedClient, + creationId, + worktreeSlug, input, }: { creationMethod: "create_worktree" | "open_project"; connectedClient: DaemonClient; - input: { cwd: string }; + creationId: string; + worktreeSlug: string; + input: { cwd: string; agent?: CreateWorkspaceRequestOptions["agent"] }; }) { - if (creationMethod === "create_worktree") { - return connectedClient.createPaseoWorktree({ - cwd: input.cwd, - worktreeSlug: createNameId(), - }); - } return connectedClient.createWorkspace({ - source: { kind: "directory", path: input.cwd }, + idempotencyKey: creationId, + agent: input.agent, + source: + creationMethod === "create_worktree" + ? { kind: "worktree", cwd: input.cwd, worktreeSlug } + : { kind: "directory", path: input.cwd }, }); } @@ -208,7 +211,7 @@ export function WorkspaceSetupDialog() { setErrorMessage(null); setCreatedWorkspace(null); setPendingAction(null); - }, [pendingWorkspaceSetup?.creationMethod, serverId, sourceDirectory]); + }, [pendingWorkspaceSetup?.creationId]); const handleClose = useCallback(() => { clearWorkspaceSetup(); @@ -249,18 +252,25 @@ export function WorkspaceSetupDialog() { }, [client, isConnected, t]); const ensureWorkspace = useCallback( - async (input: { cwd: string; attachments: MessagePayload["attachments"] }) => { + async (input: { + cwd: string; + attachments: MessagePayload["attachments"]; + agent?: CreateWorkspaceRequestOptions["agent"]; + onAgentCreated?: (agent: AgentSnapshotPayload) => void; + }) => { if (!pendingWorkspaceSetup) { throw new Error(t("workspaceSetup.errors.pendingRequired")); } - if (createdWorkspace) { + if (createdWorkspace && !input.agent) { return createdWorkspace; } const connectedClient = withConnectedClient(); const payload = await callWorkspaceCreation({ creationMethod: pendingWorkspaceSetup.creationMethod, + creationId: pendingWorkspaceSetup.creationId, + worktreeSlug: pendingWorkspaceSetup.worktreeSlug, connectedClient, input, }); @@ -271,6 +281,7 @@ export function WorkspaceSetupDialog() { ); } + if (payload.agent) input.onAgentCreated?.(payload.agent); const normalizedWorkspace = normalizeWorkspaceDescriptor(payload.workspace); mergeWorkspaces(pendingWorkspaceSetup.serverId, [normalizedWorkspace]); if (pendingWorkspaceSetup.creationMethod === "open_project") { @@ -307,8 +318,7 @@ export function WorkspaceSetupDialog() { try { setPendingAction("chat"); setErrorMessage(null); - const ensuredWorkspace = await ensureWorkspace({ cwd, attachments }); - const connectedClient = withConnectedClient(); + if (!composerState) { throw new Error(t("workspaceSetup.errors.composerStateRequired")); } @@ -322,21 +332,30 @@ export function WorkspaceSetupDialog() { }), }); const encodedImages = await encodeImages(wirePayload.images); - const workspaceDirectory = requireWorkspaceDirectory({ - workspaceId: ensuredWorkspace.id, - workspaceDirectory: ensuredWorkspace.workspaceDirectory, + if (!pendingWorkspaceSetup) throw new Error(t("workspaceSetup.errors.pendingRequired")); + let createdAgent: AgentSnapshotPayload | undefined; + const { workspaceId: _workspaceId, ...agentInput } = buildCreateAgentOptions({ + composerState, + text, + attachments: wirePayload.attachments, + encodedImages: encodedImages ?? null, + workspaceDirectory: cwd, + workspaceId: "", + provider: composerState.selectedProvider, }); - const agent = await connectedClient.createAgent( - buildCreateAgentOptions({ - composerState, - text, - attachments: wirePayload.attachments, - encodedImages: encodedImages ?? null, - workspaceDirectory, - workspaceId: ensuredWorkspace.id, - provider: composerState.selectedProvider, - }), - ); + const ensuredWorkspace = await ensureWorkspace({ + cwd, + attachments, + agent: { + ...agentInput, + clientMessageId: `${pendingWorkspaceSetup.creationId}:initial-message`, + }, + onAgentCreated: (value) => { + createdAgent = value; + }, + }); + if (!createdAgent) throw new Error("The daemon did not create the requested agent"); + const agent = createdAgent; if (!getIsStillActive()) { return; @@ -362,13 +381,13 @@ export function WorkspaceSetupDialog() { }, [ composerState, + pendingWorkspaceSetup, getIsStillActive, navigateAfterCreation, serverId, ensureWorkspace, t, toast, - withConnectedClient, supportsForgeSearch, ], ); diff --git a/packages/app/src/composer/draft/create-flow.ts b/packages/app/src/composer/draft/create-flow.ts index d1cfce1161..bd4d0fe95c 100644 --- a/packages/app/src/composer/draft/create-flow.ts +++ b/packages/app/src/composer/draft/create-flow.ts @@ -5,12 +5,11 @@ import { resolveComposerAttachmentSubmitFormat, splitComposerAttachmentsForSubmit, } from "@/composer/attachments/submit"; -import { useCreateFlowStore } from "@/stores/create-flow-store"; +import { isActiveCreateFlowForDraft, useCreateFlowStore } from "@/stores/create-flow-store"; import { handoffCreatedAgentMessageSubmission } from "@/composer/submission/writer"; import { useSessionStore } from "@/stores/session-store"; import { createUserMessage, - generateMessageId, type StreamItem, type UserMessageImageAttachment, } from "@/types/stream"; @@ -123,7 +122,7 @@ export function useDraftAgentCreateFlow({ onCreateError, }: UseDraftAgentCreateFlowOptions) { const { t } = useTranslation(); - const [machine, dispatch] = useReducer( + const [localMachine, dispatch] = useReducer( reducer, initialAttempt, (attempt): DraftAgentMachineState => @@ -135,10 +134,22 @@ export function useDraftAgentCreateFlow({ }, ); + const pending = useCreateFlowStore((state) => state.pendingByDraftId[draftId]); + // Remounts can precede model hydration. Rebuild the preview when its inputs + // arrive, and observe the original request's failure through shared state. + const machine = useMemo>(() => { + if (pending?.lifecycle === "abandoned") { + return { tag: "draft", errorMessage: pending.errorMessage ?? "" }; + } + if (pending?.lifecycle === "active" && localMachine.tag === "draft" && initialAttempt) { + return prepareCreateAttempt(initialAttempt, buildDraftAgent); + } + return localMachine; + }, [pending, localMachine, initialAttempt, buildDraftAgent]); + const setPendingCreateAttempt = useCreateFlowStore((state) => state.setPending); const updatePendingAgentId = useCreateFlowStore((state) => state.updateAgentId); const markPendingCreateLifecycle = useCreateFlowStore((state) => state.markLifecycle); - const clearPendingCreateAttempt = useCreateFlowStore((state) => state.clear); const formErrorMessage = machine.tag === "draft" ? machine.errorMessage : ""; const isSubmitting = machine.tag === "creating"; @@ -233,14 +244,16 @@ export function useDraftAgentCreateFlow({ const resolved = error instanceof Error ? error : new Error(t("composer.errors.failedToCreateAgent")); dispatch({ type: "CREATE_FAILED", message: resolved.message }); - markPendingCreateLifecycle({ draftId, lifecycle: "abandoned" }); - clearPendingCreateAttempt({ draftId }); + markPendingCreateLifecycle({ + draftId, + lifecycle: "abandoned", + errorMessage: resolved.message, + }); onCreateError?.(resolved); throw error; } }, [ - clearPendingCreateAttempt, createRequest, draftId, getPendingServerId, @@ -255,7 +268,11 @@ export function useDraftAgentCreateFlow({ const handleCreateFromInput = useCallback( async ({ text, attachments, cwd }: SubmitContext) => { - if (isSubmitting) { + const existing = useCreateFlowStore.getState().pendingByDraftId[draftId]; + if ( + isSubmitting || + isActiveCreateFlowForDraft({ pending: existing, serverId: getPendingServerId(), draftId }) + ) { throw new Error(t("composer.errors.alreadyLoading")); } @@ -296,10 +313,10 @@ export function useDraftAgentCreateFlow({ } const attempt: CreateAttempt = { - clientMessageId: generateMessageId(), + clientMessageId: `${draftId}:initial-message`, text: trimmedPrompt, timestamp: new Date(), - ...(images && images.length > 0 ? { images } : {}), + ...(images.length > 0 ? { images } : {}), ...(wirePayload.attachments.length > 0 ? { attachments: wirePayload.attachments } : {}), }; diff --git a/packages/app/src/composer/draft/workspace-tab.tsx b/packages/app/src/composer/draft/workspace-tab.tsx index 742380789f..0457dfb36f 100644 --- a/packages/app/src/composer/draft/workspace-tab.tsx +++ b/packages/app/src/composer/draft/workspace-tab.tsx @@ -25,7 +25,7 @@ import type { Agent } from "@/stores/session-store"; import { useWorkspaceFields } from "@/stores/session-store-hooks"; import { useWorkspaceDraftSubmissionStore } from "@/stores/workspace-draft-submission-store"; import { useAgentControlCommandCenterActions } from "@/command-center/agent-control-registration"; -import { requestWorkspaceDraftAgent } from "@/composer/draft/create-agent-request"; +import { encodeImages } from "@/utils/encode-images"; import type { WorkspaceFileOpenRequest } from "@/workspace/file-open"; import { shouldAutoFocusWorkspaceDraftComposer } from "@/screens/workspace/workspace-draft-pane-focus"; import { @@ -137,6 +137,7 @@ function resolveDraftModeId(input: { } async function submitDraftCreateRequest(input: { + draftId: string; attempt: { clientMessageId: string }; text: string; images?: UserMessageImageAttachment[]; @@ -196,14 +197,18 @@ async function submitDraftCreateRequest(input: { }); const attachmentsArray = Array.isArray(attachments) ? attachments : undefined; - const result = await requestWorkspaceDraftAgent(client, { + const imagesData = await encodeImages(images); + const options = { + idempotencyKey: input.draftId, config, workspaceId, - text, + initialPrompt: text, clientMessageId: attempt.clientMessageId, - ...(images ? { images } : {}), - ...(attachmentsArray ? { attachments: attachmentsArray } : {}), - }); + ...(imagesData && imagesData.length > 0 ? { images: imagesData } : {}), + ...(attachmentsArray && attachmentsArray.length > 0 ? { attachments: attachmentsArray } : {}), + }; + const creation = useWorkspaceDraftSubmissionStore.getState().creationByDraftId[input.draftId]; + const result = creation ? await creation.retry(options) : await client.createAgent(options); return { agentId: result.id, @@ -390,10 +395,7 @@ export function WorkspaceDraftAgentTab({ ); const autoSubmitConfig = resolveAutoSubmitConfig(pendingAutoSubmit); const initialCreateAttempt = useMemo(() => { - if (!pendingAutoSubmit || !pendingCreateAttempt) { - return null; - } - if (pendingAutoSubmit.clientMessageId !== pendingCreateAttempt.clientMessageId) { + if (!pendingCreateAttempt) { return null; } return { @@ -407,7 +409,7 @@ export function WorkspaceDraftAgentTab({ ? { attachments: pendingCreateAttempt.attachments } : {}), }; - }, [pendingAutoSubmit, pendingCreateAttempt]); + }, [pendingCreateAttempt]); const allowsEmptyAutoSubmit = pendingAutoSubmit?.allowEmptyText === true; const isCompactFormFactor = useIsCompactFormFactor(); const { onLayout: onInputAreaLayout, isBelow: isCompactComposerLayout } = useContainerWidthBelow( @@ -487,8 +489,13 @@ export function WorkspaceDraftAgentTab({ composerState, selectModelMessage: t("workspaceSetup.errors.selectModel"), }), - createRequest: async ({ attempt, text, images, attachments, cwd }) => - submitDraftCreateRequest({ + createRequest: async ({ attempt, text, images, attachments, cwd }) => { + if (pendingAutoSubmit?.agentCreation) { + const result = await pendingAutoSubmit.agentCreation.result; + return { agentId: result.id, result }; + } + return submitDraftCreateRequest({ + draftId, attempt, text, images, @@ -501,7 +508,8 @@ export function WorkspaceDraftAgentTab({ composerState, hostDisconnectedMessage: t("workspace.terminal.hostDisconnected"), selectModelMessage: t("workspaceSetup.errors.selectModel"), - }), + }); + }, onCreateSuccess: ({ result }) => { clearDraftInput("sent"); clearWorkspaceAttachments({ scopeKey: draftAttachmentScopeKey }); diff --git a/packages/app/src/contexts/session-context.tsx b/packages/app/src/contexts/session-context.tsx index 8adf2a188a..e097593db9 100644 --- a/packages/app/src/contexts/session-context.tsx +++ b/packages/app/src/contexts/session-context.tsx @@ -15,7 +15,7 @@ import { deriveAgentStreamTurnLiveness } from "@/timeline/session-stream-reducer import { planTimelineTailFetch } from "@/timeline/timeline-sync-plan"; import { requestTimelineReplacement } from "@/timeline/timeline-replacement"; import { type ViewedTimelineOwner } from "@/timeline/viewed-timeline-sync"; -import type { AgentAttachment, SessionOutboundMessage } from "@getpaseo/protocol/messages"; +import type { SessionOutboundMessage } from "@getpaseo/protocol/messages"; import { parseServerInfoStatusPayload } from "@getpaseo/protocol/messages"; import { buildAgentAttentionNotificationPayload, @@ -25,8 +25,6 @@ import { } from "@getpaseo/protocol/agent-attention-notification"; import type { DaemonClient } from "@getpaseo/client/internal/daemon-client"; -import type { AgentSessionConfig } from "@getpaseo/protocol/agent-types"; -import type { GitSetupOptions } from "@getpaseo/protocol/messages"; import type { AgentPermissionResponse } from "@getpaseo/protocol/agent-types"; import { getHostRuntimeStore, useHostRuntimeIsConnected } from "@/runtime/host-runtime"; import { useVoiceAudioEngineOptional, useVoiceRuntimeOptional } from "@/contexts/voice-context"; @@ -45,9 +43,7 @@ import { createInitDeferred, rejectInitDeferred, } from "@/utils/agent-initialization"; -import { encodeImages } from "@/utils/encode-images"; import { derivePendingPermissionKey } from "@/utils/agent-snapshots"; -import type { AttachmentMetadata } from "@/attachments/types"; import { useToast } from "@/contexts/toast-context"; import { toErrorMessage } from "@/utils/error-messages"; import { showProviderNoticeToast } from "@/utils/provider-notice-toast"; @@ -825,48 +821,6 @@ function SessionProviderInternal({ children, serverId, client }: SessionProvider [client], ); - const _createAgent = useCallback( - async ({ - config, - initialPrompt, - images, - attachments, - git, - worktreeName, - requestId, - }: { - config: AgentSessionConfig; - initialPrompt: string; - images?: AttachmentMetadata[]; - attachments?: AgentAttachment[]; - git?: GitSetupOptions; - worktreeName?: string; - requestId?: string; - }) => { - if (!client) { - console.warn("[Session] createAgent skipped: daemon unavailable"); - return; - } - const trimmedPrompt = initialPrompt.trim(); - let imagesData: Array<{ data: string; mimeType: string }> | undefined; - try { - imagesData = await encodeImages(images); - } catch (error) { - console.error("[Session] Failed to prepare images for agent creation:", error); - } - await client.createAgent({ - config, - ...(trimmedPrompt ? { initialPrompt: trimmedPrompt } : {}), - ...(imagesData && imagesData.length > 0 ? { images: imagesData } : {}), - ...(attachments && attachments.length > 0 ? { attachments } : {}), - ...(git ? { git } : {}), - ...(worktreeName ? { worktreeName } : {}), - ...(requestId ? { requestId } : {}), - }); - }, - [client], - ); - const _setAgentMode = useCallback( (agentId: string, modeId: string) => { if (!client) { diff --git a/packages/app/src/screens/new-workspace-screen.tsx b/packages/app/src/screens/new-workspace-screen.tsx index a9baaf007d..ba858297f4 100644 --- a/packages/app/src/screens/new-workspace-screen.tsx +++ b/packages/app/src/screens/new-workspace-screen.tsx @@ -1,3 +1,9 @@ +import type { + CreateAgentRequestOptions, + CreateWorkspaceRequestOptions, +} from "@getpaseo/client/internal/daemon-client"; +import type { AgentSnapshotPayload, CreationSnapshot } from "@getpaseo/protocol/messages"; +import { encodeImages } from "@/utils/encode-images"; import { getHostRuntimeStore } from "@/runtime/host-runtime"; import { useCallback, useEffect, useMemo, useReducer, useRef, useState } from "react"; import type { ReactElement, RefObject } from "react"; @@ -70,7 +76,6 @@ import type { KeyboardActionId } from "@/keyboard/keyboard-action-dispatcher"; import { useFormPreferences } from "@/hooks/use-form-preferences"; import { useShortcutKeys } from "@/hooks/use-shortcut-keys"; import type { CreateAgentInitialValues } from "@/hooks/use-agent-form-state"; -import { generateMessageId } from "@/types/stream"; import { toErrorMessage } from "@/utils/error-messages"; import { projectIconPlaceholderLabelFromDisplayName } from "@/utils/project-display-name"; import { @@ -87,15 +92,10 @@ import { useProjectIcons } from "@/projects/icons"; import { ICON_SIZE, type Theme } from "@/styles/theme"; import type { ComposerAttachment } from "@/attachments/types"; import { useDraftWorkspaceAttachmentScopeKey } from "@/attachments/workspace-attachments-store"; -import { requestWorkspaceDraftAgent } from "@/composer/draft/create-agent-request"; -import { buildWorkspaceDraftAgentConfig } from "@/screens/workspace/workspace-draft-agent-config"; import type { MessagePayload } from "@/composer/types"; import type { UserComposerAttachment } from "@/attachments/types"; import type { AgentAttachment, ForgeSearchItem } from "@getpaseo/protocol/messages"; -import type { - CreatePaseoWorktreeInput, - DaemonClient, -} from "@getpaseo/client/internal/daemon-client"; +import type { DaemonClient } from "@getpaseo/client/internal/daemon-client"; import type { AgentProvider } from "@getpaseo/protocol/agent-types"; import type { WorkspaceDraftTabSetup, WorkspaceTabTarget } from "@/workspace-tabs/model"; import { isEmptyWorkspaceSubmission, runCreateEmptyWorkspace } from "./new-workspace-empty"; @@ -130,10 +130,7 @@ import { type ListTerminalsPayload, upsertCreatedTerminalPayload, } from "./workspace/terminals/state"; -import { - captureWorkspaceDraftCleanup, - createWorkspaceAgentInBackground, -} from "./new-workspace/background-handoff"; +import { captureWorkspaceDraftCleanup } from "./new-workspace/background-handoff"; import { useNewWorkspaceScreenPresence } from "./new-workspace/screen-presence"; const ThemedFolderPlus = withUnistyles(FolderPlus); @@ -767,6 +764,10 @@ type SubmitOutcome = "navigated" | "background"; interface SubmitDraftInput { clearConsumedDraft: () => void; + agentCreation?: { + result: Promise; + retry: (input: CreateAgentRequestOptions) => Promise; + }; serverId: string; draftKey: string; clearDraft: (lifecycle: "sent" | "abandoned") => void; @@ -798,37 +799,22 @@ interface WorkspaceDraftSubmissionConfig { target: WorkspaceTabTarget; } -async function createAndMergeWorkspace(input: { - client: NonNullable>; - createInput: Parameters< - NonNullable>["createPaseoWorktree"] - >[0]; - mergeWorkspaces: ( - serverId: string, - workspaces: ReturnType[], - ) => void; - serverId: string; - createFailedMessage: string; -}): Promise> { - const payload = await input.client.createPaseoWorktree(input.createInput); - if (payload.error || !payload.workspace) { - throw new Error(payload.error ?? input.createFailedMessage); - } - const normalizedWorkspace = normalizeWorkspaceDescriptor(payload.workspace); - const workspaceForInitialMerge = input.createInput.firstAgentContext - ? { ...normalizedWorkspace, status: "running" as const, statusEnteredAt: new Date() } - : normalizedWorkspace; - input.mergeWorkspaces(input.serverId, [workspaceForInitialMerge]); - return normalizedWorkspace; +interface WorkspaceCreationResult { + workspace: ReturnType; + agent?: AgentSnapshotPayload; } async function createMultiplicityWorkspace(input: { + idempotencyKey: string; + worktreeSlug: string; client: NonNullable>; isolation: "local" | "worktree"; project: HostProjectListItem; sourceDirectory: string; checkoutRequest: PickerCheckoutRequest | undefined; withInitialAgent: boolean; + agent?: CreateWorkspaceRequestOptions["agent"]; + onEvent?: (snapshot: CreationSnapshot) => void; prompt: string; attachments: AgentAttachment[]; mergeWorkspaces: ( @@ -837,7 +823,7 @@ async function createMultiplicityWorkspace(input: { ) => void; serverId: string; createFailedMessage: string; -}): Promise> { +}): Promise { const projectId = getHostProjectId(input.project, input.serverId); if (!projectId) throw new Error("Project is not available on the selected host"); const isWorktree = input.isolation === "worktree"; @@ -846,12 +832,15 @@ async function createMultiplicityWorkspace(input: { attachments: input.attachments, }); const payload = await input.client.createWorkspace({ + idempotencyKey: input.idempotencyKey, + agent: input.agent, + onEvent: input.onEvent, source: isWorktree ? { kind: "worktree", cwd: input.sourceDirectory, projectId, - worktreeSlug: createNameId(), + worktreeSlug: input.worktreeSlug, ...input.checkoutRequest, } : { @@ -869,7 +858,7 @@ async function createMultiplicityWorkspace(input: { ? { ...normalizedWorkspace, status: "running" as const, statusEnteredAt: new Date() } : normalizedWorkspace; input.mergeWorkspaces(input.serverId, [workspaceForInitialMerge]); - return normalizedWorkspace; + return { workspace: normalizedWorkspace, agent: payload.agent }; } interface CreateChatAgentInput { @@ -881,7 +870,9 @@ interface CreateChatAgentInput { prompt: string; attachments: AgentAttachment[]; withInitialAgent: boolean; - }) => Promise>; + agent?: CreateWorkspaceRequestOptions["agent"]; + onEvent?: (snapshot: CreationSnapshot) => void; + }) => Promise; serverId: string; draftKey: string; clearDraft: (lifecycle: "sent" | "abandoned") => void; @@ -945,7 +936,18 @@ function buildComposerInitialValues(input: { return undefined; } -async function runCreateChatAgent(input: CreateChatAgentInput): Promise { +const pendingWorkspaceSubmissions = new Map>(); +function runCreateChatAgent(input: CreateChatAgentInput): Promise { + const key = JSON.stringify([input.serverId, input.draftId]); + const pending = pendingWorkspaceSubmissions.get(key); + if (pending) return pending; + const submission = Promise.resolve().then(() => createWorkspaceChatAgent(input)); + pendingWorkspaceSubmissions.set(key, submission); + void submission.finally(() => pendingWorkspaceSubmissions.delete(key)).catch(() => undefined); + return submission; +} + +async function createWorkspaceChatAgent(input: CreateChatAgentInput): Promise { const { payload, composerState, ensureWorkspace, serverId, clearDraft } = input; const clearConsumedDraft = captureWorkspaceDraftCleanup(input); const { text, attachments, cwd } = payload; @@ -963,36 +965,86 @@ async function runCreateChatAgent(input: CreateChatAgentInput): Promise = { + config: { + provider, + cwd, + modeId: composerState.selectedMode || undefined, + model: composerState.effectiveModelId || undefined, + thinkingOptionId: composerState.effectiveThinkingOptionId || undefined, + featureValues: composerState.featureValues, + }, + initialPrompt: text, + clientMessageId: `${input.draftId}:initial-message`, + images: images?.length ? images : undefined, + attachments: wirePayload.attachments?.length ? wirePayload.attachments : undefined, + }; + const execute = async (requestedAgent = initialAgent): Promise => { + const { agent } = await ensureWorkspace({ + cwd, + prompt: text, + attachments: workspaceNamingAttachments, + withInitialAgent: true, + agent: requestedAgent, + onEvent: (snapshot) => { + if (!snapshot.workspace || navigated) return; + navigated = true; + if (!input.isStillOnCreateScreen()) return; + const workspace = normalizeWorkspaceDescriptor(snapshot.workspace); + getHostRuntimeStore().acceptWorkspaceSnapshots(serverId, [ + { ...workspace, status: "running" }, + ]); + const initialSetup = buildWorkspaceDraftSetupForCreatedWorkspace({ + forkDraftSetup: input.forkDraftSetup, + workspaceDirectory: workspace.workspaceDirectory, + provider, + composerState, + }); + outcome = submitWorkspaceDraft({ + clearConsumedDraft, + draftKey: input.draftKey, + draftContextScopeKey: input.draftContextScopeKey, + resolveClient: input.resolveClient, + isStillOnCreateScreen: input.isStillOnCreateScreen, + serverId, + clearDraft, + draftId: input.draftId, + initialSetup, + workspaceId: workspace.id, + workspaceDirectory: workspace.workspaceDirectory, + text, + attachments, + provider, + composerState, + supportsForgeSearch: input.supportsForgeSearch, + agentCreation, + }); + }, + }); + if (!agent) throw new Error("Workspace creation returned no agent"); + return agent; + }; + const agentCreation = { + result: Promise.resolve().then(() => execute()), + retry: (request: CreateAgentRequestOptions) => + execute({ + ...initialAgent, + config: { ...request.config!, cwd }, + initialPrompt: request.initialPrompt ?? "", + clientMessageId: initialAgent.clientMessageId, + images: request.images, + attachments: request.attachments, + }), + }; + await agentCreation.result; + if (outcome === "background") clearConsumedDraft(); + return outcome; } function buildComposerConfig(input: { @@ -1054,7 +1106,7 @@ function resolveWorkspaceDraftSubmissionConfig(input: { }; } -async function submitWorkspaceDraft(input: SubmitDraftInput): Promise { +function submitWorkspaceDraft(input: SubmitDraftInput): SubmitOutcome { const { serverId, clearDraft, @@ -1068,7 +1120,7 @@ async function submitWorkspaceDraft(input: SubmitDraftInput): Promise - requestWorkspaceDraftAgent(input.resolveClient(), { - workspaceId, - config: buildWorkspaceDraftAgentConfig({ - provider: submission.provider, - cwd: submission.cwd, - ...(submission.modeId ? { modeId: submission.modeId } : {}), - ...(submission.model ? { model: submission.model } : {}), - ...(submission.thinkingOptionId - ? { thinkingOptionId: submission.thinkingOptionId } - : {}), - ...(submission.featureValues ? { featureValues: submission.featureValues } : {}), - }), - text: text.trim(), - clientMessageId, - ...(wirePayload.images.length > 0 ? { images: wirePayload.images } : {}), - ...(wirePayload.attachments.length > 0 ? { attachments: wirePayload.attachments } : {}), - }), - }); - return "background"; - } - - useCreateFlowStore.getState().setPending({ + const started = useCreateFlowStore.getState().trySetPending({ serverId, draftId, workspaceId, @@ -1120,6 +1145,7 @@ async function submitWorkspaceDraft(input: SubmitDraftInput): Promise 0 ? { images: wirePayload.images } : {}), ...(wirePayload.attachments.length > 0 ? { attachments: wirePayload.attachments } : {}), }); + if (!started) return "navigated"; useWorkspaceDraftSubmissionStore.getState().setPending({ serverId, workspaceId, @@ -1135,6 +1161,7 @@ async function submitWorkspaceDraft(input: SubmitDraftInput): Promise= v0.1.97 const supportsWorkspaceMultiplicity = useHostFeature(selectedServerId, "workspaceMultiplicity"); const supportsForgeSearch = useHostFeature(selectedServerId, "forgeSearch"); + const [creationIdentity] = useState(() => ({ + draftId: draftId ?? generateDraftId(), + worktreeSlug: createNameId(), + })); const [errorMessage, setErrorMessage] = useState(null); - const [createdWorkspace, setCreatedWorkspace] = useState | null>(null); + const [creationResult, setCreationResult] = useState< + WorkspaceCreationResult | { workspace: null } + >({ workspace: null }); const [pendingAction, setPendingAction] = useState<"chat" | "empty" | "terminal" | null>(null); const [pickerOpen, setPickerOpen] = useState(false); const [projectPickerOpen, setProjectPickerOpen] = useState(false); @@ -1696,7 +1727,7 @@ export function NewWorkspaceScreen({ return () => clearTimeout(timer); }, [pickerSearchQuery]); - const workspace = createdWorkspace; + const { workspace } = creationResult; const client = useHostRuntimeClient(selectedServerId); const isConnected = useHostRuntimeIsConnected(selectedServerId); const { @@ -1997,45 +2028,17 @@ export function NewWorkspaceScreen({ setProjectPickerOpen(nextOpen); }, []); - const buildCreateWorktreeInput = useCallback( - (input: { - cwd: string; - prompt: string; - attachments: AgentAttachment[]; - checkoutRequest: PickerCheckoutRequest | undefined; - }): CreatePaseoWorktreeInput => { - if (!selectedProject) { - throw new Error("Choose a project"); - } - if (!selectedSourceDirectory) { - throw new Error("Choose a host for this project"); - } - const firstAgentContext = buildFirstAgentContext(input); - const hostProjectId = getHostProjectId(selectedProject, selectedServerId); - if (!hostProjectId) { - throw new Error("Project is not available on the selected host"); - } - - return { - cwd: selectedSourceDirectory, - projectId: hostProjectId, - worktreeSlug: createNameId(), - ...(firstAgentContext ? { firstAgentContext } : {}), - ...input.checkoutRequest, - }; - }, - [selectedProject, selectedServerId, selectedSourceDirectory], - ); - const ensureWorkspace = useCallback( async (input: { cwd: string; prompt: string; attachments: AgentAttachment[]; withInitialAgent: boolean; + agent?: CreateWorkspaceRequestOptions["agent"]; + onEvent?: (snapshot: CreationSnapshot) => void; }) => { - if (createdWorkspace) { - return createdWorkspace; + if (creationResult.workspace) { + return creationResult; } if (!selectedProject) { throw new Error("Choose a project"); @@ -2058,33 +2061,29 @@ export function NewWorkspaceScreen({ selectedItem ?? defaultBasePickerItem(checkoutStatusForCreate), ) : undefined; - const normalizedWorkspace = supportsWorkspaceMultiplicity - ? await createMultiplicityWorkspace({ - client: connectedClient, - isolation: effectiveIsolation, - project: selectedProject, - sourceDirectory: selectedSourceDirectory, - checkoutRequest, - withInitialAgent: input.withInitialAgent, - prompt: input.prompt, - attachments: input.attachments, - mergeWorkspaces, - serverId: selectedServerId, - createFailedMessage: t("newWorkspace.errors.createWorktreeFailed"), - }) - : await createAndMergeWorkspace({ - client: connectedClient, - createInput: buildCreateWorktreeInput({ ...input, checkoutRequest }), - mergeWorkspaces, - serverId: selectedServerId, - createFailedMessage: t("newWorkspace.errors.createWorktreeFailed"), - }); - setCreatedWorkspace(normalizedWorkspace); + const normalizedWorkspace = await createMultiplicityWorkspace({ + idempotencyKey: creationIdentity.draftId, + worktreeSlug: creationIdentity.worktreeSlug, + client: connectedClient, + isolation: createsWorktree ? "worktree" : "local", + project: selectedProject, + sourceDirectory: selectedSourceDirectory, + checkoutRequest, + withInitialAgent: input.withInitialAgent, + prompt: input.prompt, + attachments: input.attachments, + agent: input.agent, + onEvent: input.onEvent, + mergeWorkspaces, + serverId: selectedServerId, + createFailedMessage: t("newWorkspace.errors.createWorktreeFailed"), + }); + setCreationResult(normalizedWorkspace); return normalizedWorkspace; }, [ - buildCreateWorktreeInput, - createdWorkspace, + creationIdentity, + creationResult, effectiveIsolation, mergeWorkspaces, queryClient, @@ -2109,7 +2108,7 @@ export function NewWorkspaceScreen({ let outcome: SubmitOutcome = "background"; await runCreateEmptyWorkspace({ payload, - ensureWorkspace, + ensureWorkspace: async (request) => (await ensureWorkspace(request)).workspace, serverId: selectedServerId, navigate: (targetServerId, workspaceId) => { if (!isStillOnCreateScreen()) { @@ -2136,7 +2135,7 @@ export function NewWorkspaceScreen({ serverId: selectedServerId, clearDraft: chatDraft.clear, draftKey, - draftId, + draftId: creationIdentity.draftId, draftContextScopeKey, supportsForgeSearch, resolveClient: withConnectedClient, @@ -2159,7 +2158,7 @@ export function NewWorkspaceScreen({ [ composerState, draftContextScopeKey, - draftId, + creationIdentity, chatDraft.clear, draftKey, ensureWorkspace, @@ -2186,7 +2185,7 @@ export function NewWorkspaceScreen({ prompt: terminalPromptText, profile: selectedTerminalProfile, profileName: selectedTerminalProfile?.name, - ensureWorkspace, + ensureWorkspace: async (request) => (await ensureWorkspace(request)).workspace, createTerminal: async (input) => { const connectedClient = withConnectedClient(); const createdTerminal = await connectedClient.createTerminal( diff --git a/packages/app/src/stores/create-flow-store.test.ts b/packages/app/src/stores/create-flow-store.test.ts index c1b4ec8d77..4b5d73bd15 100644 --- a/packages/app/src/stores/create-flow-store.test.ts +++ b/packages/app/src/stores/create-flow-store.test.ts @@ -6,6 +6,33 @@ describe("create-flow-store", () => { useCreateFlowStore.setState({ pendingByDraftId: {} }); }); + it("accepts a retry after failure while rejecting repeated active or completed submissions", () => { + const store = useCreateFlowStore.getState(); + const submission = { + draftId: "draft-retry", + serverId: "server-1", + workspaceId: "workspace-1", + agentId: null, + clientMessageId: "draft-retry:initial-message", + text: "hello", + timestamp: 1, + }; + expect(store.trySetPending(submission)).toBe(true); + expect(store.trySetPending(submission)).toBe(false); + store.markLifecycle({ + draftId: submission.draftId, + lifecycle: "abandoned", + errorMessage: "Disconnected", + }); + expect(store.trySetPending(submission)).toBe(true); + expect(useCreateFlowStore.getState().pendingByDraftId[submission.draftId]).toEqual({ + ...submission, + lifecycle: "active", + }); + store.markLifecycle({ draftId: submission.draftId, lifecycle: "sent" }); + expect(store.trySetPending(submission)).toBe(false); + }); + it("tracks lifecycle transitions explicitly", () => { const store = useCreateFlowStore.getState(); store.setPending({ diff --git a/packages/app/src/stores/create-flow-store.ts b/packages/app/src/stores/create-flow-store.ts index 2056e42c0f..95a85137a5 100644 --- a/packages/app/src/stores/create-flow-store.ts +++ b/packages/app/src/stores/create-flow-store.ts @@ -13,13 +13,14 @@ export interface PendingCreateAttempt { text: string; timestamp: number; lifecycle: CreateFlowLifecycleState; + errorMessage?: string; images?: UserMessageImageAttachment[]; attachments?: AgentAttachment[]; } export function isActiveCreateFlowForDraft(input: { pending: PendingCreateAttempt | null | undefined; - serverId: string; + serverId: string | null; draftId: string | null | undefined; }): boolean { const draftId = input.draftId?.trim(); @@ -34,15 +35,20 @@ export function isActiveCreateFlowForDraft(input: { interface CreateFlowState { pendingByDraftId: Record; setPending: (pending: Omit) => void; + trySetPending: (pending: Omit) => boolean; updateAgentId: (input: { draftId: string; agentId: string }) => void; - markLifecycle: (input: { draftId: string; lifecycle: CreateFlowLifecycleState }) => void; + markLifecycle: (input: { + draftId: string; + lifecycle: CreateFlowLifecycleState; + errorMessage?: string; + }) => void; rekeyDraft: (input: { fromDraftId: string; toDraftId: string }) => void; clear: (input: { draftId: string }) => void; clearByAgent: (input: { serverId: string; agentId: string }) => void; clearAll: () => void; } -export const useCreateFlowStore = create((set) => ({ +export const useCreateFlowStore = create((set, get) => ({ pendingByDraftId: {}, setPending: (pending) => set((state) => ({ @@ -54,6 +60,12 @@ export const useCreateFlowStore = create((set) => ({ }, }, })), + trySetPending: (pending) => { + const existing = get().pendingByDraftId[pending.draftId]; + if (existing?.serverId === pending.serverId && existing.lifecycle !== "abandoned") return false; + get().setPending(pending); + return true; + }, updateAgentId: ({ draftId, agentId }) => set((state) => { const current = state.pendingByDraftId[draftId]; @@ -67,7 +79,7 @@ export const useCreateFlowStore = create((set) => ({ }, }; }), - markLifecycle: ({ draftId, lifecycle }) => + markLifecycle: ({ draftId, lifecycle, errorMessage }) => set((state) => { const current = state.pendingByDraftId[draftId]; if (!current || current.lifecycle === lifecycle) { @@ -76,7 +88,7 @@ export const useCreateFlowStore = create((set) => ({ return { pendingByDraftId: { ...state.pendingByDraftId, - [draftId]: { ...current, lifecycle }, + [draftId]: { ...current, lifecycle, errorMessage }, }, }; }), diff --git a/packages/app/src/stores/workspace-draft-submission-store.test.ts b/packages/app/src/stores/workspace-draft-submission-store.test.ts new file mode 100644 index 0000000000..73e1a40899 --- /dev/null +++ b/packages/app/src/stores/workspace-draft-submission-store.test.ts @@ -0,0 +1,71 @@ +import { createRequire } from "node:module"; +import { fileURLToPath } from "node:url"; +import { transformFileSync } from "@babel/core"; +import { expect, test } from "vitest"; +import type { useWorkspaceDraftSubmissionStore } from "./workspace-draft-submission-store"; + +// Exercise the emitted app code: plain TypeScript execution misses Expo's omit bug. +function loadProductionStore() { + const filename = fileURLToPath(new URL("./workspace-draft-submission-store.ts", import.meta.url)); + const caller = { name: "metro", platform: "web", isDev: false, supportsStaticESM: false }; + const result = transformFileSync(filename, { + cwd: fileURLToPath(new URL("../../", import.meta.url)), + envName: "production", + caller, + }); + if (!result?.code) throw new Error("Expo produced no store code"); + const module = { + exports: {} as { useWorkspaceDraftSubmissionStore: typeof useWorkspaceDraftSubmissionStore }, + }; + new Function("require", "module", "exports", result.code)( + createRequire(import.meta.url), + module, + module.exports, + ); + return module.exports.useWorkspaceDraftSubmissionStore; +} + +test("the production app consumes a draft once and leaves other drafts pending", () => { + const store = loadProductionStore(); + const submission = { + serverId: "server", + workspaceId: "workspace", + draftId: "draft-1", + text: "hello", + attachments: [], + cwd: "/repo", + provider: "codex", + clientMessageId: "message", + timestamp: 1, + } as const; + store.getState().setPending({ ...submission, attachments: [] }); + store.getState().setPending({ ...submission, attachments: [], draftId: "draft-2" }); + expect(store.getState().consumePending({ ...submission, workspaceId: "wrong" })).toBeNull(); + expect(store.getState().consumePending(submission)).toEqual(submission); + expect(store.getState().consumePending(submission)).toBeNull(); + expect(Object.keys(store.getState().pendingByDraftId)).toEqual(["draft-2"]); +}); + +test("consuming draft presentation retains its running creation through remount and retry", () => { + const store = loadProductionStore(); + const result = new Promise(() => {}); + const creation = { result, retry: () => result }; + const submission = { + serverId: "server", + workspaceId: "workspace", + draftId: "draft-one", + text: "hello", + attachments: [], + cwd: "/repo", + provider: "codex", + clientMessageId: "message", + timestamp: 1, + agentCreation: creation, + }; + store.getState().setPending(submission); + store.getState().consumePending(submission); + expect(store.getState().pendingByDraftId[submission.draftId]).toBeUndefined(); + expect(store.getState().creationByDraftId[submission.draftId]).toBe(creation); + store.getState().clearDraftSetup({ draftId: submission.draftId }); + expect(store.getState().creationByDraftId[submission.draftId]).toBeUndefined(); +}); diff --git a/packages/app/src/stores/workspace-draft-submission-store.ts b/packages/app/src/stores/workspace-draft-submission-store.ts index 2dbd27f2a5..639701d740 100644 --- a/packages/app/src/stores/workspace-draft-submission-store.ts +++ b/packages/app/src/stores/workspace-draft-submission-store.ts @@ -1,9 +1,16 @@ +import type { CreateAgentRequestOptions } from "@getpaseo/client/internal/daemon-client"; +import type { AgentSnapshotPayload } from "@getpaseo/protocol/messages"; import { create } from "zustand"; import type { ComposerAttachment } from "@/attachments/types"; import type { AgentProvider } from "@getpaseo/protocol/agent-types"; import type { WorkspaceDraftTabSetup } from "@/workspace-tabs/model"; export interface PendingWorkspaceDraftSubmission { + /** Already-running creation. Mounting the draft only observes its result. */ + agentCreation?: { + result: Promise; + retry: (input: CreateAgentRequestOptions) => Promise; + }; serverId: string; workspaceId: string; draftId: string; @@ -28,6 +35,7 @@ export interface PendingWorkspaceDraftSetup { interface WorkspaceDraftSubmissionState { pendingByDraftId: Record; setupByDraftId: Record; + creationByDraftId: Record>; setPending: (submission: PendingWorkspaceDraftSubmission) => void; setDraftSetup: (input: { draftId: string; @@ -61,8 +69,12 @@ export const useWorkspaceDraftSubmissionStore = create ({ pendingByDraftId: {}, setupByDraftId: {}, + creationByDraftId: {}, setPending: (submission) => set((state) => ({ + creationByDraftId: submission.agentCreation + ? { ...state.creationByDraftId, [submission.draftId]: submission.agentCreation } + : state.creationByDraftId, pendingByDraftId: { ...state.pendingByDraftId, [submission.draftId]: submission, @@ -82,9 +94,10 @@ export const useWorkspaceDraftSubmissionStore = create { - if (!state.setupByDraftId[normalizedDraftId]) return state; + const creationByDraftId = { ...state.creationByDraftId }; + delete creationByDraftId[normalizedDraftId]; const { [normalizedDraftId]: _removed, ...setupByDraftId } = state.setupByDraftId; - return { setupByDraftId }; + return { setupByDraftId, creationByDraftId }; }); }, consumePending: (input) => { @@ -96,8 +109,10 @@ export const useWorkspaceDraftSubmissionStore = create { sourceWorkspaceId: "42", displayName: "project", creationMethod: "open_project", + creationId: expect.any(String), + worktreeSlug: expect.any(String), }); }); diff --git a/packages/app/src/stores/workspace-setup-store.ts b/packages/app/src/stores/workspace-setup-store.ts index 31e3bfc024..d0205ba27a 100644 --- a/packages/app/src/stores/workspace-setup-store.ts +++ b/packages/app/src/stores/workspace-setup-store.ts @@ -1,3 +1,5 @@ +import { createNameId } from "mnemonic-id"; +import { generateDraftId } from "@/stores/draft-keys"; import type { SessionOutboundMessage } from "@getpaseo/protocol/messages"; import { create } from "zustand"; import { buildWorkspaceTabPersistenceKey } from "@/workspace-tabs/model"; @@ -44,7 +46,9 @@ export function shouldSeedWorkspaceSetupTab(snapshot: WorkspaceSetupSnapshot | n } interface WorkspaceSetupStoreState { - pendingWorkspaceSetup: PendingWorkspaceSetup | null; + pendingWorkspaceSetup: + | (PendingWorkspaceSetup & { creationId: string; worktreeSlug: string }) + | null; snapshots: Record; requestedKeys: Set; surfacedFailedSetupKeys: Set; @@ -71,7 +75,13 @@ export const useWorkspaceSetupStore = create()((set, g requestedKeys: new Set(), surfacedFailedSetupKeys: new Set(), beginWorkspaceSetup: (value) => { - set({ pendingWorkspaceSetup: value }); + set({ + pendingWorkspaceSetup: { + ...value, + creationId: generateDraftId(), + worktreeSlug: createNameId(), + }, + }); }, clearWorkspaceSetup: () => { set({ pendingWorkspaceSetup: null }); diff --git a/packages/client/src/creation/index.test.ts b/packages/client/src/creation/index.test.ts new file mode 100644 index 0000000000..40828d4bfb --- /dev/null +++ b/packages/client/src/creation/index.test.ts @@ -0,0 +1,234 @@ +import { expect, test } from "vitest"; +import type { + AgentSnapshotPayload, + CreationSnapshot, + WorkspaceDescriptorPayload, +} from "@getpaseo/protocol/messages"; +import { CreationClient, type CreationResult } from "./index.js"; + +const workspace: WorkspaceDescriptorPayload = { + id: "wks_0123456789abcdef", + projectId: "project", + projectDisplayName: "Project", + projectRootPath: "/project", + workspaceDirectory: "/project/worktree", + projectKind: "git", + workspaceKind: "worktree", + name: "Worktree", + status: "running", + statusEnteredAt: null, + activityAt: null, + archivingAt: null, + scripts: [], +}; +const agent: AgentSnapshotPayload = { + id: "00000000-0000-4000-8000-000000000001", + workspaceId: workspace.id, + provider: "codex", + cwd: "/project/worktree", + model: null, + createdAt: "2026-09-11T00:00:00Z", + updatedAt: "2026-09-11T00:00:00Z", + lastUserMessageAt: null, + status: "idle", + capabilities: { + supportsStreaming: true, + supportsSessionPersistence: true, + supportsDynamicModes: false, + supportsMcpServers: false, + supportsReasoningStream: false, + supportsToolInvocations: true, + supportsRewindConversation: false, + supportsRewindFiles: false, + supportsRewindBoth: false, + }, + currentModeId: null, + availableModes: [], + pendingPermissions: [], + persistence: null, + title: "Start once", + labels: {}, +}; + +function fixture(modern: boolean) { + const requests: Array<{ kind: string; input: Record }> = []; + const legacy: Array<{ kind: string; input: unknown }> = []; + let resolve!: (result: CreationResult) => void; + const result = new Promise((done) => { + resolve = done; + }); + const client = new CreationClient({ + supports: () => modern, + requestId: () => "generated-key", + request: async (kind, input) => { + requests.push({ kind, input }); + return result; + }, + observe: () => () => {}, + legacyWorkspace: async (input) => { + legacy.push({ kind: "workspace", input }); + return { requestId: "legacy-workspace", workspace, error: null, setupTerminalId: null }; + }, + legacyAgent: async (input) => { + legacy.push({ kind: "agent", input }); + return agent; + }, + sendMessage: async (id, text, options) => { + legacy.push({ kind: "message", input: { id, text, ...options } }); + }, + }); + const input = { + idempotencyKey: "intent-one", + source: { kind: "directory" as const, path: "/project" }, + agent: { + config: { provider: "codex", cwd: "/project" }, + initialPrompt: "Start once", + clientMessageId: "message-one", + }, + }; + return { client, requests, legacy, resolve, input }; +} + +test("duplicate client submissions join one complete intent and cumulative updates never regress", async () => { + const f = fixture(true); + const phases: string[] = []; + const first = f.client.createWorkspace({ + ...f.input, + onEvent: (snapshot) => phases.push(snapshot.phase), + }); + const duplicate = f.client.createWorkspace(f.input); + expect(duplicate).toBe(first); + expect(f.requests).toEqual([{ kind: "workspace", input: { ...f.input, subscribe: true } }]); + await expect( + f.client.createWorkspace({ + ...f.input, + agent: { ...f.input.agent, initialPrompt: "Different intent" }, + }), + ).rejects.toThrow("workspace_request_key_conflict"); + const snapshot: CreationSnapshot = { + kind: "workspace", + idempotencyKey: "intent-one", + revision: 2, + phase: "workspace_ready", + workspaceId: workspace.id, + agentId: agent.id, + workspace, + error: null, + }; + f.client.receive(snapshot); + f.client.receive({ ...snapshot, revision: 1, phase: "accepted" }); + expect(phases).toEqual(["workspace_ready"]); + const late: string[] = []; + f.client.createWorkspace({ ...f.input, onEvent: (update) => late.push(update.phase) }); + expect(late).toEqual(["workspace_ready"]); + f.resolve({ + workspace, + agent, + error: null, + setupSkippedReason: "Untrusted source", + requestId: "response-one", + }); + expect(await first).toMatchObject({ + workspace, + agent, + setupSkippedReason: "Untrusted source", + requestId: "response-one", + }); + expect(await duplicate).toEqual(await first); + expect(f.legacy).toEqual([]); + f.client.close(); +}); + +test("legacy adaptation keeps workspace, agent and initial prompt sequencing inside the client", async () => { + const f = fixture(false); + const phases: string[] = []; + const result = await f.client.createWorkspace({ + ...f.input, + onEvent: (snapshot) => phases.push(snapshot.phase), + }); + expect(result).toMatchObject({ workspace, agent, error: null }); + expect(phases).toEqual(["workspace_ready"]); + expect(f.legacy).toEqual([ + { kind: "workspace", input: { idempotencyKey: "intent-one", source: f.input.source } }, + { + kind: "agent", + input: { + config: { provider: "codex", cwd: workspace.workspaceDirectory }, + workspaceId: workspace.id, + idempotencyKey: "intent-one:agent", + clientMessageId: "message-one", + }, + }, + { + kind: "message", + input: { + id: agent.id, + text: "Start once", + messageId: "message-one", + images: undefined, + attachments: undefined, + }, + }, + ]); + expect(f.requests).toEqual([]); + f.client.close(); +}); + +test("legacy daemons cannot silently replace caller-selected IDs", async () => { + const f = fixture(false); + await expect(f.client.createWorkspace({ ...f.input, workspaceId: workspace.id })).rejects.toThrow( + "caller-selected", + ); + await expect( + f.client.createAgent({ agentId: agent.id, config: f.input.agent.config }), + ).rejects.toThrow("caller-selected"); + expect(f.legacy).toEqual([]); + f.client.close(); +}); + +test("keyed creation with an empty prompt works on legacy daemons without sending an empty message", async () => { + const f = fixture(false); + await f.client.createAgent({ + idempotencyKey: "empty-agent", + config: f.input.agent.config, + initialPrompt: "", + }); + expect(f.legacy).toEqual([ + { kind: "agent", input: { idempotencyKey: "empty-agent", config: f.input.agent.config } }, + ]); + f.client.close(); +}); + +test.each([ + ["/repo/project", "/repo/project/../outside"], + ["C:\\repo\\project", "C:\\repo\\project\\..\\outside"], +])("legacy creation rejects escaping %s before creating a workspace", async (source, cwd) => { + const f = fixture(false); + await expect( + f.client.createWorkspace({ + ...f.input, + source: { kind: "directory", path: source }, + agent: { ...f.input.agent, config: { provider: "codex", cwd } }, + }), + ).rejects.toThrow("inside the workspace source"); + expect(f.legacy).toEqual([]); + f.client.close(); +}); + +test.each([ + { source: { kind: "directory" as const, path: "/project" }, cwd: "/project/src" }, + { source: { kind: "directory" as const, path: "C:\\project" }, cwd: "C:\\project\\src" }, + { source: { kind: "worktree" as const, projectId: "project" }, cwd: "/project/src" }, +])("legacy creation preserves the agent subdirectory for $source", async ({ source, cwd }) => { + const f = fixture(false); + await f.client.createWorkspace({ + ...f.input, + source, + agent: { config: { provider: "codex", cwd } }, + }); + expect(f.legacy[1]).toMatchObject({ + kind: "agent", + input: { workspaceId: workspace.id, config: { cwd: "/project/worktree/src" } }, + }); + f.client.close(); +}); diff --git a/packages/client/src/creation/index.ts b/packages/client/src/creation/index.ts new file mode 100644 index 0000000000..4ae64c34ff --- /dev/null +++ b/packages/client/src/creation/index.ts @@ -0,0 +1,276 @@ +import type { + AgentSnapshotPayload, + CreationSnapshot, + WorkspaceCreateResponse, +} from "@getpaseo/protocol/messages"; +import type { CreateAgentRequestOptions, CreateWorkspaceRequestOptions } from "../daemon-client.js"; + +type Kind = CreationSnapshot["kind"]; +type Observer = (snapshot: CreationSnapshot) => void; +export interface CreationResult { + agent?: AgentSnapshotPayload | null; + workspace?: WorkspaceCreateResponse["payload"]["workspace"]; + creation?: CreationSnapshot; + error: string | null; + errorCode?: string; + setupTerminalId?: string | null; + setupSkippedReason?: string; + requestId?: string; +} +interface Dependencies { + supports: () => boolean; + requestId: () => string; + request: (kind: Kind, input: Record) => Promise; + observe: ( + kind: Kind, + key: string, + next: (snapshot: CreationSnapshot | null) => void, + error: (error: unknown) => void, + ) => () => void; + legacyAgent: (input: CreateAgentRequestOptions) => Promise; + legacyWorkspace: ( + input: CreateWorkspaceRequestOptions, + ) => Promise; + sendMessage: ( + id: string, + text: string, + options: { + messageId: string; + images?: CreateAgentRequestOptions["images"]; + attachments?: CreateAgentRequestOptions["attachments"]; + }, + ) => Promise; +} +interface Operation { + kind: Kind; + key: string; + fingerprint: string; + input: Record; + observers: Set; + snapshot?: CreationSnapshot; + result: Promise; + resolve: (result: CreationResult) => void; + reject: (error: Error) => void; + settled: boolean; + modern: boolean; + recovering: boolean; + stopObserving?: () => void; +} + +/** Client-owned observation and compatibility. UI callbacks never advance creation. */ +export class CreationClient { + private readonly operations = new Map(); + constructor(private readonly deps: Dependencies) {} + + createAgent(input: CreateAgentRequestOptions): Promise { + const { onEvent, ...request } = input; + return this.start("agent", request, onEvent, () => this.legacyAgent(request)); + } + createWorkspace(input: CreateWorkspaceRequestOptions): Promise { + const { onEvent, ...request } = input; + return this.start("workspace", request, onEvent, async (operation) => { + if (request.workspaceId || request.agent?.agentId) + throw new Error("Update the host to use caller-selected creation IDs."); + const { agent, ...workspaceInput } = request; + const sourceCwd = + request.source.kind === "directory" ? request.source.path : request.source.cwd; + const relativeCwd = + agent && sourceCwd ? relativeDirectory(agent.config!.cwd, sourceCwd) : undefined; + const workspace = await this.deps.legacyWorkspace(workspaceInput); + if (workspace.error || !workspace.workspace) return workspace; + this.receive({ + kind: "workspace", + idempotencyKey: operation.key, + revision: 1, + phase: "workspace_ready", + workspaceId: workspace.workspace.id, + agentId: null, + workspace: workspace.workspace, + setupSkippedReason: workspace.setupSkippedReason, + error: null, + }); + if (!agent) return workspace; + const cwd = `${workspace.workspace.workspaceDirectory!.replace(/[\\/]+$/, "")}${ + relativeCwd ?? relativeDirectory(agent.config!.cwd, workspace.workspace.projectRootPath) + }`; + const created = await this.legacyAgent({ + ...agent, + config: { ...agent.config!, cwd }, + workspaceId: workspace.workspace.id, + idempotencyKey: `${operation.key}:agent`, + }); + return { ...workspace, agent: created.agent }; + }); + } + + private async legacyAgent(input: CreateAgentRequestOptions): Promise { + if (input.agentId) throw new Error("Update the host to use caller-selected creation IDs."); + // COMPAT(creationLifecycle): added in v0.8.0, remove after 2027-03-11 once the daemon floor supports creationLifecycle. + // Old keyed creates reject initialPrompt. Keep this adaptation private to the SDK. + if (input.idempotencyKey) { + const { initialPrompt = "", images, attachments, ...creation } = input; + const hasPrompt = Boolean(initialPrompt.trim() || images?.length || attachments?.length); + if (hasPrompt && input.outputSchema) + throw new Error("Update the host to create a keyed agent with structured initial output."); + const agent = await this.deps.legacyAgent(creation); + if (hasPrompt) + await this.deps.sendMessage(agent.id, initialPrompt, { + messageId: input.clientMessageId ?? `${input.idempotencyKey}:initial-message`, + images, + attachments, + }); + return { agent, error: null }; + } + return { agent: await this.deps.legacyAgent(input), error: null }; + } + + private start( + kind: Kind, + input: Record, + observer: Observer | undefined, + legacy: (operation: Operation) => Promise, + ): Promise { + const key = + typeof input.idempotencyKey === "string" ? input.idempotencyKey : this.deps.requestId(); + const identity = JSON.stringify([kind, key]); + const { requestId: _requestId, ...intent } = input; + const fingerprint = JSON.stringify(intent, (_key, value: unknown) => + value && typeof value === "object" && !Array.isArray(value) + ? Object.fromEntries(Object.entries(value).sort(([a], [b]) => a.localeCompare(b))) + : value, + ); + const previous = this.operations.get(identity); + if (previous) { + if (previous.fingerprint !== fingerprint) + return Promise.reject(new Error(`${kind}_request_key_conflict`)); + if (observer) { + previous.observers.add(observer); + if (previous.snapshot) this.notify(observer, previous.snapshot); + } + return previous.result; + } + let resolve!: Operation["resolve"]; + let reject!: Operation["reject"]; + const result = new Promise((done, fail) => { + resolve = done; + reject = fail; + }); + const operation: Operation = { + kind, + key, + input: { ...input, idempotencyKey: key }, + fingerprint, + observers: new Set(observer ? [observer] : []), + result, + resolve, + reject, + settled: false, + modern: this.deps.supports(), + recovering: false, + }; + this.operations.set(identity, operation); + if (operation.modern) void this.submit(operation); + else + void legacy(operation).then( + (value) => this.finish(operation, value), + (error) => this.fail(operation, error), + ); + return result; + } + + private async submit(operation: Operation): Promise { + try { + const result = await this.deps.request(operation.kind, { + ...operation.input, + subscribe: true, + }); + if (result.creation) this.receive(result.creation); + this.finish(operation, result); + } catch (error) { + if ( + operation.settled || + (error instanceof Error && "code" in error && error.code === "DAEMON_CONNECTION_LOST") + ) + return; + this.fail(operation, error); + } + } + receive(snapshot: CreationSnapshot): void { + const operation = this.operations.get(JSON.stringify([snapshot.kind, snapshot.idempotencyKey])); + if (!operation || (operation.snapshot && operation.snapshot.revision > snapshot.revision)) + return; + if (operation.snapshot?.revision !== snapshot.revision) { + operation.snapshot = snapshot; + for (const observer of operation.observers) this.notify(observer, snapshot); + } + if (operation.recovering && (snapshot.phase === "completed" || snapshot.phase === "failed")) { + this.finish(operation, { + agent: snapshot.agent, + workspace: snapshot.workspace, + creation: snapshot, + setupSkippedReason: snapshot.setupSkippedReason, + error: snapshot.error, + errorCode: snapshot.errorCode, + }); + } + } + reconnect(): void { + for (const operation of this.operations.values()) { + if (operation.settled || !operation.modern || operation.recovering) continue; + operation.recovering = true; + operation.stopObserving = this.deps.observe( + operation.kind, + operation.key, + (snapshot) => { + if (!snapshot) void this.submit(operation); + else { + // Re-subscription is authoritative even if its revision matches the last live event. + this.receive(snapshot); + } + }, + (error) => this.fail(operation, error), + ); + } + } + close(): void { + for (const operation of this.operations.values()) + if (!operation.settled) this.fail(operation, new Error("Daemon client closed")); + this.operations.clear(); + } + private finish(operation: Operation, result: CreationResult): void { + if (operation.settled) return; + operation.settled = true; + operation.stopObserving?.(); + operation.resolve(result); + operation.observers.clear(); + this.operations.delete(JSON.stringify([operation.kind, operation.key])); + } + private fail(operation: Operation, error: unknown): void { + if (operation.settled) return; + operation.settled = true; + operation.stopObserving?.(); + operation.reject(error instanceof Error ? error : new Error(String(error))); + operation.observers.clear(); + this.operations.delete(JSON.stringify([operation.kind, operation.key])); + } + private notify(observer: Observer, snapshot: CreationSnapshot): void { + try { + observer(snapshot); + } catch { + /* Observation does not control execution. */ + } + } +} + +function relativeDirectory(cwd: string, source: string): string { + const normalize = (value: string) => value.replace(/\\/g, "/").replace(/\/+$/, ""); + const normalized = normalize(cwd), + root = normalize(source); + // Parent traversal must never survive mapping into the newly created checkout. + if ([normalized, root].some((path) => path.split("/").includes(".."))) + throw new Error("Agent directory must be inside the workspace source"); + if (normalized === root) return ""; + if (!normalized.startsWith(`${root}/`)) + throw new Error("Agent directory must be inside the workspace source"); + return normalized.slice(root.length); +} diff --git a/packages/client/src/daemon-client.test.ts b/packages/client/src/daemon-client.test.ts index 866f5bba22..4d36bb686d 100644 --- a/packages/client/src/daemon-client.test.ts +++ b/packages/client/src/daemon-client.test.ts @@ -6323,3 +6323,169 @@ test("wire snapshot callers own expansion and receive hash references unchanged" ); expect(await request).toEqual(body); }); + +test("creation lifecycle sends the keyed agent and initial prompt as one intent", async () => { + const transport = createMockTransport(); + const client = new DaemonClient({ + url: "ws://test", + clientId: "creation-contract", + transportFactory: () => transport.transport, + reconnect: { enabled: false }, + }); + clients.push(client); + const connected = client.connect(); + transport.triggerOpen({ features: { creationLifecycle: true, agentRequestReceipts: true } }); + await connected; + const creation = client.createAgent({ + idempotencyKey: "draft-one", + provider: "codex", + cwd: "/project", + workspaceId: "wks_0123456789abcdef", + initialPrompt: "Start once", + clientMessageId: "first-message", + }); + void creation.catch(() => undefined); + const request = parseSentFrame(transport.sent[0]); + expect(request).toMatchObject({ + type: "agent.create.request", + idempotencyKey: "draft-one", + initialPrompt: "Start once", + clientMessageId: "first-message", + }); + transport.triggerMessage( + wrapSessionMessage({ + type: "agent.create.response", + payload: { requestId: request.requestId, agent: null, error: "provider unavailable" }, + }), + ); + await expect(creation).rejects.toThrow("provider unavailable"); + expect(transport.sent).toHaveLength(1); +}); + +test("creation lifecycle acknowledgement reaches the observer before the final response", async () => { + const transport = createMockTransport(); + const client = new DaemonClient({ + url: "ws://test", + clientId: "creation-progress", + transportFactory: () => transport.transport, + reconnect: { enabled: false }, + }); + clients.push(client); + const connected = client.connect(); + transport.triggerOpen({ features: { creationLifecycle: true } }); + await connected; + const phases: string[] = []; + const creation = client.createAgent({ + provider: "codex", + cwd: "/project", + idempotencyKey: "observe-one", + onEvent: (snapshot) => phases.push(snapshot.phase), + }); + void creation.catch(() => undefined); + const request = parseSentFrame(transport.sent[0]); + transport.triggerMessage( + wrapSessionMessage({ + type: "agent.create.update", + payload: { + kind: "agent", + idempotencyKey: "observe-one", + revision: 0, + phase: "accepted", + workspaceId: null, + agentId: "00000000-0000-4000-8000-000000000001", + error: null, + }, + }), + ); + expect(phases).toEqual(["accepted"]); + transport.triggerMessage( + wrapSessionMessage({ + type: "agent.create.response", + payload: { requestId: request.requestId, agent: null, error: "provider unavailable" }, + }), + ); + await expect(creation).rejects.toThrow("provider unavailable"); +}); + +test("creation reconnect observation uses connection-owned subscriptions and releases on completion", async () => { + const transport = createMockTransport(); + const client = new DaemonClient({ + url: "ws://test", + clientId: "creation-reconnect", + transportFactory: () => transport.transport, + reconnect: { enabled: false }, + }); + clients.push(client); + const features = { creationLifecycle: true, ownedSubscriptions: true }; + const connect = client.connect(); + transport.triggerOpen({ features }); + await connect; + const phases: string[] = []; + const creation = client.createWorkspace({ + idempotencyKey: "reconnect-one", + source: { kind: "directory", path: "/project" }, + onEvent: (snapshot) => phases.push(snapshot.phase), + }); + void creation.catch(() => undefined); + const snapshot = { + kind: "workspace", + idempotencyKey: "reconnect-one", + phase: "accepted", + revision: 0, + workspaceId: "wks_0123456789abcdef", + agentId: null, + error: null, + }; + for (const subscriptionId of ["first-connection", "second-connection"]) { + transport.triggerClose(); + const reconnected = client.connect(); + transport.triggerOpen({ features }); + await reconnected; + await expect.poll(() => transport.sent.length).toBe(1); + const request = parseSentFrame(transport.sent[0]); + expect(request).toMatchObject({ + type: "creation.subscribe.request", + idempotencyKey: "reconnect-one", + }); + transport.triggerMessage( + wrapSessionMessage({ + type: "creation.subscribe.response", + payload: { + requestId: request.requestId, + subscriptionId, + snapshot, + error: null, + }, + }), + ); + } + transport.triggerMessage( + wrapSessionMessage({ + type: "workspace.create.update", + payload: { + ...snapshot, + subscriptionId: "second-connection", + revision: 1, + phase: "failed", + error: "Provisioning failed", + }, + }), + ); + expect(await creation).toMatchObject({ error: "Provisioning failed" }); + await expect.poll(() => transport.sent.length).toBe(2); + const release = parseSentFrame(transport.sent.at(-1)); + expect(release).toMatchObject({ + type: "subscription.release.request", + subscriptionId: "second-connection", + }); + transport.triggerMessage( + wrapSessionMessage({ + type: "subscription.release.response", + payload: { + requestId: release.requestId, + subscriptionId: "second-connection", + }, + }), + ); + expect(phases).toEqual(["accepted", "failed"]); +}); diff --git a/packages/client/src/daemon-client.ts b/packages/client/src/daemon-client.ts index 5c2e15d906..458ccf5c92 100644 --- a/packages/client/src/daemon-client.ts +++ b/packages/client/src/daemon-client.ts @@ -6,6 +6,8 @@ import { DEFAULT_CLIENT_CAPABILITIES, type TimelineSubscription, } from "./connection/index.js"; +import { CreationClient } from "./creation/index.js"; +import type { CreationSnapshot } from "@getpaseo/protocol/messages"; import type { z } from "zod"; import type { SessionEventSubscription } from "@getpaseo/protocol/messages"; import type { ClientCapability } from "@getpaseo/protocol/client-capabilities"; @@ -370,6 +372,8 @@ export interface AgentAttentionRequiredNotification { type AgentConfigOverrides = Partial>; export interface CreateAgentRequestOptions extends AgentConfigOverrides { + agentId?: string; + onEvent?: (snapshot: CreationSnapshot) => void; config?: AgentSessionConfig; provider?: AgentProvider; cwd?: string; @@ -392,6 +396,20 @@ export interface CreateAgentRequestOptions extends AgentConfigOverrides { labels?: Record; } +export interface CreateWorkspaceRequestOptions { + source: WorkspaceCreateRequest["source"]; + title?: string; + idempotencyKey?: string; + workspaceId?: string; + agent?: Omit< + CreateAgentRequestOptions, + "workspaceId" | "onEvent" | "worktree" | "git" | "worktreeName" | "idempotencyKey" | "requestId" + >; + onEvent?: (snapshot: CreationSnapshot) => void; + firstAgentContext?: WorkspaceCreateRequest["firstAgentContext"]; + requestId?: string; +} + export interface CreatePaseoWorktreeInput extends Pick< CreatePaseoWorktreeRequest, | "cwd" @@ -1416,6 +1434,7 @@ export class DaemonClient { this.providerSnapshotUpdates.clear(); this.clearWaiters(new Error("Daemon client closed")); await this.owned.close(); + this.creations.close(); this.rejectPendingSendQueue(new Error("Daemon client closed")); this.rejectPingProbe(new Error("Daemon client closed")); this.terminalStreams.clearSlots(); @@ -2656,7 +2675,58 @@ export class DaemonClient { // Agent Lifecycle // ============================================================================ + private readonly creations = new CreationClient({ + supports: () => this.lastServerInfoMessage?.features?.creationLifecycle === true, + requestId: () => this.createRequestId(), + request: (kind, input) => + kind === "workspace" + ? this.sendCorrelatedSessionRequest({ + requestId: input.requestId as string | undefined, + message: { ...input, type: "workspace.create.request" }, + responseType: "workspace.create.response", + timeout: 0, + }) + : this.sendCorrelatedSessionRequest({ + requestId: input.requestId as string | undefined, + message: { ...input, type: "agent.create.request" }, + responseType: "agent.create.response", + timeout: 0, + }), + observe: (kind, idempotencyKey, next, error) => { + const observation = this.observe("creation.subscribe.response", { + type: "creation.subscribe.request", + kind, + idempotencyKey, + }); + observation.subscribe({ + snapshot: (result) => next(result.snapshot), + update: (message) => { + if (message.type === "workspace.create.update" || message.type === "agent.create.update") + next(message.payload); + }, + error, + }); + return () => { + void observation.release().catch(error); + }; + }, + legacyAgent: (input) => this.createLegacyAgent(input), + legacyWorkspace: (input) => this.createLegacyWorkspace(input, input.requestId), + sendMessage: (id, text, options) => this.sendMessage(id, text, options), + }); + async createAgent(options: CreateAgentRequestOptions): Promise { + const result = await this.creations.createAgent({ + ...options, + config: resolveAgentConfig(options), + }); + if (result.error || !result.agent) throw new Error(result.error ?? "Agent creation failed"); + return result.agent; + } + + private async createLegacyAgent( + options: CreateAgentRequestOptions, + ): Promise { if (options.idempotencyKey !== undefined) this.requireAgentRequestReceipts(); const requestId = this.createRequestId(options.requestId); const config = resolveAgentConfig(options); @@ -4334,18 +4404,43 @@ export class DaemonClient { } async createWorkspace( - input: { - source: WorkspaceCreateRequest["source"]; - title?: string; - firstAgentContext?: WorkspaceCreateRequest["firstAgentContext"]; - }, + input: CreateWorkspaceRequestOptions, requestId?: string, ): Promise { + const resolvedRequestId = this.createRequestId(requestId ?? input.requestId); + const result = await this.creations.createWorkspace({ + ...input, + requestId: resolvedRequestId, + ...(input.agent + ? { agent: { ...input.agent, config: resolveAgentConfig(input.agent) } } + : {}), + }); + return { + ...result, + workspace: result.workspace ?? null, + agent: result.agent ?? undefined, + setupTerminalId: result.setupTerminalId ?? null, + requestId: result.requestId ?? resolvedRequestId, + }; + } + + private async createLegacyWorkspace( + input: CreateWorkspaceRequestOptions, + requestId?: string, + ): Promise { + // COMPAT(workspaceRequestReceipts): added in v0.8.0; remove gate after 2027-03-07. + if ( + input.idempotencyKey !== undefined && + !this.lastServerInfoMessage?.features?.workspaceRequestReceipts + ) { + throw new Error("Update the host to use retry-safe workspace creation."); + } return this.sendCorrelatedSessionRequest({ requestId, message: { type: "workspace.create.request", source: input.source, + ...(input.idempotencyKey !== undefined ? { idempotencyKey: input.idempotencyKey } : {}), ...(input.title !== undefined ? { title: input.title } : {}), ...(input.firstAgentContext !== undefined ? { firstAgentContext: input.firstAgentContext } @@ -6276,6 +6371,7 @@ export class DaemonClient { ...DEFAULT_CLIENT_CAPABILITIES, ...this.config.capabilities, }); + this.creations.reconnect(); this.flushPendingSendQueue(); this.resolveConnect(); } @@ -6310,6 +6406,12 @@ export class DaemonClient { } } + if ( + consumerMessage.type === "workspace.create.update" || + consumerMessage.type === "agent.create.update" + ) { + if (!consumerMessage.payload.subscriptionId) this.creations.receive(consumerMessage.payload); + } this.resolveWaiters(consumerMessage); this.owned.receive(consumerMessage); } @@ -6493,6 +6595,15 @@ function resolveAgentConfig(options: CreateAgentRequestOptions): AgentSessionCon config, provider, cwd, + agentId: _agentId, + onEvent: _onEvent, + idempotencyKey: _idempotencyKey, + clientMessageId: _clientMessageId, + callerAgentId: _callerAgentId, + outputSchema: _outputSchema, + attachments: _attachments, + worktree: _worktree, + autoArchive: _autoArchive, env: _env, workspaceId: _workspaceId, initialPrompt: _initialPrompt, diff --git a/packages/client/src/index.ts b/packages/client/src/index.ts index 8485b063af..e113d48667 100644 --- a/packages/client/src/index.ts +++ b/packages/client/src/index.ts @@ -4,6 +4,7 @@ import type { DaemonClientConfig } from "./daemon-client.js"; import type { AgentPermissionResponse } from "@getpaseo/protocol/agent-types"; import type { AgentSnapshotPayload, + CreationSnapshot, CreateAgentRequestMessage, FetchWorkspacesRequestMessage, FetchWorkspacesResponseMessage, @@ -28,7 +29,7 @@ import type { WorkspaceDescriptorPayload, WorkspaceCreateRequest, } from "@getpaseo/protocol/messages"; -import { DaemonClient } from "./daemon-client.js"; +import { DaemonClient, type CreateAgentRequestOptions } from "./daemon-client.js"; import { createTerminalActions, type PaseoTerminalActions, @@ -142,8 +143,16 @@ export interface PaseoWorkspaceOpenOptions { requestId?: string; } -export type PaseoWorkspaceCreateOptions = Omit & { +export type PaseoWorkspaceCreateOptions = Omit< + WorkspaceCreateRequest, + "type" | "requestId" | "agent" | "subscribe" +> & { requestId?: string; + agent?: Omit< + PaseoAgentCreateOptions, + "worktree" | "git" | "onEvent" | "idempotencyKey" | "requestId" + >; + onEvent?: (snapshot: CreationSnapshot) => void; }; export interface PaseoWorkspaceArchiveResult { @@ -232,6 +241,9 @@ export interface PaseoAgentConfig { } export interface PaseoAgentCreateOptions { + idempotencyKey?: string; + agentId?: string; + onEvent?: (snapshot: CreationSnapshot) => void; config: PaseoAgentConfig; cwd: string; parent?: string | PaseoAgentHandle; @@ -513,6 +525,29 @@ export function createPaseoClient(config: PaseoClientConfig): PaseoClient { }; } +function toDaemonAgentCreateOptions( + options: PaseoAgentCreateOptions, + placement?: { workspaceId: string; cwd: string }, +): CreateAgentRequestOptions { + const { config: agentConfig, cwd, parent, title, prompt, ...requestOptions } = options; + const { provider: providerModel, options: providerOptions, ...runtimeConfig } = agentConfig; + const { provider, model } = parseProviderModel(providerModel); + return { + ...requestOptions, + config: { + ...runtimeConfig, + provider, + model, + cwd: placement?.cwd ?? cwd, + ...(title !== undefined ? { title } : {}), + ...(providerOptions !== undefined ? { providerOptions } : {}), + }, + ...(placement ? { workspaceId: placement.workspaceId } : {}), + ...(parent ? { callerAgentId: resolveAgentId(parent) } : {}), + ...(prompt !== undefined ? { initialPrompt: prompt } : {}), + }; +} + export function createPaseoApi( daemonClient: DaemonClient, scopeOptions?: { signal?: AbortSignal }, @@ -555,24 +590,7 @@ export function createPaseoApi( options: PaseoAgentCreateOptions, placement?: { workspaceId: string; cwd: string }, ) => { - const { config: agentConfig, cwd, parent, title, prompt, ...requestOptions } = options; - const { provider: providerModel, options: providerOptions, ...runtimeConfig } = agentConfig; - const { provider, model } = parseProviderModel(providerModel); - const effectiveCwd = placement?.cwd ?? cwd; - const agent = await daemonClient.createAgent({ - ...requestOptions, - config: { - ...runtimeConfig, - provider, - model, - cwd: effectiveCwd, - ...(title !== undefined ? { title } : {}), - ...(providerOptions !== undefined ? { providerOptions } : {}), - }, - ...(placement ? { workspaceId: placement.workspaceId } : {}), - ...(parent ? { callerAgentId: resolveAgentId(parent) } : {}), - ...(prompt !== undefined ? { initialPrompt: prompt } : {}), - }); + const agent = await daemonClient.createAgent(toDaemonAgentCreateOptions(options, placement)); return createAgentHandle(agent); }; const terminals = createTerminalActions(daemonClient, async (workspaceId) => { @@ -694,8 +712,11 @@ export function createPaseoApi( ref: (workspace) => createWorkspaceHandle(workspace), open: (input, requestId) => openWorkspace(daemonClient, createWorkspaceHandle, input, requestId), - create: async ({ requestId, ...options }) => { - const result = await daemonClient.createWorkspace(options, requestId); + create: async ({ requestId, agent, ...options }) => { + const result = await daemonClient.createWorkspace( + { ...options, ...(agent ? { agent: toDaemonAgentCreateOptions(agent) } : {}) }, + requestId, + ); if (result.error || !result.workspace) { throw new Error(result.error ?? "The daemon did not create a workspace"); } diff --git a/packages/protocol/src/messages.ts b/packages/protocol/src/messages.ts index 0b6c1b1882..a44311d76b 100644 --- a/packages/protocol/src/messages.ts +++ b/packages/protocol/src/messages.ts @@ -1681,7 +1681,7 @@ export type CreateAgentWorktreeTarget = z.infer; +export type AgentCreateRequest = z.infer; +export type WorkspaceInitialAgent = z.infer; + +export const WorkspaceCreationUpdateSchema = z.object({ + type: z.literal("workspace.create.update"), + payload: CreationSnapshotSchema.extend({ subscriptionId: z.string().optional() }), +}); +export const AgentCreationUpdateSchema = WorkspaceCreationUpdateSchema.extend({ + type: z.literal("agent.create.update"), +}); +export const CreationSubscribeResponseSchema = z.object({ + type: z.literal("creation.subscribe.response"), + payload: z.object({ + requestId: z.string(), + subscriptionId: z.string().optional(), + snapshot: CreationSnapshotSchema.nullable(), + error: z.string().nullable(), + }), +}); +export const AgentCreateResponseSchema = z.object({ + type: z.literal("agent.create.response"), + payload: z.object({ + requestId: z.string(), + agent: AgentSnapshotPayloadSchema.nullable(), + error: z.string().nullable(), + creation: CreationSnapshotSchema.optional(), + }), +}); + export const WorkspaceCreateResponseSchema = z.object({ type: z.literal("workspace.create.response"), payload: z.object({ + agent: AgentSnapshotPayloadSchema.optional(), + creation: CreationSnapshotSchema.optional(), workspace: WorkspaceDescriptorPayloadSchema.nullable(), setupTerminalId: z.string().nullable(), setupSkippedReason: z.string().optional(), @@ -6649,6 +6742,10 @@ export const SessionOutboundMessageSchema = z.discriminatedUnion("type", [ CancelAgentResponseMessageSchema, ClearAgentAttentionResponseMessageSchema, WorkspaceCreateResponseSchema, + AgentCreateResponseSchema, + WorkspaceCreationUpdateSchema, + AgentCreationUpdateSchema, + CreationSubscribeResponseSchema, WorkspaceClearAttentionResponseSchema, WorkspaceMarkUnreadResponseSchema, SendAgentMessageResponseMessageSchema, diff --git a/packages/server/src/server/agent/create-agent/create.ts b/packages/server/src/server/agent/create-agent/create.ts index bd4dcb16d8..814a8f3aec 100644 --- a/packages/server/src/server/agent/create-agent/create.ts +++ b/packages/server/src/server/agent/create-agent/create.ts @@ -55,6 +55,7 @@ export type EnsureWorkspaceForCreate = ( export interface CreateAgentFromSessionInput { kind: "session"; + onAgentReady?: (agent: ManagedAgent) => Promise; agentId?: string; config: AgentSessionConfig; workspaceId: string; @@ -190,6 +191,8 @@ export async function createAgentCommand( agentId: snapshot.id, }); + if (input.kind === "session") await input.onAgentReady?.(snapshot); + let liveSnapshot = snapshot; let initialPromptStarted = false; let initialPromptError: unknown | null = null; diff --git a/packages/server/src/server/agent/requests/index.test.ts b/packages/server/src/server/agent/requests/index.test.ts deleted file mode 100644 index de3b010148..0000000000 --- a/packages/server/src/server/agent/requests/index.test.ts +++ /dev/null @@ -1,143 +0,0 @@ -import { mkdtemp, rm } from "node:fs/promises"; -import { tmpdir } from "node:os"; -import path from "node:path"; -import { afterEach, expect, test } from "vitest"; -import { AgentRequests } from "./index.js"; - -const directories: string[] = []; -afterEach(async () => { - await Promise.all( - directories.splice(0).map((directory) => rm(directory, { recursive: true, force: true })), - ); -}); -async function fixture() { - const directory = await mkdtemp(path.join(tmpdir(), "agent-requests-")); - directories.push(directory); - return { directory, requests: new AgentRequests(directory) }; -} - -test("concurrent and reconstructed creates return the same durable agent", async () => { - const { requests, directory } = await fixture(); - const agents = new Set(); - let creations = 0; - const input = { - key: "create-1", - request: { provider: "test", cwd: "/project" }, - findAgent: async (id: string) => agents.has(id), - create: async (id: string) => { - creations++; - agents.add(id); - }, - }; - const ids = await Promise.all([requests.create(input), requests.create(input)]); - expect(ids[0]).toBe(ids[1]); - expect(await new AgentRequests(directory).create(input)).toBe(ids[0]); - expect(creations).toBe(1); -}); - -test("recovers creation when the agent was persisted before acknowledgement failed", async () => { - const { requests, directory } = await fixture(); - const agents = new Set(); - const input = { - key: "lost-create", - request: {}, - findAgent: async (id: string) => agents.has(id), - create: async (id: string) => { - agents.add(id); - throw new Error("acknowledgement lost"); - }, - }; - await expect(requests.create(input)).rejects.toThrow("acknowledgement lost"); - expect(await new AgentRequests(directory).create(input)).toBe([...agents][0]); - expect(agents.size).toBe(1); -}); - -test("reusing a create key with different configuration is a conflict", async () => { - const { requests } = await fixture(); - const input = { - key: "key", - request: { model: "a" }, - findAgent: async () => true, - create: async () => {}, - }; - await requests.create(input); - await expect(requests.create({ ...input, request: { model: "b" } })).rejects.toThrow( - "agent_request_key_conflict", - ); -}); - -test("message retries survive reconstruction without submitting twice", async () => { - const { requests, directory } = await fixture(); - let deliveries = 0; - const input = { - agentId: "agent", - messageId: "arrival", - request: { text: "hello" }, - send: async () => { - deliveries++; - }, - }; - await Promise.all([requests.send(input), requests.send(input)]); - await new AgentRequests(directory).send(input); - expect(deliveries).toBe(1); - await requests.send({ ...input, agentId: "another" }); - expect(deliveries).toBe(2); -}); - -test("ambiguous provider delivery is never blindly replayed after restart", async () => { - const { requests, directory } = await fixture(); - let deliveries = 0; - const input = { - agentId: "agent", - messageId: "arrival", - request: {}, - send: async () => { - deliveries++; - throw new Error("connection lost"); - }, - }; - await expect(requests.send(input)).rejects.toThrow("connection lost"); - await expect(new AgentRequests(directory).send(input)).rejects.toThrow( - "agent_request_outcome_unknown", - ); - expect(deliveries).toBe(1); -}); - -test("a creation failure with no stored agent can be retried", async () => { - const { requests } = await fixture(); - let available = false; - const input = { - key: "unavailable", - request: {}, - findAgent: async () => false, - create: async () => { - if (!available) throw new Error("provider unavailable"); - }, - }; - await expect(requests.create(input)).rejects.toThrow("provider unavailable"); - available = true; - await expect(requests.create(input)).resolves.toEqual(expect.any(String)); -}); - -test("failed local message preparation does not leave an ambiguous receipt", async () => { - const { requests, directory } = await fixture(); - let available = false; - let sends = 0; - const input = { - agentId: "agent", - messageId: "message", - request: {}, - prepare: async () => { - if (!available) throw new Error("load failed"); - }, - send: async () => { - sends++; - }, - }; - await expect(requests.send(input)).rejects.toThrow("load failed"); - available = true; - await new AgentRequests(directory).send(input); - available = false; - await requests.send(input); - expect(sends).toBe(1); -}); diff --git a/packages/server/src/server/agent/requests/index.ts b/packages/server/src/server/agent/requests/index.ts deleted file mode 100644 index ad64331c35..0000000000 --- a/packages/server/src/server/agent/requests/index.ts +++ /dev/null @@ -1,138 +0,0 @@ -import { createHash, randomUUID } from "node:crypto"; -import { readFile, rm } from "node:fs/promises"; -import path from "node:path"; -import { z } from "zod"; -import { writeJsonFileAtomic } from "../../atomic-file.js"; - -const ReceiptSchema = z.object({ - fingerprint: z.string(), - agentId: z.string(), - state: z.enum(["pending", "completed"]), -}); -type Receipt = z.infer; - -/** One daemon-owned request journal, shared by all of its socket sessions. */ -export class AgentRequests { - private readonly pending = new Map>(); - - constructor(private readonly directory: string) {} - - create(input: { - key: string; - request: unknown; - findAgent: (agentId: string) => Promise; - create: (agentId: string) => Promise; - }): Promise { - return this.execute(["create", input.key], input.request, { - agentId: randomUUID(), - recover: input.findAgent, - run: input.create, - retrySafe: async (agentId) => !(await input.findAgent(agentId)), - }); - } - - async send(input: { - agentId: string; - messageId: string; - request: unknown; - send: () => Promise; - prepare?: () => Promise; - }): Promise { - await this.execute(["send", input.agentId, input.messageId], input.request, { - agentId: input.agentId, - // A provider call can take effect before the daemon records its outcome. - // Never repeat that call merely because a process died in this window. - recover: async () => false, - run: input.send, - prepare: input.prepare, - }); - } - - private execute( - identity: string[], - request: unknown, - operation: { - agentId: string; - recover: (agentId: string) => Promise; - run: (agentId: string) => Promise; - prepare?: (() => Promise) | undefined; - retrySafe?: (agentId: string) => Promise; - }, - ): Promise { - const key = digest(identity); - const fingerprint = digest(request); - const previous = this.pending.get(key); - // Serialize even conflicting requests: each caller validates its own fingerprint. - const result = (previous ? previous.catch(() => undefined) : Promise.resolve()).then(() => - this.executeOnce(key, fingerprint, operation), - ); - this.pending.set(key, result); - void result - .finally(() => { - if (this.pending.get(key) === result) this.pending.delete(key); - }) - .catch(() => undefined); - return result; - } - - private async executeOnce( - key: string, - fingerprint: string, - operation: { - agentId: string; - recover: (agentId: string) => Promise; - run: (agentId: string) => Promise; - prepare?: (() => Promise) | undefined; - retrySafe?: (agentId: string) => Promise; - }, - ): Promise { - const file = path.join(this.directory, `${key}.json`); - const existing = await readReceipt(file); - if (existing) { - if (existing.fingerprint !== fingerprint) throw new Error("agent_request_key_conflict"); - if (existing.state === "completed") return existing.agentId; - if (!(await operation.recover(existing.agentId))) { - throw new Error("agent_request_outcome_unknown"); - } - await writeJsonFileAtomic(file, { ...existing, state: "completed" }); - return existing.agentId; - } - await operation.prepare?.(); - const receipt: Receipt = { fingerprint, agentId: operation.agentId, state: "pending" }; - await writeJsonFileAtomic(file, receipt); - try { - await operation.run(receipt.agentId); - } catch (error) { - // Keyed creation has no initial prompt. Once its normal cleanup finished, - // absence of an agent confirms that retrying cannot duplicate one. - if (await operation.retrySafe?.(receipt.agentId)) await rm(file, { force: true }); - throw error; - } - await writeJsonFileAtomic(file, { ...receipt, state: "completed" }); - return receipt.agentId; - } -} - -async function readReceipt(file: string): Promise { - try { - return ReceiptSchema.parse(JSON.parse(await readFile(file, "utf8"))); - } catch (error) { - if (error instanceof Error && "code" in error && error.code === "ENOENT") return null; - throw error; - } -} - -function digest(value: unknown): string { - return createHash("sha256") - .update( - JSON.stringify(value, (_key, candidate: unknown) => { - if (candidate !== null && typeof candidate === "object" && !Array.isArray(candidate)) { - return Object.fromEntries( - Object.entries(candidate).sort(([a], [b]) => a.localeCompare(b)), - ); - } - return candidate; - }), - ) - .digest("hex"); -} diff --git a/packages/server/src/server/authorization/operation-permissions.ts b/packages/server/src/server/authorization/operation-permissions.ts index 6a6a1e78ec..89d0d3f030 100644 --- a/packages/server/src/server/authorization/operation-permissions.ts +++ b/packages/server/src/server/authorization/operation-permissions.ts @@ -62,6 +62,8 @@ const INBOUND_PERMISSION = { client_heartbeat: "workspace.read", close_items_request: "workspace.manage", create_agent_request: ["workspace.write", "hub.execute"], + "agent.create.request": ["workspace.write", "hub.execute"], + "creation.subscribe.request": "workspace.read", create_paseo_worktree_request: "workspace.manage", create_terminal_request: "workspace.write", "daemon.config.reload.request": "daemon.manage", @@ -209,6 +211,10 @@ const INBOUND_PERMISSION = { } as const satisfies Record; const OUTBOUND_PERMISSION = { + "agent.create.response": ["workspace.write", "hub.execute"], + "agent.create.update": ["workspace.write", "hub.execute"], + "workspace.create.update": "workspace.manage", + "creation.subscribe.response": "workspace.read", activity_log: "workspace.read", "agent.config.apply.response": ["workspace.write", "hub.execute"], "agent.detach.response": "workspace.write", diff --git a/packages/server/src/server/creation/creation.e2e.test.ts b/packages/server/src/server/creation/creation.e2e.test.ts new file mode 100644 index 0000000000..f0d21be18f --- /dev/null +++ b/packages/server/src/server/creation/creation.e2e.test.ts @@ -0,0 +1,286 @@ +import { execFileSync } from "node:child_process"; +import type { z } from "zod"; +import { WebSocket } from "ws"; +import { SessionInboundMessageSchema, WSOutboundMessageSchema } from "@getpaseo/protocol/messages"; +import { randomUUID } from "node:crypto"; +import { mkdtemp, rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { expect, test, vi } from "vitest"; +import pino from "pino"; +import type { CreationSnapshot, SessionOutboundMessage } from "@getpaseo/protocol/messages"; +import { DaemonClient } from "../test-utils/daemon-client.js"; +import { createTestAgentClients } from "../test-utils/fake-agent-client.js"; +import { createTestPaseoDaemon } from "../test-utils/paseo-daemon.js"; + +function deferred() { + let resolve!: (value: T) => void; + const promise = new Promise((done) => { + resolve = done; + }); + return { promise, resolve }; +} + +test("creation progresses before agent readiness and continues after the disconnected Session is cleaned up", async () => { + const provider = deferred(); + const ready = deferred(); + const disconnected = deferred(); + const cleaned = deferred(); + const directory = await mkdtemp(join(tmpdir(), "creation-wire-")); + let agents = 0; + let prompts = 0; + const daemon = await createTestPaseoDaemon({ + logger: pino( + { level: "trace" }, + { + write(line) { + const { msg } = JSON.parse(line); + if (msg === "Client disconnected; waiting for reconnect") disconnected.resolve(); + if (msg === "agent.session.lifecycle.cleanup") cleaned.resolve(); + }, + }, + ), + agentClients: createTestAgentClients({ + beforeCreateSession: async () => { + agents++; + await provider.promise; + }, + onStartTurn: () => { + prompts++; + }, + }), + }); + const client = new DaemonClient({ + url: `ws://127.0.0.1:${daemon.port}/ws`, + appVersion: "0.7.2", + clientId: "creation-subscriber", + }); + const observer = new DaemonClient({ + url: `ws://127.0.0.1:${daemon.port}/ws`, + appVersion: "0.7.2", + clientId: "creation-independent-observer", + }); + const observerMessages: SessionOutboundMessage[] = []; + const unsubscribe = observer.subscribeRawMessages((message) => observerMessages.push(message)); + const workspaceId = "wks_0123456789abcdef"; + const agentId = randomUUID(); + const request = { + idempotencyKey: "workspace-one", + workspaceId, + source: { kind: "directory" as const, path: directory }, + agent: { + agentId, + provider: "codex", + cwd: directory, + initialPrompt: "Create once", + clientMessageId: "initial-one", + }, + }; + try { + await client.connect(); + await observer.connect(); + const first = client.createWorkspace({ + ...request, + onEvent: (snapshot) => { + if (snapshot.phase === "workspace_ready") ready.resolve(snapshot); + }, + }); + void first.catch(() => undefined); + const snapshot = await ready.promise; + expect(snapshot).toMatchObject({ workspaceId, agentId, phase: "workspace_ready" }); + expect((await observer.fetchAgents()).entries).toHaveLength(0); + expect( + observerMessages.filter((message) => message.type === "workspace.create.update"), + ).toHaveLength(0); + vi.useFakeTimers({ toFake: ["setTimeout", "clearTimeout"] }); + await client.close(); + await disconnected.promise; + await vi.advanceTimersByTimeAsync(90_000); + await cleaned.promise; + vi.useRealTimers(); + await expect(first).rejects.toThrow("closed"); + // A disconnected form cannot prevent the provider or initial prompt from starting. + provider.resolve(); + await expect.poll(() => prompts).toBe(1); + const replay = await observer.createWorkspace(request); + expect(replay.error).toBeNull(); + expect(replay.workspace?.id).toBe(workspaceId); + expect(replay.agent?.id).toBe(agentId); + expect(replay.agent?.title).toBe("Create once"); + expect(agents).toBe(1); + expect(prompts).toBe(1); + const conflict = await observer.createWorkspace({ + ...request, + idempotencyKey: "another-intent", + }); + expect(conflict.error).toBe("workspace_id_conflict"); + expect(agents).toBe(1); + } finally { + vi.useRealTimers(); + provider.resolve(); + unsubscribe(); + await client.close(); + await observer.close(); + await daemon.close(); + await rm(directory, { recursive: true, force: true }); + } +}, 60000); + +async function connectCreationPeer(port: number) { + const socket = new WebSocket(`ws://127.0.0.1:${port}/ws`); + const frames: SessionOutboundMessage[] = []; + socket.on("message", (data) => { + const frame = WSOutboundMessageSchema.parse(JSON.parse(data.toString())); + if (frame.type === "session") frames.push(frame.message); + }); + await new Promise((resolve, reject) => { + socket.once("open", resolve); + socket.once("error", reject); + }); + socket.send( + JSON.stringify({ + type: "hello", + clientType: "browser", + clientId: randomUUID(), + protocolVersion: 1, + appVersion: "0.8.0", + }), + ); + await expect + .poll(() => frames.some((m) => m.type === "status" && m.payload.status === "server_info")) + .toBe(true); + return { + close: () => socket.close(), + request: async ( + message: z.input & { requestId: string }, + ) => { + socket.send(JSON.stringify({ type: "session", message })); + const response = () => + frames.find( + (m) => + "payload" in m && "requestId" in m.payload && m.payload.requestId === message.requestId, + ); + await expect.poll(response).toBeDefined(); + return response()!; + }, + }; +} + +test.each([false, true])( + "workspace identity does not depend on subscribing (first subscribe=%s)", + async (subscribe) => { + const directory = await mkdtemp(join(tmpdir(), "creation-identity-")); + const daemon = await createTestPaseoDaemon(); + const peer = await connectCreationPeer(daemon.port); + try { + const request = { + type: "workspace.create.request" as const, + source: { kind: "directory" as const, path: directory }, + idempotencyKey: "same-workspace", + }; + const first = await peer.request({ ...request, requestId: "first", subscribe }); + const replay = await peer.request({ ...request, requestId: "replay", subscribe: !subscribe }); + if (first.type !== "workspace.create.response" || replay.type !== "workspace.create.response") + throw new Error("Expected workspace responses"); + expect(first.payload.error).toBeNull(); + expect(replay.payload.error).toBeNull(); + expect(replay.payload.workspace?.id).toBe(first.payload.workspace?.id); + } finally { + peer.close(); + await daemon.close(); + await rm(directory, { recursive: true, force: true }); + } + }, + 60000, +); + +test.each(["create_agent_request", "agent.create.request"] as const)( + "legacy and modern agent RPCs share one creation identity (first %s)", + async (type) => { + const directory = await mkdtemp(join(tmpdir(), "creation-agent-identity-")); + let creations = 0; + const daemon = await createTestPaseoDaemon({ + agentClients: createTestAgentClients({ + beforeCreateSession: async () => { + creations++; + }, + }), + }); + const peer = await connectCreationPeer(daemon.port); + try { + const request = { + config: { provider: "codex", cwd: directory }, + idempotencyKey: "same-agent", + }; + const first = await peer.request({ ...request, type, requestId: "first" }); + const replay = await peer.request({ + ...request, + type: type === "agent.create.request" ? "create_agent_request" : "agent.create.request", + requestId: "replay", + }); + const createdAgent = (message: SessionOutboundMessage) => { + if (message.type === "agent.create.response") { + expect(message.payload.error).toBeNull(); + return message.payload.agent; + } + if (message.type === "status" && message.payload.status === "agent_created") + return message.payload.agent; + throw new Error(`Unexpected creation result ${JSON.stringify(message)}`); + }; + expect(createdAgent(replay)?.id).toBe(createdAgent(first)?.id); + const agentId = createdAgent(first)!.id; + await daemon.daemon.agentManager.setTitle(agentId, "Updated after creation"); + await daemon.daemon.agentManager.flush(); + const legacyReplay = await peer.request({ + ...request, + type: "create_agent_request", + requestId: "legacy-after-update", + }); + expect(createdAgent(legacyReplay)?.title).toBe("Updated after creation"); + expect(creations).toBe(1); + } finally { + peer.close(); + await daemon.close(); + await rm(directory, { recursive: true, force: true }); + } + }, + 60000, +); + +test("legacy keyed creation preserves checkout error codes", async () => { + const directory = await mkdtemp(join(tmpdir(), "creation-checkout-error-")); + execFileSync("git", ["init", "-b", "main", directory], { stdio: "pipe" }); + execFileSync( + "git", + [ + "-c", + "user.name=Test", + "-c", + "user.email=test@example.com", + "commit", + "--allow-empty", + "-m", + "initial", + ], + { cwd: directory, stdio: "pipe" }, + ); + const daemon = await createTestPaseoDaemon({ agentClients: createTestAgentClients() }); + const peer = await connectCreationPeer(daemon.port); + try { + const result = await peer.request({ + type: "create_agent_request", + requestId: "missing-branch", + idempotencyKey: "missing-branch", + config: { provider: "codex", cwd: directory }, + worktree: { mode: "checkout-branch", branch: "does-not-exist" }, + }); + expect(result).toMatchObject({ + type: "status", + payload: { status: "agent_create_failed", errorCode: "unknown_branch" }, + }); + } finally { + peer.close(); + await daemon.close(); + await rm(directory, { recursive: true, force: true }); + } +}, 60000); diff --git a/packages/server/src/server/creation/index.test.ts b/packages/server/src/server/creation/index.test.ts new file mode 100644 index 0000000000..2f2c024078 --- /dev/null +++ b/packages/server/src/server/creation/index.test.ts @@ -0,0 +1,324 @@ +import pino from "pino"; +import { createHash } from "node:crypto"; +import { cp, mkdir, mkdtemp, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { afterEach, expect, test } from "vitest"; +import type { + AgentSnapshotPayload, + CreationSnapshot, + WorkspaceDescriptorPayload, +} from "@getpaseo/protocol/messages"; +import { CreationService, type CreationInput } from "./index.js"; + +const silentLogger = pino({ level: "silent" }); +const directories: string[] = []; +afterEach(async () => { + await Promise.all( + directories.splice(0).map((directory) => rm(directory, { recursive: true, force: true })), + ); +}); +function deferred() { + let resolve!: () => void; + const promise = new Promise((done) => { + resolve = done; + }); + return { promise, resolve }; +} +const workspace: WorkspaceDescriptorPayload = { + id: "wks_0123456789abcdef", + projectId: "project", + projectDisplayName: "Project", + projectRootPath: "/project", + workspaceDirectory: "/project/worktree", + projectKind: "git", + workspaceKind: "worktree", + name: "Worktree", + status: "running", + statusEnteredAt: null, + activityAt: null, + archivingAt: null, + scripts: [], +}; +const agent: AgentSnapshotPayload = { + id: "00000000-0000-4000-8000-000000000001", + workspaceId: workspace.id, + provider: "codex", + cwd: "/project/worktree", + model: null, + createdAt: "2026-09-11T00:00:00Z", + updatedAt: "2026-09-11T00:00:00Z", + lastUserMessageAt: null, + status: "idle", + capabilities: { + supportsStreaming: true, + supportsSessionPersistence: true, + supportsDynamicModes: false, + supportsMcpServers: false, + supportsReasoningStream: false, + supportsToolInvocations: true, + supportsRewindConversation: false, + supportsRewindFiles: false, + supportsRewindBoth: false, + }, + currentModeId: null, + availableModes: [], + pendingPermissions: [], + persistence: null, + title: "Start once", + labels: {}, +}; +async function fixture() { + const directory = await mkdtemp(join(tmpdir(), "creation-lifecycle-")); + directories.push(directory); + const service = new CreationService(directory, silentLogger); + const provider = deferred(); + const ready = deferred(); + const calls: string[] = []; + const input: CreationInput = { + kind: "workspace", + key: "submit-one", + request: { source: "/project", prompt: "Start once" }, + workspaceId: workspace.id, + agentId: agent.id, + hasAgent: true, + hasPrompt: true, + exists: async () => false, + provision: async () => { + calls.push("workspace"); + return { workspace }; + }, + createAgent: async (_id, _workspace, onReady) => { + await provider.promise; + calls.push("agent"); + await onReady(agent); + calls.push("prompt"); + return agent; + }, + }; + const updates: CreationSnapshot[] = []; + const observe = (snapshot: CreationSnapshot) => { + updates.push(snapshot); + if (snapshot.phase === "workspace_ready") ready.resolve(); + }; + return { directory, service, input, provider, ready, calls, updates, observe }; +} + +test("workspace readiness is observable before provider startup and duplicate requests join the same work", async () => { + const f = await fixture(); + const first = f.service.create(f.input, f.observe); + await f.ready.promise; + expect(f.calls).toEqual(["workspace"]); + expect(f.updates.map((s) => s.phase)).toEqual(["accepted", "workspace_ready"]); + expect(f.updates[0]).toMatchObject({ workspaceId: workspace.id, agentId: agent.id }); + const duplicate = f.service.create(f.input); + f.provider.resolve(); + expect(await first).toMatchObject({ phase: "completed", workspace, agent }); + expect(await duplicate).toEqual(await first); + expect(f.calls).toEqual(["workspace", "agent", "prompt"]); + expect(await new CreationService(f.directory, silentLogger).create(f.input)).toEqual(await first); + expect(f.calls).toEqual(["workspace", "agent", "prompt"]); +}); + +test("a failed agent startup retries only that stage with the reserved IDs", async () => { + const f = await fixture(); + let attempts = 0; + f.input.createAgent = async (id, readyWorkspace, onReady) => { + attempts++; + expect(id).toBe(agent.id); + expect(readyWorkspace).toEqual(workspace); + if (attempts === 1) throw new Error("provider unavailable"); + await onReady(agent); + return agent; + }; + expect(await f.service.create(f.input)).toMatchObject({ + phase: "failed", + failedStage: "agent", + outcomeUnknown: false, + workspace, + }); + expect(await new CreationService(f.directory, silentLogger).create(f.input)).toMatchObject({ + phase: "completed", + agent, + }); + expect(f.calls).toEqual(["workspace"]); + expect(attempts).toBe(2); +}); + +test("an uncertain prompt outcome never resends or removes its workspace and agent", async () => { + const f = await fixture(); + let prompts = 0; + f.input.createAgent = async (_id, _workspace, onReady) => { + await onReady(agent); + prompts++; + throw new Error("connection lost after provider accepted prompt"); + }; + const result = await f.service.create(f.input); + expect(result).toMatchObject({ + phase: "failed", + failedStage: "prompt", + outcomeUnknown: true, + workspace, + agent, + }); + expect(await new CreationService(f.directory, silentLogger).create(f.input)).toEqual(result); + expect(prompts).toBe(1); +}); + +test("conflicting intents and resource IDs cannot start more work", async () => { + const f = await fixture(); + const first = f.service.create(f.input, f.observe); + await f.ready.promise; + await expect(f.service.create({ ...f.input, request: { prompt: "changed" } })).rejects.toThrow( + "workspace_request_key_conflict", + ); + await expect(f.service.create({ ...f.input, key: "another-intent" })).rejects.toThrow( + "workspace_id_conflict", + ); + f.provider.resolve(); + await first; + expect(f.calls).toEqual(["workspace", "agent", "prompt"]); +}); + +test("late subscribers recover readiness and removing an observer cannot stop creation", async () => { + const f = await fixture(); + const first = f.service.create(f.input, f.observe); + await f.ready.promise; + const updates: CreationSnapshot[] = []; + const subscription = await f.service.subscribe("workspace", f.input.key, (snapshot) => + updates.push(snapshot), + ); + expect(subscription.snapshot).toMatchObject({ phase: "workspace_ready", workspace }); + subscription.unsubscribe(); + f.provider.resolve(); + await first; + expect(updates).toEqual([]); + const completed = await new CreationService(f.directory, silentLogger).subscribe( + "workspace", + f.input.key, + () => {}, + ); + expect(completed.snapshot).toMatchObject({ phase: "completed", agent, workspace }); + completed.unsubscribe(); +}); + +test("omitted resource IDs are generated once and preserved on replay", async () => { + const f = await fixture(); + f.input.workspaceId = undefined; + f.input.agentId = undefined; + f.input.provision = async (id) => ({ workspace: { ...workspace, id } }); + f.input.createAgent = async (id, createdWorkspace, onReady) => { + const created = { ...agent, id, workspaceId: createdWorkspace!.id }; + await onReady(created); + return created; + }; + const result = await f.service.create(f.input); + expect(result.workspaceId).toMatch(/^wks_[a-f0-9]{16}$/); + expect(result.agentId).toMatch( + /^[a-f0-9]{8}-[a-f0-9]{4}-4[a-f0-9]{3}-[89ab][a-f0-9]{3}-[a-f0-9]{12}$/, + ); + expect(await new CreationService(f.directory, silentLogger).create(f.input)).toEqual(result); +}); + +test("restart after a committed agent without a prompt completes without recreating the agent", async () => { + const f = await fixture(); + const recoveredDirectory = await mkdtemp(join(tmpdir(), "creation-checkpoint-")); + directories.push(recoveredDirectory); + f.input.hasPrompt = false; + let starts = 0; + f.input.createAgent = async (_id, _workspace, onReady) => { + starts++; + await onReady(agent); + // Capture the durable state at the exact milestone a process could stop after. + await cp(f.directory, recoveredDirectory, { recursive: true }); + return agent; + }; + await f.service.create(f.input); + const recovered = await new CreationService(recoveredDirectory, silentLogger).create(f.input); + expect(recovered).toMatchObject({ phase: "completed", workspace, agent }); + expect(starts).toBe(1); +}); + +test("partial provisioning without a registered workspace is not repeated", async () => { + const f = await fixture(); + let provisions = 0; + f.input.provision = async () => { + provisions++; + await writeFile(join(f.directory, "partial-checkout"), "disk was changed"); + throw new Error("Git failed after creating the checkout"); + }; + const result = await f.service.create(f.input); + expect(result).toMatchObject({ phase: "failed", failedStage: "workspace", outcomeUnknown: true }); + expect(await new CreationService(f.directory, silentLogger).create(f.input)).toEqual(result); + expect(provisions).toBe(1); +}); + +test.each(["pending", "completed"])( + "imports a %s legacy agent receipt without creating another agent", + async (state) => { + const f = await fixture(); + const legacyDirectory = join(f.directory, "legacy"); + await mkdir(legacyDirectory); + const request = { config: { cwd: "/project", provider: "codex" } }; + const hash = (value: unknown) => + createHash("sha256").update(JSON.stringify(value)).digest("hex"); + await writeFile( + join(legacyDirectory, `${hash(["create", "old-key"])}.json`), + JSON.stringify({ + fingerprint: hash({ ...request, type: "create_agent_request" }), + state, + agentId: agent.id, + }), + ); + const input: CreationInput = { + kind: "agent", + key: "old-key", + request, + hasAgent: true, + hasPrompt: false, + exists: async () => true, + readAgent: async () => agent, + createAgent: async () => { + throw new Error("Must not create again"); + }, + }; + const service = new CreationService(f.directory, silentLogger, undefined, legacyDirectory); + await expect( + service.create({ ...input, request: { config: { ...request.config, cwd: "/changed" } } }), + ).rejects.toThrow("agent_request_key_conflict"); + expect(await service.create(input)).toMatchObject({ phase: "completed", agent }); + expect(await new CreationService(f.directory, silentLogger).create(input)).toMatchObject({ + phase: "completed", + agent, + }); + }, +); + +test("observer failures are logged without failing creation", async () => { + const f = await fixture(); + const logs: unknown[] = []; + const logger = pino( + { level: "warn" }, + { + write: (line) => { + logs.push(JSON.parse(line)); + }, + }, + ); + const service = new CreationService(f.directory, logger); + f.provider.resolve(); + const result = await service.create(f.input, () => { + throw new Error("delivery failed"); + }); + expect(result.phase).toBe("completed"); + expect(f.calls).toEqual(["workspace", "agent", "prompt"]); + expect(logs).toContainEqual( + expect.objectContaining({ + level: 40, + kind: "workspace", + idempotencyKey: f.input.key, + phase: "workspace_ready", + err: expect.objectContaining({ message: "delivery failed" }), + }), + ); +}); diff --git a/packages/server/src/server/creation/index.ts b/packages/server/src/server/creation/index.ts new file mode 100644 index 0000000000..cf8b2adc2a --- /dev/null +++ b/packages/server/src/server/creation/index.ts @@ -0,0 +1,351 @@ +import type { Logger } from "pino"; +import { createHash, randomUUID } from "node:crypto"; +import { mkdir, readFile, writeFile } from "node:fs/promises"; +import { join } from "node:path"; +import { z } from "zod"; +import { + CreationSnapshotSchema, + type AgentSnapshotPayload, + type CreationSnapshot, + type WorkspaceDescriptorPayload, +} from "@getpaseo/protocol/messages"; +import { writeJsonFileAtomic } from "../atomic-file.js"; +import { generateWorkspaceId } from "../workspace-registry-model.js"; + +type Observer = (snapshot: CreationSnapshot) => void; +interface CreationRequest { + key: string; + request: { [key: string]: unknown }; + workspaceId?: string; + agentId?: string; + hasAgent: boolean; + hasPrompt: boolean; + exists: (kind: "workspace" | "agent", id: string) => Promise; + provision?: ( + workspaceId: string, + ) => Promise<{ workspace: WorkspaceDescriptorPayload; setupSkippedReason?: string }>; + createAgent?: ( + agentId: string, + workspace: WorkspaceDescriptorPayload | undefined, + onReady: (agent: AgentSnapshotPayload) => Promise, + ) => Promise; +} +export type CreationInput = CreationRequest & + ( + | { kind: "workspace" } + | { kind: "agent"; readAgent: (id: string) => Promise } + ); +const RecordSchema = z.object({ + fingerprint: z.string(), + snapshot: CreationSnapshotSchema, + inFlight: z.enum(["workspace", "agent", "prompt"]).nullable(), +}); +type Record = z.infer; + +/** Owns creation across socket lifetimes. Resource owners perform work; this module commits its milestones. */ +export class CreationService { + private admission: Promise = Promise.resolve(); + private readonly active = new Map>(); + private readonly observers = new Map>(); + constructor( + private readonly directory: string, + private readonly logger: Logger, + private readonly validateCompleted: ( + snapshot: CreationSnapshot, + ) => Promise = async () => {}, + private readonly legacyDirectory?: string, + ) {} + + async create(input: CreationInput, observer?: Observer): Promise { + const identity = identityFor(input.kind, input.key); + const admitted = this.admission.then(async () => { + await mkdir(this.directory, { recursive: true }); + const fingerprint = digest(input.request); + let record = await this.read(identity); + if (!record) record = await this.readLegacyAgent(input, fingerprint); + if (record && record.fingerprint !== fingerprint) + throw new Error(`${input.kind}_request_key_conflict`); + const running = this.active.get(identity); + if (running && record) return { completion: running, snapshot: record.snapshot }; + if (record?.snapshot.phase === "completed") await this.validateCompleted(record.snapshot); + if (record?.snapshot.phase === "completed" || record?.snapshot.outcomeUnknown) { + return { completion: Promise.resolve(record.snapshot), snapshot: record.snapshot }; + } + if (record?.inFlight) { + await this.publish(identity, record, { + phase: "failed", + error: `${input.kind}_request_outcome_unknown`, + failedStage: record.inFlight, + outcomeUnknown: true, + }); + return { completion: Promise.resolve(record.snapshot), snapshot: record.snapshot }; + } + if (!record) { + record = initialRecord(input, fingerprint); + // Persist identity before claiming resources. A failed admission can only retry these IDs. + await this.write(identity, record); + } + await this.claimResources(identity, record.snapshot, input); + if (record.snapshot.phase === "failed") { + await this.publish(identity, record, { + phase: record.snapshot.workspace ? "workspace_ready" : "accepted", + error: null, + errorCode: undefined, + failedStage: undefined, + outcomeUnknown: undefined, + }); + } + const snapshot = record.snapshot; + // Defer the runner until admission has installed the initiating observer. + const completion = Promise.resolve().then(() => this.run(identity, record, input)); + this.active.set(identity, completion); + void completion.finally(() => this.active.delete(identity)).catch(() => undefined); + return { completion, snapshot }; + }); + this.admission = admitted.then( + () => undefined, + () => undefined, + ); + // Install before admission starts, so even a fast local provision cannot outrun observation. + const unsubscribe = observer ? this.observe(identity, observer) : () => {}; + try { + const { completion, snapshot } = await admitted; + if (observer) this.notify(observer, snapshot); + return await completion; + } finally { + unsubscribe(); + } + } + + async subscribe( + kind: CreationSnapshot["kind"], + key: string, + observer: Observer, + ): Promise<{ snapshot: CreationSnapshot | null; unsubscribe: () => void }> { + const identity = identityFor(kind, key); + const unsubscribe = this.observe(identity, observer); + try { + await this.admission; + const record = await this.read(identity); + if (record?.inFlight && !this.active.has(identity)) { + await this.publish(identity, record, { + phase: "failed", + failedStage: record.inFlight, + outcomeUnknown: true, + error: `${kind}_request_outcome_unknown`, + }); + } + if (record?.snapshot.phase === "completed") await this.validateCompleted(record.snapshot); + return { snapshot: record?.snapshot ?? null, unsubscribe }; + } catch (error) { + unsubscribe(); + throw error; + } + } + + private async run( + identity: string, + record: Record, + input: CreationInput, + ): Promise { + try { + if (input.provision && !record.snapshot.workspace) { + record.inFlight = "workspace"; + await this.write(identity, record); + const { workspace, setupSkippedReason } = await input.provision( + record.snapshot.workspaceId!, + ); + record.inFlight = null; + await this.publish(identity, record, { + phase: "workspace_ready", + workspace, + workspaceId: workspace.id, + setupSkippedReason, + }); + } + if (input.hasAgent && input.createAgent && !record.snapshot.agent) { + record.inFlight = "agent"; + await this.write(identity, record); + const agent = await input.createAgent( + record.snapshot.agentId!, + record.snapshot.workspace, + async (readyAgent) => { + record.inFlight = input.hasPrompt ? "prompt" : null; + await this.publish(identity, record, { + phase: "agent_ready", + agent: readyAgent, + workspaceId: readyAgent.workspaceId ?? record.snapshot.workspaceId, + }); + }, + ); + record.inFlight = null; + if (input.hasPrompt) + await this.publish(identity, record, { phase: "prompt_started", agent }); + else record.snapshot = { ...record.snapshot, agent }; + } + record.inFlight = null; + await this.publish(identity, record, { phase: "completed" }); + } catch (error) { + const stage = record.inFlight ?? "agent"; + const resourceId = + stage === "workspace" ? record.snapshot.workspaceId : record.snapshot.agentId; + const code = + error instanceof Error && "code" in error && typeof error.code === "string" + ? error.code + : undefined; + const rejectedBeforeProvision = code === "directory_not_found" || code === "source_required"; + const unknown = + stage === "prompt" || + (stage === "workspace" && !rejectedBeforeProvision) || + (resourceId !== null && (await input.exists(stage, resourceId))); + // A failed provider startup with no registered resource can retry under the same ID. + // A partially provisioned resource or an attempted prompt needs reconciliation. + if (!unknown) record.inFlight = null; + await this.publish(identity, record, { + phase: "failed", + error: error instanceof Error ? error.message : String(error), + errorCode: code, + failedStage: stage, + outcomeUnknown: unknown, + }); + } + return record.snapshot; + } + + // COMPAT(agentRequestReceipts): added in v0.8.0, remove after 2027-03-11 once old creation receipts can expire. + // Import the previous receipt format; it is never a second execution path. + private async readLegacyAgent(input: CreationInput, fingerprint: string): Promise { + if (input.kind !== "agent" || !this.legacyDirectory) return null; + const text = await readOptional( + join(this.legacyDirectory, `${digest(["create", input.key])}.json`), + ); + if (text === null) return null; + const receipt = z + .object({ + fingerprint: z.string(), + state: z.enum(["pending", "completed"]), + agentId: z.string(), + }) + .parse(JSON.parse(text)); + if (receipt.fingerprint !== digest({ type: "create_agent_request", ...input.request })) + throw new Error("agent_request_key_conflict"); + const agent = await input.readAgent(receipt.agentId); + if (!agent && receipt.state === "completed") + throw new Error("Previously created agent no longer exists"); + const record = initialRecord({ ...input, agentId: receipt.agentId }, fingerprint); + record.snapshot = { + ...record.snapshot, + phase: agent ? "completed" : "failed", + agent: agent ?? undefined, + workspaceId: agent?.workspaceId ?? record.snapshot.workspaceId, + error: agent ? null : "agent_request_outcome_unknown", + ...(agent ? {} : { failedStage: "agent", outcomeUnknown: true }), + }; + await this.write(identityFor(input.kind, input.key), record); + return record; + } + + private async claimResources( + identity: string, + snapshot: CreationSnapshot, + input: CreationInput, + ): Promise { + if (input.kind === "workspace" && snapshot.workspaceId) + await this.claim(identity, "workspace", snapshot.workspaceId, input.exists); + if (snapshot.agentId) await this.claim(identity, "agent", snapshot.agentId, input.exists); + } + + private async claim( + identity: string, + kind: "workspace" | "agent", + id: string, + exists: CreationInput["exists"], + ): Promise { + const file = join(this.directory, `${digest([kind, id])}.claim`); + const owner = await readOptional(file); + if (owner !== null) { + if (owner !== identity) throw new Error(`${kind}_id_conflict`); + return; + } + if (await exists(kind, id)) throw new Error(`${kind}_id_conflict`); + await writeFile(file, identity, { flag: "wx", mode: 0o600 }); + } + private observe(identity: string, observer: Observer): () => void { + const observers = this.observers.get(identity) ?? new Set(); + observers.add(observer); + this.observers.set(identity, observers); + return () => { + observers.delete(observer); + if (!observers.size) this.observers.delete(identity); + }; + } + private notify(observer: Observer, snapshot: CreationSnapshot): void { + try { + observer(snapshot); + } catch (err) { + this.logger.warn( + { + err, + kind: snapshot.kind, + idempotencyKey: snapshot.idempotencyKey, + phase: snapshot.phase, + }, + "Creation observer failed", + ); + } + } + private async publish( + identity: string, + record: Record, + update: Partial, + ): Promise { + record.snapshot = { ...record.snapshot, ...update, revision: record.snapshot.revision + 1 }; + await this.write(identity, record); + for (const observer of this.observers.get(identity) ?? []) + this.notify(observer, record.snapshot); + } + private write(identity: string, record: Record): Promise { + return writeJsonFileAtomic(join(this.directory, `${identity}.json`), record); + } + private async read(identity: string): Promise { + const text = await readOptional(join(this.directory, `${identity}.json`)); + return text === null ? null : RecordSchema.parse(JSON.parse(text)); + } +} +function initialRecord(input: CreationInput, fingerprint: string): Record { + return { + fingerprint, + inFlight: null, + snapshot: { + kind: input.kind, + idempotencyKey: input.key, + revision: 0, + phase: "accepted", + error: null, + workspaceId: input.workspaceId ?? (input.kind === "workspace" ? generateWorkspaceId() : null), + agentId: input.hasAgent ? (input.agentId ?? randomUUID()) : null, + }, + }; +} +function identityFor(kind: string, key: string): string { + return digest([kind, key]); +} +function digest(input: unknown): string { + return createHash("sha256") + .update( + JSON.stringify(input, (_key, value: unknown) => { + if (value && typeof value === "object" && !Array.isArray(value)) + return Object.fromEntries(Object.entries(value).sort(([a], [b]) => a.localeCompare(b))); + return value; + }), + ) + .digest("hex"); +} +async function readOptional(file: string): Promise { + try { + return await readFile(file, "utf8"); + } catch (error) { + if (error instanceof Error && "code" in error && error.code === "ENOENT") return null; + throw error; + } +} diff --git a/packages/server/src/server/file-observer/internal/native-recursive.test.ts b/packages/server/src/server/file-observer/internal/native-recursive.test.ts index b37e12657d..5133f2f6c9 100644 --- a/packages/server/src/server/file-observer/internal/native-recursive.test.ts +++ b/packages/server/src/server/file-observer/internal/native-recursive.test.ts @@ -9,66 +9,75 @@ import { createObserverPaths } from "./paths.js"; // Native watchers may coalesce file removals into change notifications. Keep the // filesystem real while controlling which notifications reach reconciliation. -test("shallow parent scans retain nested change scopes", async () => { - const root = await mkdtemp(join(tmpdir(), "native-scopes-")); - const paths = createObserverPaths(process.platform); - const removed = [ - join(root, "root.txt"), - join(root, "child", "child.txt"), - join(root, "child", "deep", "deep.txt"), - ]; - await mkdir(join(root, "child", "deep"), { recursive: true }); - await Promise.all(removed.map((path) => writeFile(path, "before"))); - const events: FileChange[] = []; - const notifications = new EventEmitter(); - let active = true; - const observer = createFileObserver(); - const backend = createNativeRecursiveBackend( - { - root, - metrics: observer.getDiagnostics(), - isActive: () => active, - isIgnored: () => false, - isPathInside: paths.isInside, - queueEvent: (type, path) => events.push({ type, path }), - fail: (error) => { - throw error; - }, - }, - paths, - (_root, listener) => { - notifications.on("change", listener); - return { - close: () => { - notifications.removeAllListeners(); +test.each(["existing", "new"])( + "shallow scans recover coalesced deletes of %s files in nested scopes", + async (population) => { + const root = await mkdtemp(join(tmpdir(), "native-scopes-")); + const paths = createObserverPaths(process.platform); + const removed = [ + join(root, "root.txt"), + join(root, "child", "child.txt"), + join(root, "child", "deep", "deep.txt"), + ]; + await mkdir(join(root, "child", "deep"), { recursive: true }); + if (population === "existing") { + await Promise.all(removed.map((path) => writeFile(path, "before"))); + } + const events: FileChange[] = []; + const notifications = new EventEmitter(); + let active = true; + const observer = createFileObserver(); + const backend = createNativeRecursiveBackend( + { + root, + metrics: observer.getDiagnostics(), + isActive: () => active, + isIgnored: () => false, + isPathInside: paths.isInside, + queueEvent: (type, path) => events.push({ type, path }), + fail: (error) => { + throw error; }, - on: (event, onError) => notifications.on(event, onError), - }; - }, - ); - try { - await backend.start(); - await Promise.all(removed.map((path) => rm(path))); - vi.useFakeTimers({ toFake: ["setTimeout", "clearTimeout", "performance"] }); - for (const path of removed) notifications.emit("change", "change", path); - await vi.advanceTimersByTimeAsync(8_000); - vi.useRealTimers(); - await expect - .poll(() => - events - .filter((event) => event.type === "delete") - .map((event) => event.path) - .sort(), - ) - .toEqual([...removed].sort()); - } finally { - vi.useRealTimers(); - active = false; - await backend.close(); - await observer.close(); - await rm(root, { recursive: true, force: true }); - } -}); + }, + paths, + (_root, listener) => { + notifications.on("change", listener); + return { + close: () => { + notifications.removeAllListeners(); + }, + on: (event, onError) => notifications.on(event, onError), + }; + }, + ); + try { + await backend.start(); + if (population === "new") { + await Promise.all(removed.map((path) => writeFile(path, "before"))); + for (const path of removed) notifications.emit("change", "change", path); + } + await Promise.all(removed.map((path) => rm(path))); + vi.useFakeTimers({ toFake: ["setTimeout", "clearTimeout", "performance"] }); + for (const path of removed) notifications.emit("change", "change", path); + await vi.advanceTimersByTimeAsync(8_000); + vi.useRealTimers(); + await expect + .poll(() => + events + .filter((event) => event.type === "delete") + .map((event) => event.path) + .sort(), + ) + .toEqual([...removed].sort()); + } finally { + vi.useRealTimers(); + active = false; + await backend.close(); + await observer.close(); + await rm(root, { recursive: true, force: true }); + } + }, +); test("a file announced only as changed remains visible to coalesced deletion scans", async () => { const root = await mkdtemp(join(tmpdir(), "native-change-only-")); diff --git a/packages/server/src/server/message-receipts/index.test.ts b/packages/server/src/server/message-receipts/index.test.ts new file mode 100644 index 0000000000..10c1615a6a --- /dev/null +++ b/packages/server/src/server/message-receipts/index.test.ts @@ -0,0 +1,77 @@ +import { mkdtemp, rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import { afterEach, expect, test } from "vitest"; +import { MessageReceipts } from "./index.js"; + +const directories: string[] = []; +afterEach(async () => { + await Promise.all( + directories.splice(0).map((directory) => rm(directory, { recursive: true, force: true })), + ); +}); +async function fixture() { + const directory = await mkdtemp(path.join(tmpdir(), "agent-requests-")); + directories.push(directory); + return { directory, requests: new MessageReceipts(directory) }; +} + +test("message retries survive reconstruction without submitting twice", async () => { + const { requests, directory } = await fixture(); + let deliveries = 0; + const input = { + agentId: "agent", + messageId: "arrival", + request: { text: "hello" }, + send: async () => { + deliveries++; + }, + }; + await Promise.all([requests.send(input), requests.send(input)]); + await new MessageReceipts(directory).send(input); + expect(deliveries).toBe(1); + await requests.send({ ...input, agentId: "another" }); + expect(deliveries).toBe(2); +}); + +test("ambiguous provider delivery is never blindly replayed after restart", async () => { + const { requests, directory } = await fixture(); + let deliveries = 0; + const input = { + agentId: "agent", + messageId: "arrival", + request: {}, + send: async () => { + deliveries++; + throw new Error("connection lost"); + }, + }; + await expect(requests.send(input)).rejects.toThrow("connection lost"); + await expect(new MessageReceipts(directory).send(input)).rejects.toThrow( + "agent_request_outcome_unknown", + ); + expect(deliveries).toBe(1); +}); + +test("failed local message preparation does not leave an ambiguous receipt", async () => { + const { requests, directory } = await fixture(); + let available = false; + let sends = 0; + const input = { + agentId: "agent", + messageId: "message", + request: {}, + prepare: async () => { + if (!available) throw new Error("load failed"); + }, + send: async () => { + sends++; + }, + }; + await expect(requests.send(input)).rejects.toThrow("load failed"); + available = true; + await new MessageReceipts(directory).send(input); + available = false; + await requests.send(input); + expect(sends).toBe(1); +}); diff --git a/packages/server/src/server/message-receipts/index.ts b/packages/server/src/server/message-receipts/index.ts new file mode 100644 index 0000000000..6103160b12 --- /dev/null +++ b/packages/server/src/server/message-receipts/index.ts @@ -0,0 +1,81 @@ +import { createHash } from "node:crypto"; +import { readFile } from "node:fs/promises"; +import path from "node:path"; +import { z } from "zod"; +import { writeJsonFileAtomic } from "../atomic-file.js"; + +const ReceiptSchema = z.object({ + fingerprint: z.string(), + state: z.enum(["pending", "completed"]), + agentId: z.string(), +}); +interface SendMessageInput { + agentId: string; + messageId: string; + request: unknown; + send: () => Promise; + prepare?: () => Promise; +} + +/** Owns message delivery receipts; creation is owned by CreationService. */ +export class MessageReceipts { + private readonly pending = new Map>(); + constructor(private readonly directory: string) {} + + send(input: SendMessageInput): Promise { + // Preserve the existing on-disk identity and shape across daemon upgrades. + const key = digest(["send", input.agentId, input.messageId]); + const previous = this.pending.get(key); + const result = (previous ? previous.catch(() => undefined) : Promise.resolve()).then(() => + this.sendOnce(key, input), + ); + this.pending.set(key, result); + void result + .finally(() => { + if (this.pending.get(key) === result) this.pending.delete(key); + }) + .catch(() => undefined); + return result; + } + + private async sendOnce(key: string, input: SendMessageInput): Promise { + const file = path.join(this.directory, `${key}.json`); + const fingerprint = digest(input.request); + const existing = await readReceipt(file); + if (existing) { + if (existing.fingerprint !== fingerprint) throw new Error("agent_request_key_conflict"); + if (existing.state === "completed") return; + // A provider may have accepted the message before its receipt was committed. + throw new Error("agent_request_outcome_unknown"); + } + await input.prepare?.(); + const receipt = { fingerprint, agentId: input.agentId }; + await writeJsonFileAtomic(file, { ...receipt, state: "pending" }); + await input.send(); + await writeJsonFileAtomic(file, { ...receipt, state: "completed" }); + } +} + +async function readReceipt(file: string): Promise | null> { + try { + return ReceiptSchema.parse(JSON.parse(await readFile(file, "utf8"))); + } catch (error) { + if (error instanceof Error && "code" in error && error.code === "ENOENT") return null; + throw error; + } +} + +function digest(value: unknown): string { + return createHash("sha256") + .update( + JSON.stringify(value, (_key, candidate: unknown) => { + if (candidate !== null && typeof candidate === "object" && !Array.isArray(candidate)) { + return Object.fromEntries( + Object.entries(candidate).sort(([a], [b]) => a.localeCompare(b)), + ); + } + return candidate; + }), + ) + .digest("hex"); +} diff --git a/packages/server/src/server/paseo-worktree-service.ts b/packages/server/src/server/paseo-worktree-service.ts index dea17e1bb9..dc93e30298 100644 --- a/packages/server/src/server/paseo-worktree-service.ts +++ b/packages/server/src/server/paseo-worktree-service.ts @@ -31,6 +31,7 @@ import type { FirstAgentContext } from "@getpaseo/protocol/messages"; import { runWithGitCommandPriority } from "../utils/run-git-command.js"; export interface CreatePaseoWorktreeInput extends CreateWorktreeCoreInput { + workspaceId?: string; projectId?: string; title?: string; } @@ -93,6 +94,7 @@ async function createPaseoWorktreeWithPriority( const workspace = await deps.workspaceProvisioning.createWorkspaceForWorktree({ sourceCwd: workspaceCwdPlan.inputCwd, projectId: input.projectId, + workspaceId: input.workspaceId, repoRoot: createdWorktree.repoRoot, cwd: workspaceCwd, worktreeRoot: createdWorktree.worktree.worktreePath, diff --git a/packages/server/src/server/plugins/lifecycle.e2e.test.ts b/packages/server/src/server/plugins/lifecycle.e2e.test.ts index 48af2cd80c..a6a27ba5bc 100644 --- a/packages/server/src/server/plugins/lifecycle.e2e.test.ts +++ b/packages/server/src/server/plugins/lifecycle.e2e.test.ts @@ -5,7 +5,7 @@ import { expect, test } from "vitest"; import { DaemonClient } from "../test-utils/daemon-client.js"; import { createTestPaseoDaemon } from "../test-utils/paseo-daemon.js"; -test("a plugin transforms workspace creation and observes its committed lifecycle", async () => { +test("a plugin transforms workspace creation once across receipt replays and observes its committed lifecycle", async () => { const directory = await mkdtemp(path.join(tmpdir(), "paseo-lifecycle-")); const daemon = await createTestPaseoDaemon({ daemonVersion: "0.8.0" }); const client = new DaemonClient({ url: `ws://127.0.0.1:${daemon.port}/ws`, appVersion: "0.8.0" }); @@ -19,6 +19,7 @@ test("a plugin transforms workspace creation and observes its committed lifecycl ` export default function contribute(server) { server.before("workspace.create", ({ request }) => { + console.log(JSON.stringify({ hook: "workspace.before" })); return { ...request, title: "Created through a hook" }; }); server.on("workspace.created", (event) => { @@ -36,10 +37,17 @@ export default function contribute(server) { await client.patchDaemonConfig({ pluginsEnabled: true }); await client.installDirectoryPlugin(directory); const created = await client.createWorkspace({ + idempotencyKey: "plugin-workspace", source: { kind: "directory", path: directory }, }); expect(created.workspace?.name).toBe("Created through a hook"); const workspaceId = created.workspace!.id; + const replay = await client.createWorkspace({ + idempotencyKey: "plugin-workspace", + source: { kind: "directory", path: directory }, + }); + expect(replay.error).toBeNull(); + expect(replay.workspace?.id).toBe(workspaceId); await client.archiveWorkspace(workspaceId); await expect .poll(async () => { @@ -53,6 +61,7 @@ export default function contribute(server) { }); }) .toMatchObject([ + { hook: "workspace.before" }, { hook: "workspace.created", event: { workspace: { id: workspaceId, cwd: directory } } }, { hook: "workspace.archived", event: { workspace: { id: workspaceId } } }, ]); diff --git a/packages/server/src/server/session.test.ts b/packages/server/src/server/session.test.ts index a32f5265c9..7a6384f9a6 100644 --- a/packages/server/src/server/session.test.ts +++ b/packages/server/src/server/session.test.ts @@ -1,4 +1,7 @@ -import { createAgentRequestsStub } from "./test-utils/session-stubs.js"; +import { + createMessageReceiptsStub, + createTestCreationService, +} from "./test-utils/session-stubs.js"; import { execSync } from "child_process"; import { existsSync, mkdtempSync, realpathSync, rmSync, symlinkSync, writeFileSync } from "fs"; import { tmpdir } from "os"; @@ -364,7 +367,8 @@ function createSessionForTest(options: SessionForTestOptions = {}): Session { const messages = options.messages ?? []; const sessionOptions: SessionOptions = { - agentRequests: createAgentRequestsStub(), + messageReceipts: createMessageReceiptsStub(), + creationService: createTestCreationService(), clientId: options.clientId ?? "test-client", onMessage: (message) => messages.push(message), ...(options.targetedMessages diff --git a/packages/server/src/server/session.ts b/packages/server/src/server/session.ts index 21aca8bf90..a5daf44b52 100644 --- a/packages/server/src/server/session.ts +++ b/packages/server/src/server/session.ts @@ -1,7 +1,11 @@ import type { BrowserToolsBroker } from "./browser-tools/broker.js"; import { BrowserAutomationHostCapabilitySchema } from "@getpaseo/protocol/browser-automation/capabilities"; import type { SessionEventSubscription } from "@getpaseo/protocol/messages"; -import type { AgentRequests } from "./agent/requests/index.js"; +import { relative } from "node:path"; +import { isAbsolute } from "node:path"; +import { CreationService } from "./creation/index.js"; +import type { CreationSnapshot, AgentCreateRequest } from "@getpaseo/protocol/messages"; +import type { MessageReceipts } from "./message-receipts/index.js"; import equal from "fast-deep-equal"; import { SessionDelivery, type OwnedSubscription } from "./session/owned-subscriptions/index.js"; import { v4 as uuidv4 } from "uuid"; @@ -195,7 +199,6 @@ import { } from "./session/git-mutation/git-mutation-service.js"; import { createWorkspaceProvisioningService, - WorkspaceProvisioningError, type WorkspaceProvisioningService, } from "./session/workspace-provisioning/workspace-provisioning-service.js"; import { @@ -452,7 +455,8 @@ export interface SessionOptions { worktreesRoot?: string; agentManager: AgentManager; agentStorage: AgentStorage; - agentRequests: Pick; + messageReceipts: Pick; + creationService: Pick; projectRegistry: ProjectRegistry; workspaceRegistry: WorkspaceRegistry; directorySync?: DirectorySyncService; @@ -621,6 +625,16 @@ function resolveWorkspaceLabelService( return service ?? null; } +function hasInitialCreationPrompt( + request?: Pick, +): boolean { + return Boolean( + request && + buildAgentPrompt(request.initialPrompt?.trim() ?? "", request.images, request.attachments) + .length, + ); +} + function workspaceLabelErrorCode(error: unknown): string { if ( error instanceof WorkspaceLabelError || @@ -765,8 +779,9 @@ export class Session { private readonly daemonSession: DaemonSession; private readonly hubExecutionController: HubExecutionController | null; private readonly workspaceScripts: WorkspaceScriptsService; - private readonly agentRequests: Pick; + private readonly messageReceipts: Pick; private readonly createAgentLifecycleDispatch: CreateAgentLifecycleDispatch; + private readonly creationService: Pick; constructor(options: SessionOptions) { const { @@ -840,7 +855,8 @@ export class Session { this.onWorkspaceRecovered = onWorkspaceRecovered ?? null; this.pushNotifications = pushNotifications; this.paseoHome = paseoHome; - this.agentRequests = options.agentRequests; + this.messageReceipts = options.messageReceipts; + this.creationService = options.creationService; this.projectIcons = new ProjectIconReader(paseoHome); this.worktreesRoot = worktreesRoot; this.pluginRuntime = pluginRuntime; @@ -2255,6 +2271,7 @@ export class Session { this.dispatchAgentRelationshipMessage(msg) ?? this.dispatchAgentTimelineMessage(msg, source) ?? this.dispatchHubExecutionMessage(msg) ?? + this.dispatchCreationMessage(msg, source) ?? this.dispatchAgentLifecycleMessage(msg) ?? this.dispatchAgentConfigMessage(msg) ?? this.dispatchCheckoutMessage(msg) ?? @@ -2631,6 +2648,22 @@ export class Session { return undefined; } + private dispatchCreationMessage( + msg: SessionInboundMessage, + source?: object, + ): Promise | undefined { + switch (msg.type) { + case "agent.create.request": + return this.handleAgentCreation(msg, source); + case "creation.subscribe.request": + return this.handleCreationSubscription(msg, source); + case "workspace.create.request": + return this.handleWorkspaceCreation(msg, source); + default: + return undefined; + } + } + private dispatchAgentLifecycleMessage(msg: SessionInboundMessage): Promise | undefined { switch (msg.type) { case "fetch_agents_request": @@ -2828,8 +2861,6 @@ export class Session { return this.handleArchiveWorkspaceRequest(msg); case "project.remove.request": return this.handleProjectRemoveRequest(msg); - case "workspace.create.request": - return this.handleWorkspaceCreateRequest(msg); case "workspace.title.set.request": return this.handleWorkspaceTitleSetRequest(msg.workspaceId, msg.title, msg.requestId); case "workspace.pin.set.request": @@ -3814,6 +3845,265 @@ export class Session { /** * Handle create agent request */ + private creationUpdate(snapshot: CreationSnapshot): SessionOutboundMessage { + return { + type: snapshot.kind === "workspace" ? "workspace.create.update" : "agent.create.update", + payload: snapshot, + }; + } + + private async handleCreationSubscription( + request: Extract, + source?: object, + ): Promise { + let unsubscribe = () => {}; + const owner = + request.subscribe === false + ? undefined + : this.delivery.begin( + "creation", + undefined, + () => unsubscribe(), + JSON.stringify([request.kind, request.idempotencyKey]), + ); + try { + const observation = await this.creationService.subscribe( + request.kind, + request.idempotencyKey, + (snapshot) => owner?.emit(this.creationUpdate(snapshot)), + ); + unsubscribe = observation.unsubscribe; + if (!owner || owner.signal.aborted) unsubscribe(); + this.emitForSource( + { + type: "creation.subscribe.response", + payload: { + requestId: request.requestId, + subscriptionId: owner?.responseId, + snapshot: observation.snapshot, + error: null, + }, + }, + source, + ); + } catch (error) { + await owner?.release(); + this.emitForSource( + { + type: "creation.subscribe.response", + payload: { + requestId: request.requestId, + snapshot: null, + error: error instanceof Error ? error.message : String(error), + }, + }, + source, + ); + } + } + + private creationResourceExists = async ( + kind: "workspace" | "agent", + id: string, + ): Promise => + kind === "workspace" + ? (await this.workspaceRegistry.get(id)) !== null + : this.agentManager.getAgent(id) != null || (await this.agentStorage.get(id)) !== null; + + private createRequestedAgent( + request: AgentCreateRequest, + observer?: (snapshot: CreationSnapshot) => void, + ): Promise { + const { requestId, type: _type, subscribe: _subscribe, idempotencyKey, ...intent } = request; + return this.creationService.create( + { + kind: "agent", + key: idempotencyKey ?? requestId, + request: intent, + workspaceId: request.workspaceId, + agentId: request.agentId, + hasAgent: true, + hasPrompt: hasInitialCreationPrompt(request), + exists: this.creationResourceExists, + readAgent: async (id) => { + const record = await this.agentStorage.get(id); + return record ? this.buildStoredAgentPayload(record) : null; + }, + createAgent: async (id, _workspace, onReady) => { + try { + return await this.createSessionAgent( + { ...request, type: "create_agent_request" }, + id, + onReady, + ); + } catch (error) { + throw new WorktreeRequestError(toWorktreeWireError(error)); + } + }, + }, + observer, + ); + } + + private async handleAgentCreation(request: AgentCreateRequest, source?: object): Promise { + const { requestId, subscribe } = request; + const progress = subscribe + ? this.delivery.operation( + (message) => + message.type === "workspace.create.update" || message.type === "agent.create.update", + () => {}, + ) + : undefined; + try { + const creation = await this.createRequestedAgent( + request, + progress ? (snapshot) => progress.emit(this.creationUpdate(snapshot)) : undefined, + ); + this.emitForSource( + { + type: "agent.create.response", + payload: { requestId, agent: creation.agent ?? null, error: creation.error, creation }, + }, + source, + ); + } catch (error) { + this.emitForSource( + { + type: "agent.create.response", + payload: { + requestId, + agent: null, + error: error instanceof Error ? error.message : String(error), + }, + }, + source, + ); + } finally { + await progress?.release(); + } + } + + private async handleWorkspaceCreation( + request: Extract, + source?: object, + ): Promise { + const { requestId, subscribe, type: _type, idempotencyKey, ...intent } = request; + const progress = subscribe + ? this.delivery.operation( + (message) => + message.type === "workspace.create.update" || message.type === "agent.create.update", + () => {}, + ) + : undefined; + try { + if ( + request.agent && + !this.authorization.allowsPermission("workspace.write") && + !this.authorization.allowsPermission("hub.execute") + ) { + throw new Error("Session is not authorized to create an agent"); + } + const agentInput = request.agent; + const creation = await this.creationService.create( + { + kind: "workspace", + key: idempotencyKey ?? requestId, + request: intent, + workspaceId: request.workspaceId, + agentId: agentInput?.agentId, + hasAgent: Boolean(agentInput), + hasPrompt: hasInitialCreationPrompt(agentInput), + exists: this.creationResourceExists, + provision: async (id) => { + const descriptor = await this.createRequestedWorkspace( + { + ...request, + firstAgentContext: agentInput + ? { prompt: agentInput.initialPrompt, attachments: agentInput.attachments } + : request.firstAgentContext, + }, + id, + ); + const workspace = await this.workspaceRegistry.get(descriptor.id); + return { + workspace: descriptor, + setupSkippedReason: workspace?.untrustedSource + ? formatWorkspaceAutomationBlockedMessage(workspace.untrustedSource) + : undefined, + }; + }, + createAgent: agentInput + ? async (id, workspace, onReady) => { + if (!workspace?.workspaceDirectory) + throw new Error("Created workspace has no directory"); + const sourceCwd = + request.source.kind === "directory" + ? request.source.path + : await resolveWorktreeSourceCwd(request.source, this.projectRegistry); + const relativeCwd = relative(resolve(sourceCwd), resolve(agentInput.config.cwd)); + if ( + relativeCwd === ".." || + relativeCwd.startsWith(`..${sep}`) || + isAbsolute(relativeCwd) + ) + throw new Error("Agent directory must be inside the workspace source"); + return this.createSessionAgent( + { + ...agentInput, + type: "create_agent_request", + requestId, + config: { + ...agentInput.config, + cwd: resolve(workspace.workspaceDirectory, relativeCwd), + }, + workspaceId: workspace.id, + }, + id, + onReady, + ); + } + : undefined, + }, + progress ? (snapshot) => progress.emit(this.creationUpdate(snapshot)) : undefined, + ); + this.emitForSource( + { + type: "workspace.create.response", + payload: { + requestId, + workspace: creation.workspace ?? null, + agent: creation.agent, + creation, + setupSkippedReason: creation.setupSkippedReason, + error: creation.error, + errorCode: creation.errorCode, + setupTerminalId: null, + }, + }, + source, + ); + } catch (error) { + this.emitForSource( + { + type: "workspace.create.response", + payload: { + requestId, + workspace: null, + error: error instanceof Error ? error.message : String(error), + errorCode: + error instanceof Error && "code" in error && typeof error.code === "string" + ? error.code + : undefined, + setupTerminalId: null, + }, + }, + source, + ); + } finally { + await progress?.release(); + } + } + private async handleCreateAgentRequest(msg: CreateAgentRequestMessage): Promise { try { let agent: AgentSnapshotPayload; @@ -3821,18 +4111,13 @@ export class Session { if (msg.initialPrompt !== undefined) { throw new Error("Idempotent creation requires sending the initial prompt separately"); } - const { requestId: _requestId, idempotencyKey, ...request } = msg; - const id = await this.agentRequests.create({ - key: idempotencyKey, - request, - findAgent: async (agentId) => - this.agentManager.getAgent(agentId) != null || - (await this.agentStorage.get(agentId)) !== null, - create: async (agentId) => { - await this.createSessionAgent(msg, agentId); - }, - }); - const record = await this.agentStorage.get(id); + const creation = await this.createRequestedAgent({ ...msg, type: "agent.create.request" }); + if (creation.error || !creation.agent) + throw new SessionRequestError( + creation.errorCode ?? "unknown", + creation.error ?? "Agent creation failed", + ); + const record = await this.agentStorage.get(creation.agent.id); if (!record) throw new Error("Previously created agent no longer exists"); agent = this.buildStoredAgentPayload(record); } else { @@ -3848,7 +4133,7 @@ export class Session { }, }); } catch (error) { - const wireError = toWorktreeWireError(error); + const wireError = error instanceof SessionRequestError ? error : toWorktreeWireError(error); this.sessionLogger.error({ err: error }, "Failed to create agent"); this.emit({ type: "status", @@ -3874,6 +4159,7 @@ export class Session { private async createSessionAgent( msg: CreateAgentRequestMessage, agentId?: string, + onAgentReady?: (agent: AgentSnapshotPayload) => Promise, ): Promise { const { config, @@ -3943,6 +4229,10 @@ export class Session { }, { kind: "session", + onAgentReady: async (agent) => { + createdAgentId = agent.id; + await onAgentReady?.(await this.buildAgentPayload(agent)); + }, agentId, config: resolvedIntent.config, workspaceId: resolvedIntent.intent.workspaceId, @@ -6338,61 +6628,35 @@ export class Session { } } - private async handleWorkspaceCreateRequest( + private async createRequestedWorkspace( request: Extract, - ): Promise { - try { - if (this.pluginRuntime) { - const { type, requestId, ...input } = request; - const transformed = await this.pluginRuntime.before("workspace.create", input); - request = { ...transformed, type, requestId }; - } - if (request.source.kind === "directory") { - await this.handleWorkspaceCreateLocal(request); - return; - } - await this.handleWorkspaceCreateWorktree(request); - } catch (error) { - const message = error instanceof Error ? error.message : "Failed to create workspace"; - this.sessionLogger.error( - { err: error, sourceKind: request.source.kind, requestId: request.requestId }, - "Failed to create workspace", - ); - const errorCode = error instanceof WorkspaceProvisioningError ? error.code : undefined; - this.emit({ - type: "workspace.create.response", - payload: { - requestId: request.requestId, - workspace: null, - setupTerminalId: null, - error: message, - errorCode, - }, - }); + workspaceId?: string, + ): Promise { + let creationRequest = request; + // Hooks belong to the operation: retries fingerprint the caller's input + // and must not rerun hooks or compare their potentially changing output. + if (this.pluginRuntime) { + const { type, requestId, ...input } = request; + const transformed = await this.pluginRuntime.before("workspace.create", input); + creationRequest = { ...transformed, type, requestId }; } + return creationRequest.source.kind === "directory" + ? this.handleWorkspaceCreateLocal(creationRequest, workspaceId) + : this.handleWorkspaceCreateWorktree(creationRequest, workspaceId); } private async handleWorkspaceCreateLocal( request: Extract, - ): Promise { + workspaceId?: string, + ): Promise { if (request.source.kind !== "directory") { - return; + throw new Error("Unexpected workspace source"); } const cwd = expandTilde(request.source.path); const directoryExists = await this.filesystem.isDirectory(cwd).catch(() => false); if (!directoryExists) { - this.emit({ - type: "workspace.create.response", - payload: { - requestId: request.requestId, - workspace: null, - setupTerminalId: null, - error: `Directory not found: ${cwd}`, - errorCode: "directory_not_found", - }, - }); - return; + throw new SessionRequestError("directory_not_found", `Directory not found: ${cwd}`); } const explicitTitle = request.title?.trim() || null; @@ -6401,19 +6665,10 @@ export class Session { cwd, explicitTitle ?? promptTitle, request.source.projectId, - { expectsInitialAgent: Boolean(request.firstAgentContext) }, + { expectsInitialAgent: Boolean(request.firstAgentContext), workspaceId }, ); await this.syncWorkspaceGitObserverForWorkspace(workspace); const descriptor = await this.describeWorkspaceRecord(workspace); - this.emit({ - type: "workspace.create.response", - payload: { - requestId: request.requestId, - workspace: descriptor, - setupTerminalId: null, - error: null, - }, - }); await this.emitCreatedWorkspaceUpdate( descriptor, request.firstAgentContext ? "running" : undefined, @@ -6437,29 +6692,24 @@ export class Session { { currentSelection: this.getFocusedAgentSelectionForCwd(workspace.cwd) }, ); } + return descriptor; } private async handleWorkspaceCreateWorktree( request: Extract, - ): Promise { + workspaceId?: string, + ): Promise { if (request.source.kind !== "worktree") { - return; + throw new Error("Unexpected workspace source"); } const source = request.source; if (!source.cwd && !source.projectId) { - this.emit({ - type: "workspace.create.response", - payload: { - requestId: request.requestId, - workspace: null, - setupTerminalId: null, - error: "cwd or projectId is required for a worktree-backed workspace", - errorCode: "source_required", - }, - }); - return; + throw new SessionRequestError( + "source_required", + "cwd or projectId is required for a worktree-backed workspace", + ); } const sourceCwd = await resolveWorktreeSourceCwd(source, this.projectRegistry); @@ -6467,6 +6717,7 @@ export class Session { const result = await this.createPaseoWorktreeWorkflow( { cwd: sourceCwd, + workspaceId, projectId: source.projectId, worktreeSlug: source.worktreeSlug, action: source.action, @@ -6483,26 +6734,11 @@ export class Session { ); const descriptor = await this.describeCreatedWorktreeWorkspace(result); - this.emit({ - type: "workspace.create.response", - payload: { - requestId: request.requestId, - workspace: descriptor, - setupTerminalId: null, - ...(result.workspace.untrustedSource - ? { - setupSkippedReason: formatWorkspaceAutomationBlockedMessage( - result.workspace.untrustedSource, - ), - } - : {}), - error: null, - }, - }); await this.emitCreatedWorkspaceUpdate( descriptor, request.firstAgentContext ? "running" : undefined, ); + return descriptor; } private async handleOpenProjectRequest( @@ -7763,6 +7999,19 @@ export class Session { } } + private async prepareAgentMessage(agentId: string, text: string): Promise { + await ensureAgentLoaded(agentId, { + agentManager: this.agentManager, + agentStorage: this.agentStorage, + logger: this.sessionLogger, + }); + const stored = await this.agentStorage.get(agentId); + if (stored && !stored.title && !stored.lastUserMessageAt) { + const { provisionalTitle } = resolveCreateAgentTitles({ initialPrompt: text }); + if (provisionalTitle) await this.agentManager.setTitle(agentId, provisionalTitle); + } + } + private async handleSendAgentMessageRequest( msg: Extract, ): Promise { @@ -7813,16 +8062,12 @@ export class Session { } }; if (msg.messageId) { - await this.agentRequests.send({ + await this.messageReceipts.send({ agentId, messageId: msg.messageId, request: { prompt, activeTurnBehavior: msg.activeTurnBehavior ?? "interrupt" }, prepare: async () => { - await ensureAgentLoaded(agentId, { - agentManager: this.agentManager, - agentStorage: this.agentStorage, - logger: this.sessionLogger, - }); + await this.prepareAgentMessage(agentId, msg.text); }, send, }); diff --git a/packages/server/src/server/session.workspace-git-watch.test.ts b/packages/server/src/server/session.workspace-git-watch.test.ts index 581a142572..836703fd30 100644 --- a/packages/server/src/server/session.workspace-git-watch.test.ts +++ b/packages/server/src/server/session.workspace-git-watch.test.ts @@ -1,4 +1,7 @@ -import { createAgentRequestsStub } from "./test-utils/session-stubs.js"; +import { + createMessageReceiptsStub, + createTestCreationService, +} from "./test-utils/session-stubs.js"; import { describe, expect, test, vi } from "vitest"; import path from "node:path"; import type pino from "pino"; @@ -185,7 +188,8 @@ function createSessionForWorkspaceGitWatchTests(options?: { }; const session = new Session({ - agentRequests: createAgentRequestsStub(), + messageReceipts: createMessageReceiptsStub(), + creationService: createTestCreationService(), clientId: "test-client", permissions: OWNER_PERMISSIONS, onMessage: (message) => emitted.push(message as { type: string; payload: unknown }), diff --git a/packages/server/src/server/session.workspace-resolution-invariants.test.ts b/packages/server/src/server/session.workspace-resolution-invariants.test.ts index c444d9ed24..e848e46f01 100644 --- a/packages/server/src/server/session.workspace-resolution-invariants.test.ts +++ b/packages/server/src/server/session.workspace-resolution-invariants.test.ts @@ -1,4 +1,7 @@ -import { createAgentRequestsStub } from "./test-utils/session-stubs.js"; +import { + createMessageReceiptsStub, + createTestCreationService, +} from "./test-utils/session-stubs.js"; // Invariant tests for cwd → workspace resolution on the open_project_request path. // Each test encodes a default behavior we want from `findOrCreateWorkspaceForDirectory`. // Run to see which invariants the current code already satisfies (green) and which @@ -89,7 +92,8 @@ function createHarness(input: { }; const session = new Session({ - agentRequests: createAgentRequestsStub(), + messageReceipts: createMessageReceiptsStub(), + creationService: createTestCreationService(), clientId: "test", permissions: OWNER_PERMISSIONS, appVersion: null, diff --git a/packages/server/src/server/session.workspaces.test.ts b/packages/server/src/server/session.workspaces.test.ts index e8df2788cf..2866732abe 100644 --- a/packages/server/src/server/session.workspaces.test.ts +++ b/packages/server/src/server/session.workspaces.test.ts @@ -1,4 +1,7 @@ -import { createAgentRequestsStub } from "./test-utils/session-stubs.js"; +import { + createMessageReceiptsStub, + createTestCreationService, +} from "./test-utils/session-stubs.js"; import { execFileSync } from "node:child_process"; import { existsSync, @@ -642,7 +645,8 @@ function createSessionForWorkspaceTests( const session = asTestSession( new Session({ - agentRequests: createAgentRequestsStub(), + messageReceipts: createMessageReceiptsStub(), + creationService: createTestCreationService(), clientId: "test-client", permissions: OWNER_PERMISSIONS, appVersion: options.appVersion ?? null, @@ -1003,7 +1007,8 @@ test("create_agent_request keeps requested child cwd when grouped under an exist const emitted: SessionOutboundMessage[] = []; const session = asTestSession( new Session({ - agentRequests: createAgentRequestsStub(), + messageReceipts: createMessageReceiptsStub(), + creationService: createTestCreationService(), clientId: "test-client", serverId: "test-server", permissions: OWNER_PERMISSIONS, @@ -1157,7 +1162,8 @@ test("create_agent_request launches from an exact subdirectory in a created work const emitted: SessionOutboundMessage[] = []; const session = new Session({ - agentRequests: createAgentRequestsStub(), + messageReceipts: createMessageReceiptsStub(), + creationService: createTestCreationService(), clientId: "test-client", permissions: OWNER_PERMISSIONS, appVersion: null, @@ -1295,7 +1301,8 @@ test("create_agent_request does not title an existing workspace from the agent p let generateCalls = 0; const session = asTestSession( new Session({ - agentRequests: createAgentRequestsStub(), + messageReceipts: createMessageReceiptsStub(), + creationService: createTestCreationService(), clientId: "test-client", permissions: OWNER_PERMISSIONS, appVersion: null, @@ -1565,7 +1572,8 @@ test("archive emits an authoritative agent_update upsert for subscribed clients" const session = asTestSession( new Session({ - agentRequests: createAgentRequestsStub(), + messageReceipts: createMessageReceiptsStub(), + creationService: createTestCreationService(), clientId: "test-client", permissions: OWNER_PERMISSIONS, onMessage: (message) => emitted.push(message), @@ -2048,7 +2056,8 @@ test("close_items_request archives agents and kills terminals in one batch", asy const cancelAgentRun = vi.fn(async () => ({ status: "settled" as const })); const session = asTestSession( new Session({ - agentRequests: createAgentRequestsStub(), + messageReceipts: createMessageReceiptsStub(), + creationService: createTestCreationService(), clientId: "test-client", permissions: OWNER_PERMISSIONS, onMessage: (message) => emitted.push(message), @@ -2217,7 +2226,8 @@ test("close_items_request archives stored agents that are not currently loaded", const session = asTestSession( new Session({ - agentRequests: createAgentRequestsStub(), + messageReceipts: createMessageReceiptsStub(), + creationService: createTestCreationService(), clientId: "test-client", permissions: OWNER_PERMISSIONS, onMessage: (message) => emitted.push(message), @@ -2377,7 +2387,8 @@ test("close_items_request continues after an archive failure", async () => { const killTerminalBestEffort = vi.fn(); const session = asTestSession( new Session({ - agentRequests: createAgentRequestsStub(), + messageReceipts: createMessageReceiptsStub(), + creationService: createTestCreationService(), clientId: "test-client", permissions: OWNER_PERMISSIONS, onMessage: (message) => emitted.push(message), @@ -3649,7 +3660,8 @@ test("workspace update stream keeps persisted workspace visible after agents sto const session = asTestSession( new Session({ - agentRequests: createAgentRequestsStub(), + messageReceipts: createMessageReceiptsStub(), + creationService: createTestCreationService(), clientId: "test-client", permissions: OWNER_PERMISSIONS, onMessage: (message) => emitted.push(message), diff --git a/packages/server/src/server/session/workspace-provisioning/workspace-provisioning-service.ts b/packages/server/src/server/session/workspace-provisioning/workspace-provisioning-service.ts index a89e044883..ecd338ff4e 100644 --- a/packages/server/src/server/session/workspace-provisioning/workspace-provisioning-service.ts +++ b/packages/server/src/server/session/workspace-provisioning/workspace-provisioning-service.ts @@ -40,6 +40,7 @@ export interface ImportWorkspaceResult { export interface CreateWorktreeWorkspaceInput { sourceCwd: string; projectId?: string; + workspaceId?: string; repoRoot: string; cwd: string; worktreeRoot: string; @@ -61,7 +62,7 @@ export interface WorkspaceProvisioningService { cwd: string, title?: string | null, projectId?: string, - context?: { expectsInitialAgent?: boolean }, + context?: { expectsInitialAgent?: boolean; workspaceId?: string }, ): Promise; createWorkspaceForWorktree( input: CreateWorktreeWorkspaceInput, @@ -201,7 +202,7 @@ export function createWorkspaceProvisioningService(deps: { cwd: string, title?: string | null, projectId?: string, - context?: { expectsInitialAgent?: boolean }, + context?: { expectsInitialAgent?: boolean; workspaceId?: string }, ): Promise { const normalizedCwd = resolve(cwd); const checkout = await workspaceGitService.getCheckout(normalizedCwd); @@ -211,7 +212,7 @@ export function createWorkspaceProvisioningService(deps: { await findOrCreateProjectForDirectory(normalizedCwd); const timestamp = new Date().toISOString(); const workspace = createPersistedWorkspaceRecord({ - workspaceId: generateWorkspaceId(), + workspaceId: context?.workspaceId ?? generateWorkspaceId(), projectId: project.projectId, ...initialWorkspacePlacement({ source: "checkout", cwd: normalizedCwd, checkout }), title: title?.trim() || null, @@ -237,7 +238,7 @@ export function createWorkspaceProvisioningService(deps: { }); const timestamp = new Date().toISOString(); const workspace = createPersistedWorkspaceRecord({ - workspaceId: generateWorkspaceId(), + workspaceId: input.workspaceId ?? generateWorkspaceId(), projectId: project.projectId, ...initialWorkspacePlacement({ source: "created_worktree", diff --git a/packages/server/src/server/snapshot-mutation-ownership.test.ts b/packages/server/src/server/snapshot-mutation-ownership.test.ts index 8e32c27cbe..2cfe5d3e18 100644 --- a/packages/server/src/server/snapshot-mutation-ownership.test.ts +++ b/packages/server/src/server/snapshot-mutation-ownership.test.ts @@ -1,4 +1,7 @@ -import { createAgentRequestsStub } from "./test-utils/session-stubs.js"; +import { + createMessageReceiptsStub, + createTestCreationService, +} from "./test-utils/session-stubs.js"; import os from "node:os"; import path from "node:path"; import { mkdtempSync, rmSync } from "node:fs"; @@ -100,7 +103,8 @@ describe("snapshot mutation ownership boundary", () => { const session = asInternals( new Session({ - agentRequests: createAgentRequestsStub(), + messageReceipts: createMessageReceiptsStub(), + creationService: createTestCreationService(), clientId: "test-client", permissions: OWNER_PERMISSIONS, onMessage, diff --git a/packages/server/src/server/test-utils/fake-agent-client.ts b/packages/server/src/server/test-utils/fake-agent-client.ts index 323e9944c7..2a437f9bbf 100644 --- a/packages/server/src/server/test-utils/fake-agent-client.ts +++ b/packages/server/src/server/test-utils/fake-agent-client.ts @@ -62,6 +62,7 @@ interface FakeAgentSessionOptions { } export interface TestAgentClientOptions { + beforeCreateSession?: () => Promise; closeSession?: () => Promise; onStartTurn?: (prompt: AgentPromptInput) => void; supportsMcpServers?: boolean; @@ -1212,6 +1213,7 @@ class FakeAgentClient implements AgentClient { config: AgentSessionConfig, _launchContext?: AgentLaunchContext, ): Promise { + await this.options.beforeCreateSession?.(); return new FakeAgentSession({ providerName: this.provider, config: { ...config }, diff --git a/packages/server/src/server/test-utils/session-stubs.ts b/packages/server/src/server/test-utils/session-stubs.ts index a6f404e62a..1c98686042 100644 --- a/packages/server/src/server/test-utils/session-stubs.ts +++ b/packages/server/src/server/test-utils/session-stubs.ts @@ -1,5 +1,10 @@ +import pino from "pino"; import { randomUUID } from "node:crypto"; -import { vi } from "vitest"; +import { afterEach, vi } from "vitest"; +import { rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { CreationService } from "../creation/index.js"; import { getAgentProviderDefinition } from "@getpaseo/protocol/provider-manifest"; @@ -281,13 +286,8 @@ export function createProviderSnapshotManagerStub(): { }; } -export function createAgentRequestsStub(): SessionOptions["agentRequests"] { +export function createMessageReceiptsStub(): SessionOptions["messageReceipts"] { return { - async create(input) { - const agentId = randomUUID(); - await input.create(agentId); - return agentId; - }, send: (input) => input.send(), }; } @@ -304,3 +304,17 @@ export function createProviderSnapshot( }), }; } + +const creationDirectories: string[] = []; +afterEach(async () => { + await Promise.all( + creationDirectories + .splice(0) + .map((directory) => rm(directory, { recursive: true, force: true })), + ); +}); +export function createTestCreationService(): SessionOptions["creationService"] { + const directory = join(tmpdir(), `session-creation-${randomUUID()}`); + creationDirectories.push(directory); + return new CreationService(directory, pino({ level: "silent" })); +} diff --git a/packages/server/src/server/websocket-server.ts b/packages/server/src/server/websocket-server.ts index d14094a674..705a53342e 100644 --- a/packages/server/src/server/websocket-server.ts +++ b/packages/server/src/server/websocket-server.ts @@ -1,4 +1,7 @@ -import { AgentRequests } from "./agent/requests/index.js"; +import { stat } from "node:fs/promises"; +import type { CreationSnapshot } from "@getpaseo/protocol/messages"; +import { CreationService } from "./creation/index.js"; +import { MessageReceipts } from "./message-receipts/index.js"; import { WebSocket, WebSocketServer } from "ws"; import type { IncomingMessage, Server as HTTPServer } from "http"; import { join } from "path"; @@ -529,7 +532,8 @@ export class VoiceAssistantWebSocketServer { private readonly daemonRuntimeConfig: DaemonRuntimeConfig | undefined; private readonly agentManager: AgentManager; private readonly agentStorage: AgentStorage; - private readonly agentRequests: AgentRequests; + private readonly messageReceipts: MessageReceipts; + private readonly creationService: CreationService; private readonly projectRegistry: ProjectRegistry; private readonly workspaceRegistry: WorkspaceRegistry; private readonly workspaceLabelService: WorkspaceLabelService | null; @@ -585,6 +589,25 @@ export class VoiceAssistantWebSocketServer { private readonly pluginRuntime: SessionOptions["pluginRuntime"]; private readonly orchestrationSkills: SessionOptions["orchestrationSkills"]; + private async validateCompletedCreation(snapshot: CreationSnapshot): Promise { + if (snapshot.workspace && snapshot.kind === "workspace") { + const workspace = await this.workspaceRegistry.get(snapshot.workspace.id); + if (!workspace) throw new Error("Previously created workspace no longer exists"); + const directory = await stat(workspace.cwd).catch(() => null); + if (!directory?.isDirectory()) + throw Object.assign(new Error(`Directory not found: ${workspace.cwd}`), { + code: "directory_not_found", + }); + } + if ( + snapshot.agentId && + !this.agentManager.getAgent(snapshot.agentId) && + !(await this.agentStorage.get(snapshot.agentId)) + ) { + throw new Error("Previously created agent no longer exists"); + } + } + constructor( server: HTTPServer, logger: pino.Logger, @@ -649,7 +672,13 @@ export class VoiceAssistantWebSocketServer { this.orchestrationSkills = orchestrationSkills; this.agentManager = agentManager; this.agentStorage = agentStorage; - this.agentRequests = new AgentRequests(join(paseoHome, "agent-requests")); + this.messageReceipts = new MessageReceipts(join(paseoHome, "agent-requests")); + this.creationService = new CreationService( + join(paseoHome, "creations"), + this.logger.child({ module: "creation" }), + (snapshot) => this.validateCompletedCreation(snapshot), + join(paseoHome, "agent-requests"), + ); this.projectRegistry = projectRegistry ?? createNoopProjectRegistry(); this.workspaceRegistry = workspaceRegistry ?? createNoopWorkspaceRegistry(); this.workspaceLabelService = workspaceLabelService ?? null; @@ -1425,7 +1454,8 @@ export class VoiceAssistantWebSocketServer { worktreesRoot: this.worktreesRoot, agentManager: this.agentManager, agentStorage: this.agentStorage, - agentRequests: this.agentRequests, + messageReceipts: this.messageReceipts, + creationService: this.creationService, projectRegistry: this.projectRegistry, workspaceRegistry: this.workspaceRegistry, workspaceLabelService: this.workspaceLabelService ?? undefined, @@ -1646,6 +1676,8 @@ export class VoiceAssistantWebSocketServer { features: { ownedSubscriptions: true, agentRequestReceipts: true, + workspaceRequestReceipts: true, + creationLifecycle: true, hubAgentRpc: true, // COMPAT(directorySync): added in v0.3.x, remove gate after 2027-02-12. directorySync: true, diff --git a/packages/server/src/server/wire-compat.test.ts b/packages/server/src/server/wire-compat.test.ts index aca7046728..34abdebc3f 100644 --- a/packages/server/src/server/wire-compat.test.ts +++ b/packages/server/src/server/wire-compat.test.ts @@ -1,4 +1,7 @@ -import { createAgentRequestsStub } from "./test-utils/session-stubs.js"; +import { + createMessageReceiptsStub, + createTestCreationService, +} from "./test-utils/session-stubs.js"; import pino from "pino"; import { z } from "zod"; import { describe, expect, test } from "vitest"; @@ -208,7 +211,8 @@ function createSessionForWireCompatTest(options?: { ]; const session = new Session({ - agentRequests: createAgentRequestsStub(), + messageReceipts: createMessageReceiptsStub(), + creationService: createTestCreationService(), clientId: "wire-compat-client", permissions: OWNER_PERMISSIONS, clientCapabilities: options?.clientCapabilities ?? null, diff --git a/packages/server/src/server/workspace-create-errors.e2e.test.ts b/packages/server/src/server/workspace-create-errors.e2e.test.ts index bc42b77596..a5c442fcd6 100644 --- a/packages/server/src/server/workspace-create-errors.e2e.test.ts +++ b/packages/server/src/server/workspace-create-errors.e2e.test.ts @@ -1,5 +1,5 @@ import { test, expect } from "vitest"; -import { rmSync } from "node:fs"; +import { mkdtempSync, rmSync } from "node:fs"; import { tmpdir } from "node:os"; import path from "node:path"; @@ -49,3 +49,30 @@ test("workspace.create surfaces each early-reject error branch", async () => { rmSync(missingDir, { recursive: true, force: true }); } }, 180000); + +test("workspace creation replay rejects a checkout removed after completion", async () => { + const daemon = await createTestPaseoDaemon(); + const directory = mkdtempSync(path.join(tmpdir(), "workspace-replay-")); + const client = new DaemonClient({ + url: `ws://127.0.0.1:${daemon.port}/ws`, + appVersion: "0.1.82", + }); + try { + await client.connect(); + const request = { + source: { kind: "directory" as const, path: directory }, + idempotencyKey: "removed-checkout", + }; + const created = await client.createWorkspace(request); + expect(created.error).toBeNull(); + expect(created.workspace).not.toBeNull(); + rmSync(directory, { recursive: true, force: true }); + const replayed = await client.createWorkspace(request); + expect(replayed.workspace).toBeNull(); + expect(replayed.errorCode).toBe("directory_not_found"); + } finally { + await client.close().catch(() => undefined); + await daemon.close(); + rmSync(directory, { recursive: true, force: true }); + } +}, 180000); From 799c91b39d6a8dad6fb4ccf385d789285a29e260 Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Mon, 14 Sep 2026 19:10:32 +0200 Subject: [PATCH 33/46] fix(app): restore Android tablet model picker interactions (#4845) Restore provider/model selection in Android tablet modal windows by giving both native modal hosts their own gesture-handler root. Preserve phone sheet and web portal behavior. Include native regression scripts, test dependency resolution, and verified CI test corrections. Generated media and logs are excluded. Refs #4475 and #4377; consolidates #4721 and #4771. Co-authored-by: Mike P. Kuhl Co-authored-by: Cameron Chin --- docs/floating-panels.md | 5 +- packages/app/e2e/mobile/modal-sheet/README.md | 49 ++++++++++++++++++- .../modal-sheet/model-tablet-agent.android.ad | 23 +++++++++ .../modal-sheet/model-tablet.android.ad | 30 ++++++++++++ .../support/helpers/agent-timeline-gate.ts | 7 ++- packages/app/e2e/support/helpers/creation.ts | 3 +- .../src/components/adaptive-modal-sheet.tsx | 9 +++- packages/app/src/components/ui/combobox.tsx | 4 +- packages/app/vitest.config.ts | 28 +++++++++-- .../src/commands/daemon/lifecycle.e2e.test.ts | 7 +-- 10 files changed, 149 insertions(+), 16 deletions(-) create mode 100644 packages/app/e2e/mobile/modal-sheet/model-tablet-agent.android.ad create mode 100644 packages/app/e2e/mobile/modal-sheet/model-tablet.android.ad diff --git a/docs/floating-panels.md b/docs/floating-panels.md index 616288fbf3..54f3c92630 100644 --- a/docs/floating-panels.md +++ b/docs/floating-panels.md @@ -58,7 +58,10 @@ Android touches sailed straight through to the chat scroll view behind it. Two escape hatches in the codebase: - **`Modal`** (combobox, dropdown menu and tooltip on native) — opens a new Android window, so - hit-testing starts fresh in that window. Side effect: a Modal opening on + hit-testing starts fresh in that window. If its children use gesture-handler, + the Modal owner must wrap its content in a full-height `GestureHandlerRootView`: + the app's root does not span Android windows. React context alone does not + establish a native gesture root. Side effect: a Modal opening on Android can detach the IME from an underlying TextInput. Fine for combobox (it has its own input) and tooltip (no input). **Not** fine for autocomplete (the composer's input must stay focused so the user keeps typing). diff --git a/packages/app/e2e/mobile/modal-sheet/README.md b/packages/app/e2e/mobile/modal-sheet/README.md index f72dc883eb..d95034dad4 100644 --- a/packages/app/e2e/mobile/modal-sheet/README.md +++ b/packages/app/e2e/mobile/modal-sheet/README.md @@ -1,4 +1,6 @@ -# Native sheet gestures +# Native modal and sheet gestures + +## Compact sheet gestures Use an Android emulator at 1080×1920, density 420, with the checkout's native app bundle loaded. Connect the app to an isolated daemon, enable plugins, and install `plugin-examples/modal-ui` there. @@ -22,7 +24,7 @@ its parent without selecting a model or submitting a prompt. It does not assert These scripts live outside the default mobile suite because they require the installed example and an explicit connected host. See [mobile testing](../../../../../docs/mobile-testing.md) for device setup. -## Recorded verification +### Recorded verification Android API 35, 1080×1920 at density 420, development binary 0.7.2 with this checkout's JavaScript. Plugin tests used an isolated daemon. Recordings show the same body drag @@ -45,3 +47,46 @@ Root typecheck, lint and format: passed The model form's catalog stayed on “Loading…” in the debug app even though the isolated daemon's provider API returned models. Its sheet dismissal/reopen was exercised; populated model selection was not. iOS and Electron were not exercised. + +## Tablet model selection + +`model-tablet.android.ad` exercises the New workspace popover. `model-tablet-agent.android.ad` +exercises the modal used by an existing agent's narrow composer on a wide device. Both drill into +a populated provider, select a real model, assert the updated trigger, reopen, and dismiss with +Android Back. New workspace also pans beyond the short list viewport and flings back without +selecting a row or dismissing the popover. They do not submit a prompt. + +Use an English-language Android tablet at 1600×2560, density 320 (800dp), with this checkout's +JavaScript and a connected isolated daemon. On a Pixel Tablet AVD, rotate to portrait explicitly; +its natural landscape orientation letterboxes this portrait-locked app into the compact layout: + +```sh +adb -s "$ANDROID_SERIAL" shell settings put system accelerometer_rotation 0 +adb -s "$ANDROID_SERIAL" shell settings put system user_rotation 1 +adb -s "$ANDROID_SERIAL" shell wm density 320 +``` + +Prepare an idle agent through the daemon's public `createAgent` API with a real provider/model and +no initial prompt. Set `MODEL_QA_AGENT_ID` and `MODEL_QA_AGENT_TITLE` to that agent. In New workspace, +remember a model first (the phone layout works on the broken baseline), then close the picker. +Choose a model visible on the provider's first page and set its catalog ID and displayed label. +Set `MODEL_QA_LAST_MODEL_ID` to its initially offscreen last model; use a short overflowing catalog +(six rows during this verification) for the coordinate-based pan/fling regression: + +```sh +agent-device test packages/app/e2e/mobile/modal-sheet/model-tablet.android.ad \ + packages/app/e2e/mobile/modal-sheet/model-tablet-agent.android.ad \ + --serial "$ANDROID_SERIAL" --metro-port "$MODEL_QA_METRO_PORT" \ + --env FORM_URL="paseo://new?serverId=${SHEET_QA_SERVER_ID}" \ + --env AGENT_URL="paseo://h/${SHEET_QA_SERVER_ID}/agent/${MODEL_QA_AGENT_ID}" \ + --env AGENT_TITLE="$MODEL_QA_AGENT_TITLE" \ + --env PROVIDER_ID=codex --env MODEL_ID="$MODEL_QA_MODEL_ID" \ + --env MODEL_LABEL="$MODEL_QA_MODEL_LABEL" --env LAST_MODEL_ID="$MODEL_QA_LAST_MODEL_ID" \ + --artifacts-dir /tmp/paseo-model-tablet-qa +``` + +The existing-agent journey needs the sidebar pinned so its composer stays narrow. Start each run with no modal open; +a failed provider tap leaves its modal open, so dismiss it before the next run. These host-dependent +journeys remain outside the default mobile suite, alongside the compact sheet journeys above. + +Keep generated screenshots, recordings, and run logs outside the repository. diff --git a/packages/app/e2e/mobile/modal-sheet/model-tablet-agent.android.ad b/packages/app/e2e/mobile/modal-sheet/model-tablet-agent.android.ad new file mode 100644 index 0000000000..b8a570c4fb --- /dev/null +++ b/packages/app/e2e/mobile/modal-sheet/model-tablet-agent.android.ad @@ -0,0 +1,23 @@ +# Idle existing agent in a narrow composer on a wide Android device (AdaptiveModalSheet). +# AGENT_URL=paseo://h//agent/; AGENT_TITLE identifies the loaded route. +context platform=android timeout=60000 retries=0 +env APP_ID=sh.paseo.debug +env PROVIDER_ID=codex + +open "${APP_ID}" "${AGENT_URL}" +wait "label=\"${AGENT_TITLE}\"" 20000 +wait "id=\"combined-model-selector\"" 10000 +press "id=\"combined-model-selector\"" +wait "id=\"model-provider-${PROVIDER_ID}\"" 10000 +press "id=\"model-provider-${PROVIDER_ID}\"" +wait "id=\"compact-model-list\"" 3000 +press "id=\"model-row-${PROVIDER_ID}-${MODEL_ID}\"" +wait "id=\"combined-model-selector\" label=\"Select model (${MODEL_LABEL})\"" 3000 + +press "id=\"combined-model-selector\"" +wait "id=\"model-provider-${PROVIDER_ID}\"" 10000 +press "id=\"model-provider-${PROVIDER_ID}\"" +wait "id=\"model-row-${PROVIDER_ID}-${MODEL_ID}\"" 3000 +back +wait "id=\"combined-model-selector\" label=\"Select model (${MODEL_LABEL})\"" 3000 +close diff --git a/packages/app/e2e/mobile/modal-sheet/model-tablet.android.ad b/packages/app/e2e/mobile/modal-sheet/model-tablet.android.ad new file mode 100644 index 0000000000..74564a13c6 --- /dev/null +++ b/packages/app/e2e/mobile/modal-sheet/model-tablet.android.ad @@ -0,0 +1,30 @@ +# Connected host with a selected model, 1600x2560 at density 320. See README.md. +# FORM_URL=paseo://new?serverId=; MODEL_ID and MODEL_LABEL name a real catalog model. +context platform=android timeout=60000 retries=0 +env APP_ID=sh.paseo.debug +env PROVIDER_ID=codex + +open "${APP_ID}" "${FORM_URL}" +wait "label=\"Workspace project\"" 20000 +press "id=\"combined-model-selector\"" +wait "id=\"combobox-desktop-container\"" 10000 +# A remembered model opens its provider's list. Return to the provider page first. +press "label=\"Back\"" +wait "id=\"model-provider-${PROVIDER_ID}\"" 10000 +press "id=\"model-provider-${PROVIDER_ID}\"" +wait "id=\"compact-model-list\"" 3000 +press "id=\"model-row-${PROVIDER_ID}-${MODEL_ID}\"" +wait "id=\"combined-model-selector\" label=\"Select model (${MODEL_LABEL})\"" 3000 + +# A drag that leaves the short viewport must scroll without selecting or dismissing. +press "id=\"combined-model-selector\"" +wait "id=\"model-row-${PROVIDER_ID}-${MODEL_ID}\"" 3000 +gesture pan 1000 1850 0 -700 600 +wait "id=\"compact-model-list\"" 3000 +wait "id=\"model-row-${PROVIDER_ID}-${LAST_MODEL_ID}\"" 3000 +gesture fling down 1000 1750 500 +wait "id=\"model-row-${PROVIDER_ID}-${MODEL_ID}\"" 3000 +# Android Back dismisses the whole popover without changing the remembered selection. +back +wait "id=\"combined-model-selector\" label=\"Select model (${MODEL_LABEL})\"" 3000 +close diff --git a/packages/app/e2e/support/helpers/agent-timeline-gate.ts b/packages/app/e2e/support/helpers/agent-timeline-gate.ts index 690a3067ae..9e435068ad 100644 --- a/packages/app/e2e/support/helpers/agent-timeline-gate.ts +++ b/packages/app/e2e/support/helpers/agent-timeline-gate.ts @@ -293,11 +293,14 @@ export async function holdRewindCompletion( } if ( !released && - sessionMessage?.type === "agent.rewind.response" && + (sessionMessage?.type === "agent.rewind.response" || + sessionMessage?.type === "agent.timeline.replacement") && payload?.agentId === agentId ) { + // The replacement also signals completion and removes the rewound row/menu. + // Hold it with the response so the pending-state assertion observes a pending rewind. delayedForwards.push(() => ws.send(message)); - resolveDelayedResponse?.(); + if (sessionMessage.type === "agent.rewind.response") resolveDelayedResponse?.(); return; } ws.send(message); diff --git a/packages/app/e2e/support/helpers/creation.ts b/packages/app/e2e/support/helpers/creation.ts index c29bbeeeeb..df0ec30066 100644 --- a/packages/app/e2e/support/helpers/creation.ts +++ b/packages/app/e2e/support/helpers/creation.ts @@ -165,7 +165,8 @@ export async function createCreationScenario(page: Page) { await pressSubmitBeforeTheNextRender(page, button); }, async expectPromptVisible(prompt?: string) { - const rows = page.getByTestId("user-message"); + // Inactive agent tabs retain their timeline DOM beside the visible draft. + const rows = page.getByTestId("user-message").filter({ visible: true }); await expect(prompt ? rows.filter({ hasText: prompt }) : rows.first()).toBeVisible(); }, async expectWorkspaceReadyBeforeAgentCompletion() { diff --git a/packages/app/src/components/adaptive-modal-sheet.tsx b/packages/app/src/components/adaptive-modal-sheet.tsx index 8c20d2f7bb..6be02a5f74 100644 --- a/packages/app/src/components/adaptive-modal-sheet.tsx +++ b/packages/app/src/components/adaptive-modal-sheet.tsx @@ -19,6 +19,7 @@ import { type BottomSheetBackgroundProps, } from "@gorhom/bottom-sheet"; import Animated, { useAnimatedStyle } from "react-native-reanimated"; +import { GestureHandlerRootView } from "react-native-gesture-handler"; import { ArrowLeft, Search, X } from "lucide-react-native"; import { IsolatedBottomSheetModal, @@ -71,6 +72,9 @@ const SCROLL_CONTENT_GROW = { flexGrow: 1 }; const ABSOLUTE_FILL_STYLE = { ...StyleSheet.absoluteFillObject }; const styles = StyleSheet.create((theme) => ({ + nativeModalRoot: { + flex: 1, + }, desktopOverlay: { ...StyleSheet.absoluteFillObject, backgroundColor: "rgba(0,0,0,0.55)", @@ -724,7 +728,10 @@ export function AdaptiveModalSheet({ onDismiss={notifyNativeModalDismiss} hardwareAccelerated > - {desktopContent} + {/* Android Modal opens a separate window outside the app's gesture root. */} + + {desktopContent} + ); } diff --git a/packages/app/src/components/ui/combobox.tsx b/packages/app/src/components/ui/combobox.tsx index 7ee9407dca..74748e423a 100644 --- a/packages/app/src/components/ui/combobox.tsx +++ b/packages/app/src/components/ui/combobox.tsx @@ -34,6 +34,7 @@ import { BottomSheetBackgroundProps, } from "@gorhom/bottom-sheet"; import Animated, { FadeIn, FadeOut } from "react-native-reanimated"; +import { GestureHandlerRootView } from "react-native-gesture-handler"; import { Check, File, Folder, Search } from "lucide-react-native"; import { flip, @@ -1266,7 +1267,8 @@ function DesktopComboboxBody(props: DesktopBodyProps): ReactElement { visible={props.isOpen} onRequestClose={props.handleClose} > - {overlay} + {/* Android Modal opens a separate window outside the app's gesture root. */} + {overlay} ); } diff --git a/packages/app/vitest.config.ts b/packages/app/vitest.config.ts index 3f96ad3bc0..ad77e0d20f 100644 --- a/packages/app/vitest.config.ts +++ b/packages/app/vitest.config.ts @@ -56,17 +56,27 @@ export default defineConfig({ server: { deps: { fallbackCJS: true, - inline: ["zustand", "@tanstack/react-query", "react-native-web"], + inline: [ + "zustand", + "@tanstack/react-query", + "react-native-web", + "react-native-gesture-handler", + ], }, }, }, - // Reanimated ships one file per platform and picks between them by extension - // (`findHostInstance.web.js`). Vite's dependency optimizer does not apply `resolve.extensions`, + // Reanimated and gesture-handler pick platform files by extension + // (e.g. `GestureHandlerRootView.web.js`). Vite's optimizer does not apply `resolve.extensions`, // so it scans the native files and dies on imports react-native-web has no answer for. // Unbundled, the same imports go through the resolver below and land on the web files. optimizeDeps: { - include: ["react/jsx-runtime"], - exclude: ["react-native-reanimated"], + // Bundle the CJS dependencies of the excluded gesture-handler package for the browser. + include: [ + "react/jsx-runtime", + "react-native-gesture-handler > hoist-non-react-statics", + "react-native-gesture-handler > invariant", + ], + exclude: ["react-native-reanimated", "react-native-gesture-handler"], }, // The globals a React Native bundler defines, which esbuild is no longer there to supply for // the package excluded above. @@ -101,6 +111,14 @@ export default defineConfig({ replacement: path.resolve(__dirname, "../relay/src/index.ts"), }, { find: "@", replacement: path.resolve(__dirname, "src") }, + // The CJS entry bypasses Vite's React Native alias and web-extension resolution. + { + find: /^react-native-gesture-handler$/, + replacement: path.resolve( + rootNodeModules, + "react-native-gesture-handler/lib/module/index.js", + ), + }, // Must precede the `react-native` alias: a string `find` matches by prefix, so this subpath // would otherwise resolve inside a react-native-web *file* and break the dependency scan. // Reanimated only imports it on the native path, which no test takes. diff --git a/packages/cli/src/commands/daemon/lifecycle.e2e.test.ts b/packages/cli/src/commands/daemon/lifecycle.e2e.test.ts index ea6cfc48f9..6c2da1a350 100644 --- a/packages/cli/src/commands/daemon/lifecycle.e2e.test.ts +++ b/packages/cli/src/commands/daemon/lifecycle.e2e.test.ts @@ -76,11 +76,12 @@ async function fixture() { .poll( async () => { status = await ok(["status", "--home", home], overrides); - return status.connectedDaemon; + return status; }, { timeout: 30_000 }, ) - .toBe("reachable"); + // A connected socket can outlive a status RPC timeout without reporting the worker. + .toMatchObject({ connectedDaemon: "reachable", workerPid: expect.any(Number) }); return status; } async function configure(home: string, listen: string) { @@ -132,7 +133,7 @@ test("managed two-home restart retains its supervisor and never routes ordinary expect(launchA.listen).toBe(`127.0.0.1:${portA}`); expect(launchB.listen).toBe(`127.0.0.1:${portB}`); const beforeA = await f.liveStatus(a); - const beforeB = await f.ok(["--home", b, "daemon", "status"], poisoned); + const beforeB = await f.liveStatus(b, poisoned); if (process.platform !== "win32") { for (const home of [a, b, path.join(f.root, ".paseo")]) expect((await stat(home)).mode & 0o777).toBe(0o700); From fdb5540a943586d8e9149101ebc713667b2c36f1 Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Mon, 14 Sep 2026 21:24:51 +0200 Subject: [PATCH 34/46] docs(skills): clarify workspace comparison base selection (#4860) --- skills/paseo/SKILL.md | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/skills/paseo/SKILL.md b/skills/paseo/SKILL.md index 280db160fe..60ca70057a 100644 --- a/skills/paseo/SKILL.md +++ b/skills/paseo/SKILL.md @@ -23,6 +23,8 @@ For a local daemon, `project create` defaults to the current directory and resol **`create_workspace`** — create a workspace independently of any agent. Required: `isolation` (`local` or `worktree`). Worktree isolation supports `mode: "branch-off" | "checkout-branch" | "checkout-pr"`: use `branchName`/`baseBranch` for a new branch, `branch` for an existing branch, or `prNumber` plus optional `forge`/`projectPath` for a change request. `worktreeSlug` controls the managed path. Returns the workspace descriptor centered on `workspaceId`. +Choose `baseBranch` explicitly: `origin/main` selects the remote-tracking branch; `refs/heads/main` selects local main. Bare `main` prefers local main when it exists, otherwise origin/main. Paseo retains the resolved ref for workspace comparisons, even after rebasing the branch or changing its PR target. + **`list_workspaces`** — list active workspaces. **`archive_workspace`** — `{ workspaceId }`. Archives the workspace, its agents, and its terminals. Local directories remain; Paseo removes an owned worktree only after its final active workspace reference is archived. @@ -113,11 +115,11 @@ Don't poll `list_agents` or `get_agent_status` to "check on" a running agent. Th The CLI and tools use the same ownership semantics even where their syntax differs: ```bash -paseo workspace create --isolation worktree --mode branch-off --new-branch fix-x --base main +paseo workspace create --isolation worktree --mode branch-off --new-branch fix-x --base origin/main paseo workspace create --isolation worktree --mode checkout-branch --branch existing-work paseo workspace create --isolation worktree --mode checkout-pr --pr-number 42 paseo run --provider codex/gpt-5.4 --mode full-access --workspace "" -paseo run --provider codex/gpt-5.4 --mode full-access --new-workspace worktree --worktree-mode branch-off --new-branch fix-x --base main "" +paseo run --provider codex/gpt-5.4 --mode full-access --new-workspace worktree --worktree-mode branch-off --new-branch fix-x --base origin/main "" paseo send "" paseo ls paseo schedule create --cron "*/15 * * * *" "ping main build" From 836f1a9c23f7c59a09c6112f2e73c3f875687e80 Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Mon, 14 Sep 2026 21:25:38 +0200 Subject: [PATCH 35/46] fix(ci): consolidate E2E repairs and wait for Metro readiness (#4858) * fix(desktop): wait for Metro HTTP readiness before launching Electron Expo briefly listens while probing port availability. A successful TCP connection can therefore launch Electron before Metro starts and leave it exiting on connection refusal. Require the configured renderer origin to report the running packager status. * test(ci): consolidate fixture and readiness corrections Await the connection and UI states each journey owns, isolate Git fixture contents before observation, and hold real Markdown frames at asserted checkpoints. Keep creation completion diagnostics explicit without claiming the original Windows failure is fixed. * test(desktop): describe Metro readiness through HTTP states Keep protocol mechanics in the fixture and assert readiness only after a successful packager response. Cover non-success status and unrelated HTTP content without inline synchronization in the test body. --- .../e2e/browser/composer-autocomplete.spec.ts | 3 +- .../e2e/browser/composer-diff-stat.spec.ts | 9 ++- .../owned-subscriptions-old-daemon.spec.ts | 1 + .../settings-toggle-tab-regression.spec.ts | 14 ++-- .../e2e/browser/streaming-markdown.spec.ts | 2 +- .../support/helpers/agent-timeline-gate.ts | 68 +++++++++++++++++++ .../command-center-workspace-actions.ts | 2 +- .../app/e2e/support/helpers/import-session.ts | 6 +- .../e2e/support/helpers/streaming-markdown.ts | 27 ++++++-- packages/desktop/e2e/browser-tabs.e2e.mjs | 22 +++--- .../desktop/scripts/dev-runner-readiness.mjs | 17 +++++ .../scripts/dev-runner-readiness.test.mjs | 41 +++++++++++ packages/desktop/scripts/dev-runner.mjs | 33 +-------- .../server/src/server/creation/index.test.ts | 2 +- 14 files changed, 183 insertions(+), 64 deletions(-) create mode 100644 packages/desktop/scripts/dev-runner-readiness.mjs create mode 100644 packages/desktop/scripts/dev-runner-readiness.test.mjs diff --git a/packages/app/e2e/browser/composer-autocomplete.spec.ts b/packages/app/e2e/browser/composer-autocomplete.spec.ts index 4119f7c692..01bdfd45d2 100644 --- a/packages/app/e2e/browser/composer-autocomplete.spec.ts +++ b/packages/app/e2e/browser/composer-autocomplete.spec.ts @@ -9,6 +9,7 @@ import { expectWorkspaceTabVisible, openSessions } from "../support/helpers/arch import { daemonWsRoutePattern } from "../support/helpers/daemon-port"; import { getServerId } from "../support/helpers/server-id"; import { switchWorkspaceViaSidebar } from "../support/helpers/workspace-ui"; +import { expectMobileAgentSidebarVisible } from "../support/helpers/sidebar"; const TEST_COMMANDS = [ { @@ -671,7 +672,7 @@ test.describe("Composer autocomplete", () => { }); await page.getByRole("button", { name: "Open menu" }).click(); - await expect(page.getByTestId("sidebar-sessions")).toBeInViewport({ timeout: 5_000 }); + await expectMobileAgentSidebarVisible(page); const popoverBox = await popover.boundingBox(); expect(popoverBox).not.toBeNull(); diff --git a/packages/app/e2e/browser/composer-diff-stat.spec.ts b/packages/app/e2e/browser/composer-diff-stat.spec.ts index 5926f30c73..455c58180a 100644 --- a/packages/app/e2e/browser/composer-diff-stat.spec.ts +++ b/packages/app/e2e/browser/composer-diff-stat.spec.ts @@ -1,4 +1,4 @@ -import { rm, writeFile } from "node:fs/promises"; +import { writeFile } from "node:fs/promises"; import path from "node:path"; import { expect, test, type Page } from "../support/fixtures"; import { openAgentRoute, seedMockAgentWorkspace } from "../support/helpers/mock-agent"; @@ -30,10 +30,13 @@ async function seedChangedAgent(repoPrefix: string) { const workspace = await seedMockAgentWorkspace({ repoPrefix, title: "Composer diff stat", - repo: { withRemote: true }, + repo: { + withRemote: true, + // Exclude the local remote before the daemon starts observing this checkout. + files: [{ path: ".gitignore", content: "/remote.git/\n" }], + }, }); try { - await rm(path.join(workspace.cwd, "remote.git"), { recursive: true }); await writeFile( path.join(workspace.cwd, "README.md"), "# Temp Repo\nexport const one = 1;\nexport const two = 2;\n", diff --git a/packages/app/e2e/browser/owned-subscriptions-old-daemon.spec.ts b/packages/app/e2e/browser/owned-subscriptions-old-daemon.spec.ts index 1bd1f549cf..5040cf9f46 100644 --- a/packages/app/e2e/browser/owned-subscriptions-old-daemon.spec.ts +++ b/packages/app/e2e/browser/owned-subscriptions-old-daemon.spec.ts @@ -113,6 +113,7 @@ test("does not repeat an assistant block when the current app paginates a publis page, "compatibility-after-reconnect: emit 1 coalesced agent stream updates", ); + await agent.client.connect(); await agent.client.waitForFinish(agent.agentId, 20_000); await page.screenshot({ path: test.info().outputPath("old-daemon-live-reconnect.png") }); }); diff --git a/packages/app/e2e/browser/settings-toggle-tab-regression.spec.ts b/packages/app/e2e/browser/settings-toggle-tab-regression.spec.ts index 5b52c7025d..2c1fd0cc24 100644 --- a/packages/app/e2e/browser/settings-toggle-tab-regression.spec.ts +++ b/packages/app/e2e/browser/settings-toggle-tab-regression.spec.ts @@ -1,6 +1,6 @@ import { buildHostAgentDetailRoute, buildHostWorkspaceRoute } from "@/utils/host-routes"; import { expect, test } from "../support/fixtures"; -import { createIdleAgent, openWorkspaceWithAgents } from "../support/helpers/archive-tab"; +import { createMockIdleAgent, openWorkspaceWithAgents } from "../support/helpers/archive-tab"; import { waitForTabBar, expectAgentTabActive } from "../support/helpers/launcher"; import { seedWorkspace } from "../support/helpers/seed-client"; import { getServerId } from "../support/helpers/server-id"; @@ -104,12 +104,12 @@ test.describe("Settings toggle tab regression", () => { const workspace = await seedWorkspace({ repoPrefix: "settings-toggle-tab-" }); try { - const firstAgent = await createIdleAgent(workspace.client, { + const firstAgent = await createMockIdleAgent(workspace.client, { cwd: workspace.repoPath, workspaceId: workspace.workspaceId, title: `settings-toggle-a-${Date.now()}`, }); - const secondAgent = await createIdleAgent(workspace.client, { + const secondAgent = await createMockIdleAgent(workspace.client, { cwd: workspace.repoPath, workspaceId: workspace.workspaceId, title: `settings-toggle-b-${Date.now()}`, @@ -165,12 +165,12 @@ test.describe("Settings toggle tab regression", () => { const workspace = await seedWorkspace({ repoPrefix: "settings-composer-height-" }); try { - const firstAgent = await createIdleAgent(workspace.client, { + const firstAgent = await createMockIdleAgent(workspace.client, { cwd: workspace.repoPath, workspaceId: workspace.workspaceId, title: `settings-composer-height-a-${Date.now()}`, }); - const secondAgent = await createIdleAgent(workspace.client, { + const secondAgent = await createMockIdleAgent(workspace.client, { cwd: workspace.repoPath, workspaceId: workspace.workspaceId, title: `settings-composer-height-b-${Date.now()}`, @@ -225,12 +225,12 @@ test.describe("Settings toggle tab regression", () => { const workspace = await seedWorkspace({ repoPrefix: "agent-route-refresh-" }); try { - const firstAgent = await createIdleAgent(workspace.client, { + const firstAgent = await createMockIdleAgent(workspace.client, { cwd: workspace.repoPath, workspaceId: workspace.workspaceId, title: `agent-route-refresh-a-${Date.now()}`, }); - const secondAgent = await createIdleAgent(workspace.client, { + const secondAgent = await createMockIdleAgent(workspace.client, { cwd: workspace.repoPath, workspaceId: workspace.workspaceId, title: `agent-route-refresh-b-${Date.now()}`, diff --git a/packages/app/e2e/browser/streaming-markdown.spec.ts b/packages/app/e2e/browser/streaming-markdown.spec.ts index 00aa16e7cc..b1c76f3d89 100644 --- a/packages/app/e2e/browser/streaming-markdown.spec.ts +++ b/packages/app/e2e/browser/streaming-markdown.spec.ts @@ -14,7 +14,7 @@ test("formats unfinished Markdown while streaming and preserves the completed re await withStreamingMarkdown(page, testInfo, async (agent) => { await requestStreamingMarkdown(agent); await expectUnfinishedBold(page); - await expectUnfinishedLink(page, testInfo); + await expectUnfinishedLink(page, agent, testInfo); await expectFinishedMarkdown(page, agent, testInfo); await expectReloadedMarkdown(page); }); diff --git a/packages/app/e2e/support/helpers/agent-timeline-gate.ts b/packages/app/e2e/support/helpers/agent-timeline-gate.ts index 9e435068ad..7651f32a94 100644 --- a/packages/app/e2e/support/helpers/agent-timeline-gate.ts +++ b/packages/app/e2e/support/helpers/agent-timeline-gate.ts @@ -609,3 +609,71 @@ async function delayAgentTimelineResponse( waitForDelayedResponse: () => delayedResponse, }; } + +/** Holds real incoming frames so intermediate rendering assertions do not race the producer. */ +export async function holdAssistantStream(page: Page, agentId: string) { + const pending: Array<{ forward(): void; text: string }> = []; + let holding = false; + let released = false; + let forwardedText = ""; + let notifyFrame: (() => void) | undefined; + let notifyInitialTimeline!: () => void; + const initialTimeline = new Promise((resolve) => { + notifyInitialTimeline = resolve; + }); + + await page.routeWebSocket(daemonWsRoutePattern(), (ws) => { + const server = ws.connectToServer(); + ws.onMessage((message) => server.send(message)); + server.onMessage((message) => { + const session = getSessionMessage(message); + const payload = session ? getPayload(session) : null; + if (session?.type === "fetch_agent_timeline_response" && payload?.agentId === agentId) { + notifyInitialTimeline(); + } + const event = payload?.event as + | { type?: string; item?: { type?: string; text?: string } } + | undefined; + const text = + session?.type === "agent_stream" && + payload?.agentId === agentId && + event?.type === "timeline" && + event.item?.type === "assistant_message" + ? (event.item.text ?? "") + : ""; + if (text) holding = true; + if (holding && !released) { + pending.push({ forward: () => ws.send(message), text }); + notifyFrame?.(); + } else { + ws.send(message); + } + }); + }); + + return { + waitForInitialTimeline: () => initialTimeline, + async showThrough(prefix: string): Promise { + while (forwardedText.length < prefix.length) { + const frame = pending.shift(); + if (!frame) { + await new Promise((resolve) => { + notifyFrame = resolve; + }); + continue; + } + forwardedText += frame.text; + frame.forward(); + } + if (forwardedText !== prefix) { + throw new Error( + `Stream did not stop at ${JSON.stringify(prefix)}: ${JSON.stringify(forwardedText)}`, + ); + } + }, + release() { + released = true; + for (const frame of pending.splice(0)) frame.forward(); + }, + }; +} diff --git a/packages/app/e2e/support/helpers/command-center-workspace-actions.ts b/packages/app/e2e/support/helpers/command-center-workspace-actions.ts index 5aba36208a..f3908dea28 100644 --- a/packages/app/e2e/support/helpers/command-center-workspace-actions.ts +++ b/packages/app/e2e/support/helpers/command-center-workspace-actions.ts @@ -28,7 +28,7 @@ export async function openWorkspaceFromCommandCenter( ): Promise { const panel = await openCommandCenter(page); await panel.getByTestId("command-center-input").fill(title); - await page.keyboard.press("Enter"); + await action(panel, title).click(); await expectAppRoute(page, buildHostWorkspaceRoute(getServerId(), seeded.workspaceId), { timeout: 30_000, }); diff --git a/packages/app/e2e/support/helpers/import-session.ts b/packages/app/e2e/support/helpers/import-session.ts index 86818f6f4e..0bf2fc16e9 100644 --- a/packages/app/e2e/support/helpers/import-session.ts +++ b/packages/app/e2e/support/helpers/import-session.ts @@ -23,8 +23,7 @@ export class ImportSessionFlow { await expectMobileAgentSidebarVisible(this.page); const button = this.page.getByTestId("sidebar-import-session"); await expect(button).toHaveAccessibleName("Import session"); - await button.hover(); - await expect(this.page.getByText("Import session", { exact: true })).toBeVisible(); + await expect(button).toBeInViewport(); } async openGlobally() { await expect(this.page.getByTestId("sidebar-import-session")).toBeVisible(); @@ -49,6 +48,9 @@ export class ImportSessionFlow { before?: [string, string]; folders?: Array<[string, string]>; }) { + for (const id of input.first ?? input.before ?? []) { + await expect(this.page.getByTestId(rowTestId(id))).toHaveCount(1); + } const ids = await this.rowIds(); if (input.first) expect(ids.slice(0, input.first.length)).toEqual(input.first.map(rowTestId)); if (input.before) { diff --git a/packages/app/e2e/support/helpers/streaming-markdown.ts b/packages/app/e2e/support/helpers/streaming-markdown.ts index 144b706f5d..3609641d15 100644 --- a/packages/app/e2e/support/helpers/streaming-markdown.ts +++ b/packages/app/e2e/support/helpers/streaming-markdown.ts @@ -1,10 +1,15 @@ +import { holdAssistantStream } from "./agent-timeline-gate"; import { expect, type Page, type TestInfo } from "@playwright/test"; import { openAgentRoute, seedMockAgentWorkspace, type MockAgentWorkspace } from "./mock-agent"; +interface StreamingMarkdownAgent extends MockAgentWorkspace { + stream: Awaited>; +} + export async function withStreamingMarkdown( page: Page, testInfo: TestInfo, - run: (agent: MockAgentWorkspace) => Promise, + run: (agent: StreamingMarkdownAgent) => Promise, ): Promise { testInfo.setTimeout(120_000); const agent = await seedMockAgentWorkspace({ @@ -16,16 +21,22 @@ export async function withStreamingMarkdown( mockStreamingAssistantIntervalMs: 400, }, }); + const stream = await holdAssistantStream(page, agent.agentId); try { await openAgentRoute(page, agent); - await run(agent); + await stream.waitForInitialTimeline(); + await run({ ...agent, stream }); } finally { + stream.release(); await agent.cleanup(); } } -export async function requestStreamingMarkdown(agent: MockAgentWorkspace): Promise { +export async function requestStreamingMarkdown(agent: StreamingMarkdownAgent): Promise { await agent.client.sendAgentMessage(agent.agentId, "Show the formatted streaming response."); + // Exercise late assertions: the producer finishes before the browser consumes its frames. + await agent.client.waitForFinish(agent.agentId, 30_000); + await agent.stream.showThrough("**Bold"); } export async function expectUnfinishedBold(page: Page): Promise { @@ -37,8 +48,13 @@ export async function expectUnfinishedBold(page: Page): Promise { await expect(message).not.toContainText("*"); } -export async function expectUnfinishedLink(page: Page, testInfo: TestInfo): Promise { +export async function expectUnfinishedLink( + page: Page, + agent: StreamingMarkdownAgent, + testInfo: TestInfo, +): Promise { const message = page.getByTestId("assistant-message").last(); + await agent.stream.showThrough("**Bold text stays bold** and [Paseo docs"); await expect(message).toContainText("Paseo docs"); await expect(message.getByRole("link", { name: "Paseo docs" })).toHaveCount(0); await expect(message).not.toContainText("["); @@ -48,9 +64,10 @@ export async function expectUnfinishedLink(page: Page, testInfo: TestInfo): Prom export async function expectFinishedMarkdown( page: Page, - agent: MockAgentWorkspace, + agent: StreamingMarkdownAgent, testInfo: TestInfo, ): Promise { + agent.stream.release(); await agent.client.waitForFinish(agent.agentId, 30_000); await expectCompletedMarkdown(page); await captureMarkdown(page, testInfo, "completed-markdown"); diff --git a/packages/desktop/e2e/browser-tabs.e2e.mjs b/packages/desktop/e2e/browser-tabs.e2e.mjs index 20c9866bc0..208661e033 100644 --- a/packages/desktop/e2e/browser-tabs.e2e.mjs +++ b/packages/desktop/e2e/browser-tabs.e2e.mjs @@ -390,17 +390,12 @@ async function clickGuestElement(page, client, browserId, selector) { }); const elementRect = JSON.parse(evaluated.resultJson); assert(elementRect, `Guest element ${selector} was unavailable`); - const webviewRect = await page.evaluate((id) => { - const webview = document.querySelector(`[data-paseo-browser-id="${id}"]`); - if (!(webview instanceof HTMLElement)) return null; - const rect = webview.getBoundingClientRect(); - return { x: rect.x, y: rect.y }; - }, browserId); - assert(webviewRect, `Browser webview ${browserId} was unavailable`); - await page.mouse.click( - webviewRect.x + elementRect.x + elementRect.width / 2, - webviewRect.y + elementRect.y + elementRect.height / 2, - ); + await page.locator(`[data-paseo-browser-id="${browserId}"]`).click({ + position: { + x: elementRect.x + elementRect.width / 2, + y: elementRect.y + elementRect.height / 2, + }, + }); } async function selectDeviceSize(page, label) { @@ -614,7 +609,10 @@ async function runRegression({ await page.waitForFunction( (id) => { const webview = document.querySelector(`[data-paseo-browser-id="${id}"]`); - return webview && webview.parentElement?.id !== "paseo-browser-resident-webviews"; + const surface = webview?.parentElement; + return ( + surface?.getAttribute("aria-hidden") === "false" && surface.style.pointerEvents === "auto" + ); }, browserId, { timeout: timeoutMs }, diff --git a/packages/desktop/scripts/dev-runner-readiness.mjs b/packages/desktop/scripts/dev-runner-readiness.mjs new file mode 100644 index 0000000000..b3fc07eb6e --- /dev/null +++ b/packages/desktop/scripts/dev-runner-readiness.mjs @@ -0,0 +1,17 @@ +export async function waitForMetro(url, timeoutMs = 60_000) { + const statusUrl = new URL("/status", url); + const deadline = Date.now() + timeoutMs; + while (Date.now() < deadline) { + try { + const response = await fetch(statusUrl, { signal: AbortSignal.timeout(1_000) }); + const body = (await response.text()).trim(); + if (response.status === 200 && body === "packager-status:running") { + return; + } + } catch { + // Expo temporarily binds the port while checking availability, before Metro starts. + } + await new Promise((resolve) => setTimeout(resolve, 250)); + } + throw new Error(`Timed out waiting for Metro at ${url}`); +} diff --git a/packages/desktop/scripts/dev-runner-readiness.test.mjs b/packages/desktop/scripts/dev-runner-readiness.test.mjs new file mode 100644 index 0000000000..c3e2cc9fcb --- /dev/null +++ b/packages/desktop/scripts/dev-runner-readiness.test.mjs @@ -0,0 +1,41 @@ +import http from "node:http"; +import { expect, test } from "vitest"; +import { waitForMetro } from "./dev-runner-readiness.mjs"; + +async function startMetroStatusServer(responses) { + let lastResponse = null; + let nextResponse = 0; + const server = http.createServer((request, response) => { + const reply = responses[Math.min(nextResponse++, responses.length - 1)]; + lastResponse = { path: request.url, ...reply }; + response.writeHead(reply.status); + response.end(reply.body); + }); + await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve)); + return { + url: `http://127.0.0.1:${server.address().port}`, + lastResponse: () => lastResponse, + async close() { + server.closeAllConnections(); + await new Promise((resolve) => server.close(resolve)); + }, + }; +} + +test("Metro readiness rejects an unrelated listener before accepting the packager status", async () => { + const metro = await startMetroStatusServer([ + { status: 503, body: "packager-status:running" }, + { status: 200, body: "unrelated HTTP server" }, + { status: 200, body: "packager-status:running" }, + ]); + try { + await waitForMetro(metro.url, 2_000); + expect(metro.lastResponse()).toEqual({ + path: "/status", + status: 200, + body: "packager-status:running", + }); + } finally { + await metro.close(); + } +}); diff --git a/packages/desktop/scripts/dev-runner.mjs b/packages/desktop/scripts/dev-runner.mjs index 852bf7d85c..008b77d15f 100644 --- a/packages/desktop/scripts/dev-runner.mjs +++ b/packages/desktop/scripts/dev-runner.mjs @@ -1,5 +1,4 @@ #!/usr/bin/env node -import net from "node:net"; import path from "node:path"; import { execFileSync, spawn } from "node:child_process"; import { createRequire } from "node:module"; @@ -10,6 +9,7 @@ import { resolveChildKillTarget, } from "./dev-runner-config.mjs"; +import { waitForMetro } from "./dev-runner-readiness.mjs"; import { resolveDevElectronArgs } from "./dev-runner-args.mjs"; const scriptDir = path.dirname(fileURLToPath(import.meta.url)); @@ -134,35 +134,6 @@ function stopAll(signal) { }, 50); } -async function waitForPort(port, host = "127.0.0.1", timeoutMs = 60_000) { - const deadline = Date.now() + timeoutMs; - - while (Date.now() < deadline) { - if (await canConnect(port, host)) { - return; - } - await new Promise((resolve) => setTimeout(resolve, 250)); - } - - throw new Error(`Timed out waiting for ${host}:${port}`); -} - -function canConnect(port, host) { - return new Promise((resolve) => { - const socket = net.createConnection({ port, host }); - socket.setTimeout(1000); - socket.once("connect", () => { - socket.destroy(); - resolve(true); - }); - socket.once("timeout", () => { - socket.destroy(); - resolve(false); - }); - socket.once("error", () => resolve(false)); - }); -} - registerDevRunnerShutdownSignals({ signalSource: process, stop: stopAll }); spawnChild("metro", "npx", ["expo", "start", "--port", String(expoPort)], { @@ -179,7 +150,7 @@ spawnChild("metro", "npx", ["expo", "start", "--port", String(expoPort)], { }); try { - await waitForPort(expoPort); + await waitForMetro(expoDevUrl); } catch (error) { console.error(`[dev] ${error.message}`); exitCode = 1; diff --git a/packages/server/src/server/creation/index.test.ts b/packages/server/src/server/creation/index.test.ts index 2f2c024078..f4f0205225 100644 --- a/packages/server/src/server/creation/index.test.ts +++ b/packages/server/src/server/creation/index.test.ts @@ -176,7 +176,7 @@ test("conflicting intents and resource IDs cannot start more work", async () => "workspace_id_conflict", ); f.provider.resolve(); - await first; + expect(await first).toMatchObject({ phase: "completed", error: null }); expect(f.calls).toEqual(["workspace", "agent", "prompt"]); }); From 77c5c8f17e1f33ec6c77d413bb6c6485b510d8b3 Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Mon, 14 Sep 2026 22:25:31 +0200 Subject: [PATCH 36/46] Prevent duplicate catalog choices from crashing agent drafts (#4839) * fix(providers): keep duplicate catalogs from breaking agent drafts Normalize model identities before publication and when materializing older catalogs, preserving the first definition and configured overrides. Keep explicit default variants separate from the base model selection. * fix(providers): preserve saved variant identities across resumes Persist the selection encoding so legacy names remain literal. Refresh provider persistence when a selection changes, before another prompt is sent. * test(app): let fixtures own catalog journey cleanup * fix(providers): preserve the existing default variant contract Keep one Default catalog choice and let the provider resolve an omitted variant. Remove the encoding and persistence machinery; other variant IDs retain their literal meaning. * test(creation): expose failed completion after conflicting requests Creation returns a failed snapshot instead of rejecting. Assert the result before its side effects so platform failures report the underlying error. * test(app): wait for the seed client after daemon restart The browser and seed client reconnect independently. Wait for the seed connection before issuing post-restart RPCs instead of assuming browser readiness covers both clients. * test(app): isolate visible prompts and checkout diff fixtures Inactive tabs retain message DOM, so prompt assertions must inspect visible rows. Ignore the local fixture remote before observation rather than deleting it after the daemon has started tracking the checkout. * fix(file-observer): preserve final deletions when batching events A duplicate creation notification followed by deletion was collapsed into an update, hiding final absence from consumers that distinguish deletes. Keep final deletes while retaining update semantics for atomic replacement. * fix(desktop): wait for Metro HTTP readiness before launching Electron Expo briefly listens while probing port availability. A successful TCP connection can therefore launch Electron before Metro starts and leave it exiting on connection refusal. Require the configured renderer origin to report the running packager status. * test(ci): consolidate fixture and readiness corrections Await the connection and UI states each journey owns, isolate Git fixture contents before observation, and hold real Markdown frames at asserted checkpoints. Keep creation completion diagnostics explicit without claiming the original Windows failure is fixed. --- .../model-search-across-providers.spec.ts | 114 +++++++++++++++++- .../app/e2e/support/fixtures/catalog-acp.cjs | 18 +++ .../src/data/provider-snapshot-cache.test.ts | 37 ++++++ .../app/src/data/provider-snapshot-cache.ts | 22 +++- packages/protocol/src/agent-types.ts | 11 ++ .../agent/provider-snapshot-manager.test.ts | 73 +++++++++++ .../server/agent/provider-snapshot-manager.ts | 10 +- .../agent/providers/opencode-agent.test.ts | 109 +++++++++++++++++ .../server/agent/providers/opencode-agent.ts | 7 +- 9 files changed, 392 insertions(+), 9 deletions(-) create mode 100644 packages/app/e2e/support/fixtures/catalog-acp.cjs diff --git a/packages/app/e2e/browser/model-search-across-providers.spec.ts b/packages/app/e2e/browser/model-search-across-providers.spec.ts index 4a4ab31ea2..ba2c37f895 100644 --- a/packages/app/e2e/browser/model-search-across-providers.spec.ts +++ b/packages/app/e2e/browser/model-search-across-providers.spec.ts @@ -1,4 +1,7 @@ -import { test } from "../support/fixtures"; +import path from "node:path"; +import type { DaemonClient } from "@getpaseo/client/internal/daemon-client"; +import { test, expect, type Page } from "../support/fixtures"; +import { connectDaemonClient } from "../support/helpers/daemon-client-loader"; import { closeModelPicker, expectModelSearchEmptyState, @@ -15,7 +18,7 @@ import { expectSearchResultsVirtualized, } from "../support/helpers/agent-profiles"; import { expectComposerVisible } from "../support/helpers/composer"; -import { clickNewChat, gotoWorkspace } from "../support/helpers/launcher"; +import { clickNewChat, clickNewTerminal, gotoWorkspace } from "../support/helpers/launcher"; import { seedWorkspace } from "../support/helpers/seed-client"; const MOCK_PROVIDER_LABEL = "Mock Load Test"; @@ -158,3 +161,110 @@ test.describe("Cross-provider model search", () => { } }); }); + +async function setRuntimeCatalog(client: DaemonClient, count: number, cwd: string) { + await client.patchDaemonConfig({ + providers: { + gemini: { + extends: "acp", + label: "Catalog provider", + enabled: true, + command: [ + process.execPath, + path.resolve(__dirname, "../support/fixtures/catalog-acp.cjs"), + String(count), + ], + }, + }, + }); + await expect + .poll( + async () => + (await client.getProvidersSnapshot({ cwd })).entries.find( + (entry) => entry.provider === "gemini", + )?.status, + { timeout: 30_000 }, + ) + .toBe("ready"); +} +async function reloadSavedDraft(page: Page) { + await page.evaluate(() => + localStorage.setItem( + "@paseo:e2e-disable-default-seed-once", + localStorage.getItem("@paseo:e2e-seed-nonce")!, + ), + ); + await page.reload(); + await expectComposerVisible(page); +} +async function expectOneCatalogChoice(page: Page, label: string) { + await openModelPicker(page); + await page.getByRole("button", { name: "Back", exact: true }).click(); + await searchAllModels(page, label); + await expect(page.getByTestId("model-row-gemini-gemini-3.5-flash")).toHaveCount(1); + await closeModelPicker(page); +} + +async function configureModelOverride(client: DaemonClient, cwd: string) { + await client.patchDaemonConfig({ + providers: { + gemini: { + additionalModels: [{ id: "gemini-3.5-flash", label: "Configured model", isDefault: true }], + }, + }, + }); + await expect + .poll( + async () => + (await client.getProvidersSnapshot({ cwd: cwd })).entries.find( + (entry) => entry.provider === "gemini", + )?.models, + { timeout: 30_000 }, + ) + .toMatchObject([{ id: "gemini-3.5-flash", label: "Configured model", isDefault: true }]); +} + +const catalogTest = test.extend<{ catalogClient: DaemonClient }>({ + catalogClient: async ({ e2eWorker }, provide) => { + void e2eWorker; + const client = await connectDaemonClient({ clientIdPrefix: "catalog-models" }); + try { + await provide(client); + } finally { + try { + await client.patchDaemonConfig({ removeProviders: ["gemini"] }); + } finally { + await client.close(); + } + } + }, +}); + +catalogTest( + "New Agent and saved drafts stay usable with repeated runtime model rows", + async ({ page, withWorkspace, catalogClient: client }, testInfo) => { + const workspace = await withWorkspace({ prefix: "catalog-models-" }); + await test.step("open a draft with a one-row runtime catalog", async () => { + await setRuntimeCatalog(client, 1, workspace.repoPath); + await gotoWorkspace(page, workspace.workspaceId); + await expectComposerVisible(page); + }); + await test.step("open New Agent after the provider publishes repeated model rows", async () => { + await clickNewTerminal(page); + await setRuntimeCatalog(client, 2, workspace.repoPath); + await clickNewChat(page); + await expectComposerVisible(page); + await expectOneCatalogChoice(page, "Gemini 3.5 Flash"); + await page.screenshot({ path: testInfo.outputPath("duplicate-catalog-draft.png") }); + }); + await test.step("reopen the saved draft with the same catalog", async () => { + await reloadSavedDraft(page); + await expectOneCatalogChoice(page, "Gemini 3.5 Flash"); + }); + await test.step("retain configured model overrides when runtime IDs repeat", async () => { + await configureModelOverride(client, workspace.repoPath); + await reloadSavedDraft(page); + await expectOneCatalogChoice(page, "Configured model"); + }); + }, +); diff --git a/packages/app/e2e/support/fixtures/catalog-acp.cjs b/packages/app/e2e/support/fixtures/catalog-acp.cjs new file mode 100644 index 0000000000..fc6eafee73 --- /dev/null +++ b/packages/app/e2e/support/fixtures/catalog-acp.cjs @@ -0,0 +1,18 @@ +const readline = require("node:readline"); +const model = { modelId: "gemini-3.5-flash", name: "Gemini 3.5 Flash" }; +readline.createInterface({ input: process.stdin }).on("line", (line) => { + const request = JSON.parse(line); + if (request.id === undefined) return; + let result = {}; + if (request.method === "initialize") + result = { protocolVersion: 1, agentCapabilities: {}, authMethods: [] }; + if (request.method === "session/new") + result = { + sessionId: "catalog-diagnostic", + models: { + currentModelId: model.modelId, + availableModels: Array.from({ length: Number(process.argv[2]) }, () => model), + }, + }; + process.stdout.write(JSON.stringify({ jsonrpc: "2.0", id: request.id, result }) + "\n"); +}); diff --git a/packages/app/src/data/provider-snapshot-cache.test.ts b/packages/app/src/data/provider-snapshot-cache.test.ts index b69b0f5d52..424657e3fa 100644 --- a/packages/app/src/data/provider-snapshot-cache.test.ts +++ b/packages/app/src/data/provider-snapshot-cache.test.ts @@ -130,6 +130,43 @@ function writeSnapshot( } describe("provider snapshot cache", () => { + it("normalizes duplicate model identities in live, legacy and persisted catalogs", async () => { + const storage = createStorage(); + const entries = snapshotEntries("shared"); + const first = entries[0].models![0]; + entries[0].models!.push({ ...first, label: "Duplicate" }); + const compactSnapshot = compactProviderSnapshot(entries); + const payload = { requestId: "catalog", generatedAt: "2026-09-01T00:00:00.000Z", entries }; + const cache = createProviderSnapshotCache(storage); + const legacy = await cache.materialize("server-1", payload); + expect(legacy.entries[0].models).toEqual([first]); + const live = await cache.materialize("server-1", { + ...payload, + snapshotHash: "old-hash", + compactSnapshot, + }); + expect(live.entries[0].models).toEqual([first]); + expect(live.compactSnapshot).toEqual(compactSnapshot); + await cache.write({ + serverId: "server-1", + cwd: "/repo", + hash: "old-hash", + generatedAt: payload.generatedAt, + compactSnapshot, + }); + const restarted = createProviderSnapshotCache(storage); + const saved = await restarted.read("server-1", "/repo"); + expect(saved?.entries[0].models).toEqual([first]); + const unchanged = await restarted.materialize("server-1", { + ...payload, + entries: [], + snapshotHash: "old-hash", + notModified: true, + }); + expect(unchanged.entries[0].models).toEqual([first]); + expect(unchanged.snapshotHash).toBe("old-hash"); + }); + it("removes externally lost body references and retains independent orphan bodies on reconciliation", async () => { const storage = createStorage(); const cache = createProviderSnapshotCache(storage); diff --git a/packages/app/src/data/provider-snapshot-cache.ts b/packages/app/src/data/provider-snapshot-cache.ts index 04a10303e3..3871cbb1e0 100644 --- a/packages/app/src/data/provider-snapshot-cache.ts +++ b/packages/app/src/data/provider-snapshot-cache.ts @@ -1,6 +1,9 @@ import AsyncStorage from "@react-native-async-storage/async-storage"; import { Buffer } from "buffer"; -import type { ProviderSnapshotEntry } from "@getpaseo/protocol/agent-types"; +import { + normalizeAgentModelCatalog, + type ProviderSnapshotEntry, +} from "@getpaseo/protocol/agent-types"; import { expandProviderSnapshot, type CompactProviderSnapshot, @@ -77,6 +80,17 @@ export class ProviderSnapshotCacheMissError extends Error { } } +// COMPAT(duplicateModelCatalog): added in v0.8.1, remove after 2027-03-14 once +// the daemon floor is v0.8.1 and pre-fix cached catalogs are no longer supported. +// Keep the original compact body/hash: normalization belongs to its decoded view. +function normalizeCatalogs(entries: ProviderSnapshotEntry[]): ProviderSnapshotEntry[] { + return entries.map((entry) => { + if (!entry.models) return entry; + const models = normalizeAgentModelCatalog(entry.models); + return models === entry.models ? entry : { ...entry, models }; + }); +} + function cacheKey(serverId: string, cwd: string | null): string { return `${CACHE_KEY_PREFIX}:${JSON.stringify([serverId, "cwd", cwd])}`; } @@ -303,7 +317,7 @@ export function createProviderSnapshotCache( const body = { ...stored, compactSnapshot: stored.compactSnapshot, - entries: expandProviderSnapshot(stored.compactSnapshot), + entries: normalizeCatalogs(expandProviderSnapshot(stored.compactSnapshot)), }; bodies.set(key, body); return body; @@ -328,7 +342,7 @@ export function createProviderSnapshotCache( return { async materialize(serverId, snapshot) { const { snapshotHash: hash, compactSnapshot } = snapshot; - if (!hash) return snapshot; + if (!hash) return { ...snapshot, entries: normalizeCatalogs(snapshot.entries) }; let body = await safely(async () => { const cached = await readBody(serverId, hash); if (cached) return cached; @@ -348,7 +362,7 @@ export function createProviderSnapshotCache( hash, generatedAt: snapshot.generatedAt, compactSnapshot, - entries: expandProviderSnapshot(compactSnapshot), + entries: normalizeCatalogs(expandProviderSnapshot(compactSnapshot)), }; } return { diff --git a/packages/protocol/src/agent-types.ts b/packages/protocol/src/agent-types.ts index 83932d3506..ea412c16be 100644 --- a/packages/protocol/src/agent-types.ts +++ b/packages/protocol/src/agent-types.ts @@ -102,6 +102,17 @@ export function normalizeAgentModelDefinition(model: AgentModelDefinition): Agen return { ...model, defaultThinkingOptionId }; } +/** A provider addresses models by ID; repeated rows retain the first definition. */ +export function normalizeAgentModelCatalog(models: AgentModelDefinition[]): AgentModelDefinition[] { + const ids = new Set(); + const unique = models.filter((model) => { + if (ids.has(model.id)) return false; + ids.add(model.id); + return true; + }); + return unique.length === models.length ? models : unique; +} + export interface ProviderSnapshotEntry { provider: AgentProvider; status: ProviderStatus; diff --git a/packages/server/src/server/agent/provider-snapshot-manager.test.ts b/packages/server/src/server/agent/provider-snapshot-manager.test.ts index 68837c8c5f..3f81e5e9c7 100644 --- a/packages/server/src/server/agent/provider-snapshot-manager.test.ts +++ b/packages/server/src/server/agent/provider-snapshot-manager.test.ts @@ -138,6 +138,79 @@ async function runTestCatalogActivities( } describe("ProviderSnapshotManager public surface", () => { + test("publishes one model per provider identity and retains the first definition", async () => { + const first = { provider: "codex", id: "shared", label: "First", isDefault: true }; + const manager = new ProviderSnapshotManager({ + logger: createTestLogger(), + extraClients: { + codex: createExtraClient("codex", { + isAvailable: async () => true, + fetchCatalog: async () => ({ + models: [first, { ...first, label: "Duplicate" }], + modes: [], + }), + }), + claude: createExtraClient("claude", { + isAvailable: async () => true, + fetchCatalog: async () => ({ models: [{ ...first, provider: "claude" }], modes: [] }), + }), + }, + }); + try { + const codex = await manager.getProvider({ provider: "codex", wait: true }); + const claude = await manager.getProvider({ provider: "claude", wait: true }); + expect(codex.models).toEqual([first]); + expect(claude.models).toEqual([{ ...first, provider: "claude" }]); + } finally { + manager.destroy(); + } + }); + + test("normalizes plugin model catalogs at the same publication boundary", async () => { + const manager = new ProviderSnapshotManager({ logger: createTestLogger() }); + manager.replacePluginProviders([ + { + id: "catalog-plugin", + label: "Catalog plugin", + async connect() { + let listener: ((event: ProviderEvent) => void) | undefined; + return { + version: 1, + capabilities: [], + async send(input) { + if (input.type === "catalog") + listener?.({ + type: "catalog", + requestId: input.requestId, + catalog: { + models: [ + { id: "same", label: "First" }, + { id: "same", label: "Second" }, + ], + modes: [], + }, + }); + }, + onEvent(next) { + listener = next; + return () => { + listener = undefined; + }; + }, + async close() {}, + }; + }, + }, + ]); + try { + const entry = await manager.getProvider({ provider: "catalog-plugin", wait: true }); + expect(entry.models).toHaveLength(1); + expect(entry.models?.[0]).toMatchObject({ id: "same", label: "First" }); + } finally { + manager.destroy(); + } + }); + test("carries a plugin provider icon in snapshot metadata", () => { const iconSvg = ''; const registration: ProviderRegistration = { diff --git a/packages/server/src/server/agent/provider-snapshot-manager.ts b/packages/server/src/server/agent/provider-snapshot-manager.ts index 610f7c8322..0307dc14be 100644 --- a/packages/server/src/server/agent/provider-snapshot-manager.ts +++ b/packages/server/src/server/agent/provider-snapshot-manager.ts @@ -1,3 +1,4 @@ +import { normalizeAgentModelCatalog } from "@getpaseo/protocol/agent-types"; import { createHash } from "node:crypto"; import { EventEmitter } from "node:events"; import { homedir } from "node:os"; @@ -1021,13 +1022,20 @@ export class ProviderSnapshotManager { return; } + const models = normalizeAgentModelCatalog(catalog.models); + if (models.length !== catalog.models.length) { + this.logger.warn( + { provider, discardedRows: catalog.models.length - models.length }, + "Provider catalog contains repeated model IDs; retaining the first definition", + ); + } setEntry({ ...base, defaultModeId: catalog.defaultModeId === undefined ? base.defaultModeId : catalog.defaultModeId, status: "ready", enabled: true, - models: catalog.models, + models, modes: catalog.modes, fetchedAt: new Date().toISOString(), }); diff --git a/packages/server/src/server/agent/providers/opencode-agent.test.ts b/packages/server/src/server/agent/providers/opencode-agent.test.ts index 8b2acae1bd..b1c9df8a03 100644 --- a/packages/server/src/server/agent/providers/opencode-agent.test.ts +++ b/packages/server/src/server/agent/providers/opencode-agent.test.ts @@ -612,6 +612,115 @@ describe("OpenCodeAgentClient adapter smoke tests", () => { rmSync(cwd, { recursive: true, force: true }); }, 120_000); + test.each([ + { name: "absent", variants: {} }, + { name: "empty", variants: { default: {} } }, + { name: "configured", variants: { default: { reasoningEffort: "low" } } }, + ])("catalog exposes one Default when upstream default is $name", async ({ variants }) => { + const runtime = new TestOpenCodeHarness(); + const openCodeClient = new TestOpenCodeClient(); + openCodeClient.providerListResponse = { + data: { + connected: ["catalog-provider"], + all: [ + { + id: "catalog-provider", + name: "Catalog provider", + source: "api", + models: { + model: { + name: "Variant model", + variants: { ...variants, high: {}, "variant:default": {} }, + }, + }, + }, + ], + }, + }; + runtime.enqueueClient(openCodeClient); + const cwd = tmpCwd(); + try { + const client = new OpenCodeAgentClient(logger, undefined, { + serverManager: runtime, + createClient: runtime.createClient, + resolveHomeDir: () => cwd, + }); + const catalog = await client.fetchCatalog({ scope: "global", force: false }); + const options = catalog.models[0].thinkingOptions ?? []; + expect(options).toEqual([ + { id: "default", label: "Default", isDefault: true }, + { id: "high", label: "high" }, + { id: "variant:default", label: "variant:default" }, + ]); + expect(catalog.models[0].defaultThinkingOptionId).toBe("default"); + const execution = new TestOpenCodeClient(); + execution.sessionCreateResponse = { data: { id: "ses_catalog_variants" } }; + execution.sessionPromptAsyncEvents = [ + { type: "session.idle", properties: { sessionID: "ses_catalog_variants" } }, + ]; + runtime.enqueueClient(execution); + const session = await client.createSession({ + provider: "opencode", + cwd, + model: "catalog-provider/model", + thinkingOptionId: "default", + }); + try { + await collectTurnEvents(streamSession(session, "Use OpenCode's default")); + await session.setThinkingOption!("high"); + await collectTurnEvents(streamSession(session, "Use high")); + await session.setThinkingOption!("variant:default"); + await collectTurnEvents(streamSession(session, "Use the literal variant name")); + await session.setThinkingOption!("default"); + await collectTurnEvents(streamSession(session, "Return to OpenCode's default")); + expect(execution.calls.sessionPromptAsync).toEqual([ + expect.not.objectContaining({ variant: expect.anything() }), + expect.objectContaining({ variant: "high" }), + expect.objectContaining({ variant: "variant:default" }), + expect.not.objectContaining({ variant: expect.anything() }), + ]); + } finally { + await session.close(); + } + } finally { + rmSync(cwd, { recursive: true, force: true }); + } + }); + + test.each([ + { saved: "default", variant: undefined }, + { saved: "high", variant: "high" }, + { saved: "variant:default", variant: "variant:default" }, + ])("resume preserves saved thinking choice $saved", async ({ saved, variant }) => { + const runtime = new TestOpenCodeHarness(); + const execution = new TestOpenCodeClient(); + execution.sessionPromptAsyncEvents = [ + { type: "session.idle", properties: { sessionID: "ses_saved_variant" } }, + ]; + runtime.enqueueClient(execution); + const client = new OpenCodeAgentClient(logger, undefined, { + serverManager: runtime, + createClient: runtime.createClient, + }); + const cwd = tmpCwd(); + try { + const session = await client.resumeSession( + { provider: "opencode", sessionId: "ses_saved_variant", metadata: { cwd } }, + { thinkingOptionId: saved }, + ); + try { + await collectTurnEvents(streamSession(session, "Keep my saved choice")); + expect(execution.calls.sessionPromptAsync.map((request) => request.variant)).toEqual([ + variant, + ]); + } finally { + await session.close(); + } + } finally { + rmSync(cwd, { recursive: true, force: true }); + } + }); + test("fetchCatalog returns models with required fields", async () => { const runtime = new TestOpenCodeHarness(); const openCodeClient = new TestOpenCodeClient(); diff --git a/packages/server/src/server/agent/providers/opencode-agent.ts b/packages/server/src/server/agent/providers/opencode-agent.ts index 19d5f95939..084ca70d7c 100644 --- a/packages/server/src/server/agent/providers/opencode-agent.ts +++ b/packages/server/src/server/agent/providers/opencode-agent.ts @@ -806,11 +806,14 @@ function buildOpenCodeModelDefinition( }, ): AgentModelDefinition { const rawVariants = model.variants ? Object.keys(model.variants) : []; - // OpenCode lists only overrides; its base model behavior is selected by omitting `variant`. + // Like OpenCode's web UI, Default omits `variant` and lets OpenCode resolve it. + // Reserve that choice instead of exposing a second upstream `default` entry. const thinkingOptions = rawVariants.length ? [ { id: OPENCODE_DEFAULT_VARIANT_ID, label: "Default", isDefault: true }, - ...rawVariants.map((id) => ({ id, label: id })), + ...rawVariants + .filter((id) => id !== OPENCODE_DEFAULT_VARIANT_ID) + .map((id) => ({ id, label: id })), ] : []; From 13979e9c386ec2b403562e0846ef18f175fcae14 Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Mon, 14 Sep 2026 23:02:45 +0200 Subject: [PATCH 37/46] fix(server): discover fork PRs with URL-valued branch remotes (#4862) Resolve direct repository URLs as well as named remotes when deriving checkout PR identity, preserving fork-owner filtering and batched polling. --- .../server/src/utils/checkout-git.test.ts | 30 +++++++++++++++++++ packages/server/src/utils/checkout-git.ts | 23 ++++++++++++-- 2 files changed, 50 insertions(+), 3 deletions(-) diff --git a/packages/server/src/utils/checkout-git.test.ts b/packages/server/src/utils/checkout-git.test.ts index f83ee46ae5..b5f505ea2d 100644 --- a/packages/server/src/utils/checkout-git.test.ts +++ b/packages/server/src/utils/checkout-git.test.ts @@ -2991,6 +2991,36 @@ const x = 1; }); }); + it.each([ + "git@github.com:contributor/paseo.git", + "https://github.com/contributor/paseo.git", + "ssh://git@github.com/contributor/paseo.git", + ])("preserves fork PR identity with branch.remote=%s", async (branchRemote) => { + execFileSync("git", ["remote", "add", "origin", "git@github.com:getpaseo/paseo.git"], { + cwd: repoDir, + }); + execFileSync("git", ["checkout", "-b", "topic"], { cwd: repoDir }); + execFileSync("git", ["config", "branch.topic.remote", branchRemote], { + cwd: repoDir, + }); + execFileSync("git", ["config", "branch.topic.pushRemote", branchRemote], { + cwd: repoDir, + }); + execFileSync("git", ["config", "branch.topic.merge", "refs/heads/topic"], { + cwd: repoDir, + }); + const headSha = execFileSync("git", ["rev-parse", "HEAD"], { + cwd: repoDir, + encoding: "utf8", + }).trim(); + + expect(await readPullRequestLookupTargetFromFacts(repoDir, paseoHome)).toEqual({ + headRef: "topic", + headRepositoryOwner: "contributor", + headSha, + }); + }); + it("does not attach an owner when the tracked remote is the same GitHub repository", async () => { execFileSync("git", ["checkout", "-b", "local-feature"], { cwd: repoDir }); execFileSync("git", ["remote", "add", "origin", "git@github.com:getpaseo/paseo.git"], { diff --git a/packages/server/src/utils/checkout-git.ts b/packages/server/src/utils/checkout-git.ts index 8e2a89f0bd..e1246a90a5 100644 --- a/packages/server/src/utils/checkout-git.ts +++ b/packages/server/src/utils/checkout-git.ts @@ -1369,6 +1369,23 @@ async function getGitConfigValue( } } +async function resolveGitRemoteUrl({ + cwd, + remote, + context, +}: { + cwd: string; + remote: string; + context?: CheckoutContext; +}): Promise { + const configuredUrl = await getGitConfigValue(cwd, `remote.${remote}.url`, context); + if (configuredUrl) { + return configuredUrl; + } + // Branch remote settings accept repository URLs as well as configured remote names. + return parseGitRemoteLocation(remote) ? remote : null; +} + async function getGitRemotePushUrl( cwd: string, remoteName: string, @@ -1433,7 +1450,7 @@ async function resolvePullRequestStatusLookupTarget( } const [branchRemoteUrl, originRemoteUrl, resolvedBaseRef] = await Promise.all([ - branchRemoteName ? getGitConfigValue(cwd, `remote.${branchRemoteName}.url`, context) : null, + branchRemoteName ? resolveGitRemoteUrl({ cwd, remote: branchRemoteName, context }) : null, getGitConfigValue(cwd, "remote.origin.url", context), getResolvedBaseRefForCwd(cwd, context), ]); @@ -1950,7 +1967,7 @@ async function resolvePullRequestLookupTargetFromPushConfig( const [pushRefspec, pushRemoteUrl, originRemoteUrl, resolvedBaseRef] = await Promise.all([ getGitConfigValue(cwd, `remote.${pushRemoteName}.push`, context), - getGitConfigValue(cwd, `remote.${pushRemoteName}.url`, context), + resolveGitRemoteUrl({ cwd, remote: pushRemoteName, context }), knownOriginRemoteUrl === null ? getGitConfigValue(cwd, "remote.origin.url", context) : null, knownResolvedBaseRef === null ? getResolvedBaseRefForCwd(cwd, context) : null, ]); @@ -2058,7 +2075,7 @@ export async function getCheckoutSnapshotFacts( getGitConfigValue(cwd, `branch.${inspected.currentBranch}.merge`, context), branchRemoteName === "origin" ? inspected.remoteUrl - : getGitConfigValue(cwd, `remote.${branchRemoteName}.url`, context), + : resolveGitRemoteUrl({ cwd, remote: branchRemoteName, context }), ]); } } From 0c5f472da1e22ea7227bc99769981674d6f7ab04 Mon Sep 17 00:00:00 2001 From: Matt Cowger Date: Mon, 14 Sep 2026 14:03:20 -0700 Subject: [PATCH 38/46] fix(plugins): expose server settings access (#4674) * fix(plugins): expose server settings access * fix(plugins): isolate settings subscriber state * test(plugins): read persisted settings before clients connect * test(app): reconnect seed client after old daemon restart * test(server): drain file creation events before deletion assertions * test(creation): expose errors when completion fails --------- Co-authored-by: Mohamed Boudra --- docs/plugins.md | 4 + .../owned-subscriptions-old-daemon.spec.ts | 1 + packages/plugin/src/server/contracts.ts | 19 ++- packages/plugin/src/server/index.ts | 2 + .../server/src/server/creation/index.test.ts | 2 +- .../src/server/file-observer/index.test.ts | 13 +- .../src/server/plugins/plugin-process.ts | 4 +- .../src/server/plugins/settings.e2e.test.ts | 122 +++++++++++++++++- .../src/server/plugins/settings/index.test.ts | 46 +++++++ .../src/server/plugins/settings/index.ts | 122 +++++++++++++----- public-docs/plugins/v0.8/providers.md | 5 + public-docs/plugins/v0.8/reference.md | 32 ++++- 12 files changed, 325 insertions(+), 47 deletions(-) diff --git a/docs/plugins.md b/docs/plugins.md index 6cf231932a..10c5b6413d 100644 --- a/docs/plugins.md +++ b/docs/plugins.md @@ -477,6 +477,10 @@ Its writer lives with the plugin subprocess, while its directory lives outside m so updates and reloads retain values. Settings-change notifications must not enter the catalog reload path: that path disposes the plugin and would destroy open drafts after every save. +`server.registerSettings(definition)` returns a server-side handle. Use `read()` for the current +`ready` or `invalid` state and `subscribe()` for successful saves, resets, and migrations. The +subscription cleanup belongs in the plugin's contribution cleanup when it outlives the entry. + ## Contribute a theme `addTheme` takes a small light or dark palette and a display name. Paseo expands it through the diff --git a/packages/app/e2e/browser/owned-subscriptions-old-daemon.spec.ts b/packages/app/e2e/browser/owned-subscriptions-old-daemon.spec.ts index 5040cf9f46..17f4db2e85 100644 --- a/packages/app/e2e/browser/owned-subscriptions-old-daemon.spec.ts +++ b/packages/app/e2e/browser/owned-subscriptions-old-daemon.spec.ts @@ -113,6 +113,7 @@ test("does not repeat an assistant block when the current app paginates a publis page, "compatibility-after-reconnect: emit 1 coalesced agent stream updates", ); + // The browser and the seed client reconnect independently after the restart. await agent.client.connect(); await agent.client.waitForFinish(agent.agentId, 20_000); await page.screenshot({ path: test.info().outputPath("old-daemon-live-reconnect.png") }); diff --git a/packages/plugin/src/server/contracts.ts b/packages/plugin/src/server/contracts.ts index a296c28b1e..c1cf10fc15 100644 --- a/packages/plugin/src/server/contracts.ts +++ b/packages/plugin/src/server/contracts.ts @@ -9,10 +9,27 @@ export interface PluginHandlerContext { paseo: PaseoApi; } +export type PluginSettingsState = + | { + status: "ready"; + revision: string; + values: ZodOutput; + } + | { + status: "invalid"; + revision: string; + error: string; + }; + +export interface PluginSettings { + read(): Promise>; + subscribe(listener: (state: PluginSettingsState) => void | Promise): PluginCleanup; +} + export interface PluginServerContext extends PluginLifecycleRegistration { registerSettings( definition: import("../settings.js").SettingsDefinition, - ): void; + ): PluginSettings; handle( contract: PluginRpcContract, handler: ( diff --git a/packages/plugin/src/server/index.ts b/packages/plugin/src/server/index.ts index c673f25303..9e1d78b8bb 100644 --- a/packages/plugin/src/server/index.ts +++ b/packages/plugin/src/server/index.ts @@ -2,6 +2,8 @@ export type { PluginHandlerContext, PluginServerContext, PluginServerContribution, + PluginSettings, + PluginSettingsState, } from "./contracts.js"; export type { PluginHookContext, diff --git a/packages/server/src/server/creation/index.test.ts b/packages/server/src/server/creation/index.test.ts index f4f0205225..1eaf4a7fd9 100644 --- a/packages/server/src/server/creation/index.test.ts +++ b/packages/server/src/server/creation/index.test.ts @@ -113,7 +113,7 @@ test("workspace readiness is observable before provider startup and duplicate re expect(f.updates[0]).toMatchObject({ workspaceId: workspace.id, agentId: agent.id }); const duplicate = f.service.create(f.input); f.provider.resolve(); - expect(await first).toMatchObject({ phase: "completed", workspace, agent }); + expect(await first).toMatchObject({ phase: "completed", error: null, workspace, agent }); expect(await duplicate).toEqual(await first); expect(f.calls).toEqual(["workspace", "agent", "prompt"]); expect(await new CreationService(f.directory, silentLogger).create(f.input)).toEqual(await first); diff --git a/packages/server/src/server/file-observer/index.test.ts b/packages/server/src/server/file-observer/index.test.ts index 0427cd823b..455a75b806 100644 --- a/packages/server/src/server/file-observer/index.test.ts +++ b/packages/server/src/server/file-observer/index.test.ts @@ -213,7 +213,8 @@ test("observes a thousand concurrent writes and remains healthy after delete and const directories = Array.from({ length: 20 }, (_, index) => join(root, `dir-${index}`)); await Promise.all(directories.map((directory) => mkdir(directory))); const observed = new Map>(); - const subscription = await subscribeToFileChanges(root, (error, events) => { + const observer = createObserver(); + const subscription = await observer.subscribe(root, (error, events) => { expect(error).toBeNull(); for (const event of events) { const types = observed.get(event.path) ?? new Set(); @@ -240,6 +241,16 @@ test("observes a thousand concurrent writes and remains healthy after delete and .poll(() => paths.filter((path) => !observed.has(path)), { timeout: 60_000 }) .toEqual([]); + // Native notifications arrive before reconciliation finishes. Drain that work + // so pending creates cannot coalesce with this test's separate deletion phase. + await expect + .poll(() => observer.getDiagnostics(), { timeout: 10_000 }) + .toMatchObject({ + pendingEventCount: 0, + pendingReconciliationWorkCount: 0, + reconciliationInFlightCount: 0, + }); + const removedPaths = paths.slice(0, 100); await Promise.all(removedPaths.map((path) => rm(path))); await expect diff --git a/packages/server/src/server/plugins/plugin-process.ts b/packages/server/src/server/plugins/plugin-process.ts index ee8a598a64..29523a7c00 100644 --- a/packages/server/src/server/plugins/plugin-process.ts +++ b/packages/server/src/server/plugins/plugin-process.ts @@ -10,6 +10,7 @@ import * as pluginProviderRuntime from "@getpaseo/plugin/server/provider"; import * as pluginAcpRuntime from "@getpaseo/plugin/server/acp"; import type { SettingsDefinition, PluginRpcContract } from "@getpaseo/plugin"; import type { PluginHandlerContext } from "@getpaseo/plugin/server"; +import type { ZodType } from "zod"; import { ProviderEventSchema, type ProviderConnection, @@ -23,7 +24,7 @@ import { createPluginClientId } from "./plugin-session-identity.js"; import { PluginSettingsStore } from "./settings/index.js"; let settingsStore: PluginSettingsStore | null = null; -function registerSettings(definition: SettingsDefinition) { +function registerSettings(definition: SettingsDefinition) { if (!settingsStore) throw new Error("Plugin settings storage is unavailable"); const handlers = settingsStore.register(definition); register(handlers.read.contract, handlers.read.handle); @@ -33,6 +34,7 @@ function registerSettings(definition: SettingsDefinition) { register(handlers.reset.contract, (input) => handlers.reset.handle(handlers.reset.contract.input.parse(input)), ); + return handlers.settings; } type RpcHandler = (input: unknown, context: PluginHandlerContext) => unknown | Promise; diff --git a/packages/server/src/server/plugins/settings.e2e.test.ts b/packages/server/src/server/plugins/settings.e2e.test.ts index 5828e84beb..a6ccf3f2cc 100644 --- a/packages/server/src/server/plugins/settings.e2e.test.ts +++ b/packages/server/src/server/plugins/settings.e2e.test.ts @@ -1,19 +1,102 @@ import { resolveDaemonVersion } from "../daemon-version.js"; -import { mkdtemp, rm, writeFile } from "node:fs/promises"; +import { mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; import path from "node:path"; -import { expect, test } from "vitest"; +import { expect, onTestFinished, test } from "vitest"; import { z } from "zod"; -import { settingsRpc } from "@getpaseo/plugin"; +import { defineRpc, settingsRpc } from "@getpaseo/plugin"; import { DaemonClient } from "../test-utils/daemon-client.js"; import { createTestPaseoDaemon } from "../test-utils/paseo-daemon.js"; +test("server reads saved settings after daemon restart before any client connects", async () => { + const root = await mkdtemp(path.join(tmpdir(), "settings-restart-")); + onTestFinished(() => rm(root, { recursive: true, force: true })); + const directory = path.join(root, "plugin"); + const startupReport = path.join(root, "startup.json"); + await mkdir(path.join(directory, "server"), { recursive: true }); + await writeFile( + path.join(directory, "paseo-plugin.json"), + JSON.stringify({ + id: "settings-startup", + requirements: { paseo: `>=${resolveDaemonVersion(import.meta.url)}` }, + }), + ); + await writeFile( + path.join(directory, "server", "report.ts"), + `import { writeFile } from "node:fs/promises"; +export async function reportStartup(settings) { + const state = await settings.read(); + await writeFile(${JSON.stringify(startupReport)}, JSON.stringify(state)); +}`, + ); + await writeFile( + path.join(directory, "index.server.ts"), + `import { defineSettings } from "@getpaseo/plugin"; +import { z } from "zod"; +import { reportStartup } from "./server/report"; +export default function(server) { + const settings = server.registerSettings(defineSettings({ id: "display", scope: "host", version: 1, schema: z.object({ enabled: z.boolean().default(true) }) })); + const startup = reportStartup(settings); + return async () => { await startup; }; +}`, + ); + const options = { + paseoHomeRoot: path.join(root, "daemon"), + staticDir: path.join(root, "static"), + cleanup: false, + pluginsEnabled: true, + plugins: { + "settings-startup": { source: "directory" as const, path: directory, enabled: true }, + }, + }; + const first = await createTestPaseoDaemon(options); + onTestFinished(() => first.close()); + const rpc = settingsRpc("display"); + const readStartup = async () => + rpc.read.output.parse(JSON.parse(await readFile(startupReport, "utf8"))); + await expect + .poll(readStartup) + .toEqual({ status: "ready", revision: "missing", values: { enabled: true } }); + + const client = new DaemonClient({ url: `ws://127.0.0.1:${first.port}/ws`, appVersion: "0.8.0" }); + onTestFinished(() => client.close()); + await client.connect(); + const saved = rpc.write.output.parse( + await client.invokePluginRpc("settings-startup", rpc.write.name, { + revision: "missing", + values: { enabled: false }, + }), + ); + expect(saved).toMatchObject({ status: "saved", values: { enabled: false } }); + const persisted = rpc.read.output.parse( + await client.invokePluginRpc("settings-startup", rpc.read.name, {}), + ); + await client.close(); + await first.close(); + await rm(startupReport); + + const restarted = await createTestPaseoDaemon(options); + onTestFinished(() => restarted.close()); + // No client is created for this daemon: the report comes from server startup alone. + await expect.poll(readStartup).toEqual(persisted); +}, 60_000); + test("two clients share settings, observe changes, and preserve values through plugin lifecycle", async () => { const directory = await mkdtemp(path.join(tmpdir(), "settings-plugin-")); const daemon = await createTestPaseoDaemon(); const first = new DaemonClient({ url: `ws://127.0.0.1:${daemon.port}/ws`, appVersion: "0.7.2" }); const second = new DaemonClient({ url: `ws://127.0.0.1:${daemon.port}/ws`, appVersion: "0.7.2" }); const rpc = settingsRpc("display"); + const serverReadRpc = defineRpc({ + name: "settings-test.server-read", + input: z.object({}), + output: z.json(), + }); + const serverChangesRpc = defineRpc({ + name: "settings-test.server-changes", + input: z.object({}), + output: z.object({ count: z.number().int() }), + }); const read = async (client: DaemonClient, pluginId = "settings-test") => rpc.read.output.parse(await client.invokePluginRpc(pluginId, rpc.read.name, {})); const changed: string[] = []; @@ -27,9 +110,18 @@ test("two clients share settings, observe changes, and preserve values through p ); await writeFile( path.join(directory, "index.server.ts"), - `import { defineSettings } from "@getpaseo/plugin"; + `import { defineRpc, defineSettings } from "@getpaseo/plugin"; import { z } from "zod"; -export default function(server) { server.registerSettings(defineSettings({ id: "display", scope: "host", version: 1, schema: z.object({ enabled: z.boolean().default(true) }) })); return () => {}; }`, +const serverRead = defineRpc({ name: "settings-test.server-read", input: z.object({}), output: z.json() }); +const serverChanges = defineRpc({ name: "settings-test.server-changes", input: z.object({}), output: z.object({ count: z.number().int() }) }); +export default function(server) { + const settings = server.registerSettings(defineSettings({ id: "display", scope: "host", version: 1, schema: z.object({ enabled: z.boolean().default(true) }) })); + let changes = 0; + settings.subscribe(() => { changes += 1; }); + server.handle(serverRead, () => settings.read()); + server.handle(serverChanges, () => ({ count: changes })); + return () => {}; +}`, ); await first.connect(); await second.connect(); @@ -42,6 +134,16 @@ export default function(server) { server.registerSettings(defineSettings({ id: " await first.installDirectoryPlugin(directory); const initial = await read(first); expect(initial).toMatchObject({ status: "ready", values: { enabled: true } }); + expect( + serverReadRpc.output.parse( + await first.invokePluginRpc("settings-test", serverReadRpc.name, {}), + ), + ).toMatchObject({ status: "ready", values: { enabled: true } }); + expect( + serverChangesRpc.output.parse( + await first.invokePluginRpc("settings-test", serverChangesRpc.name, {}), + ), + ).toEqual({ count: 0 }); expect( await second.invokePluginRpc("settings-test", rpc.write.name, { revision: initial.revision, @@ -50,6 +152,16 @@ export default function(server) { server.registerSettings(defineSettings({ id: " ).toMatchObject({ status: "saved" }); await expect.poll(() => changed).toEqual(["display"]); expect(await read(first)).toMatchObject({ values: { enabled: false } }); + expect( + serverReadRpc.output.parse( + await first.invokePluginRpc("settings-test", serverReadRpc.name, {}), + ), + ).toMatchObject({ status: "ready", values: { enabled: false } }); + expect( + serverChangesRpc.output.parse( + await first.invokePluginRpc("settings-test", serverChangesRpc.name, {}), + ), + ).toEqual({ count: 1 }); expect( await first.invokePluginRpc("settings-test", rpc.write.name, { revision: initial.revision, diff --git a/packages/server/src/server/plugins/settings/index.test.ts b/packages/server/src/server/plugins/settings/index.test.ts index c367c93be3..de81919eae 100644 --- a/packages/server/src/server/plugins/settings/index.test.ts +++ b/packages/server/src/server/plugins/settings/index.test.ts @@ -3,6 +3,7 @@ import { tmpdir } from "node:os"; import path from "node:path"; import { afterEach, expect, test } from "vitest"; import { defineSettings } from "@getpaseo/plugin"; +import type { PluginSettingsState } from "@getpaseo/plugin/server"; import { z } from "zod"; import { PluginSettingsStore } from "./index.js"; @@ -19,6 +20,10 @@ const definition = defineSettings({ count: z.number().int().min(1).default(5), }), }); +type DisplaySettingsState = PluginSettingsState; +function mutateNotification(state: DisplaySettingsState): void { + if (state.status === "ready") state.values.count = 99; +} async function setup() { const directory = await mkdtemp(path.join(tmpdir(), "plugin-settings-")); roots.push(directory); @@ -47,6 +52,47 @@ test("defaults, atomic saves, concurrent revisions, and restart persistence", as expect(changes).toEqual(["display"]); }); +test("server settings read current values and notify after successful writes", async () => { + const { handlers } = await setup(); + const notifications: unknown[] = []; + const unsubscribe = handlers.settings.subscribe((state) => { + notifications.push(state); + mutateNotification(state); + }); + + expect(await handlers.settings.read()).toEqual({ + status: "ready", + revision: "missing", + values: { enabled: true, count: 5 }, + }); + const saved = await handlers.write.handle({ + revision: "missing", + values: { enabled: false, count: 10 }, + }); + expect(saved).toMatchObject({ status: "saved", values: { enabled: false, count: 10 } }); + const current = await handlers.settings.read(); + expect(current).toMatchObject({ + status: "ready", + values: { enabled: false, count: 10 }, + }); + expect(notifications).toEqual([ + { + status: "ready", + revision: current.revision, + values: { enabled: false, count: 99 }, + }, + ]); + + expect( + await handlers.write.handle({ revision: current.revision, values: { count: -1 } }), + ).toMatchObject({ status: "invalid" }); + expect(notifications).toHaveLength(1); + + unsubscribe(); + await handlers.reset.handle({ revision: current.revision }); + expect(notifications).toHaveLength(1); +}); + test("validation failure preserves disk and leaves the writer usable", async () => { const { handlers, changes } = await setup(); expect(await handlers.write.handle({ revision: "missing", values: { count: -1 } })).toMatchObject( diff --git a/packages/server/src/server/plugins/settings/index.ts b/packages/server/src/server/plugins/settings/index.ts index ccdd3e5dd6..775a47f6a4 100644 --- a/packages/server/src/server/plugins/settings/index.ts +++ b/packages/server/src/server/plugins/settings/index.ts @@ -1,9 +1,9 @@ import { createHash, randomUUID } from "node:crypto"; import { mkdir, readFile, rename, rm, writeFile } from "node:fs/promises"; import path from "node:path"; -import { z } from "zod"; -import { defineSettings, type SettingsDefinition } from "@getpaseo/plugin"; -import { settingsRpc } from "@getpaseo/plugin"; +import { z, type ZodType } from "zod"; +import { defineSettings, settingsRpc, type SettingsDefinition } from "@getpaseo/plugin"; +import type { PluginSettings, PluginSettingsState } from "@getpaseo/plugin/server"; const envelopeSchema = z.object({ version: z.number().int().positive(), values: z.json() }); function message(error: unknown): string { @@ -12,6 +12,25 @@ function message(error: unknown): string { } const revisionOf = (raw: string) => createHash("sha256").update(raw).digest("hex"); +type SettingsListener = ( + state: PluginSettingsState, +) => void | Promise; +type SettingsWriteState = + | { status: "saved"; revision: string; values: z.output } + | { status: "conflict"; error: string } + | { status: "invalid"; error: string }; + +function reportListenerError(id: string, error: unknown): void { + console.error(`Plugin settings subscriber failed for ${id}`, error); +} + +function copySettingsState( + state: PluginSettingsState, +): PluginSettingsState { + if (state.status === "invalid") return { ...state }; + return { ...state, values: structuredClone(state.values) }; +} + /** One instance per installation. The subprocess lifetime gives writes a single owner. */ export class PluginSettingsStore { private readonly definitions = new Map(); @@ -24,24 +43,43 @@ export class PluginSettingsStore { this.changed = changed; } - register(definition: SettingsDefinition) { + register(definition: SettingsDefinition) { defineSettings(definition); if (this.definitions.has(definition.id)) throw new Error(`Duplicate settings: ${definition.id}`); this.definitions.set(definition.id, definition); + const listeners = new Set>(); + const notify = (state: PluginSettingsState): void => { + for (const listener of listeners) { + try { + void Promise.resolve(listener(copySettingsState(state))).catch((error) => + reportListenerError(definition.id, error), + ); + } catch (error) { + reportListenerError(definition.id, error); + } + } + }; const rpc = settingsRpc(definition.id); + const read = () => this.serial(() => this.read(definition, notify)); + const write = (input: z.output) => + this.serial(() => this.write(definition, input.revision, input.values, "save", notify)); + const reset = (input: z.output) => + this.serial(() => this.write(definition, input.revision, {}, "reset", notify)); + const settings: PluginSettings = { + read, + subscribe(listener) { + listeners.add(listener); + return () => { + listeners.delete(listener); + }; + }, + }; return { - read: { contract: rpc.read, handle: () => this.serial(() => this.read(definition)) }, - write: { - contract: rpc.write, - handle: (input: z.output) => - this.serial(() => this.write(definition, input.revision, input.values, "save")), - }, - reset: { - contract: rpc.reset, - handle: (input: z.output) => - this.serial(() => this.write(definition, input.revision, {}, "reset")), - }, + settings, + read: { contract: rpc.read, handle: read }, + write: { contract: rpc.write, handle: write }, + reset: { contract: rpc.reset, handle: reset }, }; } @@ -62,9 +100,10 @@ export class PluginSettingsStore { } } - private async read( - definition: SettingsDefinition, - ): Promise["read"]["output"]>> { + private async read( + definition: SettingsDefinition, + notify: (state: PluginSettingsState) => void, + ): Promise> { const stored = await this.stored(definition.id); try { const envelope = stored.raw === null ? null : envelopeSchema.parse(JSON.parse(stored.raw)); @@ -76,58 +115,71 @@ export class PluginSettingsStore { throw new Error(`Settings version ${envelope.version} requires a migration`); values = await definition.migrate(values, envelope.version); } - const parsed = z.json().parse(await definition.schema.parseAsync(values)); - const revision = - envelope && envelope.version !== definition.version - ? await this.persist(definition, parsed) - : stored.revision; - return { status: "ready", values: parsed, revision }; + const parsed = await definition.schema.parseAsync(values); + z.json().parse(parsed); + const migrated = envelope !== null && envelope.version !== definition.version; + const revision = migrated ? await this.persist(definition, parsed) : stored.revision; + const result: PluginSettingsState = { + status: "ready", + values: parsed, + revision, + }; + if (migrated) { + notify(result); + this.changed(definition.id); + } + return result; } catch (error) { return { status: "invalid", revision: stored.revision, error: message(error) }; } } - private async write( - definition: SettingsDefinition, + private async write( + definition: SettingsDefinition, revision: string, values: unknown, intent: "save" | "reset", - ): Promise["write"]["output"]>> { + notify: (state: PluginSettingsState) => void, + ): Promise> { const stored = await this.stored(definition.id); if (stored.revision !== revision) return { status: "conflict", error: "Settings changed on another client. Reload before saving again.", }; - let parsed: z.output>; + let parsed: z.output; try { if (intent === "save" && stored.raw !== null) { const envelope = envelopeSchema.parse(JSON.parse(stored.raw)); if (envelope.version !== definition.version) throw new Error("Reload or reset settings before saving a different schema version"); } - parsed = z.json().parse(await definition.schema.parseAsync(values)); + parsed = await definition.schema.parseAsync(values); + z.json().parse(parsed); } catch (error) { return { status: "invalid", error: message(error) }; } - return { status: "saved", values: parsed, revision: await this.persist(definition, parsed) }; + const nextRevision = await this.persist(definition, parsed); + notify({ status: "ready", values: parsed, revision: nextRevision }); + this.changed(definition.id); + return { status: "saved", values: parsed, revision: nextRevision }; } - private async persist( - definition: SettingsDefinition, - values: z.output>, + private async persist( + definition: SettingsDefinition, + values: z.output, ) { + const jsonValues = z.json().parse(values); await mkdir(this.directory, { recursive: true }); const target = path.join(this.directory, `${definition.id}.json`); const temporary = `${target}.${randomUUID()}.tmp`; - const raw = JSON.stringify({ version: definition.version, values }); + const raw = JSON.stringify({ version: definition.version, values: jsonValues }); try { await writeFile(temporary, raw, { mode: 0o600 }); await rename(temporary, target); } finally { await rm(temporary, { force: true }); } - this.changed(definition.id); return revisionOf(raw); } } diff --git a/public-docs/plugins/v0.8/providers.md b/public-docs/plugins/v0.8/providers.md index 71eaa08dce..744366ade6 100644 --- a/public-docs/plugins/v0.8/providers.md +++ b/public-docs/plugins/v0.8/providers.md @@ -117,6 +117,11 @@ later through `session.config`. prompt, MCP servers, tool policy, model, mode, settings, opaque provider options, and persistence preference. +For persisted host-scoped plugin settings, use the handle returned by `server.registerSettings()`. +Read it in catalog and session callbacks, and subscribe when the provider keeps in-memory +configuration. See [settings](./reference.md#persisted-values) for the `read()` and `subscribe()` +contract. + Create the native session with the selected catalog values, then publish its effective state: ```ts diff --git a/public-docs/plugins/v0.8/reference.md b/public-docs/plugins/v0.8/reference.md index b8049c27d0..2dd5bd3cdd 100644 --- a/public-docs/plugins/v0.8/reference.md +++ b/public-docs/plugins/v0.8/reference.md @@ -1044,9 +1044,22 @@ export const preferences = defineSettings({ }); ``` -Register it with `server.registerSettings(preferences)` in `index.server.ts` before returning -cleanup. This server entry is required for built-in persistence; a screen using its own data -can remain client-only. +Register it in `index.server.ts` before returning cleanup. The returned handle lets server code +read the document and react to changes. A screen using its own data can remain client-only. + +```ts +export default function contribute(server: PluginServerContext) { + const settings = server.registerSettings(preferences); + + settings.subscribe((next) => { + if (next.status === "ready") { + console.log("Settings changed", next.revision); + } + }); + + return () => {}; +} +``` | Definition field | Contract | | ------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------ | @@ -1065,6 +1078,19 @@ Call `useSettings(preferences)` in any contributed component. It returns a discr | `invalid` | `error` and `revision`; stored data is preserved. | | `error` | `error` from the read/connection. | +The server handle exposes `read()` and `subscribe()`. `read()` returns the same `ready` or +`invalid` state as the client hook, including the opaque revision. `subscribe()` returns a cleanup +function and receives a new `ready` state after a successful save, reset, or migration. Invalid +writes and revision conflicts do not notify subscribers. Listener failures are logged without +turning a committed write into a failed save. + +```ts +const current = await settings.read(); +if (current.status === "ready") { + // Use current.values and current.revision. +} +``` + Every state also exposes `saving`, `saveError`, and these actions: - `save(values, revision): Promise` validates and saves a complete document. Returns From 0aca3b605a6f3dc779428d8c9aa394287a1381f6 Mon Sep 17 00:00:00 2001 From: Matt Cowger Date: Mon, 14 Sep 2026 14:03:38 -0700 Subject: [PATCH 39/46] fix(plugins): transform whole timeline items before grouping and Markdown splitting (#4675) Send every original tool call and each whole accumulated assistant message through plugin transformers before display grouping or Markdown splitting. Preserve streaming callbacks, original payloads, native Markdown block identities, and chat outline reading position. Reject legacy fragment caches and retain failing-first plugin-interface and browser regressions. Fixes #4673. Fixes #4789. Consolidates #4820. Co-authored-by: Matt Cowger Co-authored-by: Jegor Kitskerkin Co-authored-by: Mohamed Boudra --- docs/agent-stream-performance.md | 6 +- docs/plugins.md | 6 +- packages/app/e2e/browser/chat-outline.spec.ts | 27 ++ .../app/e2e/browser/plugin-timeline.spec.ts | 35 ++ .../app/e2e/support/helpers/chat-outline.ts | 35 ++ .../e2e/support/helpers/plugin-timeline.ts | 144 +++++++ .../support/helpers/timeline-pagination.ts | 11 +- .../chat-outline/use-chat-outline.ts | 23 +- .../app/src/agent-stream/presentation.test.ts | 351 ++++++++++++++++++ packages/app/src/agent-stream/presentation.ts | 138 +++++++ packages/app/src/agent-stream/view.tsx | 60 ++- .../app/src/plugins/timeline/projection.ts | 19 +- .../src/runtime/replica-cache/index.test.ts | 25 +- .../app/src/runtime/replica-cache/index.ts | 7 +- .../timeline/session-stream-reducers.test.ts | 55 ++- .../src/timeline/session-stream-reducers.ts | 46 +-- packages/app/src/types/stream.test.ts | 18 +- packages/app/src/types/stream.ts | 95 +---- public-docs/plugins/v0.8/reference.md | 7 +- 19 files changed, 865 insertions(+), 243 deletions(-) create mode 100644 packages/app/e2e/browser/plugin-timeline.spec.ts create mode 100644 packages/app/e2e/support/helpers/plugin-timeline.ts create mode 100644 packages/app/src/agent-stream/presentation.test.ts create mode 100644 packages/app/src/agent-stream/presentation.ts diff --git a/docs/agent-stream-performance.md b/docs/agent-stream-performance.md index 0d2fd30fa6..19d84d2f50 100644 --- a/docs/agent-stream-performance.md +++ b/docs/agent-stream-performance.md @@ -12,7 +12,8 @@ provider deltas (every provider streams incrementally) → recordTimeline: one canonical row per flushed item → agent_stream ws message → reducer queue (app, one commit per frame) → session store - → paced reveal (app, per assistant/reasoning item) → markdown blocks → paint + → source-item plugin transforms → native Markdown blocks / tool grouping + → paced reveal (app, per displayed item) → paint ``` Every provider delivers incremental text, so there is no provider that needs special handling: Claude via `includePartialMessages`, Codex via `agent_message_delta`, ACP agents via `agent_message_chunk`, Pi and OMP via `text_delta`. @@ -28,6 +29,9 @@ So arrival sets a _target_ and the reveal rate is derived from the backlog inste - **The coalescer is leading + trailing.** The first delta after an idle window flushes synchronously; only the rest of the burst waits for the trailing timer. Reverting to trailing-only adds a full window to the first character of every turn. Same shape and the same reason as `TerminalOutputCoalescer`. - **The leading flush adds a canonical row, and that is fine.** A burst's first chunk lands as its own timeline row. `mergeAssistantChunks` / `mergeReasoningChunks` in `timeline-projection.ts` join contiguous same-turn rows, and clients read the projected timeline, so history is unaffected. Tests that assert on raw rows have to account for the extra row; tests that assert on what a client sees do not. - **The store holds the full text; only the rendered slice is paced.** Copy, selection, the chat outline, and scroll geometry all read the same string the user can see. Pacing the store instead would leave the bottom anchor chasing a content height that is ahead of the reveal. +- **Markdown blocks belong to presentation.** `agent-stream/presentation.ts` retains completed native + blocks and parses only the growing last block during append. Splitting in the reducer discards + paragraph separators and exposes fragments to plugin callbacks, which need the whole source text. - **First sight of a text is revealed whole.** Only growth is paced. This is what makes history hydration, timeline replay, a virtualized row remounting on scroll, and an already-finished message all render complete on first paint without a special case for each. - **Leaving `phase: "streaming"` snaps the reveal.** A completed turn must never be left holding characters. `layoutStream` sets the phase, so anything outside the live head with an active turn is already complete. - **The reducer queue commits on a frame, with a timer as the ceiling.** A frame callback never fires in a hidden tab, so a timer races it and wins when nothing is painting — the store has to keep advancing either way. diff --git a/docs/plugins.md b/docs/plugins.md index 10c5b6413d..98ae45093f 100644 --- a/docs/plugins.md +++ b/docs/plugins.md @@ -371,8 +371,10 @@ work inside it. See the maintained [composer pill example](../plugin-examples/lo Timeline transformers and renderers are client contributions. The daemon's canonical rows and built-in projection stay unchanged. The app transforms each source item while building the render -model, for both fetched history and live events. The input includes `phase: "streaming" | "complete"`. -Paseo memoizes by source-item reference and derives every replacement ID from the source identity, so +model, for both fetched history and live events, before native Markdown splitting and Overview +tool grouping. Assistant callbacks receive the accumulated source text, never display fragments. +Live assistant messages use `phase: "streaming"`; committing to history makes them `"complete"`. +Paseo memoizes by source-item reference and phase and derives replacement IDs from source identity, so streaming updates preserve mounted component identity. `query.itemType` selects one public `AgentTimelineItem.type`. The callback owns any detailed diff --git a/packages/app/e2e/browser/chat-outline.spec.ts b/packages/app/e2e/browser/chat-outline.spec.ts index 8e4532ee4f..bc58e46ab5 100644 --- a/packages/app/e2e/browser/chat-outline.spec.ts +++ b/packages/app/e2e/browser/chat-outline.spec.ts @@ -20,6 +20,8 @@ import { pointAtChatOutlineRowEdge, pressEnterOnFocusedPrompt, splitCurrentPanelRight, + withStreamingMarkdownOutline, + expectReadingStreamedMarkdown, } from "../support/helpers/chat-outline"; import { expectTimelineAtMaximumScrollWithPromptVisible, @@ -41,6 +43,31 @@ const WIDE_VIEWPORT = { width: 1440, height: 900 }; const LOADED_TURNS = 16; test.describe("desktop chat outline", () => { + test("keeps the prompt marked while reading split Markdown blocks and after completion", async ({ + page, + }) => { + test.setTimeout(120_000); + const prompt = "Explain in several paragraphs."; + await withStreamingMarkdownOutline(async (agent) => { + await agent.client.sendAgentMessage(agent.agentId, "Earlier prompt."); + await agent.client.waitForFinish(agent.agentId, 30_000); + await page.setViewportSize(WIDE_VIEWPORT); + await openAgentTimeline(page, agent); + await agent.client.sendAgentMessage(agent.agentId, prompt); + await expectChatOutlinePrompts(page, 2); + + await expectReadingStreamedMarkdown(page, prompt); + await expectActiveChatOutlinePrompt(page, 2); + await agent.client.waitForFinish(agent.agentId, 30_000); + await expectActiveChatOutlinePrompt(page, 2); + + await page.reload({ waitUntil: "domcontentloaded" }); + await expectActiveChatOutlinePrompt(page, 2); + await clickChatOutlineRowEdge(page, 2); + await expectTimelinePromptLandedBelowTop(page, prompt); + }); + }); + test("indexes unloaded prompts and jumps with one bounded merged page", async ({ page }) => { test.setTimeout(120_000); const agent = await seedLongMockAgentTimeline({ turns: 80 }); diff --git a/packages/app/e2e/browser/plugin-timeline.spec.ts b/packages/app/e2e/browser/plugin-timeline.spec.ts new file mode 100644 index 0000000000..a344611a4f --- /dev/null +++ b/packages/app/e2e/browser/plugin-timeline.spec.ts @@ -0,0 +1,35 @@ +import { test } from "../support/fixtures"; +import { + withTimelinePlugin, + requestPluginTimeline, + interactWithStreamingCard, + expectWholeCompletedCard, + expectBothConsecutiveTools, +} from "../support/helpers/plugin-timeline"; + +for (const width of [1100, 390]) { + test(`assistant plugin receives the whole streaming message at width ${width}`, async ({ + page, + }, info) => { + await page.setViewportSize({ width, height: 900 }); + await withTimelinePlugin(page, info, "assistant", async (agent) => { + await requestPluginTimeline(agent); + await interactWithStreamingCard(page); + await agent.client.waitForFinish(agent.agentId, 30_000); + await expectWholeCompletedCard(page); + // Local state is checked during growth; the viewport remounts on the history handoff. + await page.reload({ waitUntil: "domcontentloaded" }); + await expectWholeCompletedCard(page); + }); + }); +} + +test("Overview preserves both consecutive tool plugin cards", async ({ page }, info) => { + await withTimelinePlugin(page, info, "tools", async (agent) => { + await requestPluginTimeline(agent); + await agent.client.waitForFinish(agent.agentId, 30_000); + await expectBothConsecutiveTools(page); + await page.reload({ waitUntil: "domcontentloaded" }); + await expectBothConsecutiveTools(page); + }); +}); diff --git a/packages/app/e2e/support/helpers/chat-outline.ts b/packages/app/e2e/support/helpers/chat-outline.ts index b89f4e6e2a..8ace116ec7 100644 --- a/packages/app/e2e/support/helpers/chat-outline.ts +++ b/packages/app/e2e/support/helpers/chat-outline.ts @@ -2,6 +2,41 @@ import { expect, type Locator, type Page } from "@playwright/test"; import { openSettings } from "./app"; import { openSettingsSection } from "./settings"; import { runWorkspaceActionFromCommandCenter } from "./command-center-workspace-actions"; +import { seedMockAgentWorkspace, type MockAgentWorkspace } from "./mock-agent"; + +export async function withStreamingMarkdownOutline( + run: (agent: MockAgentWorkspace) => Promise, +): Promise { + const agent = await seedMockAgentWorkspace({ + repoPrefix: "chat-outline-markdown-", + title: "Streaming Markdown outline", + featureValues: { + mockStreamingAssistantResponse: Array.from( + { length: 60 }, + (_, index) => `Paragraph ${index + 1}.`, + ).join("\n\n"), + mockStreamingAssistantIntervalMs: 80, + }, + }); + try { + await run(agent); + } finally { + await agent.cleanup(); + } +} + +export async function expectReadingStreamedMarkdown(page: Page, prompt: string): Promise { + await expect(page.getByText("Paragraph 30.", { exact: true }).last()).toBeVisible(); + const timeline = page.locator('[data-testid="agent-chat-scroll"]:visible').first(); + const promptRow = timeline.getByTestId("user-message").filter({ hasText: prompt }); + await expect + .poll(async () => { + const [viewport, row] = await Promise.all([timeline.boundingBox(), promptRow.boundingBox()]); + return viewport && row ? row.y + row.height - viewport.y : Number.POSITIVE_INFINITY; + }) + .toBeLessThan(0); + await expect(page.getByRole("button", { name: "Stop agent", exact: true })).toBeVisible(); +} export function chatOutlineRail(page: Page): Locator { return page.getByTestId("chat-outline-rail"); diff --git a/packages/app/e2e/support/helpers/plugin-timeline.ts b/packages/app/e2e/support/helpers/plugin-timeline.ts new file mode 100644 index 0000000000..d6656f3d3a --- /dev/null +++ b/packages/app/e2e/support/helpers/plugin-timeline.ts @@ -0,0 +1,144 @@ +import { mkdtemp, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import { expect, type Page, type TestInfo } from "@playwright/test"; +import { pluginRequirements } from "./plugin-fixture"; +import { connectNewWorkspaceDaemonClient } from "./new-workspace"; +import { openAgentRoute, seedMockAgentWorkspace, type MockAgentWorkspace } from "./mock-agent"; + +const PLUGIN_ID = "timeline-transform-e2e"; +export const ASSISTANT_TEXT = + "Skill header\n\n# Instructions\n\nRead every paragraph and preserve the full message while this response is still streaming."; + +const CLIENT_SOURCE = `import React, { useState } from "react"; +import { Text, View, Pressable } from "react-native"; +import { z } from "zod"; + +function Card({ item }) { + const [count, setCount] = useState(0); + return + {item.data.label + " " + item.data.phase} + {item.data.text} + setCount(count + 1)}> + {"Card clicks " + count} + + ; +} + +export default function contribute(client) { + for (const itemType of ["assistant_message", "tool_call"]) { + client.addTimelineTransformer({ + id: itemType.replaceAll("_", "-"), + query: { itemType }, + transform({ item, phase }) { + return { items: [{ type: "plugin", kind: "card", version: 1, data: { + label: item.type === "tool_call" ? "Tool " + item.name : "Assistant", + text: item.type === "tool_call" ? item.callId : item.text, + phase, + } }] }; + }, + }); + } + client.addTimelineRenderer({ + kind: "card", version: 1, + schema: z.object({ label: z.string(), text: z.string(), phase: z.enum(["streaming", "complete"]) }), + Component: Card, + }); + return () => {}; +}`; + +export async function withTimelinePlugin( + page: Page, + info: TestInfo, + mode: "assistant" | "tools", + run: (agent: MockAgentWorkspace) => Promise, +): Promise { + info.setTimeout(120_000); + await page.addInitScript(() => { + localStorage.setItem( + "@paseo:app-settings", + JSON.stringify({ toolCallDetailLevel: "overview" }), + ); + }); + const directory = await mkdtemp(path.join(tmpdir(), "paseo-timeline-plugin-")); + const agent = await seedMockAgentWorkspace({ + repoPrefix: "timeline-plugin-", + title: "Timeline plugin regression", + model: "ten-second-stream", + featureValues: + mode === "assistant" + ? { + mockStreamingAssistantResponse: ASSISTANT_TEXT, + mockStreamingAssistantIntervalMs: 300, + } + : {}, + }); + const pluginClient = await connectNewWorkspaceDaemonClient({ ownProjects: false }); + const previous = await pluginClient.getDaemonConfig(); + try { + await writeFile( + path.join(directory, "paseo-plugin.json"), + JSON.stringify({ id: PLUGIN_ID, requirements: pluginRequirements }), + ); + await writeFile(path.join(directory, "index.client.tsx"), CLIENT_SOURCE); + await pluginClient.patchDaemonConfig({ pluginsEnabled: true }); + await pluginClient.installDirectoryPlugin(directory); + await openAgentRoute(page, agent); + await expect(page.getByRole("textbox", { name: "Message agent..." }).first()).toBeVisible(); + await run(agent); + await info.attach("timeline-plugin", { + body: await page.screenshot({ path: info.outputPath("timeline-plugin.png") }), + contentType: "image/png", + }); + } catch (error) { + await info.attach("timeline-before-cleanup", { + body: await page.screenshot({ path: info.outputPath("timeline-before-cleanup.png") }), + contentType: "image/png", + }); + throw error; + } finally { + await pluginClient.removePlugin(PLUGIN_ID); + await pluginClient.patchDaemonConfig({ + pluginsEnabled: previous.config.pluginsEnabled ?? false, + }); + await pluginClient.close(); + await agent.cleanup(); + await rm(directory, { recursive: true, force: true }); + } +} + +export async function requestPluginTimeline(agent: MockAgentWorkspace): Promise { + await agent.client.sendAgentMessage(agent.agentId, "Exercise timeline transforms."); +} + +export async function interactWithStreamingCard(page: Page): Promise { + await expect(page.getByRole("heading", { name: "Assistant streaming", exact: true })).toHaveCount( + 1, + ); + await page.getByRole("button", { name: "Card clicks 0", exact: true }).click(); + await expect(page.getByText(/Skill header\s+# Instructions/)).toBeVisible(); + await expect(page.getByRole("heading", { name: "Assistant streaming", exact: true })).toHaveCount( + 1, + ); + await expect(page.getByRole("button", { name: "Card clicks 1", exact: true })).toBeVisible(); + await expect(page.getByTestId("assistant-message")).toHaveCount(0); +} + +export async function expectWholeCompletedCard(page: Page): Promise { + await expect(page.getByRole("heading", { name: "Assistant complete", exact: true })).toHaveCount( + 1, + ); + await expect(page.getByText(ASSISTANT_TEXT, { exact: true })).toBeVisible(); + await expect(page.getByTestId("assistant-message")).toHaveCount(0); +} + +export async function expectBothConsecutiveTools(page: Page): Promise { + // read and grep are consecutive source calls in the fixture. Overview used to + // send only the later call to the plugin, so the read card never existed. + const read = page.getByRole("heading", { name: "Tool read complete", exact: true }).first(); + await read.scrollIntoViewIfNeeded(); + await expect(read).toBeVisible(); + const grep = page.getByRole("heading", { name: "Tool grep complete", exact: true }).first(); + await grep.scrollIntoViewIfNeeded(); + await expect(grep).toBeVisible(); +} diff --git a/packages/app/e2e/support/helpers/timeline-pagination.ts b/packages/app/e2e/support/helpers/timeline-pagination.ts index 0cdd34857c..d6ca1b38e4 100644 --- a/packages/app/e2e/support/helpers/timeline-pagination.ts +++ b/packages/app/e2e/support/helpers/timeline-pagination.ts @@ -402,8 +402,15 @@ export async function expectStableHistoryStartGutter(page: Page): Promise export async function scrollTimelinePromptIntoView(page: Page, prompt: string): Promise { const timeline = page.locator('[data-testid="agent-chat-scroll"]:visible').first(); const row = timeline.getByTestId("user-message").filter({ hasText: prompt }); - await row.scrollIntoViewIfNeeded(); - await expect(row).toBeVisible(); + const [viewport, target] = await Promise.all([timeline.boundingBox(), row.boundingBox()]); + if (!viewport || !target) { + throw new Error(`Expected a rendered timeline prompt for ${prompt}`); + } + // Programmatic scrolling does not express the user's intent to stop following output. + await timeline.hover(); + await page.mouse.wheel(0, target.y - viewport.y - (viewport.height - target.height) / 2); + await expect(row).toBeInViewport({ ratio: 1 }); + await waitForTimelineGeometryToSettle(page); await expect .poll(async () => (await readTimelineViewport(page)).scrollTop) .toBeGreaterThan(HISTORY_START_THRESHOLD_PX); diff --git a/packages/app/src/agent-stream/chat-outline/use-chat-outline.ts b/packages/app/src/agent-stream/chat-outline/use-chat-outline.ts index 6d66ec4379..39938a4b9c 100644 --- a/packages/app/src/agent-stream/chat-outline/use-chat-outline.ts +++ b/packages/app/src/agent-stream/chat-outline/use-chat-outline.ts @@ -41,7 +41,7 @@ export interface ChatOutline { prompts: ChatOutlinePrompt[]; activePrompt: ActivePromptSource; jumpToPrompt: (seq: number) => void; - reportReadingPosition: (rowId: string | null) => void; + reportReadingPosition: (seq: number | null) => void; } export function useChatOutline({ @@ -59,7 +59,7 @@ export function useChatOutline({ const [index, setIndex] = useState(null); const [pendingJump, setPendingJump] = useState(null); const [activePrompt] = useState(createActivePromptPublisher); - const readingRowIdRef = useRef(null); + const readingSeqRef = useRef(null); const nextJumpRequestIdRef = useRef(0); const nextIndexRequestIdRef = useRef(0); const loadedItems = useMemo(() => [...tail, ...(head ?? NO_STREAM_ITEMS)], [head, tail]); @@ -107,26 +107,21 @@ export function useChatOutline({ }; }, [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. + // The transcript resolves display rows (including Markdown blocks and plugin cards) to + // timeline positions. The outline uses the complete index, including unloaded prompts. const publishActivePrompt = useStableEvent(() => { - const rowId = readingRowIdRef.current; - const anchorSeq = - rowId === null - ? null - : (loadedItems.find((item) => item.id === rowId)?.timelineCursor?.seq ?? null); - activePrompt.publish(resolveActivePromptSeq(prompts, anchorSeq)); + activePrompt.publish(resolveActivePromptSeq(prompts, readingSeqRef.current)); }); - const reportReadingPosition = useStableEvent((rowId: string | null) => { - readingRowIdRef.current = rowId; + const reportReadingPosition = useStableEvent((seq: number | null) => { + readingSeqRef.current = seq; publishActivePrompt(); }); useEffect(() => { nextJumpRequestIdRef.current += 1; setPendingJump(null); - readingRowIdRef.current = null; + readingSeqRef.current = null; activePrompt.publish(null); }, [activePrompt, agentId, timelineEpoch]); @@ -134,7 +129,7 @@ export function useChatOutline({ // who never scrolls would otherwise sit on an unmarked rail. useEffect(() => { publishActivePrompt(); - }, [loadedItems, prompts, publishActivePrompt]); + }, [prompts, publishActivePrompt]); useEffect(() => { if (pendingJump === null) return; diff --git a/packages/app/src/agent-stream/presentation.test.ts b/packages/app/src/agent-stream/presentation.test.ts new file mode 100644 index 0000000000..05df7180bf --- /dev/null +++ b/packages/app/src/agent-stream/presentation.test.ts @@ -0,0 +1,351 @@ +import { QueryClient } from "@tanstack/react-query"; +import { describe, expect, it } from "vitest"; +import type { AgentStreamEventPayload } from "@getpaseo/protocol/messages"; +import { runPluginClientBundle, type PluginClientRuntime } from "@/plugins/evaluate"; +import type { InstalledPlugin } from "@/plugins/types"; +import { + applyStreamEvent, + hydrateStreamState, + type StreamItem, + type ToolCallItem, +} from "@/types/stream"; +import { transformTimelineItem, type TimelineItemTransform } from "@/plugins/timeline/model"; +import { createStreamPresentation } from "./presentation"; + +const runtime = { + paseo: {}, + async rpc() {}, + openSettings() {}, + openSurface() {}, + openPanel() {}, + addHeaderButton() { + return { update() {}, remove() {} }; + }, + addComposerPill() { + return { update() {}, remove() {} }; + }, +} as unknown as PluginClientRuntime; + +function installProbe( + itemType: "assistant_message" | "tool_call" | "reasoning", + condition = "true", +): InstalledPlugin { + const clientBundle = `(function() { + return { default: function(client) { + client.addTimelineTransformer({ + id: "probe", + query: { itemType: ${JSON.stringify(itemType)} }, + transform: function({ item, phase }) { + if (!(${condition})) return undefined; + return { + items: [{ + type: "plugin", + kind: "probe", + version: 1, + data: item.type === "tool_call" + ? { callId: item.callId, name: item.name, status: item.status, + detail: item.detail, ...(item.metadata ? { metadata: item.metadata } : {}), error: item.error, phase: phase } + : { text: item.text, phase: phase } + }] + }; + } + }); + return function() {}; + } }; + })`; + const evaluated = runPluginClientBundle("probe-plugin", clientBundle, runtime); + return { + ...evaluated, + serverId: "host-1", + clientBundle, + lifetime: new AbortController(), + queryClient: new QueryClient(), + }; +} + +function installedTransform(plugin: InstalledPlugin): TimelineItemTransform { + return (input) => transformTimelineItem({ ...input, plugins: [plugin] }); +} + +function toolCall(callId: string, name: string): ToolCallItem { + return { + kind: "tool_call", + id: `tool-${callId}`, + timestamp: new Date("2026-01-01T00:00:00.000Z"), + payload: { + source: "agent", + data: { + provider: "codex", + callId, + name, + status: "completed", + error: null, + detail: { type: "unknown", input: { callId }, output: { callId } }, + }, + }, + }; +} + +function pluginData(items: StreamItem[]): unknown[] { + return items.flatMap((item) => (item.kind === "plugin" ? [item.data] : [])); +} + +const presentationOptions = { level: "overview" as const, isTurnActive: true }; + +function streamHarness(transform?: TimelineItemTransform) { + let state: { tail: StreamItem[]; head: StreamItem[] } = { tail: [], head: [] }; + const present = createStreamPresentation(); + return { + send(event: AgentStreamEventPayload) { + state = applyStreamEvent({ ...state, event, timestamp: new Date(1000) }); + return this.render(); + }, + render(nextTransform = transform) { + return present({ ...presentationOptions, ...state, transform: nextTransform }); + }, + source: () => state, + }; +} + +function assistant(text: string, messageId = "message-1"): AgentStreamEventPayload { + return { + type: "timeline", + provider: "claude", + item: { type: "assistant_message", messageId, text }, + }; +} + +function rows(result: { tail: StreamItem[]; head: StreamItem[] }): StreamItem[] { + return [...result.tail, ...result.head]; +} + +describe("stream presentation through installed plugins", () => { + it("offers every source tool call to an installed transformer in Overview mode", () => { + const calls = [toolCall("call-1", "bash"), toolCall("call-2", "read")]; + const rendered = createStreamPresentation()({ + ...presentationOptions, + tail: calls, + head: [], + transform: installedTransform(installProbe("tool_call")), + }); + expect(pluginData([...rendered.tail, ...rendered.head])).toMatchObject([ + { callId: "call-1", name: "bash", phase: "complete" }, + { callId: "call-2", name: "read", phase: "complete" }, + ]); + }); + + it.each([ + { type: "turn_completed", provider: "claude" }, + { type: "turn_canceled", provider: "claude", reason: "test cancellation" }, + { type: "turn_failed", provider: "claude", error: "test failure" }, + ] satisfies AgentStreamEventPayload[])( + "streams accumulated assistant text with one identity through $type", + (completion) => { + const harness = streamHarness(installedTransform(installProbe("assistant_message"))); + const first = rows(harness.send(assistant("Header"))); + const fullText = "Header\n\n\n# Body\n\n```ts\nconst x = 1;\n\nconst y = 2;\n```\n"; + const live = rows(harness.send(assistant(fullText.slice("Header".length)))); + const completed = rows(harness.send(completion)); + + expect(pluginData(first)).toEqual([{ text: "Header", phase: "streaming" }]); + expect(pluginData(live)).toEqual([{ text: fullText, phase: "streaming" }]); + expect(pluginData(completed)).toEqual([{ text: fullText, phase: "complete" }]); + expect(live.map((item) => item.id)).toEqual(first.map((item) => item.id)); + expect(completed.map((item) => item.id)).toEqual(first.map((item) => item.id)); + expect(rows(harness.source())).toMatchObject([{ kind: "assistant_message", text: fullText }]); + }, + ); + + it("finishes one message before streaming a different message ID", () => { + const harness = streamHarness(installedTransform(installProbe("assistant_message"))); + harness.send(assistant("First\n\nBody", "first")); + const result = harness.send(assistant("Second", "second")); + expect(pluginData(rows(result))).toEqual([ + { text: "First\n\nBody", phase: "complete" }, + { text: "Second", phase: "streaming" }, + ]); + expect(new Set(rows(result).map((item) => item.id)).size).toBe(2); + }); + + it("lets a plugin claim the accumulated message after earlier text used native Markdown", () => { + const harness = streamHarness(); + harness.send(assistant("Header\n\nBody")); + const transform = installedTransform( + installProbe("assistant_message", 'item.text.includes("Body")'), + ); + expect(pluginData(rows(harness.render(transform)))).toEqual([ + { text: "Header\n\nBody", phase: "streaming" }, + ]); + expect(rows(harness.render(transform))).toHaveLength(1); + expect(rows(harness.render()).map((item) => item.kind)).toEqual([ + "assistant_message", + "assistant_message", + ]); + }); + + it("keeps completed native blocks stable while the last block grows and finishes", () => { + const harness = streamHarness(); + const first = harness.send(assistant("Intro\n\n![Image](image.png)")); + const growing = harness.send(assistant("\n\nAfter\n")); + const last = harness.send(assistant("- item")); + const completed = harness.send({ type: "turn_completed", provider: "claude" }); + expect(first.tail).toMatchObject([{ text: "Intro", blockIndex: 0 }]); + expect(first.head).toMatchObject([{ text: "![Image](image.png)", blockIndex: 1 }]); + expect(growing.tail[0]).toBe(first.tail[0]); + expect(growing.tail[1]).toBe(first.head[0]); + expect(last.tail).toBe(growing.tail); + expect(last.head).toMatchObject([{ text: "After\n- item", blockIndex: 2 }]); + expect(completed.tail).toEqual(rows(last)); + expect(completed.tail[2]).toBe(last.head[0]); + expect(completed.head).toEqual([]); + }); + + it("keeps blank lines inside an open native code fence", () => { + const harness = streamHarness(); + harness.send(assistant("Intro\n\n```ts\nconst a = 1;")); + const result = harness.send(assistant("\n\nconst b = 2;")); + expect(result.tail).toMatchObject([{ text: "Intro" }]); + expect(result.head).toMatchObject([{ text: "```ts\nconst a = 1;\n\nconst b = 2;" }]); + }); + + it("leaves fetched native Markdown intact, including cross-paragraph references", () => { + const source = hydrateStreamState([ + { + event: assistant("[Link][docs]\n\n[docs]: https://example.com"), + timestamp: new Date(1000), + }, + ]); + const result = createStreamPresentation()({ + ...presentationOptions, + tail: source, + head: [], + transform: undefined, + }); + expect(result.tail).toEqual(source); + expect(result.tail[0]).toBe(source[0]); + }); + + it("continues to stream inline reasoning with a stable plugin row", () => { + const harness = streamHarness(installedTransform(installProbe("reasoning"))); + const first = harness.send({ + type: "timeline", + provider: "claude", + item: { type: "reasoning", text: "First" }, + }); + const second = harness.send({ + type: "timeline", + provider: "claude", + item: { type: "reasoning", text: "\n\nSecond" }, + }); + const done = harness.send({ type: "turn_completed", provider: "claude" }); + expect(pluginData(rows(second))).toEqual([{ text: "First\n\nSecond", phase: "streaming" }]); + expect(pluginData(rows(done))).toEqual([{ text: "First\n\nSecond", phase: "complete" }]); + expect(rows(first)[0]?.id).toBe(rows(done)[0]?.id); + }); + + it("groups only unclaimed tools and passes unchanged tool details to the callback", () => { + const calls = [ + toolCall("call-1", "read"), + toolCall("call-2", "read"), + toolCall("call-3", "bash"), + ]; + const result = createStreamPresentation()({ + ...presentationOptions, + isTurnActive: false, + tail: calls, + head: [], + transform: installedTransform(installProbe("tool_call", 'item.name === "bash"')), + }); + expect(result.groupsByHostId.get(result.tail[0]!.id)?.run.calls).toEqual(calls.slice(0, 2)); + expect(pluginData(rows(result))).toEqual([ + { + callId: "call-3", + name: "bash", + status: "completed", + error: null, + detail: { type: "unknown", input: { callId: "call-3" }, output: { callId: "call-3" } }, + phase: "complete", + }, + ]); + }); + + it.each([ + { status: "completed", error: null }, + { status: "failed", error: { message: "Command failed" } }, + { status: "canceled", error: null }, + ] as const)("keeps a tool card's identity and full payload through $status", (completion) => { + const harness = streamHarness(installedTransform(installProbe("tool_call"))); + const source = { + type: "tool_call" as const, + callId: "call-1", + name: "bash", + detail: { type: "shell" as const, command: "pwd", cwd: "/repo" }, + metadata: { sequence: 1 }, + }; + const running = rows( + harness.send({ + type: "timeline", + provider: "claude", + item: { + ...source, + status: "running", + error: null, + }, + }), + ); + const terminalItem = { ...source, ...completion }; + const terminal = rows( + harness.send({ + type: "timeline", + provider: "claude", + item: { + ...terminalItem, + }, + }), + ); + expect(pluginData(running)).toMatchObject([ + { callId: "call-1", status: "running", phase: "streaming" }, + ]); + expect(pluginData(terminal)).toEqual([ + { + callId: source.callId, + name: source.name, + detail: source.detail, + metadata: source.metadata, + phase: "complete", + status: completion.status, + error: terminalItem.error, + }, + ]); + expect(terminal.map((item) => item.id)).toEqual(running.map((item) => item.id)); + }); + + it("offers one complete live source assistant message to an installed transformer", () => { + const sourceText = + '[IMPORTANT: User invoked the "diagnose" skill; follow its instructions. Full skill below.]\n\n# Diagnose\n\nFind the root cause.'; + const event = { + type: "timeline" as const, + provider: "opencode" as const, + item: { type: "assistant_message" as const, messageId: "message-1", text: sourceText }, + }; + const timestamp = new Date("2026-01-01T00:00:00.000Z"); + const transform = installedTransform(installProbe("assistant_message")); + const present = createStreamPresentation(); + + const fetched = hydrateStreamState([{ event, timestamp }]); + const history = present({ ...presentationOptions, tail: fetched, head: [], transform }); + expect(pluginData(history.tail)).toEqual([{ text: sourceText, phase: "complete" }]); + + const stream = applyStreamEvent({ + tail: [], + head: [], + event, + timestamp, + }); + + const rendered = present({ ...presentationOptions, ...stream, transform }); + expect(pluginData([...rendered.tail, ...rendered.head])).toEqual([ + { text: sourceText, phase: "streaming" }, + ]); + }); +}); diff --git a/packages/app/src/agent-stream/presentation.ts b/packages/app/src/agent-stream/presentation.ts new file mode 100644 index 0000000000..9d05bdea5b --- /dev/null +++ b/packages/app/src/agent-stream/presentation.ts @@ -0,0 +1,138 @@ +import type { AssistantMessageItem, StreamItem } from "@/types/stream"; +import type { TimelineItemTransform } from "@/plugins/timeline/model"; +import { projectPluginTimelineItems } from "@/plugins/timeline/projection"; +import { splitMarkdownBlocks } from "@/utils/split-markdown-blocks"; +import { + prepareToolCallHistory, + projectToolCallDetailLevel, + type PreparedToolCallHistory, + type ToolCallDetailLevel, +} from "@/tool-calls/detail-level/projection"; + +interface PresentationInput { + tail: StreamItem[]; + head: StreamItem[]; + transform: TimelineItemTransform | undefined; + level: ToolCallDetailLevel; + isTurnActive: boolean; +} + +function retainItems(previous: StreamItem[], next: StreamItem[]): StreamItem[] { + return previous.length === next.length && previous.every((item, index) => item === next[index]) + ? previous + : next; +} + +/** Source messages reach plugins before any Markdown splitting or Overview grouping. */ +export function createStreamPresentation() { + const blocksBySource = new WeakMap(); + let liveSources = new Map(); + let historySource: StreamItem[] | undefined; + let historyTransform: TimelineItemTransform | undefined; + let historyRows: StreamItem[] = []; + let displayTail: StreamItem[] = []; + let displayHistory: StreamItem[] | undefined; + let promotedRows: StreamItem[] = []; + let preparedTail: StreamItem[] | undefined; + let preparedLevel: ToolCallDetailLevel | undefined; + let preparedHistory: PreparedToolCallHistory | null = null; + + function nativeBlocks(item: StreamItem): StreamItem[] { + if (item.kind !== "assistant_message") return [item]; + const cached = blocksBySource.get(item); + if (cached) return cached; + const previousSource = liveSources.get(item.id); + const previous = previousSource && blocksBySource.get(previousSource); + // Parse only the growing last block on append, as the old live reducer did. + // Canonical text replacements are parsed afresh instead of joining fragments. + const isAppend = previousSource && previous && item.text.startsWith(previousSource.text); + let prefix: AssistantMessageItem[] = []; + let growingText = item.text; + if (isAppend) { + prefix = previous.slice(0, -1); + growingText = + previous[previous.length - 1]!.text + item.text.slice(previousSource.text.length); + } + const textBlocks = splitMarkdownBlocks(growingText); + if (prefix.length + textBlocks.length < 2) { + blocksBySource.set(item, [item]); + return [item]; + } + + const blocks = [...prefix]; + for (const [offset, text] of textBlocks.entries()) { + const index = prefix.length + offset; + let blockText = text; + if (offset === textBlocks.length - 1) { + const trailingNewlines = /\n+$/.exec(item.text)?.[0] ?? ""; + blockText += trailingNewlines; + } + const existing = previous?.[index]; + const id = `${item.id}:block:${index}`; + // Completed display blocks keep their first cursor and object identity while + // the source message grows. The source itself always retains the latest cursor. + if (existing?.id === id && existing.text === blockText && existing.turnId === item.turnId) { + blocks.push(existing); + continue; + } + blocks.push({ + ...item, + id, + blockGroupId: item.id, + blockIndex: index, + text: blockText, + }); + } + blocksBySource.set(item, blocks); + return blocks; + } + + return (input: PresentationInput) => { + // Retained history is not reprojected or regrouped on each live text update. + if (historySource !== input.tail || historyTransform !== input.transform) { + historyRows = projectPluginTimelineItems(input.tail, input.transform).flatMap( + (item) => { + // Preserve live block identities at completion; fetched native Markdown + // stays whole so links and other cross-block constructs keep their context. + if (item.kind === "assistant_message") return blocksBySource.get(item) ?? [item]; + return [item]; + }, + ); + historySource = input.tail; + historyTransform = input.transform; + } + const head: StreamItem[] = []; + const promoted: StreamItem[] = []; + const nextLiveSources = new Map(); + for (const item of projectPluginTimelineItems(input.head, input.transform, "streaming")) { + const blocks = nativeBlocks(item); + if (item.kind === "assistant_message") nextLiveSources.set(item.id, item); + if (item.kind === "assistant_message" && blocks.length > 1) { + promoted.push(...blocks.slice(0, -1)); + head.push(blocks[blocks.length - 1]!); + } else { + head.push(...blocks); + } + } + liveSources = nextLiveSources; + const nextPromoted = retainItems(promotedRows, promoted); + if (displayHistory !== historyRows || nextPromoted !== promotedRows) { + displayTail = historyRows; + if (nextPromoted.length > 0) displayTail = [...historyRows, ...nextPromoted]; + displayHistory = historyRows; + promotedRows = nextPromoted; + } + if (preparedTail !== displayTail || preparedLevel !== input.level) { + preparedHistory = prepareToolCallHistory(input.level, displayTail); + preparedTail = displayTail; + preparedLevel = input.level; + } + return projectToolCallDetailLevel({ + level: input.level, + tail: displayTail, + head, + preparedHistory, + isTurnActive: input.isTurnActive, + }); + }; +} diff --git a/packages/app/src/agent-stream/view.tsx b/packages/app/src/agent-stream/view.tsx index 117132199e..df9670fb1d 100644 --- a/packages/app/src/agent-stream/view.tsx +++ b/packages/app/src/agent-stream/view.tsx @@ -61,10 +61,7 @@ import type { DaemonClient } from "@getpaseo/client/internal/daemon-client"; import { ToolCallDetailsContent } from "@/components/tool-call-details"; import { QuestionFormCard } from "@/components/question-form-card"; import { ToolCallSheetProvider } from "@/components/tool-call-sheet"; -import { - prepareToolCallHistory, - projectToolCallDetailLevel, -} from "@/tool-calls/detail-level/projection"; +import { createStreamPresentation } from "./presentation"; import { OverviewToolCallGroupView } from "@/tool-calls/detail-level/overview/view"; import { type AgentStreamRenderModel, buildAgentStreamRenderModel } from "./model"; import { resolveStreamRenderStrategy } from "./strategy-resolver"; @@ -108,7 +105,6 @@ import { recordRenderProfileReasons } from "@/utils/render-profiler"; import { useRetainedPanelActive } from "@/components/retained-panel"; import { useStreamHistoryWindow } from "./use-stream-history-window"; import { PluginTimelineItemView, useInstalledTimelineTransform } from "@/plugins/timeline"; -import { projectPluginTimelineItems } from "@/plugins/timeline/projection"; function renderLiveAuxiliaryNode(input: { pendingPermissions: ReactNode; @@ -537,35 +533,25 @@ const AgentStreamViewComponent = forwardRef prepareToolCallHistory(toolCallDetailLevel, effectiveStreamItems), - [effectiveStreamItems, toolCallDetailLevel], - ); - const projectedToolCalls = useMemo( + const presentStream = useMemo(() => createStreamPresentation(), []); + const presentation = useMemo( () => - projectToolCallDetailLevel({ - level: toolCallDetailLevel, + presentStream({ tail: effectiveStreamItems, head: effectiveStreamHead ?? EMPTY_STREAM_HEAD, - preparedHistory: preparedToolCallHistory, + transform: transformTimelineItem, + level: toolCallDetailLevel, isTurnActive, }), [ - effectiveStreamHead, + presentStream, effectiveStreamItems, - isTurnActive, - preparedToolCallHistory, + effectiveStreamHead, + transformTimelineItem, toolCallDetailLevel, + isTurnActive, ], ); - const projectedPlugins = useMemo( - () => ({ - tail: projectPluginTimelineItems(projectedToolCalls.tail, transformTimelineItem), - head: projectPluginTimelineItems(projectedToolCalls.head, transformTimelineItem), - }), - [projectedToolCalls.head, projectedToolCalls.tail, transformTimelineItem], - ); const { start: historyWindowStart, hasLocalHistory, @@ -573,7 +559,7 @@ const AgentStreamViewComponent = forwardRef - projectedToolCalls.groupsByHostId.get(hostId), + presentation.groupsByHostId.get(hostId), ); const renderToolCallItem = useCallback( (layoutItem: StreamLayoutItem, item: Extract) => { @@ -1017,6 +1003,14 @@ const AgentStreamViewComponent = forwardRef { + const row = + rowId === null + ? undefined + : (layoutHistoryItemById.get(rowId) ?? layoutLiveHeadItemById.get(rowId)); + chatOutline.reportReadingPosition(row?.item.timelineCursor?.seq ?? null); + }); + const renderHistoryRow = useCallback( (item: StreamItem) => renderHistoryStreamItem({ @@ -1080,11 +1074,11 @@ const AgentStreamViewComponent = forwardRef ({ - contentById: projectedToolCalls.historyGroupUpdatesByHostId, + contentById: presentation.historyGroupUpdatesByHostId, displayStateById: expandedToolCallGroupIds, globalDisplayState: isMobile, }), - [expandedToolCallGroupIds, isMobile, projectedToolCalls.historyGroupUpdatesByHostId], + [expandedToolCallGroupIds, isMobile, presentation.historyGroupUpdatesByHostId], ); return ( @@ -1103,7 +1097,7 @@ const AgentStreamViewComponent = forwardRef>(); +type AssistantPhase = "streaming" | "complete"; + +const projectionCache = new WeakMap< + TimelineItemTransform, + WeakMap>> +>(); function cloneAndFreeze(value: T): T { if (Array.isArray(value)) { @@ -77,6 +82,7 @@ function sourceTimelineItem(item: StreamItem): AgentTimelineItem | null { function transformSourceItem( item: StreamItem, transformTimelineItem: TimelineItemTransform, + assistantPhase: AssistantPhase, ): StreamItem[] { const source = sourceTimelineItem(item); if (!source) return [item]; @@ -85,7 +91,9 @@ function transformSourceItem( item.kind === "tool_call" && item.payload.source === "agent" && item.payload.data.status === "running"; - const phase = isStreamingThought || isStreamingToolCall ? "streaming" : "complete"; + const isStreamingAssistant = item.kind === "assistant_message" && assistantPhase === "streaming"; + const phase = + isStreamingAssistant || isStreamingThought || isStreamingToolCall ? "streaming" : "complete"; const transformed = transformTimelineItem({ item: cloneAndFreeze(source), phase, @@ -112,6 +120,7 @@ function transformSourceItem( export function projectPluginTimelineItems( items: StreamItem[], transformTimelineItem: TimelineItemTransform | undefined, + assistantPhase: AssistantPhase = "complete", ): StreamItem[] { if (!transformTimelineItem) return items; let bySource = projectionCache.get(transformTimelineItem); @@ -121,13 +130,13 @@ export function projectPluginTimelineItems( } let changed = false; const projected = items.flatMap((item) => { - const cached = bySource.get(item); + const cached = bySource.get(item)?.[assistantPhase]; if (cached) { changed = changed || cached.length !== 1 || cached[0] !== item; return cached; } - const output = transformSourceItem(item, transformTimelineItem); - bySource.set(item, output); + const output = transformSourceItem(item, transformTimelineItem, assistantPhase); + bySource.set(item, { ...bySource.get(item), [assistantPhase]: output }); changed = changed || output.length !== 1 || output[0] !== item; return output; }); diff --git a/packages/app/src/runtime/replica-cache/index.test.ts b/packages/app/src/runtime/replica-cache/index.test.ts index df6c3a0aa0..e4e759bc15 100644 --- a/packages/app/src/runtime/replica-cache/index.test.ts +++ b/packages/app/src/runtime/replica-cache/index.test.ts @@ -110,6 +110,12 @@ function createCache(storage: MemoryStorage, maxBytes?: number): ReplicaCache { return cache; } +function requireTimelineRow(storage: MemoryStorage): ReplicaRow { + const row = [...storage.rows.values()].find((candidate) => candidate.kind === "timeline"); + if (!row) throw new Error("timeline row was not written"); + return row; +} + function agent(id = "agent-1"): Agent { return { ...normalizeAgentSnapshot( @@ -501,8 +507,7 @@ describe("ReplicaCache", () => { hasOlder: true, }); await writer.flush(); - const row = [...storage.rows.values()].find((candidate) => candidate.kind === "timeline"); - if (!row) throw new Error("timeline row was not written"); + const row = requireTimelineRow(storage); const payload = JSON.parse(row.payload) as { items: Array> }; delete payload.items[0]?.pluginItemId; storage.rows.set(`${row.serverId}:${row.kind}:${row.id}`, { @@ -513,6 +518,22 @@ describe("ReplicaCache", () => { expect(await createCache(storage).readTimeline(SERVER_ID, "agent-1")).toBeUndefined(); }); + it("refetches old cached Markdown fragments instead of offering them as whole messages", async () => { + const storage = new MemoryStorage(); + const writer = createCache(storage); + writer.commitTimeline(SERVER_ID, "agent-1", timeline()); + await writer.flush(); + const row = requireTimelineRow(storage); + const payload = JSON.parse(row.payload) as { items: Array> }; + Object.assign(payload.items[0]!, { blockGroupId: "message-1", blockIndex: 0 }); + storage.rows.set(`${row.serverId}:${row.kind}:${row.id}`, { + ...row, + payload: JSON.stringify(payload), + }); + + expect(await createCache(storage).readTimeline(SERVER_ID, "agent-1")).toBeUndefined(); + }); + it("reads one requested agent and the focused timeline without scanning directory rows", async () => { const storage = new MemoryStorage(); const writer = createCache(storage); diff --git a/packages/app/src/runtime/replica-cache/index.ts b/packages/app/src/runtime/replica-cache/index.ts index c1db2f8418..4a6e284b32 100644 --- a/packages/app/src/runtime/replica-cache/index.ts +++ b/packages/app/src/runtime/replica-cache/index.ts @@ -124,8 +124,7 @@ const StoredTimelineItemSchema = z.discriminatedUnion("kind", [ kind: z.literal("assistant_message"), messageId: z.string().optional(), text: z.string(), - blockGroupId: z.string().optional(), - blockIndex: z.number().int().nonnegative().optional(), + // Reject old caches containing display fragments; refetch the complete source text. }), z.strictObject({ ...TimelineItemBaseShape, @@ -435,8 +434,6 @@ function serializeTimelineItem(item: StreamItem): StoredTimelineItem | null { kind: item.kind, ...(item.messageId ? { messageId: item.messageId } : {}), text: item.text, - ...(item.blockGroupId ? { blockGroupId: item.blockGroupId } : {}), - ...(item.blockIndex !== undefined ? { blockIndex: item.blockIndex } : {}), }; case "thought": return { ...base, kind: item.kind, text: item.text, status: item.status }; @@ -527,8 +524,6 @@ function deserializeBuiltinTimelineItem( kind: item.kind, ...(item.messageId ? { messageId: item.messageId } : {}), text: item.text, - ...(item.blockGroupId ? { blockGroupId: item.blockGroupId } : {}), - ...(item.blockIndex !== undefined ? { blockIndex: item.blockIndex } : {}), }; case "thought": return { ...base, kind: item.kind, text: item.text, status: item.status }; diff --git a/packages/app/src/timeline/session-stream-reducers.test.ts b/packages/app/src/timeline/session-stream-reducers.test.ts index 13897f8454..9e57142846 100644 --- a/packages/app/src/timeline/session-stream-reducers.test.ts +++ b/packages/app/src/timeline/session-stream-reducers.test.ts @@ -1901,14 +1901,14 @@ describe("processTimelineResponse", () => { expect(assistants[0]).toMatchObject({ text: "Hello", messageId: "answer-1" }); }); - it("replaces every promoted assistant block when reconciling a projected message", () => { + it("replaces the whole live assistant message when reconciling projected history", () => { const live = processAgentStreamEvents({ events: [makeStreamReducerEvent(makeAssistantTimelineEvent("First paragraph.\n\nSec"), 2)], currentTail: [], currentHead: [], currentCursor: { epoch: "epoch-1", startSeq: 1, endSeq: 1 }, }); - expect(getAssistantTexts(live.tail)).toHaveLength(1); + expect(getAssistantTexts(live.tail)).toHaveLength(0); expect(getAssistantTexts(live.head)).toHaveLength(1); const result = processTimelineResponse({ @@ -2173,7 +2173,7 @@ describe("processTimelineResponse", () => { [...result.tail, ...result.head] .filter((item) => item.kind === "assistant_message" || item.kind === "user_message") .map((item) => item.text), - ).toEqual(["New prompt", "First paragraph.", "Second paragraph", "Missed answer"]); + ).toEqual(["New prompt", "First paragraph.\n\nSecond paragraph", "Missed answer"]); }); it("does not move a prompt or its live answer around catch-up tool history", () => { @@ -2212,7 +2212,6 @@ describe("processTimelineResponse", () => { expect([...result.tail, ...result.head].map((item) => item.kind)).toEqual([ "user_message", "assistant_message", - "assistant_message", "tool_call", ]); }); @@ -2777,7 +2776,7 @@ describe("processTimelineResponse", () => { expect(result.sideEffects).not.toContainEqual(expect.objectContaining({ type: "catch_up" })); }); - it("keeps live assistant blocks ordered when merging a disjoint prompt-jump window", () => { + it("keeps whole live assistant messages ordered when merging a disjoint prompt-jump window", () => { const live = processAgentStreamEvents({ events: [ makeStreamReducerEvent( @@ -2792,8 +2791,7 @@ describe("processTimelineResponse", () => { currentCursor: undefined, }); expect(getAssistantTexts([...live.tail, ...live.head])).toEqual([ - "First paragraph.", - "Second paragraph.", + "First paragraph.\n\nSecond paragraph.", ]); const result = processTimelineResponse({ @@ -2815,8 +2813,7 @@ describe("processTimelineResponse", () => { }); expect(getAssistantTexts([...result.tail, ...result.head])).toEqual([ - "First paragraph.", - "Second paragraph.", + "First paragraph.\n\nSecond paragraph.", ]); }); @@ -3980,7 +3977,7 @@ describe("processAgentStreamEvents", () => { expect(getAssistantTexts(result.head)).toEqual(["Identified"]); }); - it("keeps a promoted live image block identity when the turn completes", () => { + it("keeps the whole live message identity when the turn completes", () => { const imageMarkdown = "![Architecture](docs/architecture.png)"; const streaming = processAgentStreamEvents({ events: [ @@ -3992,9 +3989,11 @@ describe("processAgentStreamEvents", () => { currentCursor: undefined, }); - expect([streaming.changedTail, streaming.changedHead]).toEqual([true, true]); - expect(getAssistantTexts(streaming.tail)).toEqual(["Introductory paragraph"]); - expect(getAssistantTexts(streaming.head)).toEqual([imageMarkdown]); + expect([streaming.changedTail, streaming.changedHead]).toEqual([false, true]); + expect(getAssistantTexts(streaming.tail)).toEqual([]); + expect(getAssistantTexts(streaming.head)).toEqual([ + `Introductory paragraph\n\n${imageMarkdown}`, + ]); const liveImageId = streaming.head[0]?.id; const completed = processAgentStreamEvent({ @@ -4009,13 +4008,12 @@ describe("processAgentStreamEvents", () => { item.kind === "assistant_message", ); expect(assistantBlocks.map((item) => [item.id, item.text])).toEqual([ - [streaming.tail[0]?.id, "Introductory paragraph"], - [liveImageId, imageMarkdown], + [liveImageId, `Introductory paragraph\n\n${imageMarkdown}`], ]); expect(assistantBlocks.filter((item) => item.id === liveImageId)).toHaveLength(1); }); - it("preserves a live block trailing newline after promoting completed markdown blocks", () => { + it("preserves all newlines in the accumulated live message", () => { const result = processAgentStreamEvents({ events: [ makeStreamReducerEvent( @@ -4032,21 +4030,17 @@ describe("processAgentStreamEvents", () => { currentCursor: undefined, }); - expect(result.changedTail).toBe(true); + expect(result.changedTail).toBe(false); expect(result.changedHead).toBe(true); - expect(result.tail).toHaveLength(1); - expect(result.tail[0]).toMatchObject({ - kind: "assistant_message", - text: "Done. I added `[TimelineMerge] ...` logging around the suspicious merge/reconcile paths.", - }); + expect(result.tail).toHaveLength(0); expect(result.head).toHaveLength(1); expect(result.head[0]).toMatchObject({ kind: "assistant_message", - text: "Changed:\n- [timeline-debug.ts]", + text: "Done. I added `[TimelineMerge] ...` logging around the suspicious merge/reconcile paths.\n\nChanged:\n- [timeline-debug.ts]", }); }); - it("does not promote a markdown block that is still inside an open code fence", () => { + it("preserves an open code fence in the whole source message", () => { const result = processAgentStreamEvents({ events: [ makeStreamReducerEvent(makeTimelineEvent("Before fence\n\n```ts\nconst a = 1;"), 1), @@ -4057,16 +4051,12 @@ describe("processAgentStreamEvents", () => { currentCursor: undefined, }); - expect(result.changedTail).toBe(true); - expect(result.tail).toHaveLength(1); - expect(result.tail[0]).toMatchObject({ - kind: "assistant_message", - text: "Before fence", - }); + expect(result.changedTail).toBe(false); + expect(result.tail).toHaveLength(0); expect(result.head).toHaveLength(1); expect(result.head[0]).toMatchObject({ kind: "assistant_message", - text: "```ts\nconst a = 1;\n\nconst b = 2;", + text: "Before fence\n\n```ts\nconst a = 1;\n\nconst b = 2;", }); }); @@ -4175,8 +4165,7 @@ describe("processAgentStreamEvents", () => { expect(getAssistantTexts([...result.tail, ...result.head])).toEqual([ "ABC", imageMarkdown, - "D", - "E", + "D\n\nE", ]); }); diff --git a/packages/app/src/timeline/session-stream-reducers.ts b/packages/app/src/timeline/session-stream-reducers.ts index b04ad00f02..dce9daa4da 100644 --- a/packages/app/src/timeline/session-stream-reducers.ts +++ b/packages/app/src/timeline/session-stream-reducers.ts @@ -422,13 +422,7 @@ function mergeTimelineWindow(args: { return cursor?.epoch !== payload.epoch || cursor.seq < startSeq || cursor.seq > endSeq; }); const retainedHead = projected.head; - const reservedItemIds = new Set( - [...retainedTail, ...retainedHead].flatMap((item) => - item.kind === "assistant_message" && item.blockGroupId - ? [item.id, item.blockGroupId] - : [item.id], - ), - ); + const reservedItemIds = new Set([...retainedTail, ...retainedHead].map((item) => item.id)); const hydrated = hydrateStreamState( toHydratedEvents(timelineUnits.filter((unit) => !projected.reconciledUnits.has(unit))), { @@ -823,51 +817,31 @@ function reconcileOverlappingProjectedAssistant(params: { return { tail: params.tail, head: params.head, reconciled: false }; } - const blockGroupId = match.current.blockGroupId; const messageId = projectedMessageId ?? match.current.messageId; const replacement: AssistantMessageItem = { kind: "assistant_message", - id: blockGroupId ?? match.current.id, + id: match.current.id, ...(messageId !== undefined ? { messageId } : {}), text: projectedText, timestamp: unit.timestamp, timelineCursor: { epoch: params.epoch, seq: unit.seqEnd }, }; - const belongsToBlockGroup = (item: StreamItem) => - blockGroupId !== undefined && - item.kind === "assistant_message" && - item.blockGroupId === blockGroupId; - const removeBlockGroup = (items: StreamItem[]) => - blockGroupId !== undefined ? items.filter((item) => !belongsToBlockGroup(item)) : items; const replaceMatch = (items: StreamItem[], index: number) => { - if (!blockGroupId) { - const next = [...items]; - next[index] = replacement; - return next; - } - const next: StreamItem[] = []; - let inserted = false; - for (const item of items) { - if (!belongsToBlockGroup(item)) { - next.push(item); - } else if (!inserted) { - next.push(replacement); - inserted = true; - } - } + const next = [...items]; + next[index] = replacement; return next; }; if (headMatch) { return { - tail: removeBlockGroup(params.tail), + tail: params.tail, head: replaceMatch(params.head, headMatch.index), reconciled: true, }; } return { tail: replaceMatch(params.tail, match.index), - head: removeBlockGroup(params.head), + head: params.head, reconciled: true, }; } @@ -1162,13 +1136,7 @@ function applyAcceptedTimelinePage(input: { })), { source: "canonical", - reservedItemIds: new Set( - currentTail.flatMap((item) => - item.kind === "assistant_message" && item.blockGroupId - ? [item.id, item.blockGroupId] - : [item.id], - ), - ), + reservedItemIds: new Set(currentTail.map((item) => item.id)), }, ); return { diff --git a/packages/app/src/types/stream.test.ts b/packages/app/src/types/stream.test.ts index 49fdf7daf6..f33a9c524b 100644 --- a/packages/app/src/types/stream.test.ts +++ b/packages/app/src/types/stream.test.ts @@ -883,7 +883,7 @@ describe("stream reducer canonical tool calls", () => { expect(new Set(messages.map((message) => message.id)).size).toBe(2); }); - it("keeps every promoted block when an assistant message resumes after a tool", () => { + it("keeps whole messages when an assistant resumes after a tool", () => { const messageId = "msg-promoted-resume"; let tail: StreamItem[] = []; let head: StreamItem[] = []; @@ -926,15 +926,13 @@ describe("stream reducer canonical tool calls", () => { item.kind === "assistant_message", ); expect(messages.map((message) => message.text)).toEqual([ - "Before one.", - "Before two.", - "After one.", - "After two.", + "Before one.\n\nBefore two.", + "After one.\n\nAfter two.", ]); expect(new Set(messages.map((message) => message.id)).size).toBe(messages.length); }); - it("keeps the timeline position on every promoted assistant block", () => { + it("keeps the timeline position on the whole assistant message", () => { const timelineCursor = { epoch: "epoch-1", seq: 42 }; const result = applyStreamEvent({ tail: [], @@ -949,13 +947,9 @@ describe("stream reducer canonical tool calls", () => { item.kind === "assistant_message", ); expect(messages.map((message) => message.text)).toEqual([ - "First paragraph.", - "Second paragraph.", - ]); - expect(messages.map((message) => message.timelineCursor)).toEqual([ - timelineCursor, - timelineCursor, + "First paragraph.\n\nSecond paragraph.", ]); + expect(messages.map((message) => message.timelineCursor)).toEqual([timelineCursor]); }); it("preserves old assistant merge behavior when message ids are absent", () => { diff --git a/packages/app/src/types/stream.ts b/packages/app/src/types/stream.ts index 1e8a32f06f..78789fa2d8 100644 --- a/packages/app/src/types/stream.ts +++ b/packages/app/src/types/stream.ts @@ -8,7 +8,6 @@ import { timelineItemIdentity } from "@getpaseo/protocol/timeline-identity"; import type { AgentAttachment, AgentStreamEventPayload } from "@getpaseo/protocol/messages"; import type { AttachmentMetadata } from "@/attachments/types"; import { extractTaskEntriesFromToolCall } from "../utils/tool-call-parsers"; -import { splitMarkdownBlocks } from "@/utils/split-markdown-blocks"; /** * Simple hash function for deterministic ID generation @@ -711,6 +710,7 @@ export interface AssistantMessageItem { timelineCursor?: TimelinePosition; text: string; timestamp: Date; + /** Display-only fields, assigned after source-item plugin transforms. */ blockGroupId?: string; blockIndex?: number; } @@ -1757,14 +1757,6 @@ function finalizeHeadItems(head: StreamItem[]): StreamItem[] { }); } -function createAssistantBlockId(params: { groupId: string; blockIndex: number }): string { - return `${params.groupId}:block:${params.blockIndex}`; -} - -function getTrailingNewlineSuffix(text: string): string { - return /\n+$/.exec(text)?.[0] ?? ""; -} - function getActiveAssistantHeadIndex(head: StreamItem[]): number { for (let index = head.length - 1; index >= 0; index -= 1) { if (head[index]?.kind === "assistant_message") { @@ -1793,73 +1785,6 @@ function getTailAssistantToResume(params: { return params.tailAssistant; } -function promoteCompletedAssistantBlocks(params: { tail: StreamItem[]; head: StreamItem[] }): { - tail: StreamItem[]; - head: StreamItem[]; - changedTail: boolean; - changedHead: boolean; -} { - const assistantIndex = getActiveAssistantHeadIndex(params.head); - const activeItem = params.head[assistantIndex]; - if (assistantIndex < 0 || !activeItem || activeItem.kind !== "assistant_message") { - return { - tail: params.tail, - head: params.head, - changedTail: false, - changedHead: false, - }; - } - - const blocks = splitMarkdownBlocks(activeItem.text); - if (blocks.length < 2) { - return { - tail: params.tail, - head: params.head, - changedTail: false, - changedHead: false, - }; - } - - const blockGroupId = activeItem.blockGroupId ?? activeItem.id; - const firstBlockIndex = activeItem.blockIndex ?? 0; - const completedBlocks = blocks.slice(0, -1); - const liveBlock = `${blocks[blocks.length - 1] ?? ""}${getTrailingNewlineSuffix(activeItem.text)}`; - const promotedItems = completedBlocks.map((block, offset) => ({ - ...activeItem, - id: createAssistantBlockId({ - groupId: blockGroupId, - blockIndex: firstBlockIndex + offset, - }), - blockGroupId, - blockIndex: firstBlockIndex + offset, - text: block, - })); - - const nextTail = flushHeadToTail(params.tail, promotedItems); - const liveItem: AssistantMessageItem = { - ...activeItem, - id: createAssistantBlockId({ - groupId: blockGroupId, - blockIndex: firstBlockIndex + completedBlocks.length, - }), - blockGroupId, - blockIndex: firstBlockIndex + completedBlocks.length, - text: liveBlock, - }; - const nextHead = [ - ...params.head.slice(0, assistantIndex), - liveItem, - ...params.head.slice(assistantIndex + 1), - ]; - - return { - tail: nextTail, - head: nextHead, - changedTail: nextTail !== params.tail, - changedHead: true, - }; -} - /** * Flush head items to tail, avoiding duplicates. */ @@ -2103,13 +2028,7 @@ export function applyStreamEvent(params: { if (incomingKind !== null && isStreamableKind(incomingKind)) { const reservedItemIds = incomingKind === "assistant_message" && getActiveAssistantHeadIndex(nextHead) < 0 - ? new Set( - nextTail.flatMap((item) => - item.kind === "assistant_message" && item.blockGroupId - ? [item.id, item.blockGroupId] - : [item.id], - ), - ) + ? new Set(nextTail.map((item) => item.id)) : undefined; const reduced = reduceStreamUpdate(nextHead, event, timestamp, { source, @@ -2120,16 +2039,6 @@ export function applyStreamEvent(params: { nextHead = reduced; changedHead = true; } - if (incomingKind === "assistant_message") { - const promoted = promoteCompletedAssistantBlocks({ - tail: nextTail, - head: nextHead, - }); - nextTail = promoted.tail; - nextHead = promoted.head; - changedTail = changedTail || promoted.changedTail; - changedHead = changedHead || promoted.changedHead; - } return { tail: nextTail, head: nextHead, changedTail, changedHead }; } diff --git a/public-docs/plugins/v0.8/reference.md b/public-docs/plugins/v0.8/reference.md index 2dd5bd3cdd..62c2f0ed80 100644 --- a/public-docs/plugins/v0.8/reference.md +++ b/public-docs/plugins/v0.8/reference.md @@ -854,7 +854,12 @@ export default function contribute(client: PluginClientContext) { `query.itemType` is the stable, coarse selector. Inspect the selected item inside `transform` for provider- or tool-specific recognition. Returning `undefined` keeps the original entry. Returning `items` replaces it; an empty array removes it. Item `data` must be JSON-compatible. The `phase` -input is `"streaming"` for running tool calls and loading reasoning, and `"complete"` otherwise. +input is `"streaming"` for the live assistant message, running tool calls, and loading reasoning; +it is `"complete"` for committed or fetched messages and finished tools or reasoning. +Assistant and reasoning callbacks receive the full accumulated text on each update, including +paragraph separators. Paseo invokes transformers before splitting native Markdown or grouping +tools in Overview. A claimed assistant message remains one source item throughout streaming; +return `undefined` until recognizable if the first text is insufficient to identify it. Each replacement may set an optional plugin-local `id`; otherwise Paseo uses its index within that source item's output. From 174055a63c4651bff2a5ab5a316e8d7a403a3444 Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Tue, 15 Sep 2026 00:30:35 +0200 Subject: [PATCH 40/46] feat(app): give compact Changes an overview and a straight rail (#4861) * feat(app): give compact Changes an overview and a straight rail Three fixes to the Explorer's Changes tab on phones, where there is no tree rail and the diff is the only view of what changed. No placeholder stat while the sidebar slides in. `useCheckoutDiffQuery` reported `isLoading` only when the query was enabled and connected, so an inactive retained panel settled on `files: []`. The comparison toolbar summed that into "+0 -0" and the body rendered "No changes" for a diff nobody had fetched yet, both replaced a moment later by real numbers. The result now derives from the payload alone: no payload means loading, whatever the query is doing. Callers already handle loading correctly, so nothing downstream needed a special case. Jump to file. A floating action button over the diff opens a sheet with the same changed-files tree the desktop rail renders; tapping a file closes the sheet and scrolls the diff there. `ChangedFilesTree` moved out of `diff-pane.tsx` into its own module so both surfaces own one tree, and its list is now the sheet-aware one so it scrolls with the sheet gesture on native. `DiffDocument` takes a `contentInsetBottom` so the button never traps the last line. Rails on the compact header. The tab labels sat at 20dp and the close X at 16dp while every toolbar row below them used 12dp leading and 8dp trailing. The close button was also a hand-rolled Pressable rather than a ToolbarButton. `paneContentToolbarTrailingPadding` now takes whether the row's last control is a bare glyph or a framed one: a glyph's hitbox overhangs its ink so the row pads by less than the rail, but the compact git-actions trigger paints its own frame and was landing flush against the screen edge. The commits header chevron moved to the depth-0 tree rail. Desktop geometry is unchanged. * fix(app): preserve Explorer touch targets and reset file overview * fix(app): preserve diff selections across repaints Header typography refreshes and height resizes changed the repaint callback and inadvertently cleared active source selections. Keep selection updates stable while retaining explicit source and selection-layout invalidation. * test(app): express diff copy regression as user actions * test(app): express file overview flows as user actions --- docs/design.md | 2 +- docs/explorer-sidebar.md | 2 +- packages/app/e2e/browser/changes-pane.spec.ts | 146 +++++++++++-- .../src/components/adaptive-modal-sheet.tsx | 7 +- .../components/compact-explorer-sidebar.tsx | 78 ++++--- .../explorer-sidebar-layout.test.ts | 18 ++ .../src/components/explorer-sidebar-layout.ts | 25 +++ .../app/src/components/file-explorer-pane.tsx | 5 +- .../components/ui/floating-action-button.tsx | 65 ++++++ .../ui/pane-content-toolbar.test.ts | 43 ++++ .../components/ui/pane-content-toolbar.tsx | 22 +- packages/app/src/git/changed-files-tree.tsx | 158 ++++++++++++++ .../commits-section/commits-section.test.tsx | 21 -- .../git/commits-section/commits-section.tsx | 5 +- .../src/git/diff-document/surface.native.tsx | 8 +- .../app/src/git/diff-document/surface.web.tsx | 40 ++-- packages/app/src/git/diff-document/types.ts | 2 + packages/app/src/git/diff-pane.tsx | 199 +++++------------- packages/app/src/git/jump-to-file/index.tsx | 127 +++++++++++ packages/app/src/git/use-diff-query.test.ts | 70 ++++++ packages/app/src/git/use-diff-query.ts | 27 ++- packages/app/src/i18n/resources/ar.ts | 3 + packages/app/src/i18n/resources/en.ts | 3 + packages/app/src/i18n/resources/es.ts | 3 + packages/app/src/i18n/resources/fr.ts | 3 + packages/app/src/i18n/resources/ja.ts | 3 + packages/app/src/i18n/resources/ko.ts | 3 + packages/app/src/i18n/resources/pt-BR.ts | 3 + packages/app/src/i18n/resources/ru.ts | 3 + packages/app/src/i18n/resources/zh-CN.ts | 3 + .../workspace/explorer-sidebar-tab-rail.tsx | 3 +- .../workspace/workspace-desktop-tabs-row.tsx | 2 +- 32 files changed, 858 insertions(+), 244 deletions(-) create mode 100644 packages/app/src/components/ui/floating-action-button.tsx create mode 100644 packages/app/src/components/ui/pane-content-toolbar.test.ts create mode 100644 packages/app/src/git/changed-files-tree.tsx create mode 100644 packages/app/src/git/jump-to-file/index.tsx create mode 100644 packages/app/src/git/use-diff-query.test.ts diff --git a/docs/design.md b/docs/design.md index 0b54be46a3..541fe9c6f5 100644 --- a/docs/design.md +++ b/docs/design.md @@ -20,7 +20,7 @@ A semantic element used in three or more places is a primitive. One of a kind is Primitives live in `packages/app/src/components/ui/` and `packages/app/src/components/headers/`. Card and row layout live in `packages/app/src/styles/settings.ts`. Section structure lives in `packages/app/src/screens/settings/settings-section.tsx`. -A pressable styled to look like a button is wrong; the button is `