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:
@@ -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