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