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

@@ -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'