# 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

1. `apps/booterm/src/ws/attach.ts:170-183` -- `handle.onExit` fires
2. Sends bare `{type: 'exit', code: exitCode}` to browser WS
3. Closes the socket
4. Registry is unregistered on socket `close` event (line 190)

### Proposed exit flow

1. `handle.onExit` fires
2. Read metadata from registry and ring buffer BEFORE any unregister
3. Build structured `pty_exited` frame
4. Send `pty_exited` to browser WS (replaces bare `exit` frame)
5. Close socket
6. Registry cleanup happens on socket `close` (existing behavior, unchanged)

### Cross-app wire changes

**packages/contracts/src/ws-frames.ts** -- Add `PtyExitedFrame` to `WsFrameSchema`:

```typescript
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):
```typescript
handle.onExit(({ exitCode }) => {
  socket.send(JSON.stringify({ type: 'exit', code: exitCode }));
  socket.close(1000);
});
```

New:
```typescript
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:

```typescript
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_lines` is 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\n` instead 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 `parseServerFrame` returns `{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?: boolean` field to `SessionMeta` in the registry (pre-wiring).
- In `sweepExpired`, set `meta.timedOut = true` BEFORE calling `killSession`. Do NOT call `registry.unregister()` in sweepExpired. The two-phase approach: sweepExpired flags + kills, then the `onExit` handler (firing when tmux kill takes effect) reads metadata, and the socket `close` handler does the unregister. This avoids the race where `onExit` fires after unregister deletes metadata.
- The `timed_out: true` path in `onExit` will work once `sweepExpired` is wired to an interval (future change). Until then, `meta?.timedOut` is always `undefined` and the frame defaults to `false`.

### Ring buffer last-lines helper

Add `getLastLines(paneId: string, n: number): string[]` to `apps/booterm/src/pty/registry.ts`:

```typescript
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_exited` frame 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, ...)` in `index.ts` is a one-liner but changes behavior (timeouts start killing). Reopen when: timeouts are desired.
- **Log search extras**: Already implemented in `searchRingBuffer` and the `/api/term/search` route. No additional work needed.
