feat(queue): server-side PTY-inject + idle-drained follow-up queue (W2)

The walk-away primitive: queue follow-up prompts and let a session advance itself
while you're gone — "run the tests" drains, and when Claude next goes idle "open a
PR" drains. Injection reuses writeInput (byte-identical to a keystroke, broadcasts
to all mirrored devices); the byte-shuttle is untouched.

- POST/GET/DELETE /live-sessions/:id/queue — POST/DELETE behind requireAllowedOrigin
  + a per-IP rate limit (QUEUE_RATE_MAX=20/min) + SESSION_ID_RE; text non-empty,
  byte-capped (QUEUE_ITEM_MAX_BYTES=4096, 16kb body), count-capped (QUEUE_MAX_ITEMS=10).
- Bounded per-session queue in manager (enqueueFollowup/drainOne/clearQueue);
  new optional queueLength on LiveSessionInfo.
- Idle drain: the Stop/SessionEnd /hook branch scheduleDrain()s a debounced timer
  (QUEUE_SETTLE_MS=1500). It drains exactly one entry only if the session still
  exists, hasn't exited, produced no output during settle, and is idle — three
  guards (debounced single timer + pop-one + settle re-check) → one entry per idle.
- New additive ServerMessage {type:'queue',length} broadcasts the count to mirrors
  (⧗N badge); FE enqueue via the quick-reply editor + TabApp.enqueueToActive.

Injected bytes go verbatim to the PTY (never built into a shell command). Verified
independently: typecheck + build:web clean, 1737 tests pass (real-PTY integration
covers idle-drain / one-per-idle / settle-guard). Foundation for templated launches,
auto-continue, and issue-intake (docs/ROADMAP.md).
This commit is contained in:
Yaojia Wang
2026-07-12 20:27:37 +02:00
parent e062065cd3
commit 3076843e9c
16 changed files with 1164 additions and 10 deletions

View File

@@ -115,6 +115,9 @@ export interface TerminalSessionOpts {
/** Optional: fired when new statusLine telemetry arrives (B2). Single source of
* truth — T-tabs reads session.telemetry via the getter, not its own copy. */
onTelemetry?: (telemetry: StatusTelemetry) => void
/** Optional: fired when the pending inject-queue depth changes (W2), so the tab
* can render an "N queued" badge. Broadcast to every mirrored device. */
onQueue?: (length: number) => void
/** Optional: fired on a pointerdown anywhere in this pane (split-grid focus).
* Lets TabApp move the focused-pane (activeIndex) to the clicked quadrant. */
onFocus?: () => void
@@ -137,6 +140,7 @@ export class TerminalSession {
private readonly onStatus: ((status: SessionStatus) => void) | undefined
private readonly onClaudeStatus: ((status: ClaudeStatus, detail?: string) => void) | undefined
private readonly onTelemetry: ((telemetry: StatusTelemetry) => void) | undefined
private readonly onQueue: ((length: number) => void) | undefined
private readonly onFocus: (() => void) | undefined
private readonly spawnCwd: string | undefined
private readonly initialInput: string | undefined
@@ -147,6 +151,7 @@ export class TerminalSession {
private pendingApprovalValue = false
private pendingToolValue: string | undefined = undefined
private telemetryValue: StatusTelemetry | null = null
private queueLengthValue = 0
private pendingGateValue: PermissionGate | null = null
// W1: bounded command/diff preview of the held tool, from the last pending
// status frame. Null when no approval is held or the tool wasn't previewable.
@@ -179,6 +184,7 @@ export class TerminalSession {
this.onStatus = opts.onStatus
this.onClaudeStatus = opts.onClaudeStatus
this.onTelemetry = opts.onTelemetry
this.onQueue = opts.onQueue
this.onFocus = opts.onFocus
this.spawnCwd = opts.cwd
this.initialInput = opts.initialInput
@@ -255,6 +261,12 @@ export class TerminalSession {
return this.telemetryValue
}
/** W2: current pending inject-queue depth (0 when empty), from the last
* `queue` frame. Every mirrored device sees the same value (broadcast). */
get queueLength(): number {
return this.queueLengthValue
}
/** The gate kind from the last pending status frame (B4): 'plan' or 'tool'.
* Null when no approval is held or the last status had no gate. */
get pendingGate(): PermissionGate | null {
@@ -393,6 +405,13 @@ export class TerminalSession {
this.onTelemetry?.(msg.telemetry)
break
}
case 'queue': {
// W2: pending inject-queue depth changed — update the badge on every
// mirrored device (shared session).
this.queueLengthValue = msg.length
this.onQueue?.(msg.length)
break
}
case 'exit': {
this.setStatus('exited')
const reason = msg.reason ? ` (${msg.reason})` : ''