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: