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

70
public/queue.ts Normal file
View File

@@ -0,0 +1,70 @@
/**
* public/queue.ts — W2: client helper for the server-side PTY-inject queue.
*
* Enqueue a follow-up prompt that the server fires into a session's PTY the next
* time Claude goes idle (so "run tests → open a PR" advances itself while you're
* away). Enqueue is a same-origin HTTP POST (Origin-guarded server-side), NOT a
* WS frame — it must work with zero tabs open and target any session (e.g. from
* the manage page), not just the WS-bound one.
*
* Discipline (mirrors quick-reply): every call NEVER throws — a failed fetch or
* non-2xx response returns a falsy result the caller can ignore. Byte-shuttle:
* `text` is sent verbatim; `appendEnter` tells the server to append \r.
*/
/** Result of an enqueue attempt. `length` = the new queue depth on success. */
export interface EnqueueResult {
ok: boolean
/** New queue depth (present on success). */
length?: number
/** HTTP status on a non-2xx response (absent on network error). */
status?: number
}
/** POST a follow-up prompt to a session's inject queue. Never throws. */
export async function enqueueFollowup(
sessionId: string,
text: string,
appendEnter: boolean,
): Promise<EnqueueResult> {
try {
if (typeof fetch === 'undefined') return { ok: false }
const res = await fetch(`/live-sessions/${encodeURIComponent(sessionId)}/queue`, {
method: 'POST',
credentials: 'same-origin',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ text, appendEnter }),
})
if (!res.ok) return { ok: false, status: res.status }
const data = (await res.json()) as { length?: unknown }
return { ok: true, length: typeof data.length === 'number' ? data.length : undefined }
} catch {
return { ok: false }
}
}
/** DELETE all pending entries for a session (cancel-all). Returns success. Never throws. */
export async function clearQueue(sessionId: string): Promise<boolean> {
try {
if (typeof fetch === 'undefined') return false
const res = await fetch(`/live-sessions/${encodeURIComponent(sessionId)}/queue`, {
method: 'DELETE',
credentials: 'same-origin',
})
return res.ok
} catch {
return false
}
}
/**
* Extract the queue depth from an incoming server frame. Returns the length for a
* `{type:'queue',length}` frame, else null (so callers can filter unrelated
* frames). Defensive: tolerates any unknown input shape.
*/
export function queueLengthFromFrame(msg: unknown): number | null {
if (msg === null || typeof msg !== 'object') return null
const o = msg as Record<string, unknown>
if (o['type'] !== 'queue') return null
return typeof o['length'] === 'number' ? o['length'] : null
}

View File

@@ -147,6 +147,10 @@ function clearChildren(parent: HTMLElement): void {
export interface QuickReplyOpts {
/** Called with the full byte string to inject into the terminal. */
onSend: (data: string) => void
/** W2: optional. When provided, the snippet editor gains a "Queue" button that
* enqueues the typed text as a follow-up prompt (fired on next idle) instead of
* sending it now. `appendEnter` mirrors the editor checkbox. */
onQueue?: (text: string, appendEnter: boolean) => void
}
/** Returned by mountQuickReply to allow cleanup. */
@@ -165,7 +169,7 @@ export interface QuickReplyHandle {
* Labels are set via textContent — never innerHTML (SEC-L3).
*/
export function mountQuickReply(container: HTMLElement, opts: QuickReplyOpts): QuickReplyHandle {
const { onSend } = opts
const { onSend, onQueue } = opts
// Track disposers for all event listeners so dispose() is clean.
const disposers: Array<() => void> = []
@@ -233,6 +237,22 @@ export function mountQuickReply(container: HTMLElement, opts: QuickReplyOpts): Q
editor.appendChild(labelInput)
editor.appendChild(enterWrap)
editor.appendChild(saveBtn)
// W2: "Queue" defers the typed text as a follow-up prompt (fires on next
// idle) rather than saving it as a chip. Shown only when a handler is wired.
if (onQueue !== undefined) {
const queueBtn = el('button', 'qr-editor-queue', 'Queue')
queueBtn.title = 'Queue as a follow-up (runs when Claude next goes idle)'
function onQueueClick() {
const text = textInput.value.trim()
if (!text) return // empty text — no-op, editor stays open
onQueue?.(text, enterCheck.checked)
editor.remove()
}
queueBtn.addEventListener('click', onQueueClick)
editor.appendChild(queueBtn)
}
editor.appendChild(cancelBtn)
container.appendChild(editor)

View File

@@ -31,6 +31,7 @@ import { renderDiffFile } from './diff.js'
import { renderTelemetryGauge } from './preview-grid.js'
import { mountPushToggle } from './push.js'
import { mountQuickReply } from './quick-reply.js'
import { enqueueFollowup } from './queue.js'
import { mountTimeline, type TimelineHandle } from './timeline.js'
import { createVoiceInput, type VoiceInput } from './voice.js'
import { matchCommand, type VoiceMatchContext } from './voice-commands.js'
@@ -216,7 +217,21 @@ export class TabApp {
const keybar = document.getElementById('keybar')
if (keybar?.parentElement) keybar.parentElement.insertBefore(host, keybar)
else document.body.appendChild(host)
mountQuickReply(host, { onSend: (data) => this.sendToActive(data) })
mountQuickReply(host, {
onSend: (data) => this.sendToActive(data),
// W2: "Queue" in the snippet editor enqueues a follow-up for the active
// session instead of sending it now (fires when Claude next goes idle).
onQueue: (text, appendEnter) => this.enqueueToActive(text, appendEnter),
})
}
/** W2: enqueue a follow-up prompt for the active session (best-effort — the
* POST is Origin-guarded server-side; failures are surfaced only via the badge
* not updating). No-op when no tab has attached yet. */
enqueueToActive(text: string, appendEnter: boolean): void {
const id = this.activeSessionId()
if (id === null) return
void enqueueFollowup(id, text, appendEnter)
}
/** A4: hidden activity-timeline panel; toggled per active session. */
@@ -725,6 +740,8 @@ export class TabApp {
},
// B2: telemetry is the single source of truth on the session; just re-render.
onTelemetry: () => this.refreshTab(entry),
// W2: pending inject-queue depth changed — re-render the "N queued" badge.
onQueue: () => this.refreshTab(entry),
// Split-grid: clicking anywhere in this pane makes it the focused quadrant.
onFocus: () => this.setFocused(this.tabs.indexOf(entry)),
})
@@ -1417,6 +1434,12 @@ export class TabApp {
if (label) label.textContent = title
const claude = el.querySelector('.tab-claude')
if (claude) claude.textContent = claudeIcon(cs)
// W2: "⧗N" when the session has queued follow-ups, else empty (badge hidden).
const queue = el.querySelector('.tab-queue')
if (queue) {
const n = entry.session.queueLength
queue.textContent = n > 0 ? `${n}` : ''
}
const gauge = el.querySelector<HTMLElement>('.tab-gauge')
if (gauge) renderTelemetryGauge(gauge, entry.session.telemetry, STATUSLINE_TTL_MS) // B2
}
@@ -1512,6 +1535,11 @@ export class TabApp {
claude.className = 'tab-claude'
tabEl.appendChild(claude)
// W2: pending inject-queue depth badge (filled in by refreshTab).
const queue = document.createElement('span')
queue.className = 'tab-queue'
tabEl.appendChild(queue)
// B2: per-tab telemetry gauge container (filled in by refreshTab).
const gauge = document.createElement('span')
gauge.className = 'tab-gauge'

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})` : ''