import { createTerminal, type TerminalSession } from "./terminal.js"; import { resolve, sep, win32, posix } from "node:path"; export interface TerminalListItem { id: string; name: string; cwd: string; title?: string; } export interface TerminalsChangedEvent { cwd: string; terminals: TerminalListItem[]; } export type TerminalsChangedListener = (input: TerminalsChangedEvent) => void; export interface TerminalManager { getTerminals(cwd: string): Promise; createTerminal(options: { id?: string; cwd: string; name?: string; env?: Record; command?: string; args?: string[]; }): Promise; registerCwdEnv(options: { cwd: string; env: Record }): void; getTerminal(id: string): TerminalSession | undefined; killTerminal(id: string): void; listDirectories(): string[]; killAll(): void; subscribeTerminalsChanged(listener: TerminalsChangedListener): () => void; } type AgentBoundTerminalTitleHandler = (input: { agentId: string; title: string }) => Promise | void; export function createTerminalManager(options?: { resolveAgentIdForTerminal?: (terminalId: string) => string | null; onAgentBoundTerminalTitleChange?: AgentBoundTerminalTitleHandler; }): TerminalManager { const terminalsByCwd = new Map(); const terminalsById = new Map(); const terminalExitUnsubscribeById = new Map void>(); const terminalTitleUnsubscribeById = new Map void>(); const terminalsChangedListeners = new Set(); const defaultEnvByRootCwd = new Map>(); function assertAbsolutePath(cwd: string): void { if (!posix.isAbsolute(cwd) && !win32.isAbsolute(cwd)) { throw new Error("cwd must be absolute path"); } } 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); } terminalsById.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(); } 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((title) => { emitTerminalsChanged({ cwd: session.cwd }); const normalizedTitle = title?.trim(); if (!normalizedTitle) { return; } const agentId = options?.resolveAgentIdForTerminal?.(session.id) ?? null; if (!agentId) { return; } void Promise.resolve( options?.onAgentBoundTerminalTitleChange?.({ agentId, title: normalizedTitle, }), ).catch(() => { // no-op }); }); terminalExitUnsubscribeById.set(session.id, unsubscribeExit); terminalTitleUnsubscribeById.set(session.id, unsubscribeTitle); return session; } function toTerminalListItem(input: { session: TerminalSession }): TerminalListItem { return { id: input.session.id, name: input.session.name, cwd: input.session.cwd, title: input.session.getTitle(), }; } 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 } } } return { async getTerminals(cwd: string): Promise { assertAbsolutePath(cwd); return terminalsByCwd.get(cwd) ?? []; }, async createTerminal(options: { id?: string; cwd: string; name?: string; env?: Record; command?: string; args?: string[]; }): 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 session = registerSession( await createTerminal({ ...(options.id ? { id: options.id } : {}), cwd: options.cwd, name: options.name ?? defaultName, ...(options.command ? { command: options.command } : {}), ...(options.args ? { args: options.args } : {}), ...(mergedEnv ? { env: mergedEnv } : {}), }), ); 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 }); }, getTerminal(id: string): TerminalSession | undefined { return terminalsById.get(id); }, killTerminal(id: string): void { removeSessionById(id, { kill: true }); }, 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); }; }, }; }