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

@@ -730,3 +730,40 @@ describe('loadConfig — v0.7 all new fields present in frozen result', () => {
expect(cfg).toHaveProperty('allowAutoMode')
})
})
// ── W2 inject-queue config ────────────────────────────────────────────────────
describe('loadConfig — W2 inject queue', () => {
beforeEach(() => {
mockNetworkInterfaces.mockReturnValue({})
mockHomedir.mockReturnValue('/home/testuser')
})
it('defaults: queueEnabled true, queueMaxItems 10, queueItemMaxBytes 4096, queueSettleMs 1500', () => {
const cfg = loadConfig({})
expect(cfg.queueEnabled).toBe(true)
expect(cfg.queueMaxItems).toBe(10)
expect(cfg.queueItemMaxBytes).toBe(4096)
expect(cfg.queueSettleMs).toBe(1500)
})
it('reads env overrides', () => {
const cfg = loadConfig({
QUEUE_ENABLED: '0',
QUEUE_MAX_ITEMS: '3',
QUEUE_ITEM_MAX_BYTES: '256',
QUEUE_SETTLE_MS: '500',
})
expect(cfg.queueEnabled).toBe(false)
expect(cfg.queueMaxItems).toBe(3)
expect(cfg.queueItemMaxBytes).toBe(256)
expect(cfg.queueSettleMs).toBe(500)
})
it('throws for a negative QUEUE_MAX_ITEMS (fail-fast)', () => {
expect(() => loadConfig({ QUEUE_MAX_ITEMS: '-1' })).toThrow()
})
it('throws for a non-integer QUEUE_SETTLE_MS', () => {
expect(() => loadConfig({ QUEUE_SETTLE_MS: 'soon' })).toThrow()
})
})