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:
@@ -63,6 +63,10 @@ const DEFAULT_DIFF_MAX_FILES = 300
|
||||
const DEFAULT_STATUSLINE_TTL_MS = 30_000
|
||||
// B3 worktrees
|
||||
const DEFAULT_WORKTREE_TIMEOUT_MS = 10_000
|
||||
// W2 inject queue
|
||||
const DEFAULT_QUEUE_MAX_ITEMS = 10
|
||||
const DEFAULT_QUEUE_ITEM_MAX_BYTES = 4096
|
||||
const DEFAULT_QUEUE_SETTLE_MS = 1500
|
||||
|
||||
/** Valid --permission-mode values (R0-confirmed). Whitelist for validation. */
|
||||
const PERMISSION_MODES: readonly PermissionMode[] = ['default', 'acceptEdits', 'plan', 'auto']
|
||||
@@ -384,6 +388,16 @@ export function loadConfig(env: EnvLike): Config {
|
||||
)
|
||||
const allowAutoMode = parseBool(env['ALLOW_AUTO_MODE'], false) // default off (SEC-M5)
|
||||
|
||||
// W2 server-side PTY-inject follow-up queue
|
||||
const queueEnabled = parseBool(env['QUEUE_ENABLED'], true)
|
||||
const queueMaxItems = parseNonNegativeInt(env['QUEUE_MAX_ITEMS'], 'QUEUE_MAX_ITEMS', DEFAULT_QUEUE_MAX_ITEMS)
|
||||
const queueItemMaxBytes = parseNonNegativeInt(
|
||||
env['QUEUE_ITEM_MAX_BYTES'],
|
||||
'QUEUE_ITEM_MAX_BYTES',
|
||||
DEFAULT_QUEUE_ITEM_MAX_BYTES,
|
||||
)
|
||||
const queueSettleMs = parseNonNegativeInt(env['QUEUE_SETTLE_MS'], 'QUEUE_SETTLE_MS', DEFAULT_QUEUE_SETTLE_MS)
|
||||
|
||||
// ── assemble + freeze ───────────────────────────────────────────────────────
|
||||
// `satisfies Config` on the base object verifies all existing Config fields
|
||||
// are present without triggering excess-property checks on the v0.7 additions.
|
||||
@@ -435,5 +449,10 @@ export function loadConfig(env: EnvLike): Config {
|
||||
worktreeTimeoutMs,
|
||||
defaultPermissionMode,
|
||||
allowAutoMode,
|
||||
// W2 inject queue
|
||||
queueEnabled,
|
||||
queueMaxItems,
|
||||
queueItemMaxBytes,
|
||||
queueSettleMs,
|
||||
})
|
||||
}
|
||||
|
||||
114
src/server.ts
114
src/server.ts
@@ -32,7 +32,7 @@ import type { WebSocket as WsWebSocket } from 'ws'
|
||||
import type { IncomingMessage } from 'node:http'
|
||||
|
||||
import { loadConfig } from './config.js'
|
||||
import { parseClientMessage, serialize } from './protocol.js'
|
||||
import { parseClientMessage, serialize, SESSION_ID_RE } from './protocol.js'
|
||||
import { isOriginAllowed } from './http/origin.js'
|
||||
import { parseHookEvent } from './http/hook.js'
|
||||
import { deriveApprovalPreview } from './http/approval-preview.js'
|
||||
@@ -76,6 +76,7 @@ const LOG_FIELD_MAX = 200
|
||||
const RATE_LIMIT_WINDOW_MS = 60_000
|
||||
const DECISION_RATE_MAX = 10 // POST /hook/decision ≤ 10/min/IP
|
||||
const SUBSCRIBE_RATE_MAX = 5 // POST/DELETE /push/subscribe ≤ 5/min/IP
|
||||
const QUEUE_RATE_MAX = 20 // W2: POST/DELETE /live-sessions/:id/queue ≤ 20/min/IP
|
||||
|
||||
/** The four permission modes the WS approve relay accepts (B4); else undefined. */
|
||||
const PERMISSION_MODES: ReadonlySet<string> = new Set<PermissionMode>([
|
||||
@@ -219,6 +220,47 @@ export function startServer(cfg: Config): { close(): Promise<void> } {
|
||||
// Per-IP rate limiters for the new state-changing routes (SEC-H9).
|
||||
const decisionLimiter = createRateLimiter(DECISION_RATE_MAX, RATE_LIMIT_WINDOW_MS)
|
||||
const subscribeLimiter = createRateLimiter(SUBSCRIBE_RATE_MAX, RATE_LIMIT_WINDOW_MS)
|
||||
const queueLimiter = createRateLimiter(QUEUE_RATE_MAX, RATE_LIMIT_WINDOW_MS) // W2
|
||||
|
||||
// W2: per-session settle timers for the idle-drain. A Stop/SessionEnd hook
|
||||
// schedules a debounced drain of one queue entry after cfg.queueSettleMs, so
|
||||
// the injected text lands once the prompt is ready. Cleared on shutdown; the
|
||||
// fired callback re-checks idle + a stable lastOutputAt cursor before draining.
|
||||
const drainTimers = new Map<string, ReturnType<typeof setTimeout>>()
|
||||
|
||||
/**
|
||||
* W2: schedule (or reschedule) a single idle-drain for `sessionId`. Debounced
|
||||
* per session — each Stop clears any pending timer and restarts the window, so
|
||||
* flapping Stop events fire at most one drain. On fire we drain ONE entry only
|
||||
* if the session is still idle, unexited, and its lastOutputAt has not moved
|
||||
* since scheduling (no output mid-settle = Claude really finished, not
|
||||
* mid-render). One entry per idle → natural pacing (the injected prompt makes
|
||||
* Claude work again, and its next Stop drains the next entry).
|
||||
*/
|
||||
function scheduleDrain(sessionId: string): void {
|
||||
if (!cfg.queueEnabled) return
|
||||
const session = manager.get(sessionId)
|
||||
if (session === undefined || session.exitedAt !== null) return
|
||||
if (session.queue.length === 0) return
|
||||
|
||||
const existing = drainTimers.get(sessionId)
|
||||
if (existing !== undefined) clearTimeout(existing)
|
||||
|
||||
const outputCursor = session.lastOutputAt
|
||||
const timer = setTimeout(() => {
|
||||
drainTimers.delete(sessionId)
|
||||
const s = manager.get(sessionId)
|
||||
if (s === undefined || s.exitedAt !== null) return
|
||||
// Settle guard: new output since scheduling → Claude is still active; skip
|
||||
// (a later genuine Stop reschedules). Also require the idle status to hold.
|
||||
if (s.lastOutputAt !== outputCursor) return
|
||||
if (s.claudeStatus !== 'idle') return
|
||||
manager.drainOne(sessionId)
|
||||
}, cfg.queueSettleMs)
|
||||
// Don't let a pending drain keep the process alive; a stale fire is harmless.
|
||||
timer.unref()
|
||||
drainTimers.set(sessionId, timer)
|
||||
}
|
||||
|
||||
// H3/A1: held PermissionRequest responses, keyed by sessionId. `res` is parked
|
||||
// until approve/reject or timeout. `token`+`expiresAt` = the per-decision
|
||||
@@ -350,6 +392,70 @@ export function startServer(cfg: Config): { close(): Promise<void> } {
|
||||
})
|
||||
})
|
||||
|
||||
// ── W2 inject queue: enqueue a follow-up prompt fired on next idle ────────
|
||||
// State-changing (causes shell input on idle) → Origin guard (CSRF) + per-IP
|
||||
// rate limit. Body: { text: string, appendEnter?: boolean }. Bytes are stored
|
||||
// and later injected VERBATIM (byte-shuttle) — bounded in size and count, never
|
||||
// parsed as a shell command. `text` + optional trailing \r is capped by
|
||||
// queueItemMaxBytes; the queue depth is capped by queueMaxItems.
|
||||
app.post('/live-sessions/:id/queue', express.json({ limit: '16kb' }), (req, res) => {
|
||||
if (!requireAllowedOrigin(req, res)) return
|
||||
if (!cfg.queueEnabled) {
|
||||
res.status(503).json({ error: 'queue disabled' })
|
||||
return
|
||||
}
|
||||
if (!queueLimiter(req.socket.remoteAddress ?? '', Date.now())) {
|
||||
res.status(429).end()
|
||||
return
|
||||
}
|
||||
const id = req.params.id
|
||||
if (!SESSION_ID_RE.test(id)) {
|
||||
res.status(400).json({ error: 'invalid session id' })
|
||||
return
|
||||
}
|
||||
const body = (req.body ?? {}) as Record<string, unknown>
|
||||
const rawText = body['text']
|
||||
if (typeof rawText !== 'string' || rawText.length === 0) {
|
||||
res.status(400).json({ error: 'text must be a non-empty string' })
|
||||
return
|
||||
}
|
||||
// FE decides Enter (mirrors quick-reply's appendEnter): materialize the exact
|
||||
// bytes to inject here so the stored entry is byte-identical to a keystroke.
|
||||
const text = body['appendEnter'] === true ? rawText + '\r' : rawText
|
||||
if (Buffer.byteLength(text, 'utf8') > cfg.queueItemMaxBytes) {
|
||||
res.status(413).json({ error: 'text too large' })
|
||||
return
|
||||
}
|
||||
const result = manager.enqueueFollowup(id, text)
|
||||
if (!result.ok) {
|
||||
// full → 409 (never silently drop); unknown/exited → 404.
|
||||
res.status(result.reason === 'full' ? 409 : 404).json({ error: result.reason })
|
||||
return
|
||||
}
|
||||
res.json({ length: result.length })
|
||||
})
|
||||
|
||||
// Read-only queue view (depth + the user's own queued text). No Origin guard —
|
||||
// same threat model as GET /live-sessions.
|
||||
app.get('/live-sessions/:id/queue', (req, res) => {
|
||||
const session = manager.get(req.params.id)
|
||||
if (session === undefined) {
|
||||
res.status(404).end()
|
||||
return
|
||||
}
|
||||
res.json({ length: session.queue.length, items: [...session.queue] })
|
||||
})
|
||||
|
||||
// Cancel all pending entries (escape hatch). State-changing → Origin guard.
|
||||
app.delete('/live-sessions/:id/queue', (req, res) => {
|
||||
if (!requireAllowedOrigin(req, res)) return
|
||||
if (!manager.clearQueue(req.params.id)) {
|
||||
res.status(404).end()
|
||||
return
|
||||
}
|
||||
res.json({ length: 0 })
|
||||
})
|
||||
|
||||
// CSRF guard for the state-changing DELETE routes (Arch 5b / Sec H2): the WS
|
||||
// upgrade checks Origin, but plain HTTP routes don't — without this, a foreign
|
||||
// page could fire a no-preflight DELETE and Kill-All sessions. Reuse the same
|
||||
@@ -426,6 +532,9 @@ export function startServer(cfg: Config): { close(): Promise<void> } {
|
||||
if (ev.eventClass === 'Stop' || ev.eventClass === 'SessionEnd') {
|
||||
const session = manager.get(ev.sessionId)
|
||||
if (session !== undefined) void pushService.notify(session, 'done')
|
||||
// W2: Claude just went idle — schedule a debounced drain of the next queued
|
||||
// follow-up (fires after the settle delay iff still idle + output stable).
|
||||
scheduleDrain(ev.sessionId)
|
||||
}
|
||||
res.status(204).end()
|
||||
})
|
||||
@@ -962,6 +1071,9 @@ export function startServer(cfg: Config): { close(): Promise<void> } {
|
||||
// ── Graceful shutdown helper ──────────────────────────────────────────────
|
||||
function doShutdown(): void {
|
||||
clearInterval(reapTimer)
|
||||
// W2: cancel any pending idle-drain timers so they can't fire post-shutdown.
|
||||
for (const t of drainTimers.values()) clearTimeout(t)
|
||||
drainTimers.clear()
|
||||
manager.shutdown()
|
||||
}
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
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