From c4e5a0e73923e7e45b60cebe7cd9d0a0ff0db239 Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Fri, 6 Nov 2026 05:43:58 +0100 Subject: [PATCH] feat(plugin): observe terminals across connected hosts Share host-wide terminal observations across plugin surfaces and gate them on terminalCollection. Keep directory observations compatible, and carry creation time in worker v6 state so v5 terminals can drain with an unknown timestamp. --- .../app/e2e/browser/sidebar-views.spec.ts | 1 + .../app/e2e/support/helpers/new-workspace.ts | 2 + .../app/e2e/support/helpers/sidebar-views.ts | 32 ++++ packages/app/src/data/push-router.test.ts | 8 +- packages/app/src/plugins/client-state/api.ts | 2 +- .../client-state/source.browser.test.tsx | 43 ++++- .../app/src/plugins/client-state/source.ts | 42 +++-- .../app/src/plugins/client-state/terminals.ts | 154 ++++++++++++++++++ packages/client/src/daemon-client.ts | 8 +- packages/plugin/src/client/client-state.tsx | 43 ++++- packages/plugin/src/contracts.ts | 3 +- packages/protocol/src/messages.test.ts | 40 +++++ packages/protocol/src/messages.ts | 11 +- .../protocol/src/terminal-subscription-key.ts | 7 +- .../terminal-workspace-sdk.e2e.test.ts | 131 ++++++++++++++- packages/server/src/server/session.ts | 6 + .../server/src/server/websocket-server.ts | 1 + .../workspace-script-runtime-store.test.ts | 13 ++ .../server/workspace-script-runtime-store.ts | 14 ++ .../terminal-session-controller.test.ts | 50 +++++- .../terminal/terminal-session-controller.ts | 62 ++++--- .../src/terminal/terminal-worker-process.ts | 7 +- .../terminal/terminal-worker-protocol.test.ts | 10 +- .../src/terminal/terminal-worker-protocol.ts | 4 +- packages/server/src/terminal/terminal.ts | 3 + .../terminal/worker-terminal-manager.test.ts | 6 +- .../src/terminal/worker-terminal-manager.ts | 7 + .../sidebar-views/client/terminals.tsx | 131 +++++++++++++++ .../sidebar-views/index.client.tsx | 2 + public-docs/plugins/sidebar-views.md | 13 ++ 30 files changed, 795 insertions(+), 61 deletions(-) create mode 100644 packages/app/src/plugins/client-state/terminals.ts create mode 100644 plugin-examples/sidebar-views/client/terminals.tsx diff --git a/packages/app/e2e/browser/sidebar-views.spec.ts b/packages/app/e2e/browser/sidebar-views.spec.ts index 8aabef5a93..665b682b53 100644 --- a/packages/app/e2e/browser/sidebar-views.spec.ts +++ b/packages/app/e2e/browser/sidebar-views.spec.ts @@ -9,6 +9,7 @@ test("sidebar views replace the content, preserve chrome, and recover to the def await views.expectDefault(); await views.chooseWorkspaces(); await views.exerciseExamples(); + await views.exerciseTerminals(); await views.restoreDefault(); await views.recoverFromFailure(); await views.chooseWorkspaces(); diff --git a/packages/app/e2e/support/helpers/new-workspace.ts b/packages/app/e2e/support/helpers/new-workspace.ts index 00726ecfa5..23696cf4ce 100644 --- a/packages/app/e2e/support/helpers/new-workspace.ts +++ b/packages/app/e2e/support/helpers/new-workspace.ts @@ -17,6 +17,8 @@ type NewWorkspaceDaemonClient = Pick< | "connect" | "createPaseoWorktree" | "createWorkspace" + | "createTerminal" + | "killTerminal" | "fetchAgents" | "fetchWorkspaces" | "getPaseoWorktreeList" diff --git a/packages/app/e2e/support/helpers/sidebar-views.ts b/packages/app/e2e/support/helpers/sidebar-views.ts index 73ad97c295..74952d3ff6 100644 --- a/packages/app/e2e/support/helpers/sidebar-views.ts +++ b/packages/app/e2e/support/helpers/sidebar-views.ts @@ -134,6 +134,38 @@ function sidebarViews( ) .toEqual(["Inbox"]); }, + async exerciseTerminals() { + await choose("Example terminals"); + const slot = visibleTestId(page, "plugin-sidebar-view"); + await slot + .getByRole("button", { name: `Terminals on ${getServerId()}`, exact: true }) + .click(); + await expect(slot.getByText("No terminals", { exact: true })).toBeVisible(); + const workspace = (await client.fetchWorkspaces()).entries.find( + (item) => item.id === workspaceId, + )!; + const created = await client.createTerminal( + workspace.workspaceDirectory, + "Sidebar terminal", + undefined, + { workspaceId }, + ); + if (!created.terminal) throw new Error(created.error ?? "Terminal creation failed"); + try { + await expect( + slot.getByTestId(`sidebar-terminal-open-${created.terminal.id}`), + ).toBeVisible(); + await slot.getByTestId(`sidebar-terminal-open-${created.terminal.id}`).click(); + await expect(page.getByTestId("terminal-surface").first()).toBeVisible(); + await slot.getByTestId(`sidebar-terminal-kill-${created.terminal.id}`).click(); + await expect + .poll(async () => (await client.listTerminals()).terminals.map((item) => item.id)) + .not.toContain(created.terminal!.id); + await expect(slot.getByText("No terminals", { exact: true })).toBeVisible(); + } finally { + await client.killTerminal(created.terminal.id); + } + }, async restoreDefault() { await choose("Default view"); await expectDefault(); diff --git a/packages/app/src/data/push-router.test.ts b/packages/app/src/data/push-router.test.ts index 6599c28467..d6d9011259 100644 --- a/packages/app/src/data/push-router.test.ts +++ b/packages/app/src/data/push-router.test.ts @@ -55,8 +55,8 @@ function createFakeClient(config: { rejectCheckoutDiffSubscribe?: boolean } = {} subscriptionId: string; }>; unsubscribeCheckoutDiffCalls: string[]; - subscribeTerminalCalls: Array<{ cwd: string; workspaceId?: string }>; - unsubscribeTerminalCalls: Array<{ cwd: string; workspaceId?: string }>; + subscribeTerminalCalls: Array<{ cwd?: string; workspaceId?: string }>; + unsubscribeTerminalCalls: Array<{ cwd?: string; workspaceId?: string }>; } { const handlers: Record = { providers_snapshot_update: [], @@ -71,8 +71,8 @@ function createFakeClient(config: { rejectCheckoutDiffSubscribe?: boolean } = {} subscriptionId: string; }> = []; const unsubscribeCheckoutDiffCalls: string[] = []; - const subscribeTerminalCalls: Array<{ cwd: string; workspaceId?: string }> = []; - const unsubscribeTerminalCalls: Array<{ cwd: string; workspaceId?: string }> = []; + const subscribeTerminalCalls: Array<{ cwd?: string; workspaceId?: string }> = []; + const unsubscribeTerminalCalls: Array<{ cwd?: string; workspaceId?: string }> = []; function on( type: K, diff --git a/packages/app/src/plugins/client-state/api.ts b/packages/app/src/plugins/client-state/api.ts index d0f9e45faa..4ce3425f9a 100644 --- a/packages/app/src/plugins/client-state/api.ts +++ b/packages/app/src/plugins/client-state/api.ts @@ -15,11 +15,11 @@ interface InstallationApi { } // Registry presentation clones share the installation lifetime and its client pool. const apis = new WeakMap(); -const state = createPluginClientStateSource(); export function getPluginClientApi(plugin: InstalledPlugin): InstallationApi { const cached = apis.get(plugin.lifetime.signal); if (cached) return cached; + const state = createPluginClientStateSource(); const hosts = createPluginHosts(getHostRuntimeStore(), plugin.lifetime.signal); const paseo = createPluginReferenceApi({ paseo: plugin.paseo, diff --git a/packages/app/src/plugins/client-state/source.browser.test.tsx b/packages/app/src/plugins/client-state/source.browser.test.tsx index acf3dc537b..46e31c7cab 100644 --- a/packages/app/src/plugins/client-state/source.browser.test.tsx +++ b/packages/app/src/plugins/client-state/source.browser.test.tsx @@ -7,7 +7,7 @@ import { fr } from "@/i18n/resources/fr"; import React, { act } from "react"; import { createRoot, type Root } from "react-dom/client"; import { afterEach, describe, expect, it } from "vitest"; -import { PluginClientStateProvider } from "@getpaseo/plugin/client/host"; +import { createEntityRefs, PluginClientStateProvider } from "@getpaseo/plugin/client/host"; import { useAgents, useWorkspaces, @@ -377,3 +377,44 @@ describe("sidebar data hooks through the app store and plugin provider", () => { expect(new Set(source.getSnapshot().workspaces.map((item) => item.ref.key)).size).toBe(4); }); }); + +it("observes terminals only while a terminal reader is subscribed", () => { + let active = 0; + const source = createPluginClientStateSource(undefined, { + hosts: { getSnapshot: () => [], subscribe: () => () => {} }, + terminals: { + getSnapshot: () => [], + subscribe: () => { + active++; + return () => { + active--; + }; + }, + }, + }); + const releaseWorkspace = source.subscribe(() => {}); + expect(active).toBe(0); + const releaseTerminal = source.subscribe(() => {}, { terminals: true }); + const releaseSecond = source.subscribe(() => {}, { terminals: true }); + expect(active).toBe(1); + releaseTerminal(); + expect(active).toBe(1); + releaseSecond(); + expect(active).toBe(0); + releaseWorkspace(); +}); + +it("terminal row reads propagate only the referenced host's observation error", () => { + const error = new Error("Update the host"); + const source = createPluginClientStateSource(undefined, { + hosts: { getSnapshot: () => [], subscribe: () => () => {} }, + terminals: { + getSnapshot: () => [], + subscribe: () => () => {}, + getErrors: () => new Map([["unsupported", error]]), + }, + }); + const refs = createEntityRefs(); + expect(() => source.getTerminal(refs.terminal("unsupported", "retained"))).toThrow(error); + expect(source.getTerminal(refs.terminal("supported", "missing"))).toBeNull(); +}); diff --git a/packages/app/src/plugins/client-state/source.ts b/packages/app/src/plugins/client-state/source.ts index 03a2a47296..137c6624cb 100644 --- a/packages/app/src/plugins/client-state/source.ts +++ b/packages/app/src/plugins/client-state/source.ts @@ -5,6 +5,7 @@ import { useCreateFlowStore } from "@/stores/create-flow-store"; import { useSessionStore, type SessionState } from "@/stores/session-store"; import { normalizeWorkspaceOpaqueId } from "@/utils/workspace-identity"; import { connectedPluginHosts } from "./connected-hosts"; +import { pluginTerminals } from "./terminals"; import { entityRefs } from "./refs"; import { createPluginAgentSnapshot, @@ -20,13 +21,10 @@ export interface PluginConnectedHostsSource { export interface PluginTerminalsSource { subscribe(listener: () => void): () => void; getSnapshot(): readonly PluginTerminalSnapshot[]; + getErrors?(): ReadonlyMap; } const emptyTerminals: readonly PluginTerminalSnapshot[] = Object.freeze([]); -const noTerminals: PluginTerminalsSource = { - subscribe: () => () => {}, - getSnapshot: () => emptyTerminals, -}; const listeners = new Set<() => void>(); let releaseStores: (() => void) | null = null; @@ -139,12 +137,15 @@ export function createPluginClientStateSource( serverId?: string, options: { hosts?: PluginConnectedHostsSource; terminals?: PluginTerminalsSource } = {}, ): PluginClientStateSource { - const terminals = options.terminals ?? noTerminals; + const terminals = options.terminals ?? pluginTerminals; const hosts = options.hosts ?? connectedPluginHosts; const subscribers = new Set<() => void>(); + const terminalSubscribers = new Set<() => void>(); + let releaseTerminals: (() => void) | null = null; let release: (() => void) | null = null; let previousProjections: readonly HostProjection[] = []; let previousTerminals = emptyTerminals; + let previousTerminalErrors: ReadonlyMap | undefined; let snapshot: PluginClientSnapshot = { workspaces: [], agents: [], @@ -159,41 +160,58 @@ export function createPluginClientStateSource( if (projection) projections.push(projection); } const terminalSnapshots = terminals.getSnapshot(); - if (sameSequence(previousProjections, projections) && previousTerminals === terminalSnapshots) + const terminalErrors = terminals.getErrors?.(); + if ( + sameSequence(previousProjections, projections) && + previousTerminals === terminalSnapshots && + previousTerminalErrors === terminalErrors + ) return snapshot; previousProjections = projections; previousTerminals = terminalSnapshots; + previousTerminalErrors = terminalErrors; snapshot = Object.freeze({ workspaces: Object.freeze(projections.flatMap((host) => host.snapshot.workspaces)), agents: Object.freeze(projections.flatMap((host) => host.snapshot.agents)), projects: Object.freeze(projections.flatMap((host) => host.snapshot.projects)), terminals: terminalSnapshots, + terminalErrors, }); return snapshot; } return { - subscribe(listener) { + subscribe(listener, subscriptionOptions) { subscribers.add(listener); + if (subscriptionOptions?.terminals) { + terminalSubscribers.add(listener); + releaseTerminals ??= terminals.subscribe(() => { + for (const subscriber of terminalSubscribers) subscriber(); + }); + } if (!release) { const notify = () => { for (const subscriber of subscribers) subscriber(); }; const releaseStore = subscribeStores(notify); const releaseHosts = hosts.subscribe(notify); - const releaseTerminals = terminals.subscribe(notify); release = () => { releaseStore(); releaseHosts(); - releaseTerminals(); }; } return () => { subscribers.delete(listener); + terminalSubscribers.delete(listener); + if (terminalSubscribers.size === 0 && releaseTerminals) { + releaseTerminals(); + releaseTerminals = null; + } if (subscribers.size === 0 && release) { release(); release = null; previousProjections = []; previousTerminals = emptyTerminals; + previousTerminalErrors = undefined; snapshot = { workspaces: [], agents: [], projects: [], terminals: emptyTerminals }; } }; @@ -221,6 +239,10 @@ export function createPluginClientStateSource( return detail && workspaceId ? createPluginAgentSnapshot(detail, workspaceId) : null; }, getProject: (ref) => projectHost(ref.hostId)?.projectById.get(ref.id) ?? null, - getTerminal: (ref) => terminals.getSnapshot().find((terminal) => terminal.ref === ref) ?? null, + getTerminal(ref) { + const error = terminals.getErrors?.().get(ref.hostId); + if (error) throw error; + return terminals.getSnapshot().find((terminal) => terminal.ref === ref) ?? null; + }, }; } diff --git a/packages/app/src/plugins/client-state/terminals.ts b/packages/app/src/plugins/client-state/terminals.ts new file mode 100644 index 0000000000..6b2630d7e7 --- /dev/null +++ b/packages/app/src/plugins/client-state/terminals.ts @@ -0,0 +1,154 @@ +import type { PluginTerminalSnapshot } from "@getpaseo/plugin"; +import type { DaemonClient } from "@getpaseo/client/internal/daemon-client"; +import type { TerminalsChanged } from "@getpaseo/protocol/messages"; +import { getHostRuntimeStore } from "@/runtime/host-runtime"; +import { entityRefs } from "./refs"; +import type { PluginTerminalsSource } from "./source"; + +interface TerminalHost { + serverId: string; + client: DaemonClient; + epoch: number; +} +export interface TerminalHostsSource { + subscribe(listener: () => void): () => void; + getSnapshot(): readonly TerminalHost[]; +} +interface Observation { + client: DaemonClient; + epoch: number; + items: readonly PluginTerminalSnapshot[]; + release(): void; +} + +/** One owned observation per connected host, shared by every plugin surface. */ +export function createPluginTerminalsSource(hosts: TerminalHostsSource): PluginTerminalsSource { + const observations = new Map(); + const listeners = new Set<() => void>(); + let snapshot: readonly PluginTerminalSnapshot[] = Object.freeze([]); + let errors: ReadonlyMap = new Map(); + let releaseHosts: (() => void) | null = null; + function publish() { + const next = [...observations.values()].flatMap((item) => item.items); + if (next.length !== snapshot.length || next.some((item, index) => item !== snapshot[index])) + snapshot = Object.freeze(next); + for (const listener of listeners) listener(); + } + function receive(hostId: string, observation: Observation, payload: TerminalsChanged["payload"]) { + if (observations.get(hostId) !== observation) return; + const previous = new Map(observation.items.map((item) => [item.ref.id, item])); + observation.items = Object.freeze( + payload.terminals.flatMap((terminal) => { + // Host-wide observation includes unowned legacy terminals. Sidebar navigation needs a workspace. + if (!terminal.workspaceId) return []; + const ref = entityRefs.terminal(hostId, terminal.id); + const workspace = entityRefs.workspace(hostId, terminal.workspaceId); + const title = terminal.title ?? terminal.name; + const createdAt = terminal.createdAt ?? null; + const isScript = terminal.isScript === true; + const cached = previous.get(terminal.id); + if ( + cached && + cached.workspace === workspace && + cached.title === title && + cached.createdAt === createdAt && + cached.isScript === isScript + ) + return [cached]; + return [Object.freeze({ ref, hostId, workspace, title, createdAt, isScript })]; + }), + ); + errors = new Map([...errors].filter(([id]) => id !== hostId)); + publish(); + } + function reconcile() { + const active = new Map(hosts.getSnapshot().map((host) => [host.serverId, host])); + for (const [id, observation] of observations) { + if ( + active.get(id)?.client !== observation.client || + active.get(id)?.epoch !== observation.epoch + ) { + observations.delete(id); + observation.release(); + } + } + errors = new Map([...errors].filter(([id]) => active.has(id))); + for (const [hostId, { client, epoch }] of active) { + if (observations.has(hostId)) continue; + const observation: Observation = { client, epoch, items: [], release() {} }; + observations.set(hostId, observation); + try { + // The SDK owns the capability gate; a failed host is reported only to terminal readers. + const subscription = client.observeTerminals({}); + const unsubscribe = subscription.subscribe({ + snapshot: (payload) => receive(hostId, observation, payload), + update: (message) => { + if (message.type === "terminals_changed") receive(hostId, observation, message.payload); + }, + error: (error) => { + if (observations.get(hostId) !== observation) return; + errors = new Map(errors).set( + hostId, + error instanceof Error ? error : new Error(String(error)), + ); + publish(); + }, + }); + void subscription.ready.catch(() => {}); // Observer owns error delivery. + observation.release = () => { + unsubscribe(); + void subscription.release().catch(() => {}); + }; + } catch (error) { + errors = new Map(errors).set( + hostId, + error instanceof Error ? error : new Error(String(error)), + ); + } + } + publish(); + } + return { + getSnapshot: () => snapshot, + getErrors: () => errors, + subscribe(listener) { + listeners.add(listener); + if (!releaseHosts) { + releaseHosts = hosts.subscribe(reconcile); + reconcile(); + } + return () => { + listeners.delete(listener); + if (!listeners.size && releaseHosts) { + releaseHosts(); + releaseHosts = null; + for (const observation of observations.values()) observation.release(); + observations.clear(); + snapshot = Object.freeze([]); + errors = new Map(); + } + }; + }, + }; +} + +export const pluginTerminals = createPluginTerminalsSource({ + getSnapshot() { + const runtime = getHostRuntimeStore(); + return runtime.getHosts().flatMap((host) => { + const state = runtime.getSnapshot(host.serverId); + return state?.connectionStatus === "online" && state.client + ? [{ serverId: host.serverId, client: state.client, epoch: state.connectionEpoch }] + : []; + }); + }, + subscribe(listener) { + const runtime = getHostRuntimeStore(); + const releaseRuntime = runtime.subscribeAll(listener); + const releaseHosts = runtime.subscribeHostList(listener); + return () => { + releaseRuntime(); + releaseHosts(); + }; + }, +}); diff --git a/packages/client/src/daemon-client.ts b/packages/client/src/daemon-client.ts index b29a34111c..5bdc2eb818 100644 --- a/packages/client/src/daemon-client.ts +++ b/packages/client/src/daemon-client.ts @@ -6506,10 +6506,16 @@ export class DaemonClient { // ============================================================================ observeTerminals(input: { - cwd: string; + cwd?: string; workspaceId?: string; signal?: AbortSignal; }): OwnedSubscription> { + // COMPAT(terminalCollection): added in v0.16, remove after 2027-06. + if ( + input.cwd === undefined && + this.getLastServerInfoMessage()?.features?.terminalCollection !== true + ) + throw new Error("Update the host to list terminals across workspaces."); const { signal, ...query } = input; return this.observe( "terminals_changed", diff --git a/packages/plugin/src/client/client-state.tsx b/packages/plugin/src/client/client-state.tsx index ee8a36e10e..db2a6b5f5a 100644 --- a/packages/plugin/src/client/client-state.tsx +++ b/packages/plugin/src/client/client-state.tsx @@ -11,7 +11,7 @@ import { createCollectionQuery, type Query, type Group } from "./query.js"; import { shallowEqual } from "./shallow.js"; export interface PluginClientStateSource { - subscribe(listener: () => void): () => void; + subscribe(listener: () => void, options?: { terminals?: boolean }): () => void; getWorkspace(id: string | WorkspaceRef): PluginWorkspaceSnapshot | null; getAgent(id: string | AgentRef): PluginAgentSnapshot | null; getProject(ref: ProjectRef): PluginProjectSnapshot | null; @@ -24,6 +24,7 @@ export interface PluginClientSnapshot { readonly agents: readonly PluginAgentSnapshot[]; readonly projects: readonly PluginProjectSnapshot[]; readonly terminals: readonly PluginTerminalSnapshot[]; + readonly terminalErrors?: ReadonlyMap; } const PluginClientStateContext = createContext(null); @@ -44,8 +45,24 @@ export function usePaseoSelector( type Equality = { equals(left: T, right: T): boolean }["equals"]; +function useStateSource(terminals: boolean) { + const source = usePluginClientStateSource(); + if (!source) throw new Error("Plugin state hooks must run inside a plugin host"); + return useMemo( + () => + terminals + ? { + ...source, + subscribe: (listener: () => void) => source.subscribe(listener, { terminals: true }), + } + : source, + [source, terminals], + ); +} + function createEntityHook( read: (source: PluginClientStateSource, ref: Ref) => Entity | null, + terminals = false, ) { function useEntity( ref: Ref, @@ -62,8 +79,7 @@ function createEntityHook( selector?: (entity: Entity) => Selection, equals?: Equality, ): Entity | Selection | null { - const source = usePluginClientStateSource(); - if (!source) throw new Error("Plugin state hooks must run inside a plugin host"); + const source = useStateSource(terminals); function select(entity: Entity | null): Entity | Selection | null { return entity !== null && selector ? selector(entity) : entity; } @@ -80,10 +96,14 @@ export const useWorkspace = createEntityHook((source, ref: WorkspaceRef | string // COMPAT(plugin-entity-id): string ids accepted since 0.16; remove after 2027-03. export const useAgent = createEntityHook((source, ref: AgentRef | string) => source.getAgent(ref)); export const useProject = createEntityHook((source, ref: ProjectRef) => source.getProject(ref)); -export const useTerminal = createEntityHook((source, ref: TerminalRef) => source.getTerminal(ref)); +export const useTerminal = createEntityHook( + (source, ref: TerminalRef) => source.getTerminal(ref), + true, +); function createCollectionHook( - read: (source: PluginClientStateSource) => readonly Snapshot[], + read: (source: PluginClientStateSource, query: Query) => readonly Snapshot[], + terminals = false, ) { function useCollection( query: Query & { groupBy: NonNullable["groupBy"]> }, @@ -97,12 +117,11 @@ function createCollectionHook = {}, ): readonly Snapshot["ref"][] | readonly Group[] { - const source = usePluginClientStateSource(); - if (!source) throw new Error("Plugin state hooks must run inside a plugin host"); + const source = useStateSource(terminals); const project = useMemo(() => createCollectionQuery(), []); return usePaseoSelector( source, - () => read(source), + () => read(source, query), (snapshots) => project(snapshots, query), Object.is, ); @@ -113,7 +132,13 @@ function createCollectionHook source.getSnapshot().workspaces); export const useAgents = createCollectionHook((source) => source.getSnapshot().agents); export const useProjects = createCollectionHook((source) => source.getSnapshot().projects); -export const useTerminals = createCollectionHook((source) => source.getSnapshot().terminals); +export const useTerminals = createCollectionHook((source, query) => { + const snapshot = source.getSnapshot(); + for (const [hostId, error] of snapshot.terminalErrors ?? []) { + if (!query.hosts || query.hosts.includes(hostId)) throw error; + } + return snapshot.terminals; +}, true); export function PluginClientStateProvider({ children, diff --git a/packages/plugin/src/contracts.ts b/packages/plugin/src/contracts.ts index 17c14a7edf..d99d61e8aa 100644 --- a/packages/plugin/src/contracts.ts +++ b/packages/plugin/src/contracts.ts @@ -133,5 +133,6 @@ export interface PluginTerminalSnapshot { readonly workspace: WorkspaceRef; readonly title: string; readonly isScript: boolean; - readonly createdAt: string; + /** Null for terminals owned by workers started before timestamps were tracked. */ + readonly createdAt: string | null; } diff --git a/packages/protocol/src/messages.test.ts b/packages/protocol/src/messages.test.ts index 373cef409b..ebe7d9b7d8 100644 --- a/packages/protocol/src/messages.test.ts +++ b/packages/protocol/src/messages.test.ts @@ -571,3 +571,43 @@ test.each(["all", "base", "uncommitted"] as const)( expect(parsed.attachments).toEqual([attachment]); }, ); + +describe("terminal collection protocol compatibility", () => { + test("accepts released directory observations and their snapshots", () => { + expect( + SessionInboundMessageSchema.parse({ + type: "subscribe_terminals_request", + cwd: "/repo", + requestId: "old", + }), + ).toMatchObject({ cwd: "/repo" }); + expect( + SessionOutboundMessageSchema.parse({ + type: "terminals_changed", + payload: { cwd: "/repo", terminals: [{ id: "terminal", name: "Shell" }] }, + }), + ).toMatchObject({ payload: { cwd: "/repo" } }); + }); + test("accepts capability-gated host observations and additive metadata", () => { + expect( + SessionInboundMessageSchema.parse({ type: "subscribe_terminals_request", requestId: "host" }), + ).toMatchObject({ requestId: "host" }); + expect( + SessionOutboundMessageSchema.parse({ + type: "terminals_changed", + payload: { + terminals: [ + { + id: "terminal", + name: "Shell", + cwd: "/repo", + workspaceId: "workspace", + createdAt: null, + isScript: true, + }, + ], + }, + }), + ).toMatchObject({ payload: { terminals: [{ isScript: true, createdAt: null }] } }); + }); +}); diff --git a/packages/protocol/src/messages.ts b/packages/protocol/src/messages.ts index 64f37064e2..d6a50adff5 100644 --- a/packages/protocol/src/messages.ts +++ b/packages/protocol/src/messages.ts @@ -3059,13 +3059,13 @@ export const ListTerminalsRequestSchema = z.object({ export const SubscribeTerminalsRequestSchema = z.object({ type: z.literal("subscribe_terminals_request"), requestId: z.string().optional(), - cwd: z.string(), + cwd: z.string().optional(), workspaceId: z.string().optional(), }); export const UnsubscribeTerminalsRequestSchema = z.object({ type: z.literal("unsubscribe_terminals_request"), - cwd: z.string(), + cwd: z.string().optional(), workspaceId: z.string().optional(), }); @@ -3732,6 +3732,7 @@ export const ServerInfoStatusPayloadSchema = z directorySync: z.boolean().optional(), // COMPAT(workspaceLabels): added in v0.5.0, remove after 2027-08-14. workspaceLabels: z.boolean().optional(), + terminalCollection: z.boolean().optional(), // COMPAT(workspaceBaseBranch): added after v0.11.2, remove gate after 2027-10-14. workspaceBaseBranch: z.boolean().optional(), // COMPAT(workspaceSetupRun): added in v0.8.0, remove gate after 2027-09-02. @@ -6591,6 +6592,8 @@ export const ListCommandsResponseSchema = z.object({ // ============================================================================ const TerminalInfoSchema = z.object({ + createdAt: z.string().nullable().optional(), + isScript: z.boolean().optional(), id: z.string(), name: z.string(), cwd: z.string(), @@ -6654,8 +6657,8 @@ export const TerminalsChangedSchema = z.object({ subscriptionId: z.string().optional(), requestId: z.string().optional(), workspaceId: z.string().optional(), - cwd: z.string(), - terminals: z.array(TerminalInfoSchema.omit({ cwd: true })), + cwd: z.string().optional(), + terminals: z.array(TerminalInfoSchema.partial({ cwd: true })), }), }); diff --git a/packages/protocol/src/terminal-subscription-key.ts b/packages/protocol/src/terminal-subscription-key.ts index 648ff96f71..4d64dfff83 100644 --- a/packages/protocol/src/terminal-subscription-key.ts +++ b/packages/protocol/src/terminal-subscription-key.ts @@ -3,6 +3,11 @@ // and tear down its own live subscription without disturbing the other. An // absent workspaceId (old clients) keys to the cwd on its own. The `::` // separator cannot collide with a workspace id, which is always `wks_`. -export function terminalSubscriptionKey(cwd: string, workspaceId: string | undefined): string { +export function terminalSubscriptionKey( + cwd: string | undefined, + workspaceId: string | undefined, +): string { + if (cwd === undefined) + return workspaceId === undefined ? "terminal-host" : `${workspaceId}::terminal-host`; return workspaceId === undefined ? cwd : `${workspaceId}::${cwd}`; } diff --git a/packages/server/src/server/daemon-e2e/terminal-workspace-sdk.e2e.test.ts b/packages/server/src/server/daemon-e2e/terminal-workspace-sdk.e2e.test.ts index 80c9409cef..b0c38bc985 100644 --- a/packages/server/src/server/daemon-e2e/terminal-workspace-sdk.e2e.test.ts +++ b/packages/server/src/server/daemon-e2e/terminal-workspace-sdk.e2e.test.ts @@ -1,5 +1,5 @@ import { resolveDaemonVersion } from "../daemon-version.js"; -import { mkdir, 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 { afterEach, beforeEach, expect, test } from "vitest"; @@ -269,3 +269,132 @@ test.runIf(process.platform !== "win32")( expect((await sdk.terminals.list({ workspaceId })).entries).toEqual([]); }, ); + +test("host-wide terminal observation tracks every workspace, metadata and release", async () => { + const first = await createWorkspace("Observe first"); + const second = await createWorkspace("Observe second"); + expect(client.getLastServerInfoMessage()?.features?.terminalCollection).toBe(true); + const observation = client.observeTerminals({}); + let terminals = (await observation.ready).terminals; + const update = observation.subscribe({ + snapshot: (value) => { + terminals = value.terminals; + }, + update: (message) => { + if (message.type === "terminals_changed") terminals = message.payload.terminals; + }, + }); + try { + expect(terminals).toEqual([]); + const scoped = client.observeTerminals({ workspaceId: first }); + const scopedSnapshot = await scoped.ready; + let owned = scopedSnapshot.terminals; + const releaseScoped = scoped.subscribe({ + snapshot: (value) => { + owned = value.terminals; + }, + update: (message) => { + if (message.type === "terminals_changed") owned = message.payload.terminals; + }, + }); + // Use a controlled process: interactive shells publish their own OSC title on startup. + const command = { + command: process.execPath, + args: ["-e", "console.log('READY'); process.stdin.resume()"], + }; + const a = await sdk.terminals.create({ + workspaceId: first, + name: "First terminal", + ...command, + }); + const b = await sdk.terminals.create({ + workspaceId: second, + name: "Second terminal", + ...command, + }); + await expect + .poll(async () => (await a.capture({ stripAnsi: true })).lines.join("\n")) + .toContain("READY"); + await expect + .poll(async () => (await b.capture({ stripAnsi: true })).lines.join("\n")) + .toContain("READY"); + await expect.poll(() => terminals.map((item) => item.id).sort()).toEqual([a.id, b.id].sort()); + expect(terminals.every((item) => item.cwd === cwd && item.createdAt && !item.isScript)).toBe( + true, + ); + expect(terminals.map((item) => item.workspaceId).sort()).toEqual([first, second].sort()); + await expect.poll(() => owned.map((item) => item.id)).toEqual([a.id]); + releaseScoped(); + await scoped.release(); + await client.renameTerminal({ terminalId: a.id, title: "Renamed terminal" }); + await expect + .poll(() => terminals.find((item) => item.id === a.id)?.title) + .toBe("Renamed terminal"); + await b.kill(); + await expect.poll(() => terminals.map((item) => item.id)).toEqual([a.id]); + await a.kill(); + await expect.poll(() => terminals).toEqual([]); + } finally { + update(); + await observation.release(); + } +}, 60_000); + +test("terminal collection reports workspace script ownership after runtime registration", async () => { + await writeFile( + path.join(cwd, "paseo.json"), + JSON.stringify({ + scripts: { + sleeper: { + type: "script", + command: `node -e "const fs = require('node:fs'); fs.writeFileSync('sleeper.pid', String(process.pid)); setInterval(() => { if (fs.existsSync('finish-sleeper')) process.exit(0); }, 50)"`, + }, + }, + }), + ); + const workspaceId = await createWorkspace("Script observation"); + const observation = client.observeTerminals({}); + let terminals = (await observation.ready).terminals; + const release = observation.subscribe({ + snapshot: (value) => { + terminals = value.terminals; + }, + update: (message) => { + if (message.type === "terminals_changed") terminals = message.payload.terminals; + }, + }); + try { + expect((await client.startWorkspaceScript(workspaceId, "sleeper")).error).toBeNull(); + await expect + .poll(() => + terminals.some((terminal) => terminal.workspaceId === workspaceId && terminal.isScript), + ) + .toBe(true); + const terminalId = terminals.find((terminal) => terminal.workspaceId === workspaceId)!.id; + const pidFile = path.join(cwd, "sleeper.pid"); + await expect + .poll(async () => Number(await readFile(pidFile, "utf8")), { timeout: 10_000 }) + .toBeGreaterThan(0); + const pid = Number(await readFile(pidFile, "utf8")); + await writeFile(path.join(cwd, "finish-sleeper"), "done"); + await expect.poll(() => isFixtureProcessRunning(pid), { timeout: 10_000 }).toBe(false); + // The program has exited; now close its interactive shell as well. + await sdk.terminals.ref(terminalId).kill(); + await expect + .poll(() => terminals.filter((terminal) => terminal.workspaceId === workspaceId)) + .toEqual([]); + } finally { + release(); + await observation.release(); + } +}, 60_000); + +function isFixtureProcessRunning(pid: number): boolean { + try { + process.kill(pid, 0); + return true; + } catch (error) { + if ((error as NodeJS.ErrnoException).code === "ESRCH") return false; + throw error; + } +} diff --git a/packages/server/src/server/session.ts b/packages/server/src/server/session.ts index 3dec72f45f..0a8234cfc2 100644 --- a/packages/server/src/server/session.ts +++ b/packages/server/src/server/session.ts @@ -1172,6 +1172,12 @@ export class Session { isPathWithinRoot: (rootPath, candidatePath) => this.isPathWithinRoot(rootPath, candidatePath), sessionLogger: this.sessionLogger, listTerminalWorkspaceRefs: () => this.listActiveWorkspaceRefs(), + isScriptTerminal: (terminal) => + this.scriptRuntimeStore + ?.listForWorkspace(terminal.workspaceId) + .some((script) => script.terminalId === terminal.id) ?? false, + subscribeTerminalMetadata: (listener) => + this.scriptRuntimeStore?.subscribe(listener) ?? (() => {}), getClientBufferedAmount: (source) => this.getTransportBufferedAmount(source), }); this.agentDirectory = new AgentDirectory({ diff --git a/packages/server/src/server/websocket-server.ts b/packages/server/src/server/websocket-server.ts index 7c301556c3..25bb102a20 100644 --- a/packages/server/src/server/websocket-server.ts +++ b/packages/server/src/server/websocket-server.ts @@ -2033,6 +2033,7 @@ export class VoiceAssistantWebSocketServer { directorySync: true, // COMPAT(workspaceLabels): added in v0.5.0, remove after 2027-08-14. ...(this.workspaceLabelService ? { workspaceLabels: true } : {}), + terminalCollection: true, // COMPAT(workspaceSetupRun): added in v0.7.3, remove gate after 2027-09-02. workspaceSetupRun: true, workspaceSetupObservation: true, diff --git a/packages/server/src/server/workspace-script-runtime-store.test.ts b/packages/server/src/server/workspace-script-runtime-store.test.ts index 625d1f2fe9..8b33d4ea53 100644 --- a/packages/server/src/server/workspace-script-runtime-store.test.ts +++ b/packages/server/src/server/workspace-script-runtime-store.test.ts @@ -97,3 +97,16 @@ describe("WorkspaceScriptRuntimeStore", () => { ]); }); }); + +it("notifies terminal metadata consumers when script ownership changes", () => { + const store = new WorkspaceScriptRuntimeStore(); + const observed: string[][] = []; + const release = store.subscribe(() => + observed.push(store.listForWorkspace("workspace-101").map((entry) => entry.terminalId)), + ); + store.set(createEntry()); + store.remove({ workspaceId: "workspace-101", scriptName: "web" }); + release(); + store.set(createEntry()); + expect(observed).toEqual([["terminal-1"], []]); +}); diff --git a/packages/server/src/server/workspace-script-runtime-store.ts b/packages/server/src/server/workspace-script-runtime-store.ts index a2f48199b6..f8f0ca0698 100644 --- a/packages/server/src/server/workspace-script-runtime-store.ts +++ b/packages/server/src/server/workspace-script-runtime-store.ts @@ -13,6 +13,17 @@ interface RuntimeEntryKey { } export class WorkspaceScriptRuntimeStore { + private readonly listeners = new Set<() => void>(); + subscribe(listener: () => void): () => void { + this.listeners.add(listener); + return () => { + this.listeners.delete(listener); + }; + } + private changed(): void { + for (const listener of this.listeners) listener(); + } + private readonly entries = new Map(); private readonly scriptsByWorkspace = new Map>(); @@ -31,6 +42,7 @@ export class WorkspaceScriptRuntimeStore { this.entries.set(entryKey, { ...entry }); this.addScriptToWorkspaceIndex(workspaceKey, entry.scriptName); + this.changed(); } remove(key: RuntimeEntryKey): void { @@ -42,6 +54,7 @@ export class WorkspaceScriptRuntimeStore { this.entries.delete(entryKey); this.removeScriptFromWorkspaceIndex(existing.workspaceId, existing.scriptName); + this.changed(); } listForWorkspace(workspaceId: string): ScriptRuntimeEntry[] { @@ -70,6 +83,7 @@ export class WorkspaceScriptRuntimeStore { this.entries.delete(this.toEntryKey(entry)); } this.scriptsByWorkspace.delete(this.toWorkspaceKey(workspaceId)); + this.changed(); } isRunning(key: RuntimeEntryKey): boolean { diff --git a/packages/server/src/terminal/terminal-session-controller.test.ts b/packages/server/src/terminal/terminal-session-controller.test.ts index ccb152aa23..f5648498a0 100644 --- a/packages/server/src/terminal/terminal-session-controller.test.ts +++ b/packages/server/src/terminal/terminal-session-controller.test.ts @@ -387,8 +387,25 @@ describe("terminal-session-controller with terminals held by a newer Paseo", () }; } + test("reports incompatible workers to a legacy subscription without a request id", async () => { + const outboundMessages: SessionOutboundMessage[] = []; + const controller = createController({ + terminalManager: incompatibleManager(), + emit: (message) => outboundMessages.push(message), + emitBinary: () => {}, + hasBinaryChannel: () => true, + isPathWithinRoot: isSameOrDescendantPath, + sessionLogger: createLogger(), + }); + await controller.dispatch({ type: "subscribe_terminals_request" }); + expect(outboundMessages).toEqual([ + { type: "status", payload: { status: "error", message: incompatible.message } }, + ]); + }); + test.each([ { type: "list_terminals_request" as const, requestId: "req-list" }, + { type: "subscribe_terminals_request" as const, requestId: "req-observe" }, { type: "list_terminals_request" as const, requestId: "req-list-cwd", cwd: "/work/repo" }, { type: "create_terminal_request" as const, @@ -500,8 +517,22 @@ describe("terminal-session-controller subdirectory aggregation", () => { payload: { cwd: rootCwd, terminals: [ - { id: "root-term", name: "Terminal 1", workspaceId: "ws-test", activity: null }, - { id: "subdir-term", name: "Mobile", workspaceId: "ws-test", activity: null }, + { + id: "root-term", + name: "Terminal 1", + workspaceId: "ws-test", + activity: null, + createdAt: null, + isScript: false, + }, + { + id: "subdir-term", + name: "Mobile", + workspaceId: "ws-test", + activity: null, + createdAt: null, + isScript: false, + }, ], }, }, @@ -571,6 +602,8 @@ describe("terminal-session-controller subdirectory aggregation", () => { cwd: rootCwd, workspaceId: "ws-test", activity: null, + createdAt: null, + isScript: false, }, ], requestId: "req-root", @@ -587,6 +620,8 @@ describe("terminal-session-controller subdirectory aggregation", () => { cwd: worktreeCwd, workspaceId: "ws-test", activity: null, + createdAt: null, + isScript: false, }, ], requestId: "req-worktree", @@ -672,7 +707,16 @@ describe("terminal-session-controller workspace-scoped subscriptions", () => { type: "terminals_changed", payload: { cwd, - terminals: [{ id: "a", name: "A", workspaceId: "ws-a", activity: null }], + terminals: [ + { + id: "a", + name: "A", + workspaceId: "ws-a", + activity: null, + createdAt: null, + isScript: false, + }, + ], }, }, ]); diff --git a/packages/server/src/terminal/terminal-session-controller.ts b/packages/server/src/terminal/terminal-session-controller.ts index 653524e54a..d047ee0c20 100644 --- a/packages/server/src/terminal/terminal-session-controller.ts +++ b/packages/server/src/terminal/terminal-session-controller.ts @@ -38,13 +38,12 @@ import type { TerminalSession } from "./terminal.js"; import type { TerminalManager, TerminalsChangedEvent } from "./terminal-manager.js"; import { applyTerminalSize } from "./terminal-size-ownership.js"; import { TerminalWorkerIncompatibleError } from "./terminal-worker-pool.js"; -import type { TerminalActivity } from "@getpaseo/protocol/terminal-activity"; import { terminalSubscriptionKey } from "@getpaseo/protocol/terminal-subscription-key"; const MAX_TERMINAL_STREAM_SLOTS = 256; interface TerminalDirectorySubscription { - cwd: string; + cwd: string | undefined; workspaceId: string | undefined; owner: OwnedSubscription; refresh: Promise | null; @@ -87,6 +86,8 @@ export interface TerminalSessionControllerOptions { supportsChunkedReplay?: (source: object) => boolean; isPathWithinRoot: (rootPath: string, candidatePath: string) => boolean; sessionLogger: pino.Logger; + isScriptTerminal?: (terminal: TerminalSession) => boolean; + subscribeTerminalMetadata?: (listener: () => void) => () => void; listTerminalWorkspaceRefs?: () => Promise; listTerminalWorkspaceRoots?: () => Promise; // Drives the snapshot catch-up fallback: a keeping-up client reports ~0 and @@ -163,9 +164,17 @@ export class TerminalSessionController { start(): void { if (!this.terminalManager || this.unsubscribeTerminalsChanged) return; - this.unsubscribeTerminalsChanged = this.terminalManager.subscribeTerminalsChanged((event) => { + const releaseManager = this.terminalManager.subscribeTerminalsChanged((event) => { void this.handleTerminalsChanged(event); }); + const releaseMetadata = this.options.subscribeTerminalMetadata?.(() => { + for (const subscription of this.subscribedDirectories.values()) + void this.emitTerminalsSnapshotForSubscription(subscription); + }); + this.unsubscribeTerminalsChanged = () => { + releaseManager(); + releaseMetadata?.(); + }; } getMetrics(): TerminalSessionControllerMetrics { @@ -191,6 +200,7 @@ export class TerminalSessionController { ) { return false; } + if (subscription.cwd === undefined) return true; return this.terminalBelongsToRoot(subscription.cwd, input.cwd, workspaceRoots); }); } @@ -302,15 +312,7 @@ export class TerminalSessionController { } } - private toTerminalInfo( - terminal: Pick, - ): { - id: string; - name: string; - workspaceId: string; - title?: string; - activity: TerminalActivity | null; - } { + private toTerminalInfo(terminal: TerminalSession) { const title = terminal.getTitle(); const activity = terminal.getActivity(); return { @@ -319,6 +321,8 @@ export class TerminalSessionController { workspaceId: terminal.workspaceId, ...(title ? { title } : {}), activity, + createdAt: terminal.createdAt ?? null, + isScript: this.options.isScriptTerminal?.(terminal) ?? false, }; } @@ -329,7 +333,8 @@ export class TerminalSessionController { // aggregated list — so the client's cache replacement doesn't drop the // terminals that live directly at the root. const matchingSubscriptions = Array.from(this.subscribedDirectories.values()).filter( - (subscription) => this.isPathWithinRoot(subscription.cwd, event.cwd), + (subscription) => + subscription.cwd === undefined || this.isPathWithinRoot(subscription.cwd, event.cwd), ); for (const subscription of matchingSubscriptions) { await this.emitTerminalsSnapshotForSubscription(subscription); @@ -368,6 +373,10 @@ export class TerminalSessionController { await this.emitTerminalsSnapshotForSubscription(subscription, msg.requestId); } catch (error) { await owner.release(); + if (error instanceof TerminalWorkerIncompatibleError) { + this.emitIncompatible(msg, error); + return; + } throw error; } } @@ -383,17 +392,26 @@ export class TerminalSessionController { const refresh = (async () => { do { subscription.pending = false; - const terminals = await this.getTerminalsForWorkspaceRoot( - subscription.cwd, - subscription.workspaceId, - ); + const terminals = + subscription.cwd === undefined + ? (await this.getAllTerminalSessions()).filter( + (terminal) => + subscription.workspaceId === undefined || + terminal.workspaceId === subscription.workspaceId, + ) + : 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)), + terminals: terminals.map((terminal) => + Object.assign( + this.toTerminalInfo(terminal), + subscription.cwd === undefined ? { cwd: terminal.cwd } : {}, + ), + ), ...(requestId ? { requestId } : {}), }, }); @@ -464,9 +482,13 @@ export class TerminalSessionController { // An empty list or a generic failure would read as "no terminals"; the client needs to know // they exist and why it cannot reach them. private emitIncompatible( - msg: { type: string; requestId: string }, + msg: { type: string; requestId?: string }, error: TerminalWorkerIncompatibleError, ): void { + if (msg.requestId === undefined) { + this.emit({ type: "status", payload: { status: "error", message: error.message } }); + return; + } this.emit({ type: "rpc_error", payload: { @@ -482,6 +504,8 @@ export class TerminalSessionController { if (!this.terminalManager) { return []; } + const unavailable = this.terminalManager.getUnavailableError?.(); + if (unavailable) throw unavailable; const directories = this.terminalManager.listDirectories(); const manager = this.terminalManager; const terminalsByDirectory = await Promise.all( diff --git a/packages/server/src/terminal/terminal-worker-process.ts b/packages/server/src/terminal/terminal-worker-process.ts index 855c39da59..49ad99ffb4 100644 --- a/packages/server/src/terminal/terminal-worker-process.ts +++ b/packages/server/src/terminal/terminal-worker-process.ts @@ -263,7 +263,12 @@ function runWorker(argv: string[]): void { } function terminalMetadata(session: LocalTerminalSession) { - return { ...session.getSize(), hasOutput: session.hasOutput(), title: session.getTitle() }; + return { + ...session.getSize(), + createdAt: session.createdAt, + hasOutput: session.hasOutput(), + title: session.getTitle(), + }; } function toTerminalInfo(session: LocalTerminalSession): WorkerTerminalInfo { diff --git a/packages/server/src/terminal/terminal-worker-protocol.test.ts b/packages/server/src/terminal/terminal-worker-protocol.test.ts index 6ea9a823db..9193a5dabd 100644 --- a/packages/server/src/terminal/terminal-worker-protocol.test.ts +++ b/packages/server/src/terminal/terminal-worker-protocol.test.ts @@ -1,5 +1,8 @@ import { describe, expect, it } from "vitest"; -import { classifyTerminalWorkerProtocol } from "./terminal-worker-protocol.js"; +import { + classifyTerminalWorkerProtocol, + TERMINAL_WORKER_PROTOCOL_VERSION, +} from "./terminal-worker-protocol.js"; describe("classifyTerminalWorkerProtocol", () => { it("rejects the unreleased cell protocol without a draining adapter", () => { @@ -23,3 +26,8 @@ describe("classifyTerminalWorkerProtocol", () => { expect(classifyTerminalWorkerProtocol(reported, 6)).toBe(fit); }); }); + +it("drains v5 terminals while v6 supplies creation timestamps", () => { + expect(TERMINAL_WORKER_PROTOCOL_VERSION).toBe(6); + expect(classifyTerminalWorkerProtocol(5)).toBe("previous"); +}); diff --git a/packages/server/src/terminal/terminal-worker-protocol.ts b/packages/server/src/terminal/terminal-worker-protocol.ts index e781543697..62e731a4dc 100644 --- a/packages/server/src/terminal/terminal-worker-protocol.ts +++ b/packages/server/src/terminal/terminal-worker-protocol.ts @@ -1,6 +1,8 @@ import type { TerminalExitInfo, ServerMessage, ClientMessage } from "./terminal.js"; import type { TerminalReplayEvent, TerminalReplayOptions } from "./terminal-replay.js"; export interface WorkerTerminalState { + /** COMPAT(terminal-created-at): v6; v5 draining workers omit this, remove after 2027-06. */ + createdAt?: string; rows: number; cols: number; hasOutput: boolean; @@ -43,7 +45,7 @@ export interface WorkerCreateTerminalOptions { // `shutdown`. A daemon drives a worker of its own version, drains one a version older (no new // terminals; it exits after its last terminal), shuts down anything older, and leaves a newer // worker running untouched. See docs/terminal-worker.md. -export const TERMINAL_WORKER_PROTOCOL_VERSION = 5; +export const TERMINAL_WORKER_PROTOCOL_VERSION = 6; // Test-only: the protocol version a worker reports, so tests can stand up older and newer // workers from one build. diff --git a/packages/server/src/terminal/terminal.ts b/packages/server/src/terminal/terminal.ts index b09bc8bbf6..183be5e8bf 100644 --- a/packages/server/src/terminal/terminal.ts +++ b/packages/server/src/terminal/terminal.ts @@ -88,6 +88,8 @@ export interface TerminalActivityTransition { } export interface TerminalSession { + /** Absent on terminal workers started before creation timestamps were tracked. */ + createdAt?: string; id: string; name: string; cwd: string; @@ -1378,6 +1380,7 @@ export async function createTerminal( return { id, + createdAt: new Date().toISOString(), pid: ptyProcess.pid, name, cwd, diff --git a/packages/server/src/terminal/worker-terminal-manager.test.ts b/packages/server/src/terminal/worker-terminal-manager.test.ts index 0bcc2acf92..26469f7190 100644 --- a/packages/server/src/terminal/worker-terminal-manager.test.ts +++ b/packages/server/src/terminal/worker-terminal-manager.test.ts @@ -1646,19 +1646,19 @@ it.each([1, 4])( expect(manager.getTerminal(old.terminalId)).toBeUndefined(); const workers = await workerRecords(paseoHome); expect(workers).toHaveLength(1); - expect(workers[0]!.protocolVersion).toBe(5); + expect(workers[0]!.protocolVersion).toBe(6); }, ); it("leaves a worker from a newer Paseo running, reports it incompatible, and closes it on a kill stop", async () => { const paseoHome = createTemporaryHome(); - const newer = await leaveWorkerWithTerminal(paseoHome, 6); + const newer = await leaveWorkerWithTerminal(paseoHome, 7); manager = await startRealWorkerManager({ paseoHome }); const unavailable = manager.getUnavailableError?.(); expect(unavailable).toBeInstanceOf(TerminalWorkerIncompatibleError); - expect(unavailable?.message).toContain("worker protocol 6"); + expect(unavailable?.message).toContain("worker protocol 7"); await expect(manager.getTerminals(newer.cwd)).rejects.toBeInstanceOf( TerminalWorkerIncompatibleError, ); diff --git a/packages/server/src/terminal/worker-terminal-manager.ts b/packages/server/src/terminal/worker-terminal-manager.ts index ea91d54a66..731f977bc7 100644 --- a/packages/server/src/terminal/worker-terminal-manager.ts +++ b/packages/server/src/terminal/worker-terminal-manager.ts @@ -268,6 +268,9 @@ export function createWorkerTerminalManager( }; const session: TerminalSession = { + get createdAt() { + return record.state.createdAt; + }, get id() { return record.info.id; }, @@ -373,6 +376,10 @@ export function createWorkerTerminalManager( for (const listener of Array.from(record.titleChangeListeners)) { listener(manualTitle); } + emitTerminalsChanged({ + cwd: record.info.cwd, + terminals: listTerminalItemsForCwd(record.info.cwd), + }); }, getExitInfo(): TerminalExitInfo | null { return record.exitInfo; diff --git a/plugin-examples/sidebar-views/client/terminals.tsx b/plugin-examples/sidebar-views/client/terminals.tsx new file mode 100644 index 0000000000..2106e12130 --- /dev/null +++ b/plugin-examples/sidebar-views/client/terminals.tsx @@ -0,0 +1,131 @@ +import { Component, useCallback, useMemo, useState, type ReactNode } from "react"; +import { Pressable, ScrollView, Text, View } from "react-native"; +import { useMutation } from "@tanstack/react-query"; +import { + useHosts, + useTerminals, + useTerminal, + usePaseo, + type TerminalRef, + type PluginSidebarViewProps, +} from "@getpaseo/plugin/client"; +import { Heading } from "./rows"; + +class TerminalError extends Component<{ children: ReactNode }, { error: Error | null }> { + state = { error: null as Error | null }; + static getDerivedStateFromError(error: Error) { + return { error }; + } + render() { + return this.state.error ? {this.state.error.message} : this.props.children; + } +} +function TerminalRow({ + reference, + view, +}: { + reference: TerminalRef; + view: PluginSidebarViewProps; +}) { + const terminal = useTerminal(reference); + const paseo = usePaseo(); + const focus = useCallback(() => paseo.terminals.ref(reference).focus(), [paseo, reference]); + const close = useMutation({ mutationFn: () => paseo.terminals.ref(reference).kill() }); + const kill = useCallback(() => close.mutate(), [close]); + const style = useMemo( + () => ({ color: view.theme.colors.foreground, fontSize: 12, padding: 12 }), + [view.theme], + ); + if (!terminal) return null; + return ( + + + + {terminal.title} + {terminal.isScript ? " (script)" : ""} + + + + Close terminal + + {close.error ? {close.error.message} : null} + + ); +} +function HostTerminals({ hostId, view }: { hostId: string; view: PluginSidebarViewProps }) { + const terminals = useTerminals({ hosts: [hostId], orderBy: "createdAt" }); + return ( + + {terminals.length ? ( + terminals.map((reference) => ( + + )) + ) : ( + + )} + + ); +} +function HostButton({ + hostId, + label, + select, + view, +}: { + hostId: string; + label: string; + select: (id: string) => void; + view: PluginSidebarViewProps; +}) { + const choose = useCallback(() => select(hostId), [select, hostId]); + return ( + + + + ); +} +export function Terminals(view: PluginSidebarViewProps) { + const hosts = useHosts(); + const [chosen, select] = useState(null); + const selected = chosen; + return ( + + {hosts.map((host) => ( + + ))} + {selected ? ( + + + + ) : ( + + )} + + ); +} diff --git a/plugin-examples/sidebar-views/index.client.tsx b/plugin-examples/sidebar-views/index.client.tsx index e1f43db8eb..3cf194c8a2 100644 --- a/plugin-examples/sidebar-views/index.client.tsx +++ b/plugin-examples/sidebar-views/index.client.tsx @@ -2,9 +2,11 @@ import type { PluginClientContext } from "@getpaseo/plugin/client"; import { Workspaces } from "./client/workspaces"; import { Agents } from "./client/agents"; import { Nested } from "./client/nested"; +import { Terminals } from "./client/terminals"; import { Labels } from "./client/labels"; export default function contribute(client: PluginClientContext) { const cleanups = [ + client.addSidebarView({ id: "terminals", title: "Example terminals", Component: Terminals }), client.addSidebarView({ id: "workspaces", title: "Example workspaces", Component: Workspaces }), client.addSidebarView({ id: "agents", title: "Example agents", Component: Agents }), client.addSidebarView({ id: "nested", title: "Example nested agents", Component: Nested }), diff --git a/public-docs/plugins/sidebar-views.md b/public-docs/plugins/sidebar-views.md index 25c29f86a3..542c0f6166 100644 --- a/public-docs/plugins/sidebar-views.md +++ b/public-docs/plugins/sidebar-views.md @@ -113,6 +113,19 @@ localized `indicatorLabel` to display the status Paseo shows. `status` remains t status. Agent snapshots include their workspace and parent refs. Project snapshots provide `displayName`, `rootPath`, `kind` and `iconRevision`. +## List terminals + +The terminals example selects a host, lists its terminals, focuses a terminal tab and closes a +terminal through its handle. `useTerminals({ hosts: [hostId] })` requires a host advertising +`terminalCollection`. A terminal reader throws an update-host error for an unsupported queried +host; other entity hooks continue working. Keep host selection outside an error boundary so the +user can select another host. Omit `hosts` to require the capability on every connected host. + +Terminal snapshots contain `workspace`, `title`, `isScript` and `createdAt`. A terminal kept alive +by a worker from before timestamp tracking has `createdAt: null`. Script ownership follows +workspace script runtime entries. The host observation follows terminal creation, closure, title +changes and reconnects, and releases when its last plugin subscriber leaves. + ## Act on a ref `usePaseo()` routes ref-based actions to the ref's host: