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:
@@ -30,6 +30,7 @@ import type {
|
||||
ClaudeStatus,
|
||||
Config,
|
||||
Dims,
|
||||
EnqueueResult,
|
||||
LiveSessionInfo,
|
||||
NotifyService,
|
||||
PermissionGate,
|
||||
@@ -41,7 +42,7 @@ import type {
|
||||
} from '../types.js';
|
||||
import { WS_OPEN } from '../types.js';
|
||||
import { serialize } from '../protocol.js';
|
||||
import { createSession, attachWs, broadcast, kill } from './session.js';
|
||||
import { createSession, attachWs, broadcast, kill, writeInput } from './session.js';
|
||||
import { appendEvent, makeTimelineEvent } from './timeline.js';
|
||||
import { hasSession, tmuxName } from './tmux.js';
|
||||
|
||||
@@ -194,6 +195,7 @@ export function createSessionManager(
|
||||
rows: s.pty.rows,
|
||||
telemetry: s.telemetry, // B2: latest telemetry for the thumbnail wall
|
||||
lastOutputAt: s.lastOutputAt, // T-iOS-37: unread watermark (M3 record)
|
||||
queueLength: s.queue.length, // W2: pending inject-queue depth
|
||||
}))
|
||||
.sort((a, b) => b.createdAt - a.createdAt);
|
||||
}
|
||||
@@ -323,6 +325,57 @@ export function createSessionManager(
|
||||
return count;
|
||||
}
|
||||
|
||||
/**
|
||||
* W2: append a verbatim byte string to a session's inject queue.
|
||||
*
|
||||
* The queue is a bounded FIFO (cfg.queueMaxItems). The HTTP route validates and
|
||||
* size-caps `text` before calling; here we only enforce depth + existence.
|
||||
* Immutable update: replace the frozen array wholesale, never mutate in place.
|
||||
* Broadcasts the new depth so every mirrored device updates its badge.
|
||||
*/
|
||||
function enqueueFollowup(id: string, text: string): EnqueueResult {
|
||||
const session = sessions.get(id);
|
||||
if (session === undefined) return { ok: false, reason: 'unknown' };
|
||||
if (session.exitedAt !== null) return { ok: false, reason: 'exited' };
|
||||
if (session.queue.length >= cfg.queueMaxItems) return { ok: false, reason: 'full' };
|
||||
|
||||
session.queue = Object.freeze([...session.queue, text]);
|
||||
const length = session.queue.length;
|
||||
broadcast(session, { type: 'queue', length });
|
||||
return { ok: true, length };
|
||||
}
|
||||
|
||||
/**
|
||||
* W2: pop the head entry and write it to the PTY (byte-identical to a keystroke;
|
||||
* writeInput broadcasts the resulting output to all mirrors). Returns the
|
||||
* injected string, or null when there is nothing to drain / the session is
|
||||
* unknown or already exited (double-guards L4 — writeInput is itself a no-op
|
||||
* after exit). Broadcasts the new depth.
|
||||
*/
|
||||
function drainOne(id: string): string | null {
|
||||
const session = sessions.get(id);
|
||||
if (session === undefined || session.exitedAt !== null) return null;
|
||||
if (session.queue.length === 0) return null;
|
||||
|
||||
const head = session.queue[0] as string;
|
||||
session.queue = Object.freeze(session.queue.slice(1));
|
||||
writeInput(session, head);
|
||||
broadcast(session, { type: 'queue', length: session.queue.length });
|
||||
return head;
|
||||
}
|
||||
|
||||
/**
|
||||
* W2: clear all pending entries (cancel-all escape hatch). Broadcasts depth 0.
|
||||
* Returns false for an unknown session.
|
||||
*/
|
||||
function clearQueue(id: string): boolean {
|
||||
const session = sessions.get(id);
|
||||
if (session === undefined) return false;
|
||||
session.queue = Object.freeze([] as string[]);
|
||||
broadcast(session, { type: 'queue', length: 0 });
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Called on SIGINT/SIGTERM/close. For non-tmux sessions, kill the PTY. For
|
||||
* tmux sessions (H1), kill only the client pty — the tmux server keeps the
|
||||
@@ -348,6 +401,9 @@ export function createSessionManager(
|
||||
handleStatusLine,
|
||||
sweepStuck,
|
||||
reapIdle,
|
||||
enqueueFollowup,
|
||||
drainOne,
|
||||
clearQueue,
|
||||
shutdown,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -137,6 +137,8 @@ export function createSession(
|
||||
timeline: Object.freeze([] as TimelineEvent[]),
|
||||
stuckNotified: false, // A5: re-armed to false by each pty output
|
||||
telemetry: null, // B2: updated by manager.handleStatusLine
|
||||
// W2: inject follow-up queue — empty at spawn; replaced wholesale by manager.
|
||||
queue: Object.freeze([] as string[]),
|
||||
};
|
||||
|
||||
// onData: persist to scrollback, refresh liveness, broadcast to all clients.
|
||||
|
||||
Reference in New Issue
Block a user