diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 0280d010..27b0defe 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -13,6 +13,7 @@ import { CalendarView } from './pages/CalendarView'; import { TodoView } from './pages/TodoView'; import { TodoDetailView } from './pages/TodoDetailView'; import { TaskBoard } from './pages/TaskBoard'; +import { TerminalView } from './pages/TerminalView'; import { ErrorBoundary } from './components/ErrorBoundary'; import { MobileShell } from './components/MobileShell'; import { DesktopShell } from './components/DesktopShell'; @@ -168,6 +169,22 @@ export function App() { } /> + + + + } + /> + + + + } + /> diff --git a/frontend/src/components/DesktopNav.tsx b/frontend/src/components/DesktopNav.tsx index 08d0dec7..22134864 100644 --- a/frontend/src/components/DesktopNav.tsx +++ b/frontend/src/components/DesktopNav.tsx @@ -19,6 +19,7 @@ export function DesktopNav() { }, { label: 'Calendar', path: '/calendar', match: (p) => p.startsWith('/calendar') }, { label: 'Files', path: '/files', match: (p) => p.startsWith('/files') }, + { label: 'Terminal', path: '/terminal', match: (p) => p.startsWith('/terminal') }, ]; return ( diff --git a/frontend/src/components/MobileShell.tsx b/frontend/src/components/MobileShell.tsx index 06e8c306..7b54d36a 100644 --- a/frontend/src/components/MobileShell.tsx +++ b/frontend/src/components/MobileShell.tsx @@ -2,7 +2,7 @@ import { useLocation } from 'react-router-dom'; import { useIsDesktop } from '../hooks/useMediaQuery'; import { TabBar } from './TabBar'; -const HIDE_TAB_BAR = ['/login', '/chat']; +const HIDE_TAB_BAR = ['/login', '/chat', '/terminal']; function shouldHideTabBar(pathname: string): boolean { return HIDE_TAB_BAR.some((p) => pathname === p || pathname.startsWith(p + '/')); diff --git a/frontend/src/components/TabBar.tsx b/frontend/src/components/TabBar.tsx index 44c63821..2940a97d 100644 --- a/frontend/src/components/TabBar.tsx +++ b/frontend/src/components/TabBar.tsx @@ -39,7 +39,9 @@ export function TabBar() { }, ]; - const isMoreActive = ['/tasks', '/files'].some((p) => location.pathname.startsWith(p)); + const isMoreActive = ['/tasks', '/files', '/terminal'].some((p) => + location.pathname.startsWith(p), + ); return ( <> @@ -64,6 +66,15 @@ export function TabBar() { > Files +
+ ))} +
+ + ); +} diff --git a/frontend/src/hooks/useTerminal.ts b/frontend/src/hooks/useTerminal.ts new file mode 100644 index 00000000..f05f3b5a --- /dev/null +++ b/frontend/src/hooks/useTerminal.ts @@ -0,0 +1,168 @@ +/** useTerminal — manages a WebSocket connection to the terminal backend. */ + +import { useRef, useCallback, useEffect, useState } from 'react'; +import { getWsChatUrl } from '../lib/api-fetch'; + +export interface TerminalState { + terminalId: string | null; + connected: boolean; + exited: boolean; + exitCode?: number; +} + +interface UseTerminalOptions { + sessionId: string; + cols?: number; + rows?: number; + onData: (data: string) => void; + onExit?: (exitCode: number, signal?: number) => void; + onError?: (error: string) => void; +} + +export function useTerminal({ + sessionId, + cols = 80, + rows = 24, + onData, + onExit, + onError, +}: UseTerminalOptions) { + const wsRef = useRef(null); + const terminalIdRef = useRef(null); + const [state, setState] = useState({ + terminalId: null, + connected: false, + exited: false, + }); + + // Store latest callbacks in refs to avoid reconnect churn + const onDataRef = useRef(onData); + const onExitRef = useRef(onExit); + const onErrorRef = useRef(onError); + const colsRef = useRef(cols); + const rowsRef = useRef(rows); + onDataRef.current = onData; + onExitRef.current = onExit; + onErrorRef.current = onError; + colsRef.current = cols; + rowsRef.current = rows; + + const send = useCallback((msg: Record) => { + const ws = wsRef.current; + if (ws?.readyState === WebSocket.OPEN) { + ws.send(JSON.stringify(msg)); + } + }, []); + + const connect = useCallback(() => { + if (wsRef.current?.readyState === WebSocket.OPEN) return; + + const ws = new WebSocket(getWsChatUrl()); + wsRef.current = ws; + + ws.onopen = () => { + // v2 handshake + ws.send(JSON.stringify({ type: 'hello', protocolVersion: 2 })); + }; + + ws.onmessage = (event) => { + let msg: Record; + try { + msg = JSON.parse(event.data as string); + } catch { + return; + } + + switch (msg.type) { + case 'welcome': + // Handshake complete — create terminal + send({ + type: 'terminal_create', + sessionId, + cols: colsRef.current, + rows: rowsRef.current, + }); + setState((s) => ({ ...s, connected: true })); + break; + + case 'terminal_created': + terminalIdRef.current = msg.terminalId as string; + setState((s) => ({ + ...s, + terminalId: msg.terminalId as string, + })); + break; + + case 'terminal_output': + if (msg.terminalId === terminalIdRef.current) { + onDataRef.current(msg.data as string); + } + break; + + case 'terminal_exit': + if (msg.terminalId === terminalIdRef.current) { + setState((s) => ({ + ...s, + exited: true, + exitCode: msg.exitCode as number, + })); + onExitRef.current?.(msg.exitCode as number, msg.signal as number | undefined); + } + break; + + case 'terminal_error': + onErrorRef.current?.(msg.error as string); + break; + } + }; + + ws.onclose = () => { + setState((s) => ({ ...s, connected: false })); + }; + }, [sessionId, send]); + + const writeInput = useCallback( + (data: string) => { + if (!terminalIdRef.current) return; + send({ type: 'terminal_input', terminalId: terminalIdRef.current, data }); + }, + [send], + ); + + const resize = useCallback( + (newCols: number, newRows: number) => { + if (!terminalIdRef.current) return; + send({ + type: 'terminal_resize', + terminalId: terminalIdRef.current, + cols: newCols, + rows: newRows, + }); + }, + [send], + ); + + const destroy = useCallback(() => { + if (terminalIdRef.current) { + send({ type: 'terminal_destroy', terminalId: terminalIdRef.current }); + } + wsRef.current?.close(); + wsRef.current = null; + terminalIdRef.current = null; + setState({ terminalId: null, connected: false, exited: false }); + }, [send]); + + // Cleanup on unmount + useEffect(() => { + return () => { + if (terminalIdRef.current && wsRef.current?.readyState === WebSocket.OPEN) { + wsRef.current.send( + JSON.stringify({ type: 'terminal_destroy', terminalId: terminalIdRef.current }), + ); + } + wsRef.current?.close(); + }; + }, []); + + return { state, connect, writeInput, resize, destroy }; +} diff --git a/frontend/src/main.tsx b/frontend/src/main.tsx index be203163..3edda088 100644 --- a/frontend/src/main.tsx +++ b/frontend/src/main.tsx @@ -9,6 +9,7 @@ import './styles/global.css'; import './styles/code-block.css'; import './styles/calendar.css'; import './styles/desktop.css'; +import './styles/terminal.css'; initTheme(); diff --git a/frontend/src/pages/TerminalView.tsx b/frontend/src/pages/TerminalView.tsx new file mode 100644 index 00000000..63503a2b --- /dev/null +++ b/frontend/src/pages/TerminalView.tsx @@ -0,0 +1,55 @@ +/** TerminalView — full-page interactive shell terminal. */ + +import { useRef, useEffect, useCallback, useMemo } from 'react'; +import { useNavigate, useParams } from 'react-router-dom'; +import { Terminal } from '../components/Terminal'; +import { useTerminal } from '../hooks/useTerminal'; + +export function TerminalView() { + const { sessionId } = useParams<{ sessionId: string }>(); + const navigate = useNavigate(); + const termRef = useRef<{ write: (data: string) => void } | null>(null); + + const resolvedSessionId = useMemo(() => sessionId || `terminal-${Date.now()}`, [sessionId]); + + const { state, connect, writeInput, resize, destroy } = useTerminal({ + sessionId: resolvedSessionId, + onData: useCallback((data: string) => { + termRef.current?.write(data); + }, []), + onExit: useCallback((_exitCode: number) => { + termRef.current?.write('\r\n\x1b[90m[Process exited — press any key to restart]\x1b[0m\r\n'); + }, []), + onError: useCallback((error: string) => { + termRef.current?.write(`\r\n\x1b[31mError: ${error}\x1b[0m\r\n`); + }, []), + }); + + // Connect on mount + useEffect(() => { + connect(); + return () => destroy(); + }, [connect, destroy]); + + return ( +
+
+ + + Terminal + {state.connected && !state.exited && ( + + )} + {state.exited && (exited)} + {!state.connected && !state.exited && ( + (connecting...) + )} + +
+
+ +
+ ); +} diff --git a/frontend/src/styles/terminal.css b/frontend/src/styles/terminal.css new file mode 100644 index 00000000..9ded624c --- /dev/null +++ b/frontend/src/styles/terminal.css @@ -0,0 +1,132 @@ +/* Terminal view + component styles */ + +.terminal-view { + display: flex; + flex-direction: column; + height: 100dvh; + background: #1a1a2e; + color: #e0e0e0; +} + +.terminal-header { + display: flex; + align-items: center; + padding: 8px 12px; + background: #16162a; + border-bottom: 1px solid #2a2a4e; + flex-shrink: 0; + /* Safe area for iOS notch */ + padding-top: max(8px, env(safe-area-inset-top)); +} + +.terminal-back-btn { + background: none; + border: none; + color: var(--accent, #74c0fc); + font-size: 16px; + cursor: pointer; + padding: 4px 8px; + margin-right: 8px; +} + +.terminal-title { + font-size: 15px; + font-weight: 600; + display: flex; + align-items: center; + gap: 6px; +} + +.terminal-header-spacer { + flex: 1; +} + +.terminal-status { + width: 8px; + height: 8px; + border-radius: 50%; + display: inline-block; +} + +.terminal-status--live { + background: #51cf66; +} + +.terminal-status-text { + font-size: 12px; + font-weight: 400; + color: #888; +} + +/* Terminal container — fills remaining space */ +.terminal-container { + display: flex; + flex-direction: column; + flex: 1; + min-height: 0; +} + +.terminal-xterm { + flex: 1; + min-height: 0; + padding: 4px; +} + +/* Make xterm fill its container */ +.terminal-xterm .xterm { + height: 100%; +} + +.terminal-xterm .xterm-viewport { + overflow-y: auto !important; +} + +/* Mobile keyboard toolbar */ +.terminal-toolbar { + display: flex; + gap: 4px; + padding: 6px 8px; + padding-bottom: max(6px, env(safe-area-inset-bottom)); + background: #16162a; + border-top: 1px solid #2a2a4e; + overflow-x: auto; + flex-shrink: 0; + -webkit-overflow-scrolling: touch; +} + +.terminal-toolbar-key { + background: #2a2a4e; + border: 1px solid #3a3a5e; + border-radius: 6px; + color: #e0e0e0; + font-size: 13px; + font-family: inherit; + padding: 8px 12px; + min-width: 40px; + cursor: pointer; + user-select: none; + -webkit-user-select: none; + touch-action: manipulation; + flex-shrink: 0; +} + +.terminal-toolbar-key:active { + background: #3a3a5e; +} + +.terminal-toolbar-key--active { + background: var(--accent, #74c0fc); + color: #1a1a2e; + border-color: var(--accent, #74c0fc); +} + +/* Desktop: hide toolbar (real keyboard available) */ +@media (min-width: 768px) { + .terminal-toolbar { + display: none; + } + + .terminal-xterm { + padding: 8px; + } +} diff --git a/frontend/src/types/ws-messages.ts b/frontend/src/types/ws-messages.ts index 05ae4b3e..013da20d 100644 --- a/frontend/src/types/ws-messages.ts +++ b/frontend/src/types/ws-messages.ts @@ -218,6 +218,36 @@ interface TaskDeletedMsg { taskId: string; } +// ─── Terminal messages (server → client) ─────────────────────────────────── + +export interface TerminalCreatedMsg { + type: 'terminal_created'; + terminalId: string; + sessionId: string; + pid: number; + cols: number; + rows: number; +} + +export interface TerminalOutputMsg { + type: 'terminal_output'; + terminalId: string; + data: string; +} + +export interface TerminalExitMsg { + type: 'terminal_exit'; + terminalId: string; + exitCode: number; + signal?: number; +} + +export interface TerminalErrorMsg { + type: 'terminal_error'; + terminalId?: string; + error: string; +} + export type ServerMessage = | ClientIdMsg | ReattachedMsg @@ -260,7 +290,11 @@ export type ServerMessage = | SubagentBlockEndMsg | SubagentToolResultMsg | SubagentEndMsg - | SubagentCancelledMsg; + | SubagentCancelledMsg + | TerminalCreatedMsg + | TerminalOutputMsg + | TerminalExitMsg + | TerminalErrorMsg; export interface ProgressStartMsg { type: 'progress_start'; diff --git a/package-lock.json b/package-lock.json index 46b186ea..59b0c1cd 100644 --- a/package-lock.json +++ b/package-lock.json @@ -33,6 +33,7 @@ "jose": "^5.9.0", "js-yaml": "^4.2.0", "nanoid": "^5.0.9", + "node-pty": "^1.1.0", "pino": "^10.3.1", "pino-loki": "^3.0.0", "pino-roll": "^4.0.0", @@ -9305,6 +9306,12 @@ "node": ">=10" } }, + "node_modules/node-addon-api": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-7.1.1.tgz", + "integrity": "sha512-5m3bsyrjFWE1xf7nz7YXdN4udnVtXK6/Yfgn5qnahL6bCkf2yKt4k3nuTKAtT4r3IG8JNR2ncsIMdZuAzJjHQQ==", + "license": "MIT" + }, "node_modules/node-fetch": { "version": "2.7.0", "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.7.0.tgz", @@ -9356,6 +9363,16 @@ "node": ">= 6.13.0" } }, + "node_modules/node-pty": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/node-pty/-/node-pty-1.1.0.tgz", + "integrity": "sha512-20JqtutY6JPXTUnL0ij1uad7Qe1baT46lyolh2sSENDd4sTzKZ4nmAFkeAARDKwmlLjPx6XKRlwRUxwjOy+lUg==", + "hasInstallScript": true, + "license": "MIT", + "dependencies": { + "node-addon-api": "^7.1.0" + } + }, "node_modules/node-releases": { "version": "2.0.37", "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.37.tgz", diff --git a/package.json b/package.json index df732b66..7e1c056e 100644 --- a/package.json +++ b/package.json @@ -55,6 +55,7 @@ "jose": "^5.9.0", "js-yaml": "^4.2.0", "nanoid": "^5.0.9", + "node-pty": "^1.1.0", "pino": "^10.3.1", "pino-loki": "^3.0.0", "pino-roll": "^4.0.0", diff --git a/packages/protocol/src/index.ts b/packages/protocol/src/index.ts index b0abd6da..cc18fb85 100644 --- a/packages/protocol/src/index.ts +++ b/packages/protocol/src/index.ts @@ -104,6 +104,10 @@ export { V2StopMessage, V2PermissionResponseMessage, V2SetModeMessage, + TerminalCreateMessage, + TerminalInputMessage, + TerminalResizeMessage, + TerminalDestroyMessage, IncomingWsMessageV2, } from './ws-schemas-v2.js'; diff --git a/packages/protocol/src/ws-schemas-v2.ts b/packages/protocol/src/ws-schemas-v2.ts index 8fc07d35..6d1b0c96 100644 --- a/packages/protocol/src/ws-schemas-v2.ts +++ b/packages/protocol/src/ws-schemas-v2.ts @@ -122,6 +122,33 @@ export const V2SetModeMessage = z.object({ mode: z.enum(['ask', 'agent', 'auto']), }); +// ─── Terminal messages ───────────────────────────────────────────────────── + +export const TerminalCreateMessage = z.object({ + type: z.literal('terminal_create'), + sessionId: z.string().min(1), + cols: z.number().int().min(1).max(500).optional(), + rows: z.number().int().min(1).max(500).optional(), +}); + +export const TerminalInputMessage = z.object({ + type: z.literal('terminal_input'), + terminalId: z.string().min(1), + data: z.string().max(65536), +}); + +export const TerminalResizeMessage = z.object({ + type: z.literal('terminal_resize'), + terminalId: z.string().min(1), + cols: z.number().int().min(1).max(500), + rows: z.number().int().min(1).max(500), +}); + +export const TerminalDestroyMessage = z.object({ + type: z.literal('terminal_destroy'), + terminalId: z.string().min(1), +}); + // ─── Union ────────────────────────────────────────────────────────────────── export const IncomingWsMessageV2 = z.discriminatedUnion('type', [ @@ -137,6 +164,10 @@ export const IncomingWsMessageV2 = z.discriminatedUnion('type', [ V2StopMessage, V2PermissionResponseMessage, V2SetModeMessage, + TerminalCreateMessage, + TerminalInputMessage, + TerminalResizeMessage, + TerminalDestroyMessage, ]); export type IncomingWsMessageV2 = z.infer; diff --git a/server/app.ts b/server/app.ts index abf68a93..4a327764 100644 --- a/server/app.ts +++ b/server/app.ts @@ -41,6 +41,7 @@ import { isValidInternalToken } from './internal-token.js'; import { getLocalCommit, isUpdateAvailable } from './git-version.js'; import { resolvePending } from './permissions.js'; import { createLogger } from './logger.js'; +import { listTerminals } from './terminal-manager.js'; import { handleTaskSet, handleTaskComplete, @@ -621,6 +622,13 @@ app.get('/api/service-health', (_req, res) => { res.json(healthMonitor?.getSnapshot() ?? { services: [], checkedAt: 0 }); }); +// --- Terminal API --- + +app.get('/api/terminals', (req, res) => { + const sessionId = req.query.sessionId as string | undefined; + res.json({ terminals: listTerminals(sessionId) }); +}); + // --- Task Board API --- app.get('/api/tasks', (_req, res) => { diff --git a/server/index.ts b/server/index.ts index c89da5b2..08230161 100644 --- a/server/index.ts +++ b/server/index.ts @@ -89,6 +89,7 @@ import { withSpan, withSpanAsync } from './tracing.js'; import { contextFromTraceparent } from './trace-context.js'; import { SseTransport } from './sse-transport.js'; import { createChatRestRouter } from './chat-rest-handler.js'; +import { destroyConnectionTerminals } from './terminal-manager.js'; const log = createLogger('server'); @@ -472,6 +473,9 @@ function handleChatWsV2(ws: WebSocket, connectionId: string) { transportMap.delete(ws); log.info('v2 disconnected', { connectionId, code, reason: reason?.toString() }); + // Clean up any PTY terminals owned by this connection + destroyConnectionTerminals(connectionId); + for (const sessionId of watchedSessions) { const found = registry.findBySessionId(sessionId); if (!found) continue; diff --git a/server/terminal-manager.ts b/server/terminal-manager.ts new file mode 100644 index 00000000..0d0ce11d --- /dev/null +++ b/server/terminal-manager.ts @@ -0,0 +1,245 @@ +/** Terminal Manager — PTY lifecycle for interactive shell terminals. */ + +import * as pty from 'node-pty'; +import { createLogger } from './logger.js'; + +const log = createLogger('terminal'); + +export interface TerminalInfo { + id: string; + sessionId: string; + pid: number; + cols: number; + rows: number; + cwd: string; + createdAt: number; +} + +interface ManagedTerminal { + id: string; + sessionId: string; + connectionId: string; + process: pty.IPty; + cols: number; + rows: number; + cwd: string; + createdAt: number; + /** Callback to send output data to the client. */ + onData: ((data: string) => void) | null; + /** Callback when the terminal process exits. */ + onExit: ((exitCode: number, signal?: number) => void) | null; +} + +/** Safe env vars to inherit — everything else is stripped. */ +const SAFE_ENV_KEYS = new Set([ + 'PATH', + 'HOME', + 'USER', + 'LOGNAME', + 'SHELL', + 'LANG', + 'TERM', + 'COLORTERM', + 'EDITOR', + 'VISUAL', + 'PAGER', + 'TMPDIR', + 'TZ', +]); +const SAFE_ENV_PREFIXES = ['LC_', 'XDG_']; + +const MAX_TERMINALS_PER_SESSION = 5; +const MAX_TERMINALS_GLOBAL = 50; + +let terminalCounter = 0; + +/** Active terminals keyed by terminal ID. */ +const terminals = new Map(); + +function generateTerminalId(): string { + return `term-${Date.now()}-${++terminalCounter}`; +} + +function getDefaultShell(): string { + return process.env.SHELL || (process.platform === 'win32' ? 'powershell.exe' : '/bin/zsh'); +} + +function buildSafeEnv(extra?: Record): Record { + const env: Record = {}; + for (const [key, val] of Object.entries(process.env)) { + if (val == null) continue; + if (SAFE_ENV_KEYS.has(key) || SAFE_ENV_PREFIXES.some((p) => key.startsWith(p))) { + env[key] = val; + } + } + env.TERM = 'xterm-256color'; + env.COLORTERM = 'truecolor'; + if (extra) Object.assign(env, extra); + return env; +} + +export function createTerminal( + sessionId: string, + connectionId: string, + cwd: string, + opts?: { cols?: number; rows?: number; env?: Record }, +): TerminalInfo { + if (terminals.size >= MAX_TERMINALS_GLOBAL) { + throw new Error(`Global terminal limit reached (${MAX_TERMINALS_GLOBAL})`); + } + const sessionCount = [...terminals.values()].filter((t) => t.sessionId === sessionId).length; + if (sessionCount >= MAX_TERMINALS_PER_SESSION) { + throw new Error(`Session terminal limit reached (${MAX_TERMINALS_PER_SESSION})`); + } + + const id = generateTerminalId(); + const cols = opts?.cols ?? 80; + const rows = opts?.rows ?? 24; + const shell = getDefaultShell(); + + const proc = pty.spawn(shell, [], { + name: 'xterm-256color', + cols, + rows, + cwd, + env: buildSafeEnv(opts?.env), + }); + + const managed: ManagedTerminal = { + id, + sessionId, + connectionId, + process: proc, + cols, + rows, + cwd, + createdAt: Date.now(), + onData: null, + onExit: null, + }; + + proc.onData((data) => { + managed.onData?.(data); + }); + + proc.onExit(({ exitCode, signal }) => { + log.info('terminal exited', { id, sessionId, exitCode, signal }); + managed.onExit?.(exitCode, signal); + terminals.delete(id); + }); + + terminals.set(id, managed); + log.info('terminal created', { id, sessionId, cwd, shell, pid: proc.pid }); + + return { id, sessionId, pid: proc.pid, cols, rows, cwd, createdAt: managed.createdAt }; +} + +export function writeTerminal(id: string, data: string): boolean { + const term = terminals.get(id); + if (!term) return false; + term.process.write(data); + return true; +} + +export function resizeTerminal(id: string, cols: number, rows: number): boolean { + const term = terminals.get(id); + if (!term) return false; + term.process.resize(cols, rows); + term.cols = cols; + term.rows = rows; + return true; +} + +export function destroyTerminal(id: string): boolean { + const term = terminals.get(id); + if (!term) return false; + term.process.kill(); + terminals.delete(id); + log.info('terminal destroyed', { id, sessionId: term.sessionId }); + return true; +} + +export function getTerminal(id: string): TerminalInfo | null { + const term = terminals.get(id); + if (!term) return null; + return { + id: term.id, + sessionId: term.sessionId, + pid: term.process.pid, + cols: term.cols, + rows: term.rows, + cwd: term.cwd, + createdAt: term.createdAt, + }; +} + +export function listTerminals(sessionId?: string): TerminalInfo[] { + const result: TerminalInfo[] = []; + for (const term of terminals.values()) { + if (sessionId && term.sessionId !== sessionId) continue; + result.push({ + id: term.id, + sessionId: term.sessionId, + pid: term.process.pid, + cols: term.cols, + rows: term.rows, + cwd: term.cwd, + createdAt: term.createdAt, + }); + } + return result; +} + +export function destroySessionTerminals(sessionId: string): number { + let count = 0; + for (const [id, term] of terminals.entries()) { + if (term.sessionId === sessionId) { + term.process.kill(); + terminals.delete(id); + count++; + } + } + if (count > 0) { + log.info('destroyed session terminals', { sessionId, count }); + } + return count; +} + +export function destroyConnectionTerminals(connectionId: string): number { + let count = 0; + for (const [id, term] of terminals.entries()) { + if (term.connectionId === connectionId) { + term.process.kill(); + terminals.delete(id); + count++; + } + } + if (count > 0) { + log.info('destroyed connection terminals', { connectionId, count }); + } + return count; +} + +export function getTerminalOwner(id: string): string | null { + return terminals.get(id)?.connectionId ?? null; +} + +export function setTerminalCallbacks( + id: string, + onData: (data: string) => void, + onExit: (exitCode: number, signal?: number) => void, +): boolean { + const term = terminals.get(id); + if (!term) return false; + term.onData = onData; + term.onExit = onExit; + return true; +} + +export function clearTerminalCallbacks(id: string): boolean { + const term = terminals.get(id); + if (!term) return false; + term.onData = null; + term.onExit = null; + return true; +} diff --git a/server/ws-handler-v2.ts b/server/ws-handler-v2.ts index 14e3ce1b..fdd276b6 100644 --- a/server/ws-handler-v2.ts +++ b/server/ws-handler-v2.ts @@ -23,6 +23,10 @@ import { V2InterruptMessage, V2PermissionResponseMessage, V2SetModeMessage, + TerminalCreateMessage, + TerminalInputMessage, + TerminalResizeMessage, + TerminalDestroyMessage, } from '@mitzo/protocol'; import type { z } from 'zod'; @@ -36,6 +40,10 @@ type StopMsg = z.infer; type InterruptMsg = z.infer; type PermissionMsg = z.infer; type SetModeMsg = z.infer; +type TerminalCreateMsg = z.infer; +type TerminalInputMsg = z.infer; +type TerminalResizeMsg = z.infer; +type TerminalDestroyMsg = z.infer; import { randomUUID } from 'crypto'; import { withSpan, withSpanAsync } from './tracing.js'; import { SpanStatusCode } from '@opentelemetry/api'; @@ -56,6 +64,14 @@ import { setSkillPolicy, clearSkillPolicy } from './skill-policy.js'; import { resolveSlashCommand } from './slash-commands.js'; import { buildSkillRegistry, isAllowedPath, NATIVE_COMMAND_NAMES } from './app.js'; import type { NativeCommandRegistry } from './native-commands.js'; +import { + createTerminal, + writeTerminal, + resizeTerminal, + destroyTerminal, + setTerminalCallbacks, + getTerminalOwner, +} from './terminal-manager.js'; import { createLogger } from './logger.js'; const log = createLogger('ws-v2'); @@ -912,6 +928,135 @@ export function handleSessionClose( ); } +// ─── Terminal handlers ────────────────────────────────────────────────────── + +export function handleTerminalCreate( + connectionId: string, + msg: TerminalCreateMsg, + ctx: V2HandlerContext, +): void { + withSpan( + 'ws.terminal_create', + { 'ws.connectionId': connectionId, 'ws.sessionId': msg.sessionId }, + () => { + const conn = ctx.connRegistry.get(connectionId); + if (!conn) return; + + // Resolve cwd from session metadata (worktree path or base repo) + const sessionMeta = ctx.eventStore.getSession(msg.sessionId); + const rawCwd = sessionMeta?.cwd || BASE_REPO || process.cwd(); + const cwd = isAllowedPath(rawCwd) ? rawCwd : BASE_REPO || process.cwd(); + + try { + const info = createTerminal(msg.sessionId, connectionId, cwd, { + cols: msg.cols, + rows: msg.rows, + }); + + // Wire PTY output → WS broadcast to connection + setTerminalCallbacks( + info.id, + (data) => { + conn.transport.send({ + type: 'terminal_output', + terminalId: info.id, + data, + }); + }, + (exitCode, signal) => { + conn.transport.send({ + type: 'terminal_exit', + terminalId: info.id, + exitCode, + ...(signal !== undefined ? { signal } : {}), + }); + }, + ); + + conn.transport.send({ + type: 'terminal_created', + terminalId: info.id, + sessionId: msg.sessionId, + pid: info.pid, + cols: info.cols, + rows: info.rows, + }); + + log.info('terminal created via ws', { + connectionId, + sessionId: msg.sessionId, + terminalId: info.id, + cwd, + }); + } catch (err: unknown) { + const message = err instanceof Error ? err.message : String(err); + conn.transport.send({ + type: 'terminal_error', + error: `Failed to create terminal: ${message}`, + }); + log.error('terminal create failed', { + connectionId, + sessionId: msg.sessionId, + error: message, + }); + } + }, + ); +} + +function verifyTerminalOwner( + connectionId: string, + terminalId: string, + ctx: V2HandlerContext, +): boolean { + const owner = getTerminalOwner(terminalId); + if (owner === null) { + ctx.connRegistry.get(connectionId)?.transport.send({ + type: 'terminal_error', + terminalId, + error: 'Terminal not found', + }); + return false; + } + if (owner !== connectionId) { + ctx.connRegistry.get(connectionId)?.transport.send({ + type: 'terminal_error', + terminalId, + error: 'Not terminal owner', + }); + return false; + } + return true; +} + +export function handleTerminalInput( + connectionId: string, + msg: TerminalInputMsg, + ctx: V2HandlerContext, +): void { + if (!verifyTerminalOwner(connectionId, msg.terminalId, ctx)) return; + writeTerminal(msg.terminalId, msg.data); +} + +export function handleTerminalResize( + connectionId: string, + msg: TerminalResizeMsg, + ctx: V2HandlerContext, +): void { + if (!verifyTerminalOwner(connectionId, msg.terminalId, ctx)) return; + resizeTerminal(msg.terminalId, msg.cols, msg.rows); +} + +export function handleTerminalDestroy( + connectionId: string, + msg: TerminalDestroyMsg, + ctx: V2HandlerContext, +): void { + if (!verifyTerminalOwner(connectionId, msg.terminalId, ctx)) return; + destroyTerminal(msg.terminalId); + log.info('terminal destroyed via ws', { connectionId, terminalId: msg.terminalId }); +} + // ─── Dispatcher ────────────────────────────────────────────────────────────── /** @@ -980,5 +1125,17 @@ export async function dispatchV2Message( case 'set_mode': handleSetModeV2(connectionId, msg, ctx); break; + case 'terminal_create': + handleTerminalCreate(connectionId, msg, ctx); + break; + case 'terminal_input': + handleTerminalInput(connectionId, msg, ctx); + break; + case 'terminal_resize': + handleTerminalResize(connectionId, msg, ctx); + break; + case 'terminal_destroy': + handleTerminalDestroy(connectionId, msg, ctx); + break; } }