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).
35 KiB
Junior-Developer Review: PTY Exit Notifications Plan
Scope
Artifacts reviewed:
openspec/changes/pty-exit-notifications/proposal.mdopenspec/changes/pty-exit-notifications/design.mdopenspec/changes/pty-exit-notifications/tasks.mdopenspec/changes/pty-exit-notifications/specs/pty-exit-notification/spec.md
Source files read for context:
apps/booterm/src/ws/attach.ts(onExit handler, WS lifecycle)apps/booterm/src/pty/registry.ts(SessionMeta, ring buffer, appendOutput)apps/booterm/src/pty/manager.ts(sweepExpired, killSession)packages/contracts/src/ws-frames.ts(WsFrameSchema, KNOWN_FRAME_TYPES, drift test)apps/web/src/lib/terminal-protocol.ts(ServerControlFrame, parseServerFrame)apps/web/src/hooks/terminal/useTerminalSocket.ts(WS connect, message handler, exit handling)apps/web/src/api/types.ts(web-side strict WsFrame)apps/server/src/services/inference/types.ts(InferenceFrame loose union)apps/server/src/services/broker.ts(publishFrame validation flow)packages/contracts/src/__tests__/ws-frames.test.ts(drift test)
Plain-Language Restatement
When a terminal process exits inside a booterm pane, instead of sending a bare {type: 'exit', code: N} frame and closing the socket, send a richer pty_exited frame that includes the exit code, the last few lines of output, session metadata (title, description, parent agent), and whether it was killed by a timeout. Add the frame type to the cross-app wire contract. Update the web frontend to parse and display it. Defer making the inference loop aware of it.
Question Log
Who and Why
- Q1 [Answered]: Who is the primary consumer of this notification? — The browser user (via the styled notification). Inference-loop consumption is deferred (design.md:93-95). The spec (spec.md:56-64) contradicts this — see JD-001.
- Q2 [Assumed]: Why now instead of deferring the whole thing? — The proposal says "The data is present but never surfaced on exit." The assumption is that surfacing existing data is low-effort and high-value. If the effort is actually higher than estimated (e.g. timeout integration, ring-buffer races), this assumption weakens.
- Q3 [Open]: Has the person who originally asked for this (who?) seen the current design? — No citation. The proposal mentions "the inference loop in apps/server and apps/coder cannot react" as the motivating problem, but the design defers that exact use case. If the requester expected inference-loop integration, they may reject this scope.
What and Scope
- Q4 [Assumed]: Is the old
{type: 'exit', code: N}frame removed or kept additive? — Tasks.md:16 says "the old bare exit frame is replaced (not additive)." Assumes no code anywhere depends on receiving{type: 'exit'}after upgrade. The web handler has backward compat for receiving the old type (spec.md:46-54), but the server sends only the new type. This only matters during mixed-version deployments (if booterm and web are upgraded independently). - Q5 [Answered]: What is the smallest valuable version? — The frame over the booterm WS + web handler + contract type. Timeout integration (task 4) and inference-loop integration are deferred. The spec includes an inference-loop requirement that isn't delivered — see JD-001.
- Q6 [Open]: What is the rollback plan? — Not mentioned. If the new frame breaks the frontend, how do we revert? The old
{type: 'exit'}handler still exists, so downgrading the web code is safe, but rolling back a deploy isn't described.
Assumptions and Evidence
- Q7 [Assumed]: The ring buffer contains useful "last lines" at exit time. — Depends on timing.
unregister(pid)deletes the ring buffer. The socketclosehandler callsunregister. The ordering in the current code is:handle.onExit→socket.close()→ socketcloseevent →unregister(). So the buffer is alive duringonExit. But if any future change reorders this,getLastLinesreturns[]. (See JD-004.) - Q8 [Uncited claim]: "The reference implementation (
/opt/forks/opencode-extras/opencode-pty) solves this with<pty_exited>structured notifications." — Mentioned in proposal.md:5 but no specific lines, tests, or code cited from that fork. What exactly does it do for ANSI stripping, line count, metadata shape? The design doesn't borrow specifics. - Q9 [Assumed]:
getLastLinesfiltering empty lines is the right behavior. — Filteringtrim().length > 0strips lines with only whitespace or carriage returns. But terminal output lines containing ANSI escape sequences (e.g.\x1b[31m) pass through, and a line containing only\rgets filtered. The spec says "at least one last_lines entry" in the normal-exit scenario, but if the last 5 lines were all\r-only (common in progress-bar output), the array would be empty. See JD-007. - Q10 [Open]: How does the inference loop subscribe to
pty_exitedin the future? — The design defers this but the frame already goes intoWsFrameSchema, which would passpublishFramevalidation. However,session_idin the booterm frame uses short IDs (sanitizeIdpattern), whileWsFrameSchemauses UUIDs. When someone wires up broker publish, thesession_id: z.string().min(1)field will pass Zod... but to be useful to subscribers, the session ID needs to match the server's UUID-based session key. See JD-005.
Prior Art, Specialist Domains, Done and Exit
- Q11 [Answered]: Does this conflict with any existing standard? — The cross-app convention (root CLAUDE.md) says "Adding a new WS frame type (cross-app)" requires updating three files: contracts, server InferenceFrame, web WsFrame. The design updates contracts and the booterm/web terminal protocol, but the server's
InferenceFrameand web'sWsFrameare deliberately skipped because broker publish is deferred. This is explicitly called out (design.md:93-95), so the departure from convention is intentional but unmarked. - Q12 [Open]: What does "Done" look like concretely? — Tasks.md lists checkboxes. But the spec.md:56-64 requires inference-loop subscription which is NOT in the tasks. If someone marks all tasks done, the spec is still unsatisfied. See JD-001.
- Q13 [Open]: Who owns the post-ship maintenance? — The tasks don't assign owners. When the inference-loop integration is eventually done, someone needs to know that
session_idin the booterm world doesn't match the server's UUID schema.
Assumptions
This review assumes:
- The current codebase state in the source files as read today is what the plan targets.
- The booterm WS and the server broker WS are separate connections (booterm WS is per-terminal-pane; server broker WS is per-session).
- The
pnpm -C packages/contracts buildstep correctly producesdist/exports consumed by other packages. - No non-booterm code currently depends on
{type: 'exit'}frames over the terminal WS.
Open Questions
OQ1: Does the delivery spec contradict the design on inference-loop requirement?
- Why it matters: Spec.md:56-64 says "MUST be able to react" and "MUST receive the frame." Design.md:93-95 defers it entirely. If these are both meant to be true, there's a gap: the frame is in
WsFrameSchemabut nothing publishes it through the broker. The spec would be unsatisfied on delivery. - Findings affected: JD-001
- How to resolve: Either (a) remove the inference-loop requirement from the spec, (b) make it a future-scope note instead of a MUST, or (c) ship broker integration in this same batch. The artifacts must agree.
OQ2: Will the timeout path (sweepExpired) correctly populate timed_out: true?
- Why it matters: The design's proposed
onExithandler (design.md:72) hardcodestimed_out: false. Task 4 says to setmeta.timedOut = trueinsweepExpired, but theonExithandler never reads it. The two changes are disconnected. Additionally,sweepExpiredcallsunregisterafterkillSession, which destroys the registry entry beforeonExitcan readmeta.timedOut. - Findings affected: JD-002, JD-003, JD-004
- How to resolve: The
onExithandler must readmeta?.timedOut ?? falseinstead of hardcodingfalse. ThesweepExpiredpath must not callunregisteruntil after theonExitcallback has had a chance to fire (or must send the frame itself).
OQ3: How is the "styled notification" rendered in the web frontend?
- Why it matters: Design.md:114 says "display a styled notification block." The current exit handler writes ANSI-dim text to the terminal:
\x1b[2m[process exited with code N]\x1b[0m. The new handler should "display a styled exit notification with the exit code and last output line(s)." But: is this more ANSI terminal text? A React overlay? A toast? The plan doesn't include a mockup or describe the presentation, which makes task 6.1 ambiguous. - Findings affected: JD-008
- How to resolve: Specify whether the notification is (a) written to xterm as ANSI text (extending current pattern), (b) a React component above/below the terminal, or (c) something else. Provide an example of expected output.
OQ4: What is the future compatibility story for broker publish?
- Why it matters: The
PtyExitedFrameschema usessession_id: z.string().min(1), but the server broker uses UUID-based session IDs (z.string().uuid()). Booterm uses short non-UUID session IDs. When someone eventually wires up broker publish, the frame will need either (a) a UUID-converted session_id, (b) the booterm side to map to UUID, or (c) the schema relaxed. If (a)/(b) isn't planned for now, at minimum a comment on the schema field should warn about this. - Findings affected: JD-005
- How to resolve: Add a code comment or ADR note about the session_id mismatch. Consider using
z.string().min(1)in the schema (as designed) but add a// XXX: will need UUID conversion for broker publishnote near the field definition.
Summary
The plan is structurally sound but suffers from one hard contradiction (spec vs design on inference-loop), one implementation gap (timed_out flag never read), and several ragged edges that will bite the implementer mid-task. The timeout path in particular needs re-thinking before coding begins.
| Severity | Count |
|---|---|
| Blocks decision | 0 |
| Muddies artifact | 4 |
| Worth clarifying | 4 |
| Polish | 2 |
Open Questions: 4 Specialist handoffs: 0
Full review written to: /opt/boocode/junior-dev-review.md
Findings
JD-001: Spec requires inference-loop delivery; design defers it — contradiction in MUST
- Protocol: Clarifying-Question Sweep
- Location:
specs/pty-exit-notification/spec.md:56-64vsdesign.md:93-95 - Evidence:
- Spec: "The inference loop MUST be able to react to PTY exit events via the broker." (line 57)
- Spec: "any subscriber on the per-session channel MUST receive the frame" (line 64)
- Design: "Option B for this change. The notification reaches the browser. Inference-loop integration requires a callback mechanism ... which is a separate concern." (lines 93-95)
- Deferred section: "Inference-loop broker publish: ... Reopen when: (a) the server needs to react to PTY exits" (lines 165-166)
- What the artifact claims / leaves unclear: Two MUST requirements in the spec cannot be delivered by the design as scoped. The design is aware it's deferring this, but the spec wasn't updated to match. An implementer following the spec would expect broker publish to be in scope; following the tasks, it isn't.
- Why this matters: If a QA or reviewer validates against the spec (which is the canonical requirements document), they'd flag the deliverable as incomplete. The inference-loop subscription has no task, no implementation, and no test.
- Related questions: Q1, Q6, Q12 (OQ1)
- Standard or precedent: N/A
- Specialist to consult: N/A — generalist scoping issue
- Severity: Muddies artifact
- Suggested next step: Remove or downgrade inference-loop requirements from spec.md:56-64 to "future" or "non-goal" to match the design. Or add tasks for broker integration to this batch.
JD-002: onExit handler hardcodes timed_out: false — never reads registry flag
- Protocol: Hidden-Assumption Audit
- Location:
design.md:72(code block showing the newonExithandler) - Evidence:
- Design code block lines 57-80:
handle.onExit(({ exitCode }) => { const meta = registry.get(pid); // ... const frame = { // ... timed_out: false, // <-- line 72: always false }; - Design.md:120-121: "sweepExpired sets timedOut = true before calling killSession"
- Task 2.2: Add
timedOut?: booleantoSessionMeta - Task 4.1: Set
meta.timedOut = trueinsweepExpired
- Design code block lines 57-80:
- What the artifact assumes / leaves unclear: The design assumes someone will connect the
meta.timedOutflag to the frame payload, but the code block shows a hardcodedfalse. The tasks don't mention reading the flag inonExit. The implementer following the code block literally will shiptimed_out: falseon every frame regardless of timeout status. - Why this matters: The timeout feature is one of the three named additions (exit code, last lines, timeout status). If it's always
false, the feature is broken. This isn't caught by any task checkbox. - Related questions: Q3 (OQ2)
- Standard or precedent: N/A
- Specialist to consult: N/A
- Severity: Muddies artifact
- Suggested next step: Change the code block in design.md:72 to
timed_out: meta?.timedOut ?? false. Add a task to readmeta.timedOutin theonExithandler.
JD-003: sweepExpired calls unregister before onExit can read timedOut
- Protocol: Hidden-Assumption Audit
- Location:
apps/booterm/src/pty/manager.ts:172-198(sweepExpired) vsdesign.md:119-123 - Evidence:
sweepExpiredinmanager.ts:194callsregistry.unregister(meta.paneId)AFTERkillSessionbut within the same async function- The
onExitcallback inattach.tsreadsregistry.get(pid)— but ifsweepExpiredalready calledunregister, this returnsundefined - Design.md:121: "Add a
timedOutflag toSessionMeta" — butunregisterdeletes the entire SessionMeta entry (registry.ts:58-61)
- What the artifact assumes / leaves unclear: Assumes
onExitfires beforeunregisterruns, or thatregistry.get(pid)still works afterunregister. Neither is guaranteed.sweepExpiredkills the tmux session (which eventually causes the SSH/node-pty process to exit and fireonExit), but this is all async. Theawait killSession(...)completes, thenunregisterruns synchronously. There's no coordination with theonExitcallback. - Why this matters: For timeout-killed sessions, the
onExithandler may not find the registry entry at all, resulting inmeta ?? nullfor all metadata fields (title, description, parentAgent) andtimed_out: undefinedbecomingfalse. - Related questions: Q3 (OQ2), Q7
- Standard or precedent: N/A
- Specialist to consult: N/A (but a concurrency-aware dev should look at this)
- Severity: Muddies artifact
- Suggested next step: Either (a) have
sweepExpiredNOT callunregisterbut instead set a flag that theonExithandler checks and cleans up, or (b) havesweepExpiredcapture WS socket references and send the frame itself before closing, or (c) accept that timeout notifications are best-effort and document the gap explicitly.
JD-004: Ring buffer may be deleted before getLastLines runs in timeout path
- Protocol: Evidence-and-Reasoning Check
- Location:
apps/booterm/src/pty/registry.ts:58-61(unregister),manager.ts:194,attach.ts:57-61(proposed onExit) - Evidence:
unregister(pid)inregistry.ts:58-61:sessions.delete(paneId); ringBuffers.delete(paneId);sweepExpiredinmanager.tscallsunregisterafterkillSession- Proposed
onExithandler callsgetLastLines(pid, 5)which reads fromringBuffers - Normal exit path (socket close triggers unregister): safe —
onExitfires beforesocket.close()triggers the close handler. - Timeout exit path:
sweepExpiredcallsunregisterdirectly — race withonExit.
- What the artifact assumes / leaves unclear: The design's proposed code for
onExitreads the ring buffer, assuming it's always available. The timeout integration section (design.md:117-123) doesn't mention the ring buffer deletion. An implementer following the timeout tasks alone would not know to protect against this. - Why this matters:
last_lineswill be empty[]for timeout-killed processes, even if the process produced output. The spec's "timed_out: true and the exit code" requirement doesn't mention last_lines for timeouts, but an empty array for a process that ran for 30 seconds is confusing. - Related questions: Q7
- Standard or precedent: N/A
- Specialist to consult: N/A
- Severity: Worth clarifying
- Suggested next step: Add a note in the design that timeout-killed processes may produce empty
last_linesbecause the registry/ring buffer is cleaned up by the sweep. Or refactorsweepExpiredto not destroy data theonExithandler needs.
JD-005: session_id type mismatch between booterm world and server broker UUID convention
- Protocol: Standards & Conventions Conflict Check
- Location:
design.md:32(session_id: z.string().min(1)),packages/contracts/src/ws-frames.ts:13(Uuid = z.string().uuid()) - Evidence:
- All existing frames in
WsFrameSchemauseUuidforsession_id,chat_id,message_id, etc. - The design proposes
session_id: z.string().min(1)— which accepts anything non-empty, including booterm's short IDs like"sess_abc123". - Booterm session IDs come from
sanitizeId()which validates against^[a-zA-Z0-9_-]{1,64}$— these are NOT UUIDs. - The server broker's
publishFramevalidates againstWsFrameSchemabefore publishing. If someone wires up broker publish using the booterm session_id, Zod validation passes (z.string().min(1)accepts it), but the subscriber expects a UUID-format session_id to match against.
- All existing frames in
- What the artifact assumes / leaves unclear: The schema uses
z.string().min(1)when it should either match the server's UUID convention OR have a clear migration path. The design adds the frame toWsFrameSchema(task 1), which is the cross-app contract, but booterm session IDs don't match the server's session ID format. This frame will sit in the contract with a schema that validates technically but carries the wrong-shaped data for broker consumers. - Why this matters: When inference-loop integration happens (the deferred requirement from JD-001), someone will try to publish
pty_exitedthroughpublishFrame. The frame will validate againstWsFrameSchema(good), but subscribers expecting UUID-based session_id matching will break silently. Thez.string().min(1)field makes the schema technically correct but semantically wrong. - Related questions: Q11, Q13 (OQ4)
- Standard or precedent: The rest of
WsFrameSchemausesUuidfor every session/chat/message identifier. This is the only frame that usesz.string().min(1)for a session identifier. - Specialist to consult: N/A
- Severity: Worth clarifying
- Suggested next step: Add a comment on the
session_idfield:// XXX: booterm short-ID format; broker publish will need UUID conversion. Consider adding the UUID mapping to the deferred section so future integrators know about it.
JD-006: The web's strict WsFrame union in api/types.ts is NOT updated — but it's unclear if it needs to be
- Protocol: Standards & Conventions Conflict Check
- Location: Root
CLAUDE.md("Adding a new WS frame type (cross-app)"),apps/web/src/api/types.ts:599(WsFrame union) - Evidence:
- Root CLAUDE.md says: "The server's
InferenceFrameloose union (services/inference/turn.ts) and the web's strictWsFramediscriminated union (apps/web/src/api/types.ts) still exist separately and also need updating." - The design only lists
apps/web/src/lib/terminal-protocol.tsandapps/web/src/hooks/terminal/useTerminalSocket.tsas web-side changes. - The web's
WsFrameis for the server broker WS (session stream + user events), NOT the booterm terminal WS. So it genuinely doesn't needpty_exitedunless/until broker publish happens.
- Root CLAUDE.md says: "The server's
- What the artifact assumes / leaves unclear: The design assumes the existing convention doesn't apply here because
pty_exitedflows through a different WS (booterm vs server broker). This is a correct assumption, but it's not documented. A future reader might see that contracts/WsFrameSchemahaspty_exitedbut the web'sWsFramedoesn't, assume drift, and add it — creating dead code. - Why this matters: Low today, but risks a "fix" from someone who doesn't understand the two-WS architecture.
- Related questions: Q11
- Standard or precedent: Root CLAUDE.md adding-new-WS-frame-type convention.
- Specialist to consult: N/A
- Severity: Polish
- Suggested next step: Add a
// NOT in web WsFrame — booterm WS onlycomment near thePtyExitedFramedefinition inws-frames.ts, or add a paragraph to design.md explaining why only the contract is updated, not the web/server union types.
JD-007: getLastLines filters "non-empty" lines — unclear what this means for terminal content
- Protocol: Evidence-and-Reasoning Check
- Location:
design.md:130-136 - Evidence:
export function getLastLines(paneId: string, n: number): string[] { const buf = ringBuffers.get(paneId); if (!buf || buf.length === 0) return []; const nonEmpty = buf.filter(l => l.trim().length > 0); return nonEmpty.slice(-n); }- The ring buffer stores raw PTY data split on newlines. Lines can contain ANSI escape sequences, carriage returns (common in terminal output), or be genuinely empty.
trim()removes whitespace but NOT ANSI escape characters. A line like"\x1b[31m"passes through (length 5 after trim). A line like"\r"is removed (length 0 after trim). A line containing only\rafter a progress-bar overwrite is common.- Scenario from spec.md:15-16: "a process exits immediately without producing output" — this naturally produces empty
last_lines. Fine. - But scenario spec.md:10-12: "normal process exit with output" requires "at least one
last_linesentry." If the only output lines in the last N were carriage-return-only (progress bar/spinner output), the array could be empty, failing the spec assertion.
- What the artifact assumes / leaves unclear: Assumes that filtering "non-empty" lines is straightforward for terminal content. Terminal output with progress bars, spinners, and ANSI art makes "empty" a spectrum, not a binary.
- Why this matters: The spec asserts "at least one
last_linesentry" for a normal exit with output. If the filtering is aggressive, this could fail. More importantly, the inference loop (future) or user seeinglast_linesmight be confused if a line like"\x1b[2K\x1b[1G"(erase-line + cursor-home) appears. - Related questions: Q9
- Standard or precedent: N/A
- Specialist to consult: N/A
- Severity: Worth clarifying
- Suggested next step: Define "non-empty" explicitly in the design. Consider whether ANSI stripping is needed. Add a
// NOTE: filters whitespace-only linescomment. Accept that some ANSI-only lines may appear.
JD-008: "Styled notification" for the web handler is underspecified
- Protocol: Scope & Definition-of-Done Check
- Location:
design.md:112-115,spec.md:43-44 - Evidence:
- Design.md:112-115: "Handle
pty_exitedin the message handler: Display a styled notification block (similar to existingexithandling at line 233-236) with exit code and last output lines." - Current exit handling (useTerminalSocket.ts:233-236):
if (frame?.type === 'exit') { t.write(`\r\n\x1b[2m[process exited with code ${frame.code}]\x1b[0m\r\n`); } - This writes ANSI-dim text into the xterm terminal. It's "styled" in the sense of ANSI escape codes.
- The new notification should also show
last_lines. But how? Concatenated into the same terminal write? Each line on its own row? As a separate React element outside xterm? - The existing
{type: 'exit'}handler doesn't display last lines because it doesn't have them. The new handler does. The presentation of "last output lines" is completely unspecified.
- Design.md:112-115: "Handle
- What the artifact assumes / leaves unclear: Assumes "styled notification" is unambiguous. It isn't. The two plausible implementations (ANSI text in terminal vs React component outside terminal) have very different scopes and testing implications. The tasks don't describe expected output in any testable way.
- Why this matters: Two implementers could interpret this differently and produce different shipped behavior. If the answer is "write to terminal as ANSI text," the task is small. If the answer is "build a React notification component," it's significantly larger.
- Related questions: Q5 (OQ3)
- Standard or precedent: Current code uses terminal text for exit notifications. Terminal text is the convention for this code path.
- Specialist to consult:
user-experience-designer— terminal notification UX - Severity: Muddies artifact
- Suggested next step: Clarify in design.md: "write last_lines as ANSI-dim text to xterm, each on its own line, followed by the exit code line" or "render a React notification banner above the terminal." Show an example of expected output.
JD-009: parseServerFrame return type needs update but as cast in current implementation may mask issues
- Protocol: Evidence-and-Reasoning Check
- Location:
apps/web/src/lib/terminal-protocol.ts:37-46,useTerminalSocket.ts:233-236 - Evidence:
- Current
parseServerFrame:export function parseServerFrame(data: string): ServerControlFrame | null { try { const parsed = JSON.parse(data) as { type?: string; code?: number }; if (parsed.type === 'init') return { type: 'init' }; if (parsed.type === 'exit') return { type: 'exit', code: parsed.code ?? 0 }; } catch { /* ... */ } return null; } - The
as { type?: string; code?: number }cast will NOT containlast_lines,session_title, etc. TheparseServerFrameimplementation needs to extract these fields for the newpty_exitedobject. - Task 5.2 says "Update
parseServerFrameto recognizetype: 'pty_exited'and return the structured frame."
- Current
- What the artifact assumes / leaves unclear: The task exists but the design doesn't show the updated
parseServerFrameimplementation. The return type isServerControlFrame | null— the new return variant needslast_lines,exit_code,session_title, etc. This is straightforward but the type cast pattern (as { type?: string; code?: number }) will need widening. - Why this matters: Low — the task covers it. But the design doesn't provide the updated code for review, so an implementer has to derive it from scratch. Minor polish issue.
- Related questions: N/A
- Standard or precedent: N/A
- Specialist to consult: N/A
- Severity: Polish
- Suggested next step: Add the updated
parseServerFramecode todesign.mdfor review, showing how the widened type cast and extraction work.
JD-010: Task 7 verification doesn't include a unit test for getLastLines
- Protocol: YAGNI Evidence Sweep
- Location:
tasks.md:32-37 - Evidence:
- Task 7 (Verify) checks: contracts build, booterm build, web typecheck, grep for
pty_exited - No mention of running
pnpm -C apps/booterm testor adding a test forgetLastLines getLastLinesis a new pure function with edge cases (empty buffer, non-empty lines filter, partial lines, ANSI-only lines)
- Task 7 (Verify) checks: contracts build, booterm build, web typecheck, grep for
- What the artifact assumes / leaves unclear: Assumes type-checking is sufficient verification for
getLastLines. The function has filtering logic (trim().length > 0) and slice behavior that type-checking won't catch. A unit test costs ~5 minutes to write but would catch e.g. the[]-on-empty-buffer case. - Why this matters: Not blocking, and YAGNI says don't add tests speculatively. But
getLastLinesis a pure function with clear inputs/outputs — exactly the kind of code where a missing test means the first bug ships silently. - Related questions: Q9
- Standard or precedent: Root CLAUDE.md mentions "Extract pure helpers to unit-test" —
backends/turn-guard.tsandlifecycle-decisions.tsare given as the pattern. - Specialist to consult: N/A
- Severity: Worth clarifying
- Suggested next step: Add a task 7.5:
pnpm -C apps/booterm testpasses. Or add a unit test forgetLastLinesalongside its implementation (task 2) and note it in verification.
Protocol 1 - Clarifying-Question Sweep: Complete. 13 questions generated, 5 answered, 5 assumed, 3 open. See Question Log and OQ1-OQ4.
Protocol 2 - Hidden-Assumption Audit: Complete. Four assumptions surfaced in JD-002 (hardcoded timed_out), JD-003 (unregister timing), JD-004 (ring buffer race), and JD-007 (empty-line definition).
Protocol 3 - Evidence-and-Reasoning Check: Complete. One uncited claim found (reference implementation, Q8). Number claims are absent (no "10x faster" etc.). The reasoning chain from "current data model exists" to "just surface it" is sound in principle but misses the timed-out coordination gap.
Protocol 4 - Standards & Conventions Conflict Check: Complete. One convention departure found (JD-006: web
WsFramenot updated per root CLAUDE.md) — deliberately justified but undocumented. One schema-landmine found (JD-005: session_id type mismatch with UUID convention). Drift test inws-frames.test.tswould pass becauseKNOWN_FRAME_TYPESand the Zod union would both be updated.
Protocol 5 - Specialist-Domain Boundary Check: No specialist domains deeply touched. The timeout coordination logic and ring-buffer data race are concurrency-ish but fixable by a generalist. The web notification presentation (JD-008) borders on UX — flagged for
user-experience-designerbut manageable with a clearer spec. No security, compliance, or production-readiness changes.
Protocol 6 - Scope & Definition-of-Done Check: Complete. Done is stated (tasks.md checkboxes). One undone requirement found (inference-loop spec, JD-001). Rollback is not documented. Post-ship ownership is unassigned (OQ4's session_id mismatch will need future knowledge).
Protocol 7 - YAGNI Evidence Sweep: Complete. Most items pass the evidence test (existing data pattern, explicit user ask). One YAGNI candidate surfaced (JD-010: no unit test for getLastLines — borderline, recommended to add). The
PtyExitedFrameschema entry in contracts could be argued as YAGNI (nothing publishes it through the broker yet), but adding the contract shape early is standard practice for the project and prevents future drift — keep it.
Protocol 8 - Plain-Language Reframing: Complete (see Plain-Language Restatement at top). The restatement exposes two holes: (a) the inference-loop contradiction ("tell the inference loop about PTY exits" vs "defer that") and (b) the timeout integration gap ("set a flag... that the handler never reads").
Junior-Developer Review Summary
What I Don't Understand Yet
- Spec vs Design contradiction on inference-loop delivery (OQ1). The spec says MUST. The design says definitely not now. Which one wins?
- How the timeout path actually works (OQ2). The design sets a
timedOutflag on the registry entry, but theonExithandler never reads it — it hardcodesfalse. AndsweepExpireddeletes the registry entry beforeonExitcan even look at it. - What "styled notification" means for the web frontend (OQ3). Is it more ANSI text written to xterm, or a React component? The two options have very different build and test scopes.
- How the session_id mismatch will be resolved for future broker publish (OQ4). Booterm uses short IDs; the server broker uses UUIDs. The contract schema accepts both. When someone wires up publish, it'll pass validation but carry the wrong-shaped key.
What the Artifact Seems to Assume
- The
timed_outflag propagates automatically. It doesn't — theonExithandler code explicitly setstimed_out: false. (JD-002) - Ring buffer data survives the
sweepExpiredcleanup path. It doesn't —unregisterdeletes it beforeonExitruns. (JD-004) - The reference implementation is straightforward to copy. The fork at
/opt/forks/opencode-extras/opencode-ptyis cited but no specifics (ANSI stripping, line count, metadata shape) are extracted. (Q8) - The existing unit test for
getLastLinesisn't needed. Pure functions with filtering logic are exactly the kind of code the project patterns recommend testing. (JD-010) - "Non-empty lines" is unambiguous for terminal content. Progress bars, spinners, and ANSI art make this ambiguous. (JD-007)
Where the Artifact Conflicts with How We Already Work
- Root CLAUDE.md convention for adding WS frame types says to update the web
WsFrameand serverInferenceFrame. The design skips both (correctly, because booterm WS ≠ server broker WS), but doesn't explain why. (JD-006) - UUID convention in
WsFrameSchema— all session/chat/message identifiers are UUIDs. ThePtyExitedFrameusesz.string().min(1)forsession_id, which will become a problem when broker publish is added. (JD-005)
Where a Specialist Should Take Over
user-experience-designer— The "styled notification" rendering for the web frontend needs a clearer spec than "display a styled notification block." (JD-008)
What "Done" Looks Like — and What It Doesn't
Done is the 7 task checkboxes. But:
- Spec requirement 4 (inference-loop subscription) has no checkbox. If the spec is the requirements source, done won't be done. (JD-001)
- The timeout path tasks (4.1, 4.2) look done on paper but produce the wrong behavior (always
timed_out: false). (JD-002) - No verification step runs booterm tests. (JD-010)
- No rollback plan described.
What the Artifact Includes That Has No Evidence of Being Needed
- Unit test for
getLastLines— currently absent from tasks. This is a borderline YAGNI candidate against the tasks (the tasks don't include it, but the project patterns recommend it for pure helpers). Recommend adding a test rather than skipping it, as the filtering logic (trim/length) has edge cases that type-checking won't catch.
The Artifact in Plain Terms
We want to send a richer message when a terminal process dies. Instead of "process exited with code 0", send "process exited with code 0, and here are the last 5 lines of output, and the session was called 'build' spun up by 'claude', and it was/wasn't killed by a timeout." Add the message type to the shared contract. Update the web UI to show it. Don't wire up the server-side inference loop yet.
The two things that don't work in this summary are: (1) the spec says the inference loop MUST be wired up, contradicting "don't wire it up yet", and (2) the timeout path says "timed_out: true" for timeout-killed processes, but the code always sends "timed_out: false".