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:
164
openspec/changes/pty-exit-notifications/design.md
Normal file
164
openspec/changes/pty-exit-notifications/design.md
Normal 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.
|
||||
22
openspec/changes/pty-exit-notifications/proposal.md
Normal file
22
openspec/changes/pty-exit-notifications/proposal.md
Normal 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).
|
||||
@@ -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
|
||||
39
openspec/changes/pty-exit-notifications/tasks.md
Normal file
39
openspec/changes/pty-exit-notifications/tasks.md
Normal 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
|
||||
Reference in New Issue
Block a user