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:
@@ -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'
|
||||
|
||||
Reference in New Issue
Block a user