feat(booterm): structured pty_exited WS notifications. Plan-validated, impl-validated, code-reviewed green (contracts build clean, contracts test 29/29, booterm + web typecheck clean). wip: in-progress inference/provider refactor (agents.ts, provider.ts, new llama-providers.ts, removed llama-args-validator), plus arena, dispatcher, compaction, schema changes. openspec: pty-exit-notifications complete; x-agent-flags planned (not yet implemented).
7.7 KiB
Design: PTY Exit Notifications
Overview
When a process exits in a booterm terminal pane, emit a structured pty_exited notification over the booterm WS protocol. The notification carries exit code, last output lines, session metadata, and timeout status. This is a client-facing change only; broker publish for inference-loop consumption is deferred (see Deferred section).
Architecture
Current exit flow
apps/booterm/src/ws/attach.ts:170-183--handle.onExitfires- Sends bare
{type: 'exit', code: exitCode}to browser WS - Closes the socket
- Registry is unregistered on socket
closeevent (line 190)
Proposed exit flow
handle.onExitfires- Read metadata from registry and ring buffer BEFORE any unregister
- Build structured
pty_exitedframe - Send
pty_exitedto browser WS (replaces bareexitframe) - Close socket
- Registry cleanup happens on socket
close(existing behavior, unchanged)
Cross-app wire changes
packages/contracts/src/ws-frames.ts -- Add PtyExitedFrame to WsFrameSchema:
export const PtyExitedFrame = z.object({
type: z.literal('pty_exited'),
session_id: z.string().min(1).max(64),
pane_id: z.string().min(1).max(64),
exit_code: z.number().int(),
last_lines: z.array(z.string()),
session_title: z.string().nullable().optional(),
session_description: z.string().nullable().optional(),
parent_agent: z.string().nullable().optional(),
timed_out: z.boolean(),
});
Note: session_id and pane_id use z.string().min(1).max(64) because booterm IDs are [a-zA-Z0-9_-]{1,64} (validated by sanitizeId using ID_RE in apps/booterm/src/pty/manager.ts:5). They are NOT UUIDs. This matches the existing ToolCallId pattern (z.string().min(1)) for non-UUID identifiers in the contract.
Add to KNOWN_FRAME_TYPES array. Rebuild @boocode/contracts.
apps/booterm/src/ws/attach.ts -- Replace the onExit handler:
Current (line 170-183):
handle.onExit(({ exitCode }) => {
socket.send(JSON.stringify({ type: 'exit', code: exitCode }));
socket.close(1000);
});
New:
handle.onExit(({ exitCode }) => {
// Read metadata BEFORE any cleanup — registry.get and getLastLines
// must run while the entry still exists.
const meta = registry.get(pid);
const lastLines = getLastLines(pid, 5);
const frame = {
type: 'pty_exited',
session_id: sid,
pane_id: pid,
exit_code: exitCode,
last_lines: lastLines,
session_title: meta?.title ?? null,
session_description: meta?.description ?? null,
parent_agent: meta?.parentAgent ?? null,
timed_out: meta?.timedOut ?? false,
};
if (socket.readyState === socket.OPEN) {
socket.send(JSON.stringify(frame));
}
socket.close(1000);
});
Web frontend changes
apps/web/src/lib/terminal-protocol.ts -- Add pty_exited to ServerControlFrame union:
export type ServerControlFrame =
| { type: 'init' }
| { type: 'exit'; code: number }
| { type: 'pty_exited'; session_id: string; pane_id: string;
exit_code: number; last_lines: string[];
session_title?: string | null; session_description?: string | null;
parent_agent?: string | null; timed_out: boolean };
Update parseServerFrame to recognize type: 'pty_exited' and return the structured frame.
apps/web/src/hooks/terminal/useTerminalSocket.ts -- Handle pty_exited in the message handler:
Rendering spec:
- Write a dim notification line:
\r\n\x1b[2m[process exited with code ${frame.exit_code}]\x1b[0m\r\n - If
last_linesis non-empty, write the last line (at most 1) to xterm as-is (xterm handles ANSI). Prepend a dim prefix if desired. - If
timed_out: true, write\r\n\x1b[2m[process timed out and was killed]\x1b[0m\r\ninstead of the exit code line. - Do NOT display session_title/parent_agent in the terminal -- these are metadata for the inference loop, not user-facing terminal content.
- Preserve backward compatibility: if
parseServerFramereturns{type: 'exit', code: N}(legacy frame), handle it exactly as before.
Timeout integration
The sweepExpired path in apps/booterm/src/pty/manager.ts:172-198 is currently dead code -- it is never wired to a setInterval in apps/booterm/src/index.ts. The timeout config vars (PTY_IDLE_TIMEOUT_SECONDS, PTY_ABSOLUTE_TIMEOUT_SECONDS) default to 0 and are never passed to registerWsAttachRoute.
For this change:
- Add
timedOut?: booleanfield toSessionMetain the registry (pre-wiring). - In
sweepExpired, setmeta.timedOut = trueBEFORE callingkillSession. Do NOT callregistry.unregister()in sweepExpired. The two-phase approach: sweepExpired flags + kills, then theonExithandler (firing when tmux kill takes effect) reads metadata, and the socketclosehandler does the unregister. This avoids the race whereonExitfires after unregister deletes metadata. - The
timed_out: truepath inonExitwill work oncesweepExpiredis wired to an interval (future change). Until then,meta?.timedOutis alwaysundefinedand the frame defaults tofalse.
Ring buffer last-lines helper
Add getLastLines(paneId: string, n: number): string[] to apps/booterm/src/pty/registry.ts:
export function getLastLines(paneId: string, n: number): string[] {
const buf = ringBuffers.get(paneId);
if (!buf || buf.length === 0) return [];
// Return last n non-empty, non-whitespace-only lines.
// ANSI escape sequences are preserved (xterm handles them).
// Partial lines from mid-stream exit are included as-is.
const nonEmpty = buf.filter(l => l.trim().length > 0);
return nonEmpty.slice(-n);
}
Note: appendOutput may store partial (non-newline-terminated) lines when a process exits mid-line. These are included as-is -- the last line may be truncated. This is acceptable because the existing exit handler shows no output at all.
Data flow
PTY process exits (normal or sweepExpired kill)
-> handle.onExit fires (attach.ts)
-> registry.get(paneId) reads SessionMeta [BEFORE any unregister]
-> getLastLines(paneId, 5) reads ring buffer
-> Build PtyExitedFrame with meta?.timedOut ?? false
-> socket.send(JSON.stringify(frame)) [to browser]
-> socket.close(1000)
-> socket 'close' handler calls registry.unregister(pid) [existing, unchanged]
Files touched
| File | Change |
|---|---|
packages/contracts/src/ws-frames.ts |
Add PtyExitedFrame, add to WsFrameSchema + KNOWN_FRAME_TYPES |
apps/booterm/src/ws/attach.ts |
Replace onExit handler with structured frame |
apps/booterm/src/pty/registry.ts |
Add getLastLines helper, add timedOut flag to SessionMeta |
apps/booterm/src/pty/manager.ts |
Set timedOut flag in sweepExpired before kill; remove unregister() call (cleanup moves to socket close) |
apps/web/src/lib/terminal-protocol.ts |
Add pty_exited to ServerControlFrame + parseServerFrame |
apps/web/src/hooks/terminal/useTerminalSocket.ts |
Handle pty_exited frame in message handler |
Deferred (YAGNI)
- Inference-loop broker publish: Booterm cannot directly access the server's in-memory broker. Adding HTTP callback or DB LISTEN/NOTIFY for server-side notification is a separate integration. Reopen when: (a) the server needs to react to PTY exits, or (b) a task completion workflow requires inference-loop awareness. The
pty_exitedframe type in WsFrame contract makes this straightforward to add later. - sweepExpired wiring: The timeout kill machinery is implemented but never wired to an interval. Adding
setInterval(sweepExpired, ...)inindex.tsis a one-liner but changes behavior (timeouts start killing). Reopen when: timeouts are desired. - Log search extras: Already implemented in
searchRingBufferand the/api/term/searchroute. No additional work needed.