## 1. Add PtyExitedFrame to WsFrame contract

- [x] 1.1 Add `PtyExitedFrame` Zod schema to `packages/contracts/src/ws-frames.ts` with fields: `type` (literal `'pty_exited'`), `session_id` (`z.string().min(1).max(64)`, NOT uuid -- booterm IDs are `[a-zA-Z0-9_-]{1,64}`), `pane_id` (`z.string().min(1).max(64)`, same), `exit_code` (int), `last_lines` (string array), `session_title` (nullable optional), `session_description` (nullable optional), `parent_agent` (nullable optional), `timed_out` (boolean)
- [x] 1.2 Add `PtyExitedFrame` to the `WsFrameSchema` discriminated union array
- [x] 1.3 Add `'pty_exited'` to the `KNOWN_FRAME_TYPES` const array
- [x] 1.4 Rebuild `@boocode/contracts` (`pnpm -C packages/contracts build`)

## 2. Add getLastLines helper to booterm registry

- [x] 2.1 Add `getLastLines(paneId: string, n: number): string[]` function to `apps/booterm/src/pty/registry.ts` that reads the last N non-empty lines from the ring buffer
- [x] 2.2 Add `timedOut?: boolean` field to `SessionMeta` interface in `apps/booterm/src/pty/registry.ts`

## 3. Replace booterm onExit handler with structured frame

- [x] 3.1 In `apps/booterm/src/ws/attach.ts`, replace the `handle.onExit` handler to: read `registry.get(pid)` and `getLastLines(pid, 5)` BEFORE any unregister, build a structured `pty_exited` frame with `timed_out: meta?.timedOut ?? false`, send it as JSON text to the socket, then close
- [x] 3.2 Preserve backward compatibility: the frame `type` changes from `'exit'` to `'pty_exited'` -- the old bare exit frame is replaced (not additive)

## 4. Wire timed_out flag in sweepExpired (pre-wiring)

- [x] 4.1 In `apps/booterm/src/pty/manager.ts` `sweepExpired`, set `meta.timedOut = true` before calling `killSession`
- [x] 4.2 Do NOT call `registry.unregister()` in `sweepExpired` -- let the socket `close` handler do cleanup to avoid the race where `onExit` fires after unregister deletes metadata. The `killSession` call triggers the tmux exit which triggers `onExit` which reads metadata then closes the socket which triggers `unregister`.

## 5. Update web frontend terminal protocol

- [x] 5.1 Add `pty_exited` variant to `ServerControlFrame` union in `apps/web/src/lib/terminal-protocol.ts` with fields matching the contract: `session_id`, `pane_id`, `exit_code`, `last_lines`, `session_title`, `session_description`, `parent_agent`, `timed_out`
- [x] 5.2 Update `parseServerFrame` to recognize `type: 'pty_exited'` and return the structured frame

## 6. Handle pty_exited in useTerminalSocket

- [x] 6.1 In `apps/web/src/hooks/terminal/useTerminalSocket.ts`, add a handler for `frame?.type === 'pty_exited'`: write `\r\n\x1b[2m[process exited with code ${frame.exit_code}]\x1b[0m\r\n` to xterm; if `timed_out: true`, write `\r\n\x1b[2m[process timed out and was killed]\x1b[0m\r\n` instead; if `last_lines` is non-empty, write the last line to xterm as-is
- [x] 6.2 Ensure the legacy `{type: 'exit', code: N}` handler still works (no regression)

## 7. Verify

- [x] 7.1 Run `pnpm -C packages/contracts build` -- no type errors
- [x] 7.2 Run `pnpm -C apps/booterm typecheck` -- no type errors
- [x] 7.3 Run `npx tsc -p apps/web/tsconfig.app.json --noEmit` -- no type errors
- [x] 7.4 Grep source for `pty_exited` -- should appear in contracts, booterm, and web
- [x] 7.5 Run contracts drift test: `pnpm -C packages/contracts test` -- `pty_exited` in KNOWN_FRAME_TYPES matches WsFrameSchema
