From c8a417c4fbd497ac12bf89de1abb8111606d2c3f Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Sun, 6 Sep 2026 10:17:58 +0200 Subject: [PATCH] Address terminals by workspace ID in the SDK and CLI (#4358) * feat(terminals): expose workspace ownership through the SDK and CLI Directory-based terminal creation selected the oldest workspace sharing that path. Expose explicit workspace ownership in the shared public API and CLI, retain ownership and process directories in listings, and acknowledge terminal deletion after teardown. * fix(file-observer): preserve nested shallow reconciliation scopes A parent directory scan does not visit its descendants. Collapsing change scopes discarded nested scans and left coalesced native deletions unseen until a full audit. * test(terminals): extract output waits from end-to-end scenarios --- docs/architecture.md | 2 +- docs/plugins.md | 2 +- packages/cli/src/commands/terminal/capture.ts | 6 +- packages/cli/src/commands/terminal/create.ts | 40 ++-- packages/cli/src/commands/terminal/index.ts | 12 +- packages/cli/src/commands/terminal/kill.ts | 10 +- packages/cli/src/commands/terminal/ls.ts | 18 +- packages/cli/src/commands/terminal/schema.ts | 23 +-- .../cli/src/commands/terminal/send-keys.ts | 43 +--- packages/cli/src/commands/terminal/shared.ts | 9 +- .../cli/tests/39-terminal-workspace.test.ts | 80 ++++++++ packages/cli/tests/helpers/terminal.ts | 19 ++ packages/client/README.md | 2 +- packages/client/src/index.test.ts | 1 + packages/client/src/index.ts | 33 ++- packages/client/src/terminals/index.test.ts | 35 ++++ packages/client/src/terminals/index.ts | 179 +++++++++++++++++ packages/protocol/src/messages.ts | 4 +- .../protocol/src/messages.wire-compat.test.ts | 20 ++ packages/server/package.json | 2 +- .../terminal-workspace-sdk.e2e.test.ts | 190 ++++++++++++++++++ .../internal/native-recursive.test.ts | 71 +++++++ .../internal/native-recursive.ts | 24 ++- .../server/src/server/websocket-server.ts | 1 + .../terminal-session-controller.test.ts | 18 +- .../terminal/terminal-session-controller.ts | 45 ++++- public-docs/cli.md | 19 ++ public-docs/plugins/v0.8/reference.md | 2 +- public-docs/sdk/reference.md | 40 ++++ public-docs/sdk/workspaces.md | 16 ++ 30 files changed, 832 insertions(+), 134 deletions(-) create mode 100644 packages/cli/tests/39-terminal-workspace.test.ts create mode 100644 packages/cli/tests/helpers/terminal.ts create mode 100644 packages/client/src/terminals/index.test.ts create mode 100644 packages/client/src/terminals/index.ts create mode 100644 packages/server/src/server/daemon-e2e/terminal-workspace-sdk.e2e.test.ts create mode 100644 packages/server/src/server/file-observer/internal/native-recursive.test.ts diff --git a/docs/architecture.md b/docs/architecture.md index 0ed8c46ade..134e8f3532 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -94,7 +94,7 @@ facade. App and CLI may import the low-level driver from `@getpaseo/client/internal/daemon-client` during migration, while new SDK-shaped code imports from `@getpaseo/client`. -`PaseoApi` is the capability-only boundary over workspaces, agents, providers, and config. +`PaseoApi` is the capability-only boundary over workspaces, agents, terminals, providers, and config. `PaseoClient` adds connection lifecycle. App plugin surfaces borrow an API over their selected host's client; plugin subprocesses use the same facade over a host-owned IPC transport. diff --git a/docs/plugins.md b/docs/plugins.md index 03f38b6172..ae8a241c88 100644 --- a/docs/plugins.md +++ b/docs/plugins.md @@ -219,7 +219,7 @@ typed async function. Use the host-provided `@tanstack/react-query` for request Paseo gives each plugin installation its own query client. `usePaseo()` and the handler's `{ paseo }` context expose the same `PaseoApi`: projects, -workspaces, agents, providers, and daemon config. They do not expose connection lifecycle. A surface borrows the +workspaces, agents, terminals, providers, and daemon config. They do not expose connection lifecycle. A surface borrows the selected host's existing connection; switching the screen's host changes both `usePaseo()` and `useRpc()` to that host. An offline selected host fails there and never falls through to another installation. A server handler owns an IPC-backed daemon session for the life of its subprocess. diff --git a/packages/cli/src/commands/terminal/capture.ts b/packages/cli/src/commands/terminal/capture.ts index 2dc8f2180d..98b9f2ca2e 100644 --- a/packages/cli/src/commands/terminal/capture.ts +++ b/packages/cli/src/commands/terminal/capture.ts @@ -55,7 +55,7 @@ async function executeCaptureCommand( terminalId: string, options: TerminalCaptureOptions, ): Promise<{ terminalId: string; lines: string[]; totalLines: number }> { - const { client } = await connectTerminalClient(options.host); + const { client, close } = await connectTerminalClient(options.host); try { const resolvedId = await resolveTerminalId(client, terminalId); @@ -70,7 +70,7 @@ async function executeCaptureCommand( const start = options.scrollback ? 0 : parseLineNumber("--start", options.start); const end = parseLineNumber("--end", options.end); - return await client.captureTerminal(resolvedId, { + return await client.terminals.ref(resolvedId).capture({ ...(start === undefined ? {} : { start }), ...(end === undefined ? {} : { end }), stripAnsi: !options.ansi, @@ -78,7 +78,7 @@ async function executeCaptureCommand( } catch (err) { throw toTerminalCommandError("TERMINAL_CAPTURE_FAILED", "capture terminal output", err); } finally { - await client.close().catch(() => {}); + await close().catch(() => {}); } } diff --git a/packages/cli/src/commands/terminal/create.ts b/packages/cli/src/commands/terminal/create.ts index b71833b871..9524be0223 100644 --- a/packages/cli/src/commands/terminal/create.ts +++ b/packages/cli/src/commands/terminal/create.ts @@ -1,13 +1,14 @@ import type { Command } from "commander"; -import type { SingleResult, CommandError } from "../../output/index.js"; +import type { SingleResult } from "../../output/index.js"; import { connectTerminalClient, toTerminalCommandError, type TerminalCommandOptions, } from "./shared.js"; -import { terminalSchema, type TerminalRow, toTerminalRow } from "./schema.js"; +import { terminalSchema, type TerminalRow } from "./schema.js"; export interface TerminalCreateOptions extends TerminalCommandOptions { + workspace?: string; cwd?: string; name?: string; } @@ -16,37 +17,26 @@ export async function runCreateCommand( options: TerminalCreateOptions, _command: Command, ): Promise> { - const { client } = await connectTerminalClient(options.host); - const cwd = options.cwd ?? process.cwd(); - + const { client, close } = await connectTerminalClient(options.host); try { - const opened = await client.openProject(cwd); - if (!opened.workspace) { - const error: CommandError = { - code: "WORKSPACE_OPEN_FAILED", - message: opened.error ?? "Failed to open workspace", - }; - throw error; - } - - const payload = await client.createTerminal(cwd, options.name, undefined, { - workspaceId: opened.workspace.id, + const cwd = options.cwd ?? (options.workspace ? undefined : process.cwd()); + const workspaceId = + options.workspace ?? (await client.workspaces.open(options.cwd ?? process.cwd())).id; + const terminal = await client.terminals.create({ + workspaceId, + cwd, + name: options.name, }); - if (!payload.terminal) { - const error: CommandError = { - code: "TERMINAL_CREATE_FAILED", - message: payload.error ?? "Failed to create terminal", - }; - throw error; - } + const snapshot = terminal.current(); + if (!snapshot) throw new Error("The daemon did not create a terminal"); return { type: "single", - data: toTerminalRow(payload.terminal), + data: snapshot, schema: terminalSchema, }; } catch (err) { throw toTerminalCommandError("TERMINAL_CREATE_FAILED", "create terminal", err); } finally { - await client.close().catch(() => {}); + await close().catch(() => {}); } } diff --git a/packages/cli/src/commands/terminal/index.ts b/packages/cli/src/commands/terminal/index.ts index ce647f78e6..22ffc39d31 100644 --- a/packages/cli/src/commands/terminal/index.ts +++ b/packages/cli/src/commands/terminal/index.ts @@ -1,4 +1,4 @@ -import { Command } from "commander"; +import { Command, Option } from "commander"; import { withOutput } from "../../output/index.js"; import { addDaemonHostOption, @@ -18,15 +18,19 @@ export function createTerminalCommand(): Command { terminal .command("ls") .description("List terminals") - .option("--all", "List terminals across all workspaces") - .option("--cwd ", "Workspace directory"), + .addOption( + new Option("--all", "List terminals across all workspaces").conflicts(["cwd", "workspace"]), + ) + .option("--workspace ", "Workspace ID") + .option("--cwd ", "Working directory"), ).action(withOutput(runLsCommand)); addJsonAndDaemonHostOptions( terminal .command("create") .description("Create a terminal") - .option("--cwd ", "Workspace directory") + .option("--workspace ", "Workspace ID") + .option("--cwd ", "Working directory") .option("--name ", "Terminal name"), ).action(withOutput(runCreateCommand)); diff --git a/packages/cli/src/commands/terminal/kill.ts b/packages/cli/src/commands/terminal/kill.ts index 0a574e16d8..30990d9c11 100644 --- a/packages/cli/src/commands/terminal/kill.ts +++ b/packages/cli/src/commands/terminal/kill.ts @@ -13,23 +13,23 @@ export async function runKillCommand( options: TerminalCommandOptions, _command: Command, ): Promise> { - const { client } = await connectTerminalClient(options.host); + const { client, close } = await connectTerminalClient(options.host); try { const resolvedId = await requireTerminalId(client, terminalId); - const payload = await client.killTerminal(resolvedId); + await client.terminals.ref(resolvedId).kill(); return { type: "single", data: { - terminalId: payload.terminalId, - success: payload.success, + terminalId: resolvedId, + success: true, }, schema: terminalKillSchema, }; } catch (err) { throw toTerminalCommandError("TERMINAL_KILL_FAILED", "kill terminal", err); } finally { - await client.close().catch(() => {}); + await close().catch(() => {}); } } diff --git a/packages/cli/src/commands/terminal/ls.ts b/packages/cli/src/commands/terminal/ls.ts index bcb6d90d48..bec0b0047b 100644 --- a/packages/cli/src/commands/terminal/ls.ts +++ b/packages/cli/src/commands/terminal/ls.ts @@ -5,11 +5,10 @@ import { toTerminalCommandError, type TerminalCommandOptions, } from "./shared.js"; -import { terminalSchema, type TerminalRow, toTerminalRow } from "./schema.js"; - -type TerminalListEntry = Parameters[0]; +import { terminalSchema, type TerminalRow } from "./schema.js"; export interface TerminalLsOptions extends TerminalCommandOptions { + workspace?: string; all?: boolean; cwd?: string; } @@ -18,22 +17,19 @@ export async function runLsCommand( options: TerminalLsOptions, _command: Command, ): Promise> { - const { client } = await connectTerminalClient(options.host); - const cwd = options.all ? undefined : (options.cwd ?? process.cwd()); + const { client, close } = await connectTerminalClient(options.host); + const cwd = options.all || options.workspace ? undefined : (options.cwd ?? process.cwd()); try { - const payload = - cwd === undefined ? await client.listTerminals() : await client.listTerminals(cwd); + const payload = await client.terminals.list({ cwd, workspaceId: options.workspace }); return { type: "list", - data: payload.terminals.map((terminal: TerminalListEntry) => - toTerminalRow(terminal, payload.cwd ?? cwd), - ), + data: payload.entries, schema: terminalSchema, }; } catch (err) { throw toTerminalCommandError("TERMINAL_LIST_FAILED", "list terminals", err); } finally { - await client.close().catch(() => {}); + await close().catch(() => {}); } } diff --git a/packages/cli/src/commands/terminal/schema.ts b/packages/cli/src/commands/terminal/schema.ts index 9b777ad196..def9c3c455 100644 --- a/packages/cli/src/commands/terminal/schema.ts +++ b/packages/cli/src/commands/terminal/schema.ts @@ -1,10 +1,7 @@ +import type { PaseoTerminal } from "@getpaseo/client"; import type { OutputSchema } from "../../output/index.js"; -export interface TerminalRow { - id: string; - name: string; - cwd: string; -} +export type TerminalRow = PaseoTerminal; export interface TerminalKillRow { terminalId: string; @@ -17,6 +14,7 @@ export const terminalSchema: OutputSchema = { { header: "ID", field: (row) => row.id.slice(0, 8), width: 8 }, { header: "NAME", field: "name", width: 24 }, { header: "CWD", field: "cwd", width: 48 }, + { header: "WORKSPACE", field: "workspaceId", width: 24 }, ], }; @@ -27,18 +25,3 @@ export const terminalKillSchema: OutputSchema = { { header: "SUCCESS", field: "success", width: 8 }, ], }; - -export function toTerminalRow( - terminal: { - id: string; - name: string; - cwd?: string; - }, - cwd?: string, -): TerminalRow { - return { - id: terminal.id, - name: terminal.name, - cwd: terminal.cwd ?? cwd ?? "-", - }; -} diff --git a/packages/cli/src/commands/terminal/send-keys.ts b/packages/cli/src/commands/terminal/send-keys.ts index f109cece64..5097ea3908 100644 --- a/packages/cli/src/commands/terminal/send-keys.ts +++ b/packages/cli/src/commands/terminal/send-keys.ts @@ -39,7 +39,7 @@ async function executeSendKeysCommand( keys: string[], options: TerminalSendKeysOptions, ): Promise<{ terminalId: string; keysSent: number }> { - const { client } = await connectTerminalClient(options.host); + const { client, close } = await connectTerminalClient(options.host); try { const resolvedId = await resolveTerminalId(client, terminalId); @@ -51,49 +51,16 @@ async function executeSendKeysCommand( }; } - const data = keys.map((key) => resolveKeyToken(key, options.literal === true)).join(""); - client.sendTerminalInput(resolvedId, { type: "input", data }); + const terminal = client.terminals.ref(resolvedId); + const keysSent = options.literal ? terminal.write(keys.join("")) : terminal.sendKeys(keys); return { terminalId: resolvedId, - keysSent: data.length, + keysSent, }; } catch (err) { throw toTerminalCommandError("TERMINAL_SEND_KEYS_FAILED", "send terminal keys", err); } finally { - await client.close().catch(() => {}); - } -} - -function resolveKeyToken(key: string, literal: boolean): string { - if (literal) { - return key; - } - - switch (key) { - case "Enter": - return "\r"; - case "Tab": - return "\t"; - case "Escape": - return "\u001b"; - case "Space": - return " "; - case "BSpace": - return "\u007f"; - case "C-c": - return "\u0003"; - case "C-d": - return "\u0004"; - case "C-z": - return "\u001a"; - case "C-l": - return "\u000c"; - case "C-a": - return "\u0001"; - case "C-e": - return "\u0005"; - default: - return key; + await close().catch(() => {}); } } diff --git a/packages/cli/src/commands/terminal/shared.ts b/packages/cli/src/commands/terminal/shared.ts index 6e04936575..101caf0879 100644 --- a/packages/cli/src/commands/terminal/shared.ts +++ b/packages/cli/src/commands/terminal/shared.ts @@ -1,3 +1,4 @@ +import { createPaseoApi } from "@getpaseo/client"; import { connectToDaemon, getDaemonHost } from "../../utils/client.js"; import type { CommandError, CommandOptions } from "../../output/index.js"; @@ -14,7 +15,7 @@ export async function connectTerminalClient(host?: string) { const daemonHost = getDaemonHost({ host }); try { const client = await connectToDaemon({ host }); - return { client, daemonHost }; + return { client: createPaseoApi(client), daemonHost, close: () => client.close() }; } catch (err) { const message = err instanceof Error ? err.message : String(err); const error: CommandError = { @@ -44,11 +45,11 @@ export function toTerminalCommandError(code: string, action: string, err: unknow } export async function resolveTerminalId( - client: Awaited>, + client: Awaited>["client"], idOrName: string, ): Promise { - const payload = await client.listTerminals(); - return resolveTerminalIdentifier(idOrName, payload.terminals); + const payload = await client.terminals.list(); + return resolveTerminalIdentifier(idOrName, payload.entries); } function resolveTerminalIdentifier(idOrName: string, terminals: TerminalLike[]): string | null { diff --git a/packages/cli/tests/39-terminal-workspace.test.ts b/packages/cli/tests/39-terminal-workspace.test.ts new file mode 100644 index 0000000000..b1221962f3 --- /dev/null +++ b/packages/cli/tests/39-terminal-workspace.test.ts @@ -0,0 +1,80 @@ +import assert from "node:assert/strict"; +import { createPaseoClient } from "@getpaseo/client"; +import { createE2ETestContext } from "./helpers/test-daemon.ts"; +import { waitForTerminalOutput } from "./helpers/terminal.ts"; + +const ctx = await createE2ETestContext({ timeout: 30_000 }); +const sdk = createPaseoClient({ url: `${ctx.wsUrl}/ws`, reconnect: { enabled: false } }); + +async function cli(args: string[]) { + const result = await ctx.paseo([...args, "--json"]); + assert.equal(result.exitCode, 0, result.stderr); + return JSON.parse(result.stdout); +} + +try { + await sdk.connect(); + const first = await sdk.workspaces.create({ + source: { kind: "directory", path: ctx.workDir }, + title: "main", + }); + const second = await sdk.workspaces.create({ + source: { kind: "directory", path: ctx.workDir }, + title: "feature-work", + }); + const main = await cli(["terminal", "create", "--cwd", ctx.workDir, "--name", "Main"]); + assert.equal(main.workspaceId, first.id); + const feature = await cli(["terminal", "create", "--workspace", second.id, "--name", "Feature"]); + assert.deepEqual(feature, { + id: feature.id, + name: "Feature", + cwd: ctx.workDir, + workspaceId: second.id, + }); + assert.deepEqual(await cli(["terminal", "ls", "--workspace", second.id]), [feature]); + assert.deepEqual(await cli(["terminal", "ls", "--cwd", ctx.workDir]), [main, feature]); + assert.deepEqual(await cli(["terminal", "ls", "--all"]), [main, feature]); + const invalid = await ctx.paseo([ + "terminal", + "create", + "--workspace", + "wks_missing", + "--cwd", + ctx.workDir, + "--json", + ]); + assert.notEqual(invalid.exitCode, 0); + assert.match(invalid.stderr, /not active or does not exist/); + const conflict = await ctx.paseo(["terminal", "ls", "--all", "--workspace", second.id]); + assert.notEqual(conflict.exitCode, 0); + + const terminal = await second.terminals.create({ + command: process.execPath, + args: [ + "-e", + "process.stdin.setRawMode(true); process.stdin.resume(); console.log('READY'); let hex = ''; process.stdin.on('data', data => { hex += data.toString('hex'); console.log('HEX:' + hex); });", + ], + }); + await waitForTerminalOutput(ctx.paseo, terminal.id, "READY"); + assert.deepEqual(await cli(["terminal", "send-keys", terminal.id, "-l", "Enter"]), { + terminalId: terminal.id, + keysSent: 5, + }); + await waitForTerminalOutput(ctx.paseo, terminal.id, "HEX:456e746572"); + assert.deepEqual(await cli(["terminal", "send-keys", terminal.id, "Enter"]), { + terminalId: terminal.id, + keysSent: 1, + }); + await waitForTerminalOutput(ctx.paseo, terminal.id, "HEX:456e7465720d"); + assert.deepEqual(await cli(["terminal", "kill", terminal.id]), { + terminalId: terminal.id, + success: true, + }); + await cli(["terminal", "kill", feature.id]); + assert.deepEqual(await cli(["terminal", "ls", "--workspace", second.id]), []); + assert.deepEqual(await cli(["terminal", "ls", "--workspace", first.id]), [main]); + console.log("Terminal workspace CLI: ownership, lists, input, capture, kill, and errors passed"); +} finally { + await sdk.close(); + await ctx.stop(); +} diff --git a/packages/cli/tests/helpers/terminal.ts b/packages/cli/tests/helpers/terminal.ts new file mode 100644 index 0000000000..75a0bf258d --- /dev/null +++ b/packages/cli/tests/helpers/terminal.ts @@ -0,0 +1,19 @@ +import assert from "node:assert/strict"; +import type { createE2ETestContext } from "./test-daemon.ts"; + +export async function waitForTerminalOutput( + paseo: Awaited>["paseo"], + terminalId: string, + text: string, +): Promise { + const deadline = Date.now() + 10_000; + let lines: string[] = []; + while (Date.now() < deadline) { + const result = await paseo(["terminal", "capture", terminalId, "--json"]); + assert.equal(result.exitCode, 0, result.stderr); + lines = JSON.parse(result.stdout).lines; + if (lines.some((line) => line.includes(text))) return; + await new Promise((resolve) => setTimeout(resolve, 25)); + } + throw new Error(`Missing terminal output: ${text}\nLast capture:\n${lines.join("\n")}`); +} diff --git a/packages/client/README.md b/packages/client/README.md index 444f1ddc7d..85d6757d42 100644 --- a/packages/client/README.md +++ b/packages/client/README.md @@ -26,7 +26,7 @@ await client.close(); The public API is the package root. Imports under `@getpaseo/client/internal/*` are unsupported implementation details used by Paseo's own packages. -Read the [SDK documentation](https://paseo.sh/docs/sdk) for agents, workspaces, provider discovery, events, recipes, and the API reference. Runnable TypeScript patterns also live in [`examples/`](./examples/README.md). +Read the [SDK documentation](https://paseo.sh/docs/sdk) for agents, workspaces, terminals, provider discovery, events, recipes, and the API reference. Runnable TypeScript patterns also live in [`examples/`](./examples/README.md). ## Runtime diff --git a/packages/client/src/index.test.ts b/packages/client/src/index.test.ts index be5bd06811..392a53d6b6 100644 --- a/packages/client/src/index.test.ts +++ b/packages/client/src/index.test.ts @@ -230,6 +230,7 @@ test("createPaseoApi borrows daemon capabilities without exposing connection own "config", "projects", "providers", + "terminals", "workspaces", ]); expect("connect" in paseo).toBe(false); diff --git a/packages/client/src/index.ts b/packages/client/src/index.ts index b6b8d8e7b8..3c6bf4b755 100644 --- a/packages/client/src/index.ts +++ b/packages/client/src/index.ts @@ -25,6 +25,22 @@ import type { WorkspaceCreateRequest, } from "@getpaseo/protocol/messages"; import { DaemonClient } from "./daemon-client.js"; +import { + createTerminalActions, + type PaseoTerminalActions, + type PaseoWorkspaceTerminalActions, +} from "./terminals/index.js"; +export type { + PaseoTerminal, + PaseoTerminalActions, + PaseoTerminalHandle, + PaseoTerminalCreateOptions, + PaseoTerminalListOptions, + PaseoTerminalListResult, + PaseoTerminalCaptureOptions, + PaseoTerminalCaptureResult, + PaseoWorkspaceTerminalActions, +} from "./terminals/index.js"; import type { PluginTimelineItem } from "@getpaseo/protocol/agent-types"; import type { FetchAgentsEntry, @@ -141,6 +157,7 @@ export interface PaseoWorkspaceHandle { readonly agents: { create(options: PaseoWorkspaceAgentCreateOptions): Promise; }; + readonly terminals: PaseoWorkspaceTerminalActions; current(): PaseoWorkspace | null; refresh(options?: { requestId?: string }): Promise; setTitle(title: string | null, requestId?: string): Promise<{ title: string | null }>; @@ -408,6 +425,7 @@ export interface PaseoConfigActions { } export interface PaseoApi { + readonly terminals: PaseoTerminalActions; readonly workspaces: PaseoWorkspaceActions; readonly projects: PaseoProjectActions; readonly agents: PaseoAgentActions; @@ -463,9 +481,17 @@ export function createPaseoApi(daemonClient: DaemonClient): PaseoApi { }); return createAgentHandle(agent); }; - const createWorkspaceHandle = createWorkspaceHandleFactory(daemonClient, createAgent); + const terminals = createTerminalActions(daemonClient, async (workspaceId) => { + const workspace = await createWorkspaceHandle(workspaceId).refresh(); + if (!workspace?.workspaceDirectory) { + throw new Error(`Workspace ${workspaceId} is not active or has no available directory`); + } + return workspace.workspaceDirectory; + }); + const createWorkspaceHandle = createWorkspaceHandleFactory(daemonClient, createAgent, terminals); return { + terminals, projects: { list: (options) => daemonClient.listProjects(options), }, @@ -531,6 +557,7 @@ type CreateAgent = ( function createWorkspaceHandleFactory( daemonClient: DaemonClient, createAgent: CreateAgent, + terminals: PaseoTerminalActions, ): WorkspaceHandleFactory { return (workspace) => { const id = typeof workspace === "string" ? workspace : workspace.id; @@ -582,6 +609,10 @@ function createWorkspaceHandleFactory( ); }, }, + terminals: { + create: (options) => terminals.create({ ...options, workspaceId: id }), + list: (options) => terminals.list({ ...options, workspaceId: id }), + }, current: () => current, refresh, setTitle: (title, requestId) => daemonClient.setWorkspaceTitle(id, title, requestId), diff --git a/packages/client/src/terminals/index.test.ts b/packages/client/src/terminals/index.test.ts new file mode 100644 index 0000000000..23cadbe6a9 --- /dev/null +++ b/packages/client/src/terminals/index.test.ts @@ -0,0 +1,35 @@ +import { expect, test } from "vitest"; +import { createTerminalActions } from "./index.js"; + +test("an older host rejects terminal operations before sending requests", async () => { + const unexpectedRequest = () => { + throw new Error("Unexpected daemon request"); + }; + const terminals = createTerminalActions( + { + ensureConnected: () => {}, + getLastServerInfoMessage: () => ({ + status: "server_info", + serverId: "older-host", + features: {}, + }), + createTerminal: unexpectedRequest, + listTerminals: unexpectedRequest, + sendTerminalInput: unexpectedRequest, + captureTerminal: unexpectedRequest, + killTerminal: unexpectedRequest, + }, + unexpectedRequest, + ); + + await expect(terminals.create({ workspaceId: "wks_existing" })).rejects.toThrow( + "Update the host", + ); + await expect(terminals.list({ workspaceId: "wks_existing" })).rejects.toThrow("Update the host"); + const terminal = terminals.ref("terminal-existing"); + expect(terminal.current()).toBeNull(); + expect(() => terminal.write("text")).toThrow("Update the host"); + expect(() => terminal.sendKeys(["Enter"])).toThrow("Update the host"); + expect(() => terminal.capture()).toThrow("Update the host"); + await expect(terminal.kill()).rejects.toThrow("Update the host"); +}); diff --git a/packages/client/src/terminals/index.ts b/packages/client/src/terminals/index.ts new file mode 100644 index 0000000000..e1fd874a63 --- /dev/null +++ b/packages/client/src/terminals/index.ts @@ -0,0 +1,179 @@ +import { z } from "zod"; +import type { DaemonClient } from "../daemon-client.js"; + +const TerminalSchema = z.object({ + id: z.string(), + workspaceId: z.string(), + cwd: z.string(), + name: z.string(), +}); + +export type PaseoTerminal = z.infer; + +export interface PaseoTerminalCreateOptions { + workspaceId: string; + /** Process working directory; defaults to the workspace directory. */ + cwd?: string; + name?: string; + command?: string; + args?: string[]; + size?: { rows: number; cols: number }; + requestId?: string; +} + +export interface PaseoTerminalListOptions { + /** Ownership filter. When supplied, cwd does not restrict the results. */ + workspaceId?: string; + /** Workspace root directory filter for unscoped listings. */ + cwd?: string; + requestId?: string; +} + +export interface PaseoTerminalListResult { + entries: PaseoTerminal[]; + requestId: string; +} + +export interface PaseoTerminalCaptureOptions { + start?: number; + end?: number; + stripAnsi?: boolean; + requestId?: string; +} + +export type PaseoTerminalCaptureResult = Awaited>; + +export interface PaseoTerminalHandle { + readonly id: string; + current(): PaseoTerminal | null; + refresh(options?: { requestId?: string }): Promise; + /** Sends literal input and returns its UTF-16 length. Does not await command execution. */ + write(data: string): number; + /** Expands CLI key tokens; other strings are literal. Returns the input's UTF-16 length. */ + sendKeys(keys: readonly string[]): number; + capture(options?: PaseoTerminalCaptureOptions): Promise; + kill(requestId?: string): Promise; +} + +export interface PaseoTerminalActions { + create(options: PaseoTerminalCreateOptions): Promise; + list(options?: PaseoTerminalListOptions): Promise; + ref(terminal: string | PaseoTerminal): PaseoTerminalHandle; +} + +export interface PaseoWorkspaceTerminalActions { + create(options?: Omit): Promise; + list(options?: { requestId?: string }): Promise; +} + +type TerminalClient = Pick< + DaemonClient, + | "ensureConnected" + | "getLastServerInfoMessage" + | "createTerminal" + | "listTerminals" + | "sendTerminalInput" + | "captureTerminal" + | "killTerminal" +>; + +/** @package */ +export function createTerminalActions( + daemonClient: TerminalClient, + resolveWorkspaceDirectory: (workspaceId: string) => Promise, +): PaseoTerminalActions { + function client(): TerminalClient { + daemonClient.ensureConnected(); + // COMPAT(workspaceTerminals): added in v0.7.3, remove gate after 2027-09-05. + if (daemonClient.getLastServerInfoMessage()?.features?.workspaceTerminals !== true) { + throw new Error("Update the host to use workspace terminals through the SDK."); + } + return daemonClient; + } + + const list = async (options: PaseoTerminalListOptions = {}): Promise => { + const result = await client().listTerminals(options.cwd, options.requestId, { + workspaceId: options.workspaceId, + }); + return { + entries: result.terminals.map((terminal) => TerminalSchema.parse(terminal)), + requestId: result.requestId, + }; + }; + + const ref = (terminal: string | PaseoTerminal): PaseoTerminalHandle => { + const id = typeof terminal === "string" ? terminal : terminal.id; + let current = typeof terminal === "string" ? null : terminal; + const write = (data: string): number => { + client().sendTerminalInput(id, { type: "input", data }); + return data.length; + }; + return { + id, + current: () => current, + refresh: async (options) => { + const result = await list(options); + current = result.entries.find((entry) => entry.id === id) ?? null; + return current; + }, + write, + sendKeys: (keys) => write(keys.map(resolveKeyToken).join("")), + capture: (options = {}) => { + const { requestId, ...captureOptions } = options; + return client().captureTerminal(id, captureOptions, requestId); + }, + kill: async (requestId) => { + const result = await client().killTerminal(id, requestId); + if (!result.success) throw new Error(`Failed to kill terminal ${id}`); + current = null; + }, + }; + }; + + return { + create: async ({ workspaceId, cwd, name, requestId, ...options }) => { + const driver = client(); + if (!workspaceId) throw new Error("workspaceId is required"); + const directory = cwd ?? (await resolveWorkspaceDirectory(workspaceId)); + const result = await driver.createTerminal(directory, name, requestId, { + ...options, + workspaceId, + }); + if (result.error || !result.terminal) { + throw new Error(result.error ?? "The daemon did not create a terminal"); + } + return ref(TerminalSchema.parse(result.terminal)); + }, + list, + ref, + }; +} + +function resolveKeyToken(key: string): string { + switch (key) { + case "Enter": + return "\r"; + case "Tab": + return "\t"; + case "Escape": + return "\u001b"; + case "Space": + return " "; + case "BSpace": + return "\u007f"; + case "C-c": + return "\u0003"; + case "C-d": + return "\u0004"; + case "C-z": + return "\u001a"; + case "C-l": + return "\u000c"; + case "C-a": + return "\u0001"; + case "C-e": + return "\u0005"; + default: + return key; + } +} diff --git a/packages/protocol/src/messages.ts b/packages/protocol/src/messages.ts index 6c9fd5f810..125a7a27bb 100644 --- a/packages/protocol/src/messages.ts +++ b/packages/protocol/src/messages.ts @@ -3408,6 +3408,8 @@ export const ServerInfoStatusPayloadSchema = z workspaceLabels: z.boolean().optional(), // COMPAT(workspaceSetupRun): added in v0.7.3, remove gate after 2027-09-02. workspaceSetupRun: z.boolean().optional(), + // COMPAT(workspaceTerminals): added in v0.7.3, remove gate after 2027-09-05. + workspaceTerminals: z.boolean().optional(), // COMPAT(checkoutForgeSetAutoMerge): added in v0.2.0-beta.1. Remove the // feature gate and checkoutGithubSetAutoMerge fallback after 2027-01-17 // once the supported daemon floor is >= v0.2.0. @@ -6024,7 +6026,7 @@ export const ListTerminalsResponseSchema = z.object({ type: z.literal("list_terminals_response"), payload: z.object({ cwd: z.string().optional(), - terminals: z.array(TerminalInfoSchema.omit({ cwd: true })), + terminals: z.array(TerminalInfoSchema.partial({ cwd: true })), requestId: z.string(), }), }); diff --git a/packages/protocol/src/messages.wire-compat.test.ts b/packages/protocol/src/messages.wire-compat.test.ts index db7059001b..bcfc37d5f8 100644 --- a/packages/protocol/src/messages.wire-compat.test.ts +++ b/packages/protocol/src/messages.wire-compat.test.ts @@ -8,6 +8,26 @@ import { WSHelloMessageSchema, } from "./messages.js"; +test("terminal listings accept older rows and retain new per-terminal directories", () => { + const response = { + type: "list_terminals_response", + payload: { + requestId: "terminal-list", + cwd: "/workspace", + terminals: [{ id: "terminal", name: "Shell", workspaceId: "workspace" }], + }, + }; + expect(SessionOutboundMessageSchema.parse(response)).toEqual(response); + const withDirectory = { + ...response, + payload: { + ...response.payload, + terminals: [{ ...response.payload.terminals[0], cwd: "/workspace/subdirectory" }], + }, + }; + expect(SessionOutboundMessageSchema.parse(withDirectory)).toEqual(withDirectory); +}); + const LegacySubAgentToolCallSchema = z.object({ type: z.literal("tool_call"), callId: z.string(), diff --git a/packages/server/package.json b/packages/server/package.json index cd00102b58..40d236670e 100644 --- a/packages/server/package.json +++ b/packages/server/package.json @@ -53,7 +53,7 @@ "speech:transcribe:local": "tsx scripts/transcribe-local-wav.ts", "test": "npm run test:unit && npm run test:integration", "test:unit": "vitest run --fileParallelism --exclude \"**/*.e2e.test.ts\"", - "test:integration": "vitest run --maxWorkers=1 src/server/daemon-e2e/models.e2e.test.ts src/server/daemon-e2e/live-preferences.e2e.test.ts src/server/agent/model-catalog.e2e.test.ts && vitest run --maxWorkers=1 src/server/session.create-agent-worktree-autoarchive.e2e.test.ts -t \"creates a worktree and auto-archives both\"", + "test:integration": "vitest run --maxWorkers=1 src/server/daemon-e2e/terminal-workspace-sdk.e2e.test.ts src/server/daemon-e2e/models.e2e.test.ts src/server/daemon-e2e/live-preferences.e2e.test.ts src/server/agent/model-catalog.e2e.test.ts && vitest run --maxWorkers=1 src/server/session.create-agent-worktree-autoarchive.e2e.test.ts -t \"creates a worktree and auto-archives both\"", "test:hub-cli-contract": "vitest run --maxWorkers=1 src/server/hub/hub-cli-contract.test.ts", "test:integration:all": "npm run test:e2e", "test:integration:real": "vitest run real.e2e.test.ts", 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 new file mode 100644 index 0000000000..f27e478bf4 --- /dev/null +++ b/packages/server/src/server/daemon-e2e/terminal-workspace-sdk.e2e.test.ts @@ -0,0 +1,190 @@ +import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import { afterEach, beforeEach, expect, test } from "vitest"; +import { createPaseoClient, type PaseoClient } from "@getpaseo/client"; +import { DaemonClient } from "../test-utils/daemon-client.js"; +import { createTestPaseoDaemon, type TestPaseoDaemon } from "../test-utils/paseo-daemon.js"; + +let daemon: TestPaseoDaemon; +let client: DaemonClient; +let cwd: string; +let sdk: PaseoClient; + +beforeEach(async () => { + cwd = await mkdtemp(path.join(tmpdir(), "terminal-workspace-sdk-")); + daemon = await createTestPaseoDaemon(); + client = new DaemonClient({ url: `ws://127.0.0.1:${daemon.port}/ws` }); + await client.connect(); + sdk = createPaseoClient({ url: `ws://127.0.0.1:${daemon.port}/ws` }); + await sdk.connect(); +}); + +afterEach(async () => { + await client.close(); + await sdk.close(); + await daemon.close(); + await rm(cwd, { recursive: true, force: true }); +}); + +test("SDK and workspace handles preserve ownership and actual process directories", async () => { + const first = await createWorkspace("main"); + const second = await createWorkspace("feature-work"); + const nested = path.join(cwd, "nested"); + await mkdir(nested); + const a = await sdk.terminals.create({ workspaceId: first, name: "Main" }); + const b = await sdk.workspaces.ref(second).terminals.create({ name: "Feature", cwd: nested }); + const c = await sdk.terminals.create({ + workspaceId: second, + cwd: daemon.staticDir, + name: "Outside", + }); + expect(a.current()).toEqual({ id: a.id, workspaceId: first, cwd, name: "Main" }); + expect(b.current()).toEqual({ id: b.id, workspaceId: second, cwd: nested, name: "Feature" }); + expect(c.current()).toEqual({ + id: c.id, + workspaceId: second, + cwd: daemon.staticDir, + name: "Outside", + }); + const result = await sdk.workspaces.ref(second).terminals.list({ requestId: "feature-list" }); + expect(result.requestId).toBe("feature-list"); + expect(result.entries).toEqual(expect.arrayContaining([b.current(), c.current()])); + expect(result.entries).toHaveLength(2); + expect((await sdk.terminals.list()).entries).toHaveLength(3); + expect((await sdk.terminals.list({ cwd })).entries).toEqual([a.current(), b.current()]); + const ref = sdk.terminals.ref(b.id); + expect(ref.current()).toBeNull(); + expect(await ref.refresh()).toEqual(b.current()); + await ref.kill(); + expect(await ref.refresh()).toBeNull(); + expect((await sdk.terminals.list({ workspaceId: second })).entries).toEqual([c.current()]); + expect((await sdk.terminals.list({ workspaceId: first })).entries).toEqual([a.current()]); +}); + +test("SDK creates a command terminal and sends literal input and key tokens", async () => { + const workspaceId = await createWorkspace("Input"); + const terminal = await sdk.terminals.create({ + workspaceId, + command: process.execPath, + args: [ + "-e", + "process.stdin.setRawMode(true); process.stdin.resume(); console.log('READY'); let hex = ''; process.stdin.on('data', data => { hex += data.toString('hex'); console.log('HEX:' + hex); });", + ], + size: { rows: 35, cols: 120 }, + }); + const screen = async () => (await terminal.capture({ stripAnsi: true })).lines.join("\n"); + await expect.poll(screen).toContain("READY"); + expect(terminal.write("Enter")).toBe(5); + await expect.poll(screen).toContain("HEX:456e746572"); + expect(terminal.sendKeys(["Enter", "Tab", "Escape", "C-c"])).toBe(4); + await expect.poll(screen).toContain("HEX:456e7465720d091b03"); + const capture = await terminal.capture({ + start: 0, + end: 0, + stripAnsi: true, + requestId: "capture-first", + }); + expect(capture).toMatchObject({ terminalId: terminal.id, requestId: "capture-first" }); + expect(capture.lines).toHaveLength(1); + await terminal.kill(); + expect(terminal.current()).toBeNull(); + expect((await sdk.terminals.list({ workspaceId })).entries).toEqual([]); +}); + +test("terminal creation rejects unknown and archived owners, including explicit cwd overrides", async () => { + const workspaceId = await createWorkspace("Archived"); + await sdk.workspaces.ref(workspaceId).archive(); + for (const id of [workspaceId, "wks_missing"]) { + await expect(sdk.terminals.create({ workspaceId: id })).rejects.toThrow(/not active/); + await expect(sdk.terminals.create({ workspaceId: id, cwd })).rejects.toThrow(/not active/); + const raw = await client.createTerminal(cwd, undefined, undefined, { workspaceId: id }); + expect(raw).toMatchObject({ + terminal: null, + error: `Workspace ${id} is not active or does not exist`, + }); + } + expect((await sdk.terminals.list()).entries).toEqual([]); + expect((await client.fetchWorkspaces()).entries).toEqual([]); +}); + +test("plugin handlers operate terminals through their host-owned Paseo API", async () => { + const workspaceId = await createWorkspace("Plugin workspace"); + const pluginDirectory = path.join(cwd, "plugin"); + await mkdir(pluginDirectory); + await writeFile( + path.join(pluginDirectory, "paseo-plugin.json"), + JSON.stringify({ id: "terminal-sdk" }), + ); + await writeFile( + path.join(pluginDirectory, "index.server.ts"), + ` +import { defineRpc } from "@getpaseo/plugin"; +import { z } from "zod"; +const operate = defineRpc({ name: "operate", input: z.object({ workspaceId: z.string(), command: z.string() }), output: z.object({ workspaceIds: z.array(z.string()), lines: z.array(z.string()), remaining: z.number() }) }); +async function waitForTerminalOutput(terminal, text) { + const deadline = Date.now() + 10000; + while (Date.now() < deadline) { + const capture = await terminal.capture({ stripAnsi: true }); + if (capture.lines.some(line => line.includes(text))) return capture.lines; + await new Promise(resolve => setTimeout(resolve, 20)); + } + throw new Error("Timed out waiting for plugin terminal output: " + text); +} +export default function contribute(server) { + server.handle(operate, async ({ workspaceId, command }, { paseo }) => { + const workspace = paseo.workspaces.ref(workspaceId); + const terminal = await workspace.terminals.create({ command, args: ["-e", "process.stdin.setRawMode(true); process.stdin.resume(); console.log('PLUGIN READY'); let hex = ''; process.stdin.on('data', data => { hex += data.toString('hex'); console.log('PLUGIN:' + hex); });"] }); + try { + await waitForTerminalOutput(terminal, "PLUGIN READY"); + terminal.write("Enter"); + await waitForTerminalOutput(terminal, "PLUGIN:456e746572"); + terminal.sendKeys(["Enter"]); + const lines = await waitForTerminalOutput(terminal, "PLUGIN:456e7465720d"); + const listed = await workspace.terminals.list(); + await paseo.terminals.ref(terminal.id).kill(); + return { workspaceIds: listed.entries.map(entry => entry.workspaceId), lines, remaining: (await workspace.terminals.list()).entries.length }; + } finally { + await terminal.kill(); + } + }); + return () => {}; +}`, + ); + await client.patchDaemonConfig({ pluginsEnabled: true }); + await client.installDirectoryPlugin(pluginDirectory); + try { + const result = await client.invokePluginRpc("terminal-sdk", "operate", { + workspaceId, + command: process.execPath, + }); + expect(result).toMatchObject({ + workspaceIds: [workspaceId], + remaining: 0, + lines: expect.arrayContaining([expect.stringContaining("PLUGIN:456e7465720d")]), + }); + } finally { + await client.removePlugin("terminal-sdk"); + } +}, 30_000); + +async function createWorkspace(title: string): Promise { + const result = await client.createWorkspace({ source: { kind: "directory", path: cwd }, title }); + if (!result.workspace) throw new Error(result.error ?? "Workspace creation failed"); + return result.workspace.id; +} + +test("listing by workspace ID keeps terminals in a shared directory separate", async () => { + const first = await createWorkspace("main"); + const second = await createWorkspace("feature-work"); + await client.createTerminal(cwd, "main terminal", undefined, { workspaceId: first }); + const created = await client.createTerminal(cwd, "feature terminal", undefined, { + workspaceId: second, + }); + expect(created.error).toBeNull(); + + const result = await client.listTerminals(undefined, undefined, { workspaceId: second }); + expect(result.terminals).toEqual([ + expect.objectContaining({ id: created.terminal?.id, workspaceId: second, cwd }), + ]); +}); diff --git a/packages/server/src/server/file-observer/internal/native-recursive.test.ts b/packages/server/src/server/file-observer/internal/native-recursive.test.ts new file mode 100644 index 0000000000..c97048ab47 --- /dev/null +++ b/packages/server/src/server/file-observer/internal/native-recursive.test.ts @@ -0,0 +1,71 @@ +import { EventEmitter } from "node:events"; +import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { expect, test, vi } from "vitest"; +import { createFileObserver, type FileChange } from "../index.js"; +import { createNativeRecursiveBackend } from "./native-recursive.js"; +import { createObserverPaths } from "./paths.js"; + +// Native watchers may coalesce file removals into change notifications. Keep the +// filesystem real while controlling which notifications reach reconciliation. +test("shallow parent scans retain nested change scopes", async () => { + const root = await mkdtemp(join(tmpdir(), "native-scopes-")); + const paths = createObserverPaths(process.platform); + const removed = [ + join(root, "root.txt"), + join(root, "child", "child.txt"), + join(root, "child", "deep", "deep.txt"), + ]; + await mkdir(join(root, "child", "deep"), { recursive: true }); + await Promise.all(removed.map((path) => writeFile(path, "before"))); + const events: FileChange[] = []; + const notifications = new EventEmitter(); + let active = true; + const observer = createFileObserver(); + const backend = createNativeRecursiveBackend( + { + root, + metrics: observer.getDiagnostics(), + isActive: () => active, + isIgnored: () => false, + isPathInside: paths.isInside, + queueEvent: (type, path) => events.push({ type, path }), + fail: (error) => { + throw error; + }, + }, + paths, + (_root, listener) => { + notifications.on("change", listener); + return { + close: () => { + notifications.removeAllListeners(); + }, + on: (event, onError) => notifications.on(event, onError), + }; + }, + ); + try { + await backend.start(); + await Promise.all(removed.map((path) => rm(path))); + vi.useFakeTimers({ toFake: ["setTimeout", "clearTimeout", "performance"] }); + for (const path of removed) notifications.emit("change", "change", path); + await vi.advanceTimersByTimeAsync(8_000); + vi.useRealTimers(); + await expect + .poll(() => + events + .filter((event) => event.type === "delete") + .map((event) => event.path) + .sort(), + ) + .toEqual([...removed].sort()); + } finally { + vi.useRealTimers(); + active = false; + await backend.close(); + await observer.close(); + await rm(root, { recursive: true, force: true }); + } +}); diff --git a/packages/server/src/server/file-observer/internal/native-recursive.ts b/packages/server/src/server/file-observer/internal/native-recursive.ts index e5ef077535..de001b232a 100644 --- a/packages/server/src/server/file-observer/internal/native-recursive.ts +++ b/packages/server/src/server/file-observer/internal/native-recursive.ts @@ -1,4 +1,4 @@ -import { type Dirent, type FSWatcher, watch } from "node:fs"; +import { type Dirent, watch } from "node:fs"; import { readdir, stat } from "node:fs/promises"; import { dirname, join, resolve } from "node:path"; import type { BackendDiagnostics, ObservationBackend, ObservationHost } from "./contracts.js"; @@ -25,15 +25,27 @@ interface Inventory { files: Set; } +type WatchDirectory = ( + root: string, + listener: (eventType: string, filename: string | null) => void, +) => { + close(): void; + on(event: "error", listener: (error: Error) => void): unknown; +}; + +const watchDirectory: WatchDirectory = (root, listener) => + watch(root, { recursive: true }, listener); + export function createNativeRecursiveBackend( host: ObservationHost, paths: ObserverPaths, + observe: WatchDirectory = watchDirectory, ): ObservationBackend { - return new NativeRecursiveBackend(host, paths); + return new NativeRecursiveBackend(host, paths, observe); } class NativeRecursiveBackend implements ObservationBackend { - private watcher: FSWatcher | null = null; + private watcher: ReturnType | null = null; private files = new Set(); private directories = new Set(); private entries = new Map(); @@ -62,6 +74,7 @@ class NativeRecursiveBackend implements ObservationBackend { constructor( private readonly host: ObservationHost, private readonly paths: ObserverPaths, + private readonly observe: WatchDirectory, ) {} async start(): Promise { @@ -114,7 +127,7 @@ class NativeRecursiveBackend implements ObservationBackend { } private watchRoot(): void { - const watcher = watch(this.host.root, { recursive: true }, (eventType, filename) => { + const watcher = this.observe(this.host.root, (eventType, filename) => { if (!this.host.isActive()) return; this.host.metrics.nativeEventCount += 1; if (!filename) { @@ -262,7 +275,8 @@ class NativeRecursiveBackend implements ObservationBackend { await this.reconcileSubtree(directory, generation); if (!this.canCommit(generation)) return; } - for (const directory of this.paths.collapse(changeScopes)) { + // Parent scans are shallow, so a queued parent cannot cover its children. + for (const directory of new Set(changeScopes)) { const alreadyCovered = forcedLocalScopes.has(directory) || forcedRecursiveScopes.some((scope) => this.host.isPathInside(scope, directory)); diff --git a/packages/server/src/server/websocket-server.ts b/packages/server/src/server/websocket-server.ts index 6e5b037526..ed289eb95e 100644 --- a/packages/server/src/server/websocket-server.ts +++ b/packages/server/src/server/websocket-server.ts @@ -1682,6 +1682,7 @@ export class VoiceAssistantWebSocketServer { "terminal-input-mode-replay": true, // COMPAT(terminalSizeOwnership): added in v0.2.6, remove gate after 2027-02-02. "terminal-size-ownership": true, + workspaceTerminals: true, // COMPAT(rewind): added in v0.1.X, drop the gate when floor >= v0.1.X. rewind: true, // COMPAT(agentTimelinePromptIndex): added in v0.2.X, drop the gate when floor >= v0.2.X. diff --git a/packages/server/src/terminal/terminal-session-controller.test.ts b/packages/server/src/terminal/terminal-session-controller.test.ts index 16ff7b64e6..9fbd7e3d2a 100644 --- a/packages/server/src/terminal/terminal-session-controller.test.ts +++ b/packages/server/src/terminal/terminal-session-controller.test.ts @@ -300,7 +300,7 @@ describe("terminal-session-controller legacy terminal creation", () => { hasBinaryChannel: () => true, isPathWithinRoot: isSameOrDescendantPath, sessionLogger: createLogger(), - listTerminalWorkspaceRefs: async () => [], + listTerminalWorkspaceRefs: async () => [{ workspaceId: "ws-1", cwd: "/work/repo" }], }); await controller.dispatch({ @@ -548,7 +548,13 @@ describe("terminal-session-controller subdirectory aggregation", () => { payload: { cwd: rootCwd, terminals: [ - { id: "root-term", name: "Terminal 1", workspaceId: "ws-test", activity: null }, + { + id: "root-term", + name: "Terminal 1", + cwd: rootCwd, + workspaceId: "ws-test", + activity: null, + }, ], requestId: "req-root", }, @@ -558,7 +564,13 @@ describe("terminal-session-controller subdirectory aggregation", () => { payload: { cwd: worktreeCwd, terminals: [ - { id: "worktree-term", name: "Feature", workspaceId: "ws-test", activity: null }, + { + id: "worktree-term", + name: "Feature", + cwd: worktreeCwd, + workspaceId: "ws-test", + activity: null, + }, ], requestId: "req-worktree", }, diff --git a/packages/server/src/terminal/terminal-session-controller.ts b/packages/server/src/terminal/terminal-session-controller.ts index 50c4109d06..998c5c93e8 100644 --- a/packages/server/src/terminal/terminal-session-controller.ts +++ b/packages/server/src/terminal/terminal-session-controller.ts @@ -426,10 +426,16 @@ export class TerminalSessionController { } try { - const terminals = - typeof msg.cwd === "string" - ? await this.getTerminalsForWorkspaceRoot(msg.cwd, msg.workspaceId) - : await this.getAllTerminalSessions(); + let terminals: TerminalSession[]; + if (msg.workspaceId !== undefined) { + terminals = (await this.getAllTerminalSessions()).filter( + (terminal) => terminal.workspaceId === msg.workspaceId, + ); + } else if (typeof msg.cwd === "string") { + terminals = await this.getTerminalsForWorkspaceRoot(msg.cwd); + } else { + terminals = await this.getAllTerminalSessions(); + } for (const terminal of terminals) { this.ensureExitSubscription(terminal); } @@ -437,7 +443,9 @@ export class TerminalSessionController { type: "list_terminals_response", payload: { ...(msg.cwd ? { cwd: msg.cwd } : {}), - terminals: terminals.map((terminal) => this.toTerminalInfo(terminal)), + terminals: terminals.map((terminal) => + Object.assign(this.toTerminalInfo(terminal), { cwd: terminal.cwd }), + ), requestId: msg.requestId, }, }); @@ -463,7 +471,9 @@ export class TerminalSessionController { const terminalsByDirectory = await Promise.all( directories.map((cwd) => manager.getTerminals(cwd)), ); - return terminalsByDirectory.flat(); + return [ + ...new Map(terminalsByDirectory.flat().map((terminal) => [terminal.id, terminal])).values(), + ]; } private async getTerminalsForWorkspaceRoot( @@ -558,6 +568,11 @@ export class TerminalSessionController { return; } + const workspaces = await this.listTerminalWorkspaceRefs(); + if (!workspaces.some((workspace) => workspace.workspaceId === workspaceId)) { + throw new Error(`Workspace ${workspaceId} is not active or does not exist`); + } + const session = await this.terminalManager.createTerminal({ cwd: msg.cwd, workspaceId, @@ -746,12 +761,24 @@ export class TerminalSessionController { } private async handleKillTerminalRequest(msg: KillTerminalRequest): Promise { - const result = this.killTerminalForClose(msg.terminalId); + let success = false; + if (this.terminalManager) { + try { + this.detachStream(msg.terminalId, { emitExit: true }); + await this.terminalManager.killTerminalAndWait(msg.terminalId); + success = true; + } catch (error) { + this.sessionLogger.error( + { err: error, terminalId: msg.terminalId }, + "Failed to kill terminal", + ); + } + } this.emit({ type: "kill_terminal_response", payload: { - terminalId: result.terminalId, - success: result.success, + terminalId: msg.terminalId, + success, requestId: msg.requestId, }, }); diff --git a/public-docs/cli.md b/public-docs/cli.md index 10065793de..f47270144a 100644 --- a/public-docs/cli.md +++ b/public-docs/cli.md @@ -128,6 +128,25 @@ paseo workspace archive Add `--forge ` to PR checkout when Paseo cannot infer the forge from the source checkout. See [Git worktrees](/docs/worktrees) for setup hooks and services. +## Terminals + +Use the workspace ID when multiple workspaces share a directory: + +```bash +paseo terminal create --workspace --name Development +paseo terminal ls --workspace --json +paseo terminal send-keys -l "echo ready" +paseo terminal send-keys Enter +paseo terminal capture +paseo terminal kill +``` + +Creation defaults to the workspace directory. Add `--cwd ` to change the process directory while keeping that workspace as the owner. Unknown and archived workspace IDs fail. + +Without `--workspace`, creation opens the project at `--cwd` or the current directory and reuses its oldest active workspace. Listing without `--workspace` filters by `--cwd` or the current directory and can include multiple workspaces. `ls --all` lists every terminal on the host and cannot be combined with directory or workspace filters. + +Create and list results include `id`, `name`, `cwd`, and `workspaceId`. Use `--json` for structured output and the global `--host` option to target another daemon. These commands require a host that supports the [workspace terminal API](/docs/sdk/reference#clientterminals); older hosts return an update message. + ## Workspace scripts List, start, and stop the scripts configured in a workspace's `paseo.json`: diff --git a/public-docs/plugins/v0.8/reference.md b/public-docs/plugins/v0.8/reference.md index 6e3cd4f000..db73adc131 100644 --- a/public-docs/plugins/v0.8/reference.md +++ b/public-docs/plugins/v0.8/reference.md @@ -843,7 +843,7 @@ function PullRequestAction() { } ``` -The returned API covers projects, workspaces, agents, providers, and daemon config. See the [SDK API reference](/docs/sdk/reference) for its methods. Connection lifecycle methods are intentionally absent because Paseo owns the connection. +The returned API covers projects, workspaces, agents, terminals, providers, and daemon config. See the [SDK API reference](/docs/sdk/reference) for its methods. Connection lifecycle methods are intentionally absent because Paseo owns the connection. ## Add plugin-specific backend behavior diff --git a/public-docs/sdk/reference.md b/public-docs/sdk/reference.md index 26c657e7f2..aff12fa4e5 100644 --- a/public-docs/sdk/reference.md +++ b/public-docs/sdk/reference.md @@ -129,6 +129,46 @@ Creation options include `config`, `cwd`, `parent`, `title`, `prompt`, `env`, `o A workspace handle exposes `id`, `projectId`, `directory`, `name`, `status`, `current()`, `refresh()`, `setTitle(title)`, `archive()`, and `subscribe()`. Pass `null` to `setTitle` to restore the derived workspace name. Use `workspace.agents.create(options)` to create an agent without repeating the workspace ID or directory. +## `client.terminals` + +Terminal operations require a host that supports workspace terminals. An older host receives no terminal request; the SDK throws an update-host error. + +| Method | Result | Behavior | +| ------------------- | ---------------------------------- | ---------------------------------------------------------------------------------- | +| `create(options)` | `Promise` | Creates a terminal owned by the required `workspaceId`. | +| `list(options?)` | `Promise` | Returns `{ entries, requestId }`. Omit filters to list all terminals on this host. | +| `ref(terminalOrId)` | `PaseoTerminalHandle` | Creates a local handle without fetching or attaching a terminal stream. | + +Creation options: + +| Field | Meaning | +| ----------------- | ------------------------------------------------------------------------------------------------------------------------ | +| `workspaceId` | Required active workspace ID. Unknown and archived IDs fail. | +| `cwd` | Optional absolute process working directory. Defaults to the workspace directory; changing it does not change ownership. | +| `name` | Optional terminal name. | +| `command`, `args` | Optional executable and argument array. Omit them to start the default shell. | +| `size` | Optional initial viewport: `{ rows, cols }`. | +| `requestId` | Optional request correlation ID. | + +List options are `workspaceId`, `cwd`, and `requestId`. `workspaceId` selects ownership, including terminals started outside the workspace directory. When it is present, `cwd` does not restrict the results. Without an ID, `cwd` filters by workspace root directory. Each entry contains `id`, `workspaceId`, `cwd`, and `name`; `cwd` is the terminal's actual starting directory. + +Terminal handles expose: + +| Method | Result | Behavior | +| ------------------- | ------------------------------------- | ------------------------------------------------------------------------------------------------ | +| `current()` | `PaseoTerminal \| null` | Last snapshot from creation, `ref(snapshot)`, or refresh. A handle made from an ID starts empty. | +| `refresh(options?)` | `Promise` | Fetches the terminal snapshot, or `null` when it no longer exists. Accepts `requestId`. | +| `write(data)` | `number` | Sends literal text without interpreting key names. Returns the input's UTF-16 length. | +| `sendKeys(keys)` | `number` | Expands key tokens and sends the combined input. Returns its UTF-16 length. | +| `capture(options?)` | `Promise` | Returns `{ terminalId, lines, totalLines, requestId }`. | +| `kill(requestId?)` | `Promise` | Waits for terminal teardown. Killing an already-removed terminal succeeds. | + +`sendKeys()` recognizes `Enter`, `Tab`, `Escape`, `Space`, `BSpace`, `C-c`, `C-d`, `C-z`, `C-l`, `C-a`, and `C-e`. Other strings pass through literally. Input methods send without waiting for command execution or acknowledging that the terminal consumed the input. + +Capture accepts optional `start`, `end`, `stripAnsi`, and `requestId`. Line bounds are zero-based and inclusive across scrollback and the viewport. Negative bounds count from the end; omitted bounds capture all lines. `stripAnsi` defaults to `true`. A missing terminal returns empty lines. + +Use `workspace.terminals.create(options?)` and `workspace.terminals.list(options?)` to supply the workspace ID from a handle. Creation accepts the same options except `workspaceId`; listing accepts only `requestId`. Plugins get these methods through `usePaseo()` and the handler's `paseo` context. + ## `client.providers` | Method | Result | Behavior | diff --git a/public-docs/sdk/workspaces.md b/public-docs/sdk/workspaces.md index 1bc72c5f02..6775e90b3d 100644 --- a/public-docs/sdk/workspaces.md +++ b/public-docs/sdk/workspaces.md @@ -81,6 +81,22 @@ const agent = await client.agents.create({ The daemon still creates a project and a fresh workspace. Read `agent.workspaceId` when you need the generated workspace ID. +## Start a terminal in a workspace + +```ts +const terminal = await workspace.terminals.create({ name: "Development" }); +terminal.write("echo ready"); +terminal.sendKeys(["Enter"]); + +const { lines } = await terminal.capture(); +const { entries } = await workspace.terminals.list(); +await terminal.kill(); +``` + +Two workspaces may share a directory. The workspace handle supplies the ID that keeps their terminals separate. To use an ID you already have, call `client.workspaces.ref(workspaceId).terminals.create()`. + +See the [terminal API reference](/docs/sdk/reference#clientterminals) for command arguments, working-directory overrides, input, and capture options. + ## List workspaces ```ts