chore: snapshot working tree - pty_exited notifications + in-flight inference WIP

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).
This commit is contained in:
2026-06-14 12:48:47 +00:00
parent 0ed506f1da
commit b18de2a331
204 changed files with 25344 additions and 867 deletions

View File

@@ -0,0 +1,164 @@
# 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.

View File

@@ -0,0 +1,22 @@
## Why
When a process running in a booterm terminal pane exits, the browser currently receives a bare `{type: 'exit', code: N}` frame and the socket closes (`apps/booterm/src/ws/attach.ts:170-183`). There is no structured metadata: no last output lines, no session title, no parent agent attribution. The inference loop in apps/server and apps/coder cannot react when a long-running task completes because the notification carries no context beyond the exit code.
The reference implementation (`/opt/forks/opencode-extras/opencode-pty`) solves this with `<pty_exited>` structured notifications carrying exit code, last output lines, session metadata, and timeout status. Booterm already tracks all of this data (registry `SessionMeta` with `sessionId`, `paneId`, `title`, `description`, `parentAgent`; ring buffer with output lines via `appendOutput`). The data is present but never surfaced on exit.
## What Changes
- Enhance the booterm WS exit notification from a bare `{type: 'exit', code}` to a structured `pty_exited` frame carrying: exit code, last N output lines from the ring buffer, session metadata (title, description, parentAgent), and timeout status.
- Add `pty_exited` as a new frame type in the cross-app WsFrame contract (`packages/contracts`).
- Update the web frontend to parse and handle the new frame type.
## Scope
- **In scope**: structured exit notification over booterm WS; new WsFrame type in contracts; web frontend handling.
- **Out of scope**: log-search extras (already implemented in booterm registry ring buffer + search route), per-session timeouts (already implemented in registry + sweepExpired), pattern-based PTY log search (already in `searchRingBuffer`). These exist; this change only adds the exit notification. Broker publish for inference-loop consumption is deferred (see Deferred section).
## Non-goals
- Changing the booterm WS binary/text frame protocol for ongoing data.
- Adding persistence for exit events (no DB table; frames are ephemeral like all broker frames).
- Modifying the coder's PTY dispatch flow (which uses `child_process.spawn`, not booterm PTYs).

View File

@@ -0,0 +1,58 @@
## ADDED Requirements
### Requirement: Structured pty_exited frame on WS protocol
The system MUST send a structured exit notification when a PTY process exits.
- **WHEN** a process running in a booterm terminal pane exits (via `handle.onExit`)
- **THEN** booterm MUST send a structured `pty_exited` JSON text frame on the WS connection containing: `type`, `exit_code`, `last_lines` (array of recent output lines from the ring buffer), `session_id`, `session_title`, `session_description`, `parent_agent`, `timed_out` (boolean)
#### Scenario: Normal process exit with metadata
- **WHEN** a user's SSH shell process exits with code 0 after producing output
- **AND** the terminal pane was registered with title "build", description "run tests", parentAgent "claude"
- **THEN** the `pty_exited` frame MUST contain `exit_code: 0`, at least one `last_lines` entry, `session_title: "build"`, `session_description: "run tests"`, `parent_agent: "claude"`, and `timed_out: false`
#### Scenario: Process exit with no output
- **WHEN** a process exits immediately without producing output
- **THEN** the `pty_exited` frame MUST contain an empty `last_lines` array and valid session metadata
#### Scenario: Timeout-triggered exit
- **WHEN** a process is killed by the idle timeout sweep (requires sweepExpired to be wired to an interval, which is a separate change)
- **THEN** the `pty_exited` frame MUST contain `timed_out: true` and the exit code from the tmux kill
### Requirement: pty_exited frame type in WsFrame contract
The system MUST register `pty_exited` as a valid frame type in the cross-app wire contract.
- **WHEN** the `pty_exited` frame schema is added to `WsFrameSchema` in `packages/contracts/src/ws-frames.ts`
- **THEN** it MUST be included in `KNOWN_FRAME_TYPES` and validate against the discriminated union
#### Scenario: Frame validates against schema
- **WHEN** a `pty_exited` frame with all required fields is parsed
- **THEN** the Zod validation MUST pass and the frame MUST NOT be dropped
#### Scenario: Frame missing required fields
- **WHEN** a `pty_exited` frame is missing the `exit_code` field
- **THEN** the Zod validation MUST fail and the frame MUST be dropped with a log warning
### Requirement: Client parse of pty_exited frame
The web frontend MUST recognize and parse `pty_exited` frames from the booterm WS.
- **WHEN** the web frontend receives a `pty_exited` frame over the terminal WS
- **THEN** `parseServerFrame` MUST recognize it and return a structured object with `session_id`, `pane_id`, `exit_code`, `last_lines`, and session metadata
#### Scenario: Client receives pty_exited
- **WHEN** the browser receives a `pty_exited` frame
- **THEN** the terminal MUST display a styled exit notification with the exit code and last output line(s)
#### Scenario: Client receives pty_exited with timeout
- **WHEN** the browser receives a `pty_exited` frame with `timed_out: true`
- **THEN** the terminal MUST display a timeout-specific notification message
### Requirement: Backward compatibility with bare exit frame
The client MUST NOT break when receiving the legacy bare exit frame.
- **WHEN** a booterm instance sends the old `{type: 'exit', code: N}` frame (pre-upgrade)
- **THEN** the client MUST gracefully handle it as before (display exit message, no crash)
#### Scenario: Legacy exit frame received
- **WHEN** the client receives `{type: 'exit', code: 1}`
- **THEN** the terminal MUST display the exit code message without throwing

View File

@@ -0,0 +1,39 @@
## 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