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; createTerminal(options: { id?: string; cwd: string; workspaceId: string; name?: string; title?: string; env?: Record; command?: string; args?: string[]; rows?: number; cols?: number; activityToken?: string; activityUrl?: string | null; }): Promise; registerCwdEnv(options: { cwd: string; env: Record }): void; validateTerminalActivityToken(terminalId: string, token: string): "valid" | "unknown" | "invalid"; getTerminal(id: string): TerminalSession | undefined; getTerminalState( id: string, options?: TerminalStateSnapshotOptions, ): Promise; setTerminalTitle(id: string, title: string): boolean; setTerminalActivity(id: string, state: TerminalActivityState): Promise; clearTerminalAttention(id: string): Promise; killTerminal(id: string): void; killTerminalAndWait( id: string, options?: { gracefulTimeoutMs?: number; forceTimeoutMs?: number }, ): Promise; captureTerminal( id: string, options?: { start?: number; end?: number; stripAnsi?: boolean }, ): Promise; 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(); const terminalsById = new Map(); const terminalExitUnsubscribeById = new Map void>(); const terminalTitleUnsubscribeById = new Map void>(); const terminalActivityUnsubscribeById = new Map void>(); const terminalActivityTokenById = new Map(); const terminalsChangedListeners = new Set(); const terminalActivityListeners = new Set(); const terminalWorkspaceContributionChangedListeners = new Set(); const defaultEnvByRootCwd = new Map>(); 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 | 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 { 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; command?: string; args?: string[]; rows?: number; cols?: number; activityToken?: string; activityUrl?: string | null; }): Promise { 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 }): 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 { 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 { const session = terminalsById.get(id); if (!session) { return false; } session.setActivity(state); return true; }, async clearTerminalAttention(id: string): Promise { 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 { 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 { 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); }; }, }; }