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