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
This commit is contained in:
Mohamed Boudra
2026-09-06 10:17:58 +02:00
committed by GitHub
parent 78b285059f
commit c8a417c4fb
30 changed files with 832 additions and 134 deletions
+1 -1
View File
@@ -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.
+1 -1
View File
@@ -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.
@@ -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(() => {});
}
}
+15 -25
View File
@@ -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<SingleResult<TerminalRow>> {
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(() => {});
}
}
+8 -4
View File
@@ -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 <path>", "Workspace directory"),
.addOption(
new Option("--all", "List terminals across all workspaces").conflicts(["cwd", "workspace"]),
)
.option("--workspace <id>", "Workspace ID")
.option("--cwd <path>", "Working directory"),
).action(withOutput(runLsCommand));
addJsonAndDaemonHostOptions(
terminal
.command("create")
.description("Create a terminal")
.option("--cwd <path>", "Workspace directory")
.option("--workspace <id>", "Workspace ID")
.option("--cwd <path>", "Working directory")
.option("--name <name>", "Terminal name"),
).action(withOutput(runCreateCommand));
+5 -5
View File
@@ -13,23 +13,23 @@ export async function runKillCommand(
options: TerminalCommandOptions,
_command: Command,
): Promise<SingleResult<TerminalKillRow>> {
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(() => {});
}
}
+7 -11
View File
@@ -5,11 +5,10 @@ import {
toTerminalCommandError,
type TerminalCommandOptions,
} from "./shared.js";
import { terminalSchema, type TerminalRow, toTerminalRow } from "./schema.js";
type TerminalListEntry = Parameters<typeof toTerminalRow>[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<ListResult<TerminalRow>> {
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(() => {});
}
}
+3 -20
View File
@@ -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<TerminalRow> = {
{ 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<TerminalKillRow> = {
{ 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 ?? "-",
};
}
@@ -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(() => {});
}
}
+5 -4
View File
@@ -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<ReturnType<typeof connectToDaemon>>,
client: Awaited<ReturnType<typeof connectTerminalClient>>["client"],
idOrName: string,
): Promise<string | null> {
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 {
@@ -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();
}
+19
View File
@@ -0,0 +1,19 @@
import assert from "node:assert/strict";
import type { createE2ETestContext } from "./test-daemon.ts";
export async function waitForTerminalOutput(
paseo: Awaited<ReturnType<typeof createE2ETestContext>>["paseo"],
terminalId: string,
text: string,
): Promise<void> {
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")}`);
}
+1 -1
View File
@@ -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
+1
View File
@@ -230,6 +230,7 @@ test("createPaseoApi borrows daemon capabilities without exposing connection own
"config",
"projects",
"providers",
"terminals",
"workspaces",
]);
expect("connect" in paseo).toBe(false);
+32 -1
View File
@@ -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<PaseoAgentHandle>;
};
readonly terminals: PaseoWorkspaceTerminalActions;
current(): PaseoWorkspace | null;
refresh(options?: { requestId?: string }): Promise<PaseoWorkspace | null>;
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),
@@ -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");
});
+179
View File
@@ -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<typeof TerminalSchema>;
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<ReturnType<DaemonClient["captureTerminal"]>>;
export interface PaseoTerminalHandle {
readonly id: string;
current(): PaseoTerminal | null;
refresh(options?: { requestId?: string }): Promise<PaseoTerminal | null>;
/** 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<PaseoTerminalCaptureResult>;
kill(requestId?: string): Promise<void>;
}
export interface PaseoTerminalActions {
create(options: PaseoTerminalCreateOptions): Promise<PaseoTerminalHandle>;
list(options?: PaseoTerminalListOptions): Promise<PaseoTerminalListResult>;
ref(terminal: string | PaseoTerminal): PaseoTerminalHandle;
}
export interface PaseoWorkspaceTerminalActions {
create(options?: Omit<PaseoTerminalCreateOptions, "workspaceId">): Promise<PaseoTerminalHandle>;
list(options?: { requestId?: string }): Promise<PaseoTerminalListResult>;
}
type TerminalClient = Pick<
DaemonClient,
| "ensureConnected"
| "getLastServerInfoMessage"
| "createTerminal"
| "listTerminals"
| "sendTerminalInput"
| "captureTerminal"
| "killTerminal"
>;
/** @package */
export function createTerminalActions(
daemonClient: TerminalClient,
resolveWorkspaceDirectory: (workspaceId: string) => Promise<string>,
): 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<PaseoTerminalListResult> => {
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;
}
}
+3 -1
View File
@@ -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(),
}),
});
@@ -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(),
+1 -1
View File
@@ -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",
@@ -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<string> {
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 }),
]);
});
@@ -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 });
}
});
@@ -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<string>;
}
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<WatchDirectory> | null = null;
private files = new Set<string>();
private directories = new Set<string>();
private entries = new Map<string, DirectoryEntry>();
@@ -62,6 +74,7 @@ class NativeRecursiveBackend implements ObservationBackend {
constructor(
private readonly host: ObservationHost,
private readonly paths: ObserverPaths,
private readonly observe: WatchDirectory,
) {}
async start(): Promise<void> {
@@ -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));
@@ -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.
@@ -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",
},
@@ -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<void> {
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,
},
});
+19
View File
@@ -128,6 +128,25 @@ paseo workspace archive <workspace-id>
Add `--forge <name>` 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 <workspace-id> --name Development
paseo terminal ls --workspace <workspace-id> --json
paseo terminal send-keys <terminal-id> -l "echo ready"
paseo terminal send-keys <terminal-id> Enter
paseo terminal capture <terminal-id>
paseo terminal kill <terminal-id>
```
Creation defaults to the workspace directory. Add `--cwd <absolute-path>` 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`:
+1 -1
View File
@@ -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
+40
View File
@@ -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<PaseoTerminalHandle>` | Creates a terminal owned by the required `workspaceId`. |
| `list(options?)` | `Promise<PaseoTerminalListResult>` | 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<PaseoTerminal \| null>` | 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<PaseoTerminalCaptureResult>` | Returns `{ terminalId, lines, totalLines, requestId }`. |
| `kill(requestId?)` | `Promise<void>` | 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 |
+16
View File
@@ -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