New terminals were always spawned by the daemon at the hardcoded 80x24 default and only resized once the client's first resize arrived. On a slow first mount that leaves a window -- or a stuck state -- where a new terminal renders far smaller than its pane (e.g. vim in a corner). Seed the PTY with the client's measured viewport size at creation time so it is born at the right size and the race window is gone. - protocol: optional `size` on create_terminal_request. Old daemons ignore it and keep 80x24; old clients send nothing. Fully backward compatible, no capability gate. - client: a small last-measured-size cache keyed by serverId+cwd (every terminal in a workspace shares the pane) with a most-recent fallback; written on fit, read at create. - server: thread rows/cols through the controller, terminal manager, and the worker-thread boundary into the existing size-aware createTerminal. Tests: cache logic, protocol back-compat parse, controller forwarding. Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
496 lines
16 KiB
TypeScript
496 lines
16 KiB
TypeScript
import {
|
|
createTerminal,
|
|
type TerminalActivityTransition,
|
|
type TerminalSession,
|
|
type TerminalStateSnapshot,
|
|
type TerminalStateSnapshotOptions,
|
|
} from "./terminal.js";
|
|
import { captureTerminalLines, type CaptureTerminalLinesResult } from "./terminal-capture.js";
|
|
import { randomBytes, randomUUID } from "node:crypto";
|
|
import { resolve, sep } from "node:path";
|
|
import { assertAbsolutePath, isSameOrDescendantPath } from "../server/path-utils.js";
|
|
import type { TerminalActivity, TerminalActivityState } from "@getpaseo/protocol/terminal-activity";
|
|
import { deriveTerminalActivityStatusBucket } from "@getpaseo/protocol/terminal-activity";
|
|
|
|
export interface TerminalListItem {
|
|
id: string;
|
|
name: string;
|
|
cwd: string;
|
|
workspaceId: string;
|
|
title?: string;
|
|
activity: TerminalActivity | null;
|
|
}
|
|
|
|
export interface TerminalsChangedEvent {
|
|
cwd: string;
|
|
terminals: TerminalListItem[];
|
|
}
|
|
|
|
export type TerminalsChangedListener = (input: TerminalsChangedEvent) => void;
|
|
|
|
export interface TerminalActivityTransitionEvent {
|
|
terminalId: string;
|
|
name: string;
|
|
cwd: string;
|
|
workspaceId: string;
|
|
activity: TerminalActivity | null;
|
|
previous: TerminalActivity | null;
|
|
}
|
|
|
|
export type TerminalActivityListener = (event: TerminalActivityTransitionEvent) => void;
|
|
|
|
export interface TerminalWorkspaceContributionChangedEvent {
|
|
terminalId: string;
|
|
cwd: string;
|
|
workspaceId: string;
|
|
}
|
|
|
|
export type TerminalWorkspaceContributionChangedListener = (
|
|
event: TerminalWorkspaceContributionChangedEvent,
|
|
) => void;
|
|
|
|
export interface TerminalManager {
|
|
getTerminals(cwd: string, options?: { workspaceId?: string }): Promise<TerminalSession[]>;
|
|
createTerminal(options: {
|
|
id?: string;
|
|
cwd: string;
|
|
workspaceId: string;
|
|
name?: string;
|
|
title?: string;
|
|
env?: Record<string, string>;
|
|
command?: string;
|
|
args?: string[];
|
|
rows?: number;
|
|
cols?: number;
|
|
activityToken?: string;
|
|
activityUrl?: string | null;
|
|
}): Promise<TerminalSession>;
|
|
registerCwdEnv(options: { cwd: string; env: Record<string, string> }): void;
|
|
validateTerminalActivityToken(terminalId: string, token: string): "valid" | "unknown" | "invalid";
|
|
getTerminal(id: string): TerminalSession | undefined;
|
|
getTerminalState(
|
|
id: string,
|
|
options?: TerminalStateSnapshotOptions,
|
|
): Promise<TerminalStateSnapshot | null>;
|
|
setTerminalTitle(id: string, title: string): boolean;
|
|
setTerminalActivity(id: string, state: TerminalActivityState): Promise<boolean>;
|
|
clearTerminalAttention(id: string): Promise<boolean>;
|
|
killTerminal(id: string): void;
|
|
killTerminalAndWait(
|
|
id: string,
|
|
options?: { gracefulTimeoutMs?: number; forceTimeoutMs?: number },
|
|
): Promise<void>;
|
|
captureTerminal(
|
|
id: string,
|
|
options?: { start?: number; end?: number; stripAnsi?: boolean },
|
|
): Promise<CaptureTerminalLinesResult>;
|
|
listDirectories(): string[];
|
|
killAll(): void;
|
|
subscribeTerminalsChanged(listener: TerminalsChangedListener): () => void;
|
|
subscribeTerminalActivity(listener: TerminalActivityListener): () => void;
|
|
subscribeTerminalWorkspaceContributionChanged(
|
|
listener: TerminalWorkspaceContributionChangedListener,
|
|
): () => void;
|
|
}
|
|
|
|
export interface TerminalManagerOptions {
|
|
getTerminalActivityUrl?: () => string | null;
|
|
}
|
|
|
|
function createActivityToken(): string {
|
|
return randomBytes(32).toString("base64url");
|
|
}
|
|
|
|
export function createTerminalManager(
|
|
managerOptions: TerminalManagerOptions = {},
|
|
): TerminalManager {
|
|
const terminalsByCwd = new Map<string, TerminalSession[]>();
|
|
const terminalsById = new Map<string, TerminalSession>();
|
|
const terminalExitUnsubscribeById = new Map<string, () => void>();
|
|
const terminalTitleUnsubscribeById = new Map<string, () => void>();
|
|
const terminalActivityUnsubscribeById = new Map<string, () => void>();
|
|
const terminalActivityTokenById = new Map<string, string>();
|
|
const terminalsChangedListeners = new Set<TerminalsChangedListener>();
|
|
const terminalActivityListeners = new Set<TerminalActivityListener>();
|
|
const terminalWorkspaceContributionChangedListeners =
|
|
new Set<TerminalWorkspaceContributionChangedListener>();
|
|
const defaultEnvByRootCwd = new Map<string, Record<string, string>>();
|
|
|
|
function removeSessionById(id: string, options: { kill: boolean }): void {
|
|
const session = terminalsById.get(id);
|
|
if (!session) {
|
|
return;
|
|
}
|
|
|
|
const unsubscribeExit = terminalExitUnsubscribeById.get(id);
|
|
if (unsubscribeExit) {
|
|
unsubscribeExit();
|
|
terminalExitUnsubscribeById.delete(id);
|
|
}
|
|
const unsubscribeTitle = terminalTitleUnsubscribeById.get(id);
|
|
if (unsubscribeTitle) {
|
|
unsubscribeTitle();
|
|
terminalTitleUnsubscribeById.delete(id);
|
|
}
|
|
const unsubscribeActivity = terminalActivityUnsubscribeById.get(id);
|
|
if (unsubscribeActivity) {
|
|
unsubscribeActivity();
|
|
terminalActivityUnsubscribeById.delete(id);
|
|
}
|
|
|
|
terminalsById.delete(id);
|
|
terminalActivityTokenById.delete(id);
|
|
|
|
const terminals = terminalsByCwd.get(session.cwd);
|
|
if (terminals) {
|
|
const index = terminals.findIndex((terminal) => terminal.id === id);
|
|
if (index !== -1) {
|
|
terminals.splice(index, 1);
|
|
}
|
|
if (terminals.length === 0) {
|
|
terminalsByCwd.delete(session.cwd);
|
|
}
|
|
}
|
|
|
|
if (options.kill) {
|
|
session.kill();
|
|
}
|
|
|
|
const previousActivity = session.getActivity();
|
|
const previousBucket = deriveTerminalActivityStatusBucket(previousActivity);
|
|
if (previousBucket !== null) {
|
|
emitTerminalWorkspaceContributionChanged({
|
|
terminalId: session.id,
|
|
cwd: session.cwd,
|
|
workspaceId: session.workspaceId,
|
|
});
|
|
}
|
|
|
|
emitTerminalsChanged({ cwd: session.cwd });
|
|
}
|
|
|
|
function resolveDefaultEnvForCwd(cwd: string): Record<string, string> | undefined {
|
|
const normalizedCwd = resolve(cwd);
|
|
let bestMatchRoot: string | null = null;
|
|
|
|
for (const rootCwd of defaultEnvByRootCwd.keys()) {
|
|
const matches = normalizedCwd === rootCwd || normalizedCwd.startsWith(`${rootCwd}${sep}`);
|
|
if (!matches) {
|
|
continue;
|
|
}
|
|
if (!bestMatchRoot || rootCwd.length > bestMatchRoot.length) {
|
|
bestMatchRoot = rootCwd;
|
|
}
|
|
}
|
|
|
|
return bestMatchRoot ? defaultEnvByRootCwd.get(bestMatchRoot) : undefined;
|
|
}
|
|
|
|
function registerSession(session: TerminalSession): TerminalSession {
|
|
terminalsById.set(session.id, session);
|
|
const unsubscribeExit = session.onExit(() => {
|
|
removeSessionById(session.id, { kill: false });
|
|
});
|
|
const unsubscribeTitle = session.onTitleChange(() => {
|
|
emitTerminalsChanged({ cwd: session.cwd });
|
|
});
|
|
const unsubscribeActivity = session.onActivityChange((transition) => {
|
|
emitTerminalActivityTransition({ session, transition });
|
|
emitTerminalsChanged({ cwd: session.cwd });
|
|
const previousBucket = deriveTerminalActivityStatusBucket(transition.previous);
|
|
const nextBucket = deriveTerminalActivityStatusBucket(transition.activity);
|
|
if (previousBucket !== nextBucket) {
|
|
emitTerminalWorkspaceContributionChanged({
|
|
terminalId: session.id,
|
|
cwd: session.cwd,
|
|
workspaceId: session.workspaceId,
|
|
});
|
|
}
|
|
});
|
|
terminalExitUnsubscribeById.set(session.id, unsubscribeExit);
|
|
terminalTitleUnsubscribeById.set(session.id, unsubscribeTitle);
|
|
terminalActivityUnsubscribeById.set(session.id, unsubscribeActivity);
|
|
return session;
|
|
}
|
|
|
|
function toTerminalListItem(input: { session: TerminalSession }): TerminalListItem {
|
|
return {
|
|
id: input.session.id,
|
|
name: input.session.name,
|
|
cwd: input.session.cwd,
|
|
workspaceId: input.session.workspaceId,
|
|
title: input.session.getTitle(),
|
|
activity: input.session.getActivity(),
|
|
};
|
|
}
|
|
|
|
function emitTerminalsChanged(input: { cwd: string }): void {
|
|
if (terminalsChangedListeners.size === 0) {
|
|
return;
|
|
}
|
|
|
|
const terminals = (terminalsByCwd.get(input.cwd) ?? []).map((session) =>
|
|
toTerminalListItem({ session }),
|
|
);
|
|
const event: TerminalsChangedEvent = {
|
|
cwd: input.cwd,
|
|
terminals,
|
|
};
|
|
|
|
for (const listener of terminalsChangedListeners) {
|
|
try {
|
|
listener(event);
|
|
} catch {
|
|
// no-op
|
|
}
|
|
}
|
|
}
|
|
|
|
function emitTerminalActivityTransition(input: {
|
|
session: TerminalSession;
|
|
transition: TerminalActivityTransition;
|
|
}): void {
|
|
if (terminalActivityListeners.size === 0) {
|
|
return;
|
|
}
|
|
const event: TerminalActivityTransitionEvent = {
|
|
terminalId: input.session.id,
|
|
name: input.session.name,
|
|
cwd: input.session.cwd,
|
|
workspaceId: input.session.workspaceId,
|
|
activity: input.transition.activity,
|
|
previous: input.transition.previous,
|
|
};
|
|
for (const listener of terminalActivityListeners) {
|
|
try {
|
|
listener(event);
|
|
} catch {
|
|
// no-op
|
|
}
|
|
}
|
|
}
|
|
|
|
function emitTerminalWorkspaceContributionChanged(
|
|
event: TerminalWorkspaceContributionChangedEvent,
|
|
): void {
|
|
for (const listener of terminalWorkspaceContributionChangedListeners) {
|
|
try {
|
|
listener(event);
|
|
} catch {
|
|
// no-op
|
|
}
|
|
}
|
|
}
|
|
|
|
return {
|
|
async getTerminals(
|
|
cwd: string,
|
|
options?: { workspaceId?: string },
|
|
): Promise<TerminalSession[]> {
|
|
assertAbsolutePath(cwd);
|
|
|
|
// Terminals are bucketed by exact cwd, but an agent can open a terminal in
|
|
// a subdirectory of the workspace. A query for the workspace root must
|
|
// surface those too, so aggregate every bucket at or below `cwd`.
|
|
const sessions: TerminalSession[] = [];
|
|
for (const [bucketCwd, bucketSessions] of terminalsByCwd) {
|
|
if (isSameOrDescendantPath(cwd, bucketCwd)) {
|
|
sessions.push(...bucketSessions);
|
|
}
|
|
}
|
|
|
|
// When the query carries a workspaceId, two workspaces sharing a cwd must
|
|
// not see each other's terminals. A missing owner is not workspace
|
|
// membership; unscoped callers can still list those legacy terminals.
|
|
if (options?.workspaceId !== undefined) {
|
|
return sessions.filter((session) => session.workspaceId === options.workspaceId);
|
|
}
|
|
return sessions;
|
|
},
|
|
|
|
async createTerminal(options: {
|
|
id?: string;
|
|
cwd: string;
|
|
workspaceId: string;
|
|
name?: string;
|
|
title?: string;
|
|
env?: Record<string, string>;
|
|
command?: string;
|
|
args?: string[];
|
|
rows?: number;
|
|
cols?: number;
|
|
activityToken?: string;
|
|
activityUrl?: string | null;
|
|
}): Promise<TerminalSession> {
|
|
assertAbsolutePath(options.cwd);
|
|
|
|
const terminals = terminalsByCwd.get(options.cwd) ?? [];
|
|
const defaultName = `Terminal ${terminals.length + 1}`;
|
|
const inheritedEnv = resolveDefaultEnvForCwd(options.cwd);
|
|
const mergedEnv =
|
|
inheritedEnv || options.env ? { ...inheritedEnv, ...options.env } : undefined;
|
|
const terminalId = options.id ?? randomUUID();
|
|
const activityToken = options.activityToken ?? createActivityToken();
|
|
const terminalActivityUrl =
|
|
options.activityUrl === undefined
|
|
? (managerOptions.getTerminalActivityUrl?.() ?? null)
|
|
: options.activityUrl;
|
|
const activityEnv = {
|
|
PASEO_TERMINAL_ID: terminalId,
|
|
PASEO_ACTIVITY_TOKEN: activityToken,
|
|
...(terminalActivityUrl ? { PASEO_TERMINAL_ACTIVITY_URL: terminalActivityUrl } : {}),
|
|
};
|
|
terminalActivityTokenById.set(terminalId, activityToken);
|
|
let session: TerminalSession;
|
|
try {
|
|
session = registerSession(
|
|
await createTerminal({
|
|
id: terminalId,
|
|
cwd: options.cwd,
|
|
workspaceId: options.workspaceId,
|
|
name: options.name ?? defaultName,
|
|
...(options.title ? { title: options.title } : {}),
|
|
...(options.command ? { command: options.command } : {}),
|
|
...(options.args ? { args: options.args } : {}),
|
|
...(options.rows !== undefined ? { rows: options.rows } : {}),
|
|
...(options.cols !== undefined ? { cols: options.cols } : {}),
|
|
...(mergedEnv ? { env: mergedEnv } : {}),
|
|
activityEnv,
|
|
}),
|
|
);
|
|
} catch (error) {
|
|
terminalActivityTokenById.delete(terminalId);
|
|
throw error;
|
|
}
|
|
|
|
terminals.push(session);
|
|
terminalsByCwd.set(options.cwd, terminals);
|
|
emitTerminalsChanged({ cwd: options.cwd });
|
|
|
|
return session;
|
|
},
|
|
|
|
registerCwdEnv(options: { cwd: string; env: Record<string, string> }): void {
|
|
assertAbsolutePath(options.cwd);
|
|
defaultEnvByRootCwd.set(resolve(options.cwd), { ...options.env });
|
|
},
|
|
|
|
validateTerminalActivityToken(
|
|
terminalId: string,
|
|
token: string,
|
|
): "valid" | "unknown" | "invalid" {
|
|
const expected = terminalActivityTokenById.get(terminalId);
|
|
if (!expected) {
|
|
return "unknown";
|
|
}
|
|
return expected === token ? "valid" : "invalid";
|
|
},
|
|
|
|
getTerminal(id: string): TerminalSession | undefined {
|
|
return terminalsById.get(id);
|
|
},
|
|
|
|
async getTerminalState(
|
|
id: string,
|
|
options?: TerminalStateSnapshotOptions,
|
|
): Promise<TerminalStateSnapshot | null> {
|
|
return terminalsById.get(id)?.getStateSnapshot(options) ?? null;
|
|
},
|
|
|
|
setTerminalTitle(id: string, title: string): boolean {
|
|
const session = terminalsById.get(id);
|
|
if (!session) {
|
|
return false;
|
|
}
|
|
|
|
session.setTitle(title);
|
|
return true;
|
|
},
|
|
|
|
async setTerminalActivity(id: string, state: TerminalActivityState): Promise<boolean> {
|
|
const session = terminalsById.get(id);
|
|
if (!session) {
|
|
return false;
|
|
}
|
|
|
|
session.setActivity(state);
|
|
return true;
|
|
},
|
|
|
|
async clearTerminalAttention(id: string): Promise<boolean> {
|
|
const session = terminalsById.get(id);
|
|
if (!session) {
|
|
return false;
|
|
}
|
|
|
|
return session.clearActivityAttention();
|
|
},
|
|
|
|
killTerminal(id: string): void {
|
|
removeSessionById(id, { kill: true });
|
|
},
|
|
|
|
async killTerminalAndWait(
|
|
id: string,
|
|
options?: { gracefulTimeoutMs?: number; forceTimeoutMs?: number },
|
|
): Promise<void> {
|
|
const session = terminalsById.get(id);
|
|
if (!session) {
|
|
return;
|
|
}
|
|
try {
|
|
await session.killAndWait(options);
|
|
} finally {
|
|
removeSessionById(id, { kill: false });
|
|
}
|
|
},
|
|
|
|
async captureTerminal(
|
|
id: string,
|
|
options?: { start?: number; end?: number; stripAnsi?: boolean },
|
|
): Promise<CaptureTerminalLinesResult> {
|
|
const session = terminalsById.get(id);
|
|
if (!session) {
|
|
return {
|
|
lines: [],
|
|
totalLines: 0,
|
|
};
|
|
}
|
|
return captureTerminalLines(session, options);
|
|
},
|
|
|
|
listDirectories(): string[] {
|
|
return Array.from(terminalsByCwd.keys());
|
|
},
|
|
|
|
killAll(): void {
|
|
for (const id of Array.from(terminalsById.keys())) {
|
|
removeSessionById(id, { kill: true });
|
|
}
|
|
},
|
|
|
|
subscribeTerminalsChanged(listener: TerminalsChangedListener): () => void {
|
|
terminalsChangedListeners.add(listener);
|
|
return () => {
|
|
terminalsChangedListeners.delete(listener);
|
|
};
|
|
},
|
|
|
|
subscribeTerminalActivity(listener: TerminalActivityListener): () => void {
|
|
terminalActivityListeners.add(listener);
|
|
return () => {
|
|
terminalActivityListeners.delete(listener);
|
|
};
|
|
},
|
|
|
|
subscribeTerminalWorkspaceContributionChanged(
|
|
listener: TerminalWorkspaceContributionChangedListener,
|
|
): () => void {
|
|
terminalWorkspaceContributionChangedListeners.add(listener);
|
|
return () => {
|
|
terminalWorkspaceContributionChangedListeners.delete(listener);
|
|
};
|
|
},
|
|
};
|
|
}
|