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

@@ -32,7 +32,7 @@ import type { WebSocket as WsWebSocket } from 'ws'
import type { IncomingMessage } from 'node:http'
import { loadConfig } from './config.js'
import { parseClientMessage, serialize } from './protocol.js'
import { parseClientMessage, serialize, SESSION_ID_RE } from './protocol.js'
import { isOriginAllowed } from './http/origin.js'
import { parseHookEvent } from './http/hook.js'
import { deriveApprovalPreview } from './http/approval-preview.js'
@@ -76,6 +76,7 @@ const LOG_FIELD_MAX = 200
const RATE_LIMIT_WINDOW_MS = 60_000
const DECISION_RATE_MAX = 10 // POST /hook/decision ≤ 10/min/IP
const SUBSCRIBE_RATE_MAX = 5 // POST/DELETE /push/subscribe ≤ 5/min/IP
const QUEUE_RATE_MAX = 20 // W2: POST/DELETE /live-sessions/:id/queue ≤ 20/min/IP
/** The four permission modes the WS approve relay accepts (B4); else undefined. */
const PERMISSION_MODES: ReadonlySet<string> = new Set<PermissionMode>([
@@ -219,6 +220,47 @@ export function startServer(cfg: Config): { close(): Promise<void> } {
// Per-IP rate limiters for the new state-changing routes (SEC-H9).
const decisionLimiter = createRateLimiter(DECISION_RATE_MAX, RATE_LIMIT_WINDOW_MS)
const subscribeLimiter = createRateLimiter(SUBSCRIBE_RATE_MAX, RATE_LIMIT_WINDOW_MS)
const queueLimiter = createRateLimiter(QUEUE_RATE_MAX, RATE_LIMIT_WINDOW_MS) // W2
// W2: per-session settle timers for the idle-drain. A Stop/SessionEnd hook
// schedules a debounced drain of one queue entry after cfg.queueSettleMs, so
// the injected text lands once the prompt is ready. Cleared on shutdown; the
// fired callback re-checks idle + a stable lastOutputAt cursor before draining.
const drainTimers = new Map<string, ReturnType<typeof setTimeout>>()
/**
* W2: schedule (or reschedule) a single idle-drain for `sessionId`. Debounced
* per session — each Stop clears any pending timer and restarts the window, so
* flapping Stop events fire at most one drain. On fire we drain ONE entry only
* if the session is still idle, unexited, and its lastOutputAt has not moved
* since scheduling (no output mid-settle = Claude really finished, not
* mid-render). One entry per idle → natural pacing (the injected prompt makes
* Claude work again, and its next Stop drains the next entry).
*/
function scheduleDrain(sessionId: string): void {
if (!cfg.queueEnabled) return
const session = manager.get(sessionId)
if (session === undefined || session.exitedAt !== null) return
if (session.queue.length === 0) return
const existing = drainTimers.get(sessionId)
if (existing !== undefined) clearTimeout(existing)
const outputCursor = session.lastOutputAt
const timer = setTimeout(() => {
drainTimers.delete(sessionId)
const s = manager.get(sessionId)
if (s === undefined || s.exitedAt !== null) return
// Settle guard: new output since scheduling → Claude is still active; skip
// (a later genuine Stop reschedules). Also require the idle status to hold.
if (s.lastOutputAt !== outputCursor) return
if (s.claudeStatus !== 'idle') return
manager.drainOne(sessionId)
}, cfg.queueSettleMs)
// Don't let a pending drain keep the process alive; a stale fire is harmless.
timer.unref()
drainTimers.set(sessionId, timer)
}
// H3/A1: held PermissionRequest responses, keyed by sessionId. `res` is parked
// until approve/reject or timeout. `token`+`expiresAt` = the per-decision
@@ -350,6 +392,70 @@ export function startServer(cfg: Config): { close(): Promise<void> } {
})
})
// ── W2 inject queue: enqueue a follow-up prompt fired on next idle ────────
// State-changing (causes shell input on idle) → Origin guard (CSRF) + per-IP
// rate limit. Body: { text: string, appendEnter?: boolean }. Bytes are stored
// and later injected VERBATIM (byte-shuttle) — bounded in size and count, never
// parsed as a shell command. `text` + optional trailing \r is capped by
// queueItemMaxBytes; the queue depth is capped by queueMaxItems.
app.post('/live-sessions/:id/queue', express.json({ limit: '16kb' }), (req, res) => {
if (!requireAllowedOrigin(req, res)) return
if (!cfg.queueEnabled) {
res.status(503).json({ error: 'queue disabled' })
return
}
if (!queueLimiter(req.socket.remoteAddress ?? '', Date.now())) {
res.status(429).end()
return
}
const id = req.params.id
if (!SESSION_ID_RE.test(id)) {
res.status(400).json({ error: 'invalid session id' })
return
}
const body = (req.body ?? {}) as Record<string, unknown>
const rawText = body['text']
if (typeof rawText !== 'string' || rawText.length === 0) {
res.status(400).json({ error: 'text must be a non-empty string' })
return
}
// FE decides Enter (mirrors quick-reply's appendEnter): materialize the exact
// bytes to inject here so the stored entry is byte-identical to a keystroke.
const text = body['appendEnter'] === true ? rawText + '\r' : rawText
if (Buffer.byteLength(text, 'utf8') > cfg.queueItemMaxBytes) {
res.status(413).json({ error: 'text too large' })
return
}
const result = manager.enqueueFollowup(id, text)
if (!result.ok) {
// full → 409 (never silently drop); unknown/exited → 404.
res.status(result.reason === 'full' ? 409 : 404).json({ error: result.reason })
return
}
res.json({ length: result.length })
})
// Read-only queue view (depth + the user's own queued text). No Origin guard —
// same threat model as GET /live-sessions.
app.get('/live-sessions/:id/queue', (req, res) => {
const session = manager.get(req.params.id)
if (session === undefined) {
res.status(404).end()
return
}
res.json({ length: session.queue.length, items: [...session.queue] })
})
// Cancel all pending entries (escape hatch). State-changing → Origin guard.
app.delete('/live-sessions/:id/queue', (req, res) => {
if (!requireAllowedOrigin(req, res)) return
if (!manager.clearQueue(req.params.id)) {
res.status(404).end()
return
}
res.json({ length: 0 })
})
// CSRF guard for the state-changing DELETE routes (Arch 5b / Sec H2): the WS
// upgrade checks Origin, but plain HTTP routes don't — without this, a foreign
// page could fire a no-preflight DELETE and Kill-All sessions. Reuse the same
@@ -426,6 +532,9 @@ export function startServer(cfg: Config): { close(): Promise<void> } {
if (ev.eventClass === 'Stop' || ev.eventClass === 'SessionEnd') {
const session = manager.get(ev.sessionId)
if (session !== undefined) void pushService.notify(session, 'done')
// W2: Claude just went idle — schedule a debounced drain of the next queued
// follow-up (fires after the settle delay iff still idle + output stable).
scheduleDrain(ev.sessionId)
}
res.status(204).end()
})
@@ -962,6 +1071,9 @@ export function startServer(cfg: Config): { close(): Promise<void> } {
// ── Graceful shutdown helper ──────────────────────────────────────────────
function doShutdown(): void {
clearInterval(reapTimer)
// W2: cancel any pending idle-drain timers so they can't fire post-shutdown.
for (const t of drainTimers.values()) clearTimeout(t)
drainTimers.clear()
manager.shutdown()
}