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:
38
src/types.ts
38
src/types.ts
@@ -70,6 +70,11 @@ export interface Config {
|
||||
// B4 permission-mode relay
|
||||
readonly defaultPermissionMode: PermissionMode; // DEFAULT_PERMISSION_MODE, default 'default'
|
||||
readonly allowAutoMode: boolean; // ALLOW_AUTO_MODE, default false (SEC-M5)
|
||||
// W2 server-side PTY-inject follow-up queue
|
||||
readonly queueEnabled: boolean; // QUEUE_ENABLED, default true (routes 503 when off)
|
||||
readonly queueMaxItems: number; // QUEUE_MAX_ITEMS, default 10 (bounded depth, DoS guard)
|
||||
readonly queueItemMaxBytes: number; // QUEUE_ITEM_MAX_BYTES, default 4096 (bounded size)
|
||||
readonly queueSettleMs: number; // QUEUE_SETTLE_MS, default 1500 (drain-after-idle delay)
|
||||
}
|
||||
|
||||
/** process.env is structurally assignable to this; keeps the file free of `NodeJS.*`. */
|
||||
@@ -136,7 +141,11 @@ export type ServerMessage =
|
||||
* ignore it (additive + optional). */
|
||||
preview?: ApprovalPreview;
|
||||
}
|
||||
| { type: 'telemetry'; telemetry: StatusTelemetry };
|
||||
| { type: 'telemetry'; telemetry: StatusTelemetry }
|
||||
/** W2: the current pending-inject queue depth for this session. Broadcast to
|
||||
* every attached device so all mirrors show the same "N queued" badge. Older
|
||||
* clients ignore it (additive variant). */
|
||||
| { type: 'queue'; length: number };
|
||||
|
||||
/** parseClientMessage result — never throws; errors flow here (§5.3). */
|
||||
export type ParseResult =
|
||||
@@ -247,6 +256,11 @@ export interface Session {
|
||||
stuckNotified: boolean;
|
||||
/** B2: latest statusLine telemetry for this session; null until first report. */
|
||||
telemetry: StatusTelemetry | null;
|
||||
/** W2: bounded FIFO of verbatim byte strings to inject when Claude next goes
|
||||
* idle. Head fires first. Mutable runtime handle on the immutable meta (like
|
||||
* timeline): replaced wholesale (Object.freeze of a new array), never mutated
|
||||
* in place. Capped by cfg.queueMaxItems; each entry capped by queueItemMaxBytes. */
|
||||
queue: readonly string[];
|
||||
readonly pty: IPty;
|
||||
}
|
||||
|
||||
@@ -278,6 +292,9 @@ export interface LiveSessionInfo {
|
||||
* (lastOutputAt > local last-seen). Additive OPTIONAL: older consumers
|
||||
* that build or read LiveSessionInfo without it stay valid. */
|
||||
readonly lastOutputAt?: number;
|
||||
/** W2: number of pending inject-queue entries (0 when empty). Additive OPTIONAL
|
||||
* so the manage grid and /live-sessions can surface queue depth per session. */
|
||||
readonly queueLength?: number;
|
||||
}
|
||||
|
||||
/* ───────────────── project manager (v0.6, §4.3 FEATURE doc) ──────────────── */
|
||||
@@ -330,6 +347,13 @@ export interface ProjectDetail {
|
||||
claudeMd?: string; // its content (truncated for display) when present
|
||||
}
|
||||
|
||||
/** W2: outcome of SessionManager.enqueueFollowup. Success carries the new depth;
|
||||
* failure names the reason so the HTTP route can pick the right status code
|
||||
* (unknown/exited → 404, full → 409). */
|
||||
export type EnqueueResult =
|
||||
| { ok: true; length: number }
|
||||
| { ok: false; reason: 'unknown' | 'full' | 'exited' };
|
||||
|
||||
export interface SessionManager {
|
||||
handleAttach(
|
||||
ws: WebSocketLike,
|
||||
@@ -366,6 +390,18 @@ export interface SessionManager {
|
||||
sweepStuck(now: number): void;
|
||||
/** reclaim when now - max(detachedAt, lastOutputAt) > idleTtlMs (M3). Returns count. */
|
||||
reapIdle(now: number): number;
|
||||
/** W2: append verbatim `text` to a session's inject queue (bounded by
|
||||
* queueMaxItems); broadcasts the new depth. The route validates/sizes `text`
|
||||
* before calling. Never spawns; only mutates the queue handle. */
|
||||
enqueueFollowup(id: string, text: string): EnqueueResult;
|
||||
/** W2: pop the head entry and write it to the PTY (byte-identical to a
|
||||
* keystroke, broadcast to all mirrors); broadcasts the new depth. Returns the
|
||||
* injected string, or null when the queue is empty / session is unknown or
|
||||
* exited (double-guards L4). */
|
||||
drainOne(id: string): string | null;
|
||||
/** W2: clear all pending entries (cancel-all escape hatch); broadcasts depth 0.
|
||||
* Returns false for an unknown session. */
|
||||
clearQueue(id: string): boolean;
|
||||
shutdown(): void;
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user