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:
70
public/queue.ts
Normal file
70
public/queue.ts
Normal file
@@ -0,0 +1,70 @@
|
||||
/**
|
||||
* public/queue.ts — W2: client helper for the server-side PTY-inject queue.
|
||||
*
|
||||
* Enqueue a follow-up prompt that the server fires into a session's PTY the next
|
||||
* time Claude goes idle (so "run tests → open a PR" advances itself while you're
|
||||
* away). Enqueue is a same-origin HTTP POST (Origin-guarded server-side), NOT a
|
||||
* WS frame — it must work with zero tabs open and target any session (e.g. from
|
||||
* the manage page), not just the WS-bound one.
|
||||
*
|
||||
* Discipline (mirrors quick-reply): every call NEVER throws — a failed fetch or
|
||||
* non-2xx response returns a falsy result the caller can ignore. Byte-shuttle:
|
||||
* `text` is sent verbatim; `appendEnter` tells the server to append \r.
|
||||
*/
|
||||
|
||||
/** Result of an enqueue attempt. `length` = the new queue depth on success. */
|
||||
export interface EnqueueResult {
|
||||
ok: boolean
|
||||
/** New queue depth (present on success). */
|
||||
length?: number
|
||||
/** HTTP status on a non-2xx response (absent on network error). */
|
||||
status?: number
|
||||
}
|
||||
|
||||
/** POST a follow-up prompt to a session's inject queue. Never throws. */
|
||||
export async function enqueueFollowup(
|
||||
sessionId: string,
|
||||
text: string,
|
||||
appendEnter: boolean,
|
||||
): Promise<EnqueueResult> {
|
||||
try {
|
||||
if (typeof fetch === 'undefined') return { ok: false }
|
||||
const res = await fetch(`/live-sessions/${encodeURIComponent(sessionId)}/queue`, {
|
||||
method: 'POST',
|
||||
credentials: 'same-origin',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ text, appendEnter }),
|
||||
})
|
||||
if (!res.ok) return { ok: false, status: res.status }
|
||||
const data = (await res.json()) as { length?: unknown }
|
||||
return { ok: true, length: typeof data.length === 'number' ? data.length : undefined }
|
||||
} catch {
|
||||
return { ok: false }
|
||||
}
|
||||
}
|
||||
|
||||
/** DELETE all pending entries for a session (cancel-all). Returns success. Never throws. */
|
||||
export async function clearQueue(sessionId: string): Promise<boolean> {
|
||||
try {
|
||||
if (typeof fetch === 'undefined') return false
|
||||
const res = await fetch(`/live-sessions/${encodeURIComponent(sessionId)}/queue`, {
|
||||
method: 'DELETE',
|
||||
credentials: 'same-origin',
|
||||
})
|
||||
return res.ok
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract the queue depth from an incoming server frame. Returns the length for a
|
||||
* `{type:'queue',length}` frame, else null (so callers can filter unrelated
|
||||
* frames). Defensive: tolerates any unknown input shape.
|
||||
*/
|
||||
export function queueLengthFromFrame(msg: unknown): number | null {
|
||||
if (msg === null || typeof msg !== 'object') return null
|
||||
const o = msg as Record<string, unknown>
|
||||
if (o['type'] !== 'queue') return null
|
||||
return typeof o['length'] === 'number' ? o['length'] : null
|
||||
}
|
||||
@@ -147,6 +147,10 @@ function clearChildren(parent: HTMLElement): void {
|
||||
export interface QuickReplyOpts {
|
||||
/** Called with the full byte string to inject into the terminal. */
|
||||
onSend: (data: string) => void
|
||||
/** W2: optional. When provided, the snippet editor gains a "Queue" button that
|
||||
* enqueues the typed text as a follow-up prompt (fired on next idle) instead of
|
||||
* sending it now. `appendEnter` mirrors the editor checkbox. */
|
||||
onQueue?: (text: string, appendEnter: boolean) => void
|
||||
}
|
||||
|
||||
/** Returned by mountQuickReply to allow cleanup. */
|
||||
@@ -165,7 +169,7 @@ export interface QuickReplyHandle {
|
||||
* Labels are set via textContent — never innerHTML (SEC-L3).
|
||||
*/
|
||||
export function mountQuickReply(container: HTMLElement, opts: QuickReplyOpts): QuickReplyHandle {
|
||||
const { onSend } = opts
|
||||
const { onSend, onQueue } = opts
|
||||
// Track disposers for all event listeners so dispose() is clean.
|
||||
const disposers: Array<() => void> = []
|
||||
|
||||
@@ -233,6 +237,22 @@ export function mountQuickReply(container: HTMLElement, opts: QuickReplyOpts): Q
|
||||
editor.appendChild(labelInput)
|
||||
editor.appendChild(enterWrap)
|
||||
editor.appendChild(saveBtn)
|
||||
|
||||
// W2: "Queue" defers the typed text as a follow-up prompt (fires on next
|
||||
// idle) rather than saving it as a chip. Shown only when a handler is wired.
|
||||
if (onQueue !== undefined) {
|
||||
const queueBtn = el('button', 'qr-editor-queue', 'Queue')
|
||||
queueBtn.title = 'Queue as a follow-up (runs when Claude next goes idle)'
|
||||
function onQueueClick() {
|
||||
const text = textInput.value.trim()
|
||||
if (!text) return // empty text — no-op, editor stays open
|
||||
onQueue?.(text, enterCheck.checked)
|
||||
editor.remove()
|
||||
}
|
||||
queueBtn.addEventListener('click', onQueueClick)
|
||||
editor.appendChild(queueBtn)
|
||||
}
|
||||
|
||||
editor.appendChild(cancelBtn)
|
||||
|
||||
container.appendChild(editor)
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -115,6 +115,9 @@ export interface TerminalSessionOpts {
|
||||
/** Optional: fired when new statusLine telemetry arrives (B2). Single source of
|
||||
* truth — T-tabs reads session.telemetry via the getter, not its own copy. */
|
||||
onTelemetry?: (telemetry: StatusTelemetry) => void
|
||||
/** Optional: fired when the pending inject-queue depth changes (W2), so the tab
|
||||
* can render an "N queued" badge. Broadcast to every mirrored device. */
|
||||
onQueue?: (length: number) => void
|
||||
/** Optional: fired on a pointerdown anywhere in this pane (split-grid focus).
|
||||
* Lets TabApp move the focused-pane (activeIndex) to the clicked quadrant. */
|
||||
onFocus?: () => void
|
||||
@@ -137,6 +140,7 @@ export class TerminalSession {
|
||||
private readonly onStatus: ((status: SessionStatus) => void) | undefined
|
||||
private readonly onClaudeStatus: ((status: ClaudeStatus, detail?: string) => void) | undefined
|
||||
private readonly onTelemetry: ((telemetry: StatusTelemetry) => void) | undefined
|
||||
private readonly onQueue: ((length: number) => void) | undefined
|
||||
private readonly onFocus: (() => void) | undefined
|
||||
private readonly spawnCwd: string | undefined
|
||||
private readonly initialInput: string | undefined
|
||||
@@ -147,6 +151,7 @@ export class TerminalSession {
|
||||
private pendingApprovalValue = false
|
||||
private pendingToolValue: string | undefined = undefined
|
||||
private telemetryValue: StatusTelemetry | null = null
|
||||
private queueLengthValue = 0
|
||||
private pendingGateValue: PermissionGate | null = null
|
||||
// W1: bounded command/diff preview of the held tool, from the last pending
|
||||
// status frame. Null when no approval is held or the tool wasn't previewable.
|
||||
@@ -179,6 +184,7 @@ export class TerminalSession {
|
||||
this.onStatus = opts.onStatus
|
||||
this.onClaudeStatus = opts.onClaudeStatus
|
||||
this.onTelemetry = opts.onTelemetry
|
||||
this.onQueue = opts.onQueue
|
||||
this.onFocus = opts.onFocus
|
||||
this.spawnCwd = opts.cwd
|
||||
this.initialInput = opts.initialInput
|
||||
@@ -255,6 +261,12 @@ export class TerminalSession {
|
||||
return this.telemetryValue
|
||||
}
|
||||
|
||||
/** W2: current pending inject-queue depth (0 when empty), from the last
|
||||
* `queue` frame. Every mirrored device sees the same value (broadcast). */
|
||||
get queueLength(): number {
|
||||
return this.queueLengthValue
|
||||
}
|
||||
|
||||
/** The gate kind from the last pending status frame (B4): 'plan' or 'tool'.
|
||||
* Null when no approval is held or the last status had no gate. */
|
||||
get pendingGate(): PermissionGate | null {
|
||||
@@ -393,6 +405,13 @@ export class TerminalSession {
|
||||
this.onTelemetry?.(msg.telemetry)
|
||||
break
|
||||
}
|
||||
case 'queue': {
|
||||
// W2: pending inject-queue depth changed — update the badge on every
|
||||
// mirrored device (shared session).
|
||||
this.queueLengthValue = msg.length
|
||||
this.onQueue?.(msg.length)
|
||||
break
|
||||
}
|
||||
case 'exit': {
|
||||
this.setStatus('exited')
|
||||
const reason = msg.reason ? ` (${msg.reason})` : ''
|
||||
|
||||
Reference in New Issue
Block a user