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.
This commit is contained in:
Mohamed Boudra
2026-11-06 09:22:19 +01:00
parent 62bd21214b
commit c4e5a0e739
30 changed files with 795 additions and 61 deletions
@@ -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;
}
}
+6
View File
@@ -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({
@@ -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,
@@ -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"], []]);
});
@@ -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<string, ScriptRuntimeEntry>();
private readonly scriptsByWorkspace = new Map<string, Set<string>>();
@@ -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 {
@@ -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,
},
],
},
},
]);
@@ -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<void> | 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<readonly TerminalWorkspaceRef[]>;
listTerminalWorkspaceRoots?: () => Promise<readonly string[]>;
// 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<TerminalSession, "id" | "name" | "workspaceId" | "getTitle" | "getActivity">,
): {
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(
@@ -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 {
@@ -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");
});
@@ -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.
+3
View File
@@ -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,
@@ -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,
);
@@ -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;