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
@@ -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 {