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

@@ -29,6 +29,8 @@ class FakeTerminalSession {
pendingEpoch = 0
/** B2: latest statusLine telemetry (single source of truth). */
telemetry: unknown = null
/** W2: pending inject-queue depth (tests set it before driving onQueue). */
queueLength = 0
/** Captured so tests can assert initialInput ends with \r (Finding 1). */
initialInput: string | undefined = undefined
connect = vi.fn()
@@ -50,6 +52,7 @@ class FakeTerminalSession {
onActivity?: () => void
onTitle?: (t: string) => void
onTelemetry?: (t: unknown) => void
onQueue?: (n: number) => void
}
static instances: FakeTerminalSession[] = []
constructor(opts: {
@@ -60,6 +63,7 @@ class FakeTerminalSession {
onActivity?: () => void
onTitle?: (t: string) => void
onTelemetry?: (t: unknown) => void
onQueue?: (n: number) => void
[key: string]: unknown
}) {
constructed += 1
@@ -89,13 +93,28 @@ vi.mock('../public/cell-monitor.js', () => ({ mountCellMonitor }))
const mountPushToggle = vi.fn()
vi.mock('../public/push.js', () => ({ mountPushToggle }))
const quickReply: { onSend?: (d: string) => void } = {}
const mountQuickReply = vi.fn((_c: HTMLElement, opts: { onSend: (d: string) => void }) => {
quickReply.onSend = opts.onSend
return { dispose: vi.fn() }
})
const quickReply: {
onSend?: (d: string) => void
onQueue?: (text: string, appendEnter: boolean) => void
} = {}
const mountQuickReply = vi.fn(
(
_c: HTMLElement,
opts: { onSend: (d: string) => void; onQueue?: (text: string, appendEnter: boolean) => void },
) => {
quickReply.onSend = opts.onSend
quickReply.onQueue = opts.onQueue
return { dispose: vi.fn() }
},
)
vi.mock('../public/quick-reply.js', () => ({ mountQuickReply }))
// ── Mock the W2 queue helper (assert enqueue routing without real fetch) ──────
const enqueueFollowupMock = vi.fn(async () => ({ ok: true, length: 1 }))
vi.mock('../public/queue.js', () => ({
enqueueFollowup: (...args: unknown[]) => enqueueFollowupMock(...(args as [])),
}))
const timelineDispose = vi.fn()
const mountTimeline = vi.fn(() => ({ dispose: timelineDispose }))
vi.mock('../public/timeline.js', () => ({ mountTimeline }))
@@ -179,6 +198,8 @@ beforeEach(() => {
voiceStart.mockClear()
voiceStop.mockClear()
quickReply.onSend = undefined
quickReply.onQueue = undefined
enqueueFollowupMock.mockClear()
voice.onTranscript = undefined
voice.onInterim = undefined
// Default: server forbids 'auto' (loadUiConfig resolves to allowAutoMode=false).
@@ -1636,3 +1657,64 @@ describe('TabApp — split-grid watch board (v1)', () => {
expect(mountCellMonitor.mock.lastCall?.[1]).toBe('sess-1')
})
})
// ── W2: inject-queue badge + enqueue affordance ──────────────────────────────
describe('TabApp — W2 inject queue', () => {
it('a queue frame (onQueue) updates the active tabs ⧗N badge in place', () => {
const { paneHost, tabBar } = makeHosts()
const app = new TabApp(paneHost, tabBar)
app.newTab()
const inst = FakeTerminalSession.instances[0]!
// No queued items → empty badge.
expect((tabBar.querySelector('.tab-queue') as HTMLElement).textContent).toBe('')
// Server broadcasts depth 3 → badge shows ⧗3.
inst.queueLength = 3
inst.cbs.onQueue?.(3)
expect((tabBar.querySelector('.tab-queue') as HTMLElement).textContent).toBe('⧗3')
// Drains back to 0 → badge clears.
inst.queueLength = 0
inst.cbs.onQueue?.(0)
expect((tabBar.querySelector('.tab-queue') as HTMLElement).textContent).toBe('')
})
it('enqueueToActive routes to enqueueFollowup with the active session id', () => {
const { paneHost, tabBar } = makeHosts()
const app = new TabApp(paneHost, tabBar)
app.openSession('11111111-1111-4111-8111-111111111111')
app.enqueueToActive('run tests', true)
expect(enqueueFollowupMock).toHaveBeenCalledWith(
'11111111-1111-4111-8111-111111111111',
'run tests',
true,
)
})
it('the quick-reply Queue affordance enqueues for the active session', () => {
const { paneHost, tabBar } = makeHosts()
const app = new TabApp(paneHost, tabBar)
app.openSession('22222222-2222-4222-8222-222222222222')
// The onQueue handler wired into mountQuickReply routes through enqueueToActive.
quickReply.onQueue?.('open a PR', true)
expect(enqueueFollowupMock).toHaveBeenCalledWith(
'22222222-2222-4222-8222-222222222222',
'open a PR',
true,
)
})
it('enqueueToActive is a no-op when no session has attached (id null)', () => {
const { paneHost, tabBar } = makeHosts()
const app = new TabApp(paneHost, tabBar)
app.newTab() // fresh session → id null until server assigns
app.enqueueToActive('x', false)
expect(enqueueFollowupMock).not.toHaveBeenCalled()
})
})