// .pi/agent-fleet/scripts/lib/claude-bridge-core.ts // // Pure logic for the Claude Code coms bridge (.pi/agent-fleet/scripts/coms-claude-bridge.ts): // prompt formatting, completion sentinels, hook-record parsing, reply // extraction from pane text, and the serial prompt queue. No sockets, no fs, // no herdr — everything here runs under node --test. export const SENTINEL_PREFIX = "< Promise, options: { timeoutMs?: number; pollMs?: number; now?: () => number; sleep?: (ms: number) => Promise; } = {}, ): Promise<{ ready: boolean; waitedMs: number; last: ClaudePaneState | null }> { const timeoutMs = Math.max(0, options.timeoutMs ?? CLAUDE_STARTUP_TIMEOUT_MS); const pollMs = Math.max(1, options.pollMs ?? CLAUDE_STARTUP_POLL_MS); const now = options.now ?? Date.now; const sleep = options.sleep ?? ((ms: number) => new Promise((resolve) => setTimeout(resolve, ms))); const startedAt = now(); let last: ClaudePaneState | null = null; for (;;) { try { last = await probe(); if (isClaudePaneReady(last)) return { ready: true, waitedMs: now() - startedAt, last }; } catch { // Herdr may briefly race pane creation; retry inside the bounded startup window. } const remaining = timeoutMs - (now() - startedAt); if (remaining <= 0) return { ready: false, waitedMs: now() - startedAt, last }; await sleep(Math.min(pollMs, remaining)); } } /** * The caller's own deadline wins — it knows how long the work takes — falling * back to the bridge's configured timeout and always clamped to the hard cap. */ export function resolveReplyTimeoutMs(requested: unknown, fallbackMs: number, capMs = REPLY_TIMEOUT_HARD_CAP_MS): number { const asked = Number(requested); const base = Number.isFinite(asked) && asked > 0 ? asked : fallbackMs; return Math.max(1, Math.min(base, capMs)); } /** One absolute deadline shared by idle waiting and reply collection. */ export function replyDeadlineAt(startedAtMs: number, timeoutMs: number): number { return Number(startedAtMs) + Math.max(0, Number(timeoutMs) || 0); } /** * The pane accepted the prompt but did not finish inside the caller's budget. * This is deliberately not a response error: the work may still complete. */ export class ReplyPendingError extends Error { readonly pending = true; constructor(message: string) { super(message); this.name = "ReplyPendingError"; } } export function isReplyPendingError(error: unknown): error is ReplyPendingError { return error instanceof ReplyPendingError || Boolean((error as { pending?: unknown } | null)?.pending); } /** Backoff delay for the nth idle-wait attempt (0-based); the last step repeats. */ export function idleWaitDelayMs(attempt: number): number { const i = Math.max(0, Math.min(Math.floor(attempt), IDLE_WAIT_BACKOFF_MS.length - 1)); return IDLE_WAIT_BACKOFF_MS[i]; } /** * How long to wait for a busy pane. Never more than half the caller's reply * budget — waiting to start must not consume the time meant for the answer. */ export function idleWaitBudgetMs(replyTimeoutMs: number, capMs = IDLE_WAIT_CAP_MS): number { const half = Math.floor((Number(replyTimeoutMs) || 0) / 2); return Math.max(0, Math.min(capMs, half)); } export function completionSentinel(msgId: string): string { return `${SENTINEL_PREFIX}${msgId}>>`; } // The prompt as typed into the Claude Code pane. Mirrors the pi side's // "[from name @ cwd]" framing so Claude knows who is asking; in sentinel mode // (no Stop hook seen yet) it also asks for the completion marker. export function formatPanePrompt( env: { prompt: string; sender_name: string; sender_cwd: string; msg_id: string }, sentinelMode: boolean, requestBoundary?: string, ): string { const header = `[coms message from ${env.sender_name} @ ${env.sender_cwd}] `; const sentinelNote = sentinelMode ? `\n\nEnd your reply with this exact line so the bridge can capture it: ${completionSentinel(env.msg_id)}${requestBoundary ? ` ${requestBoundary}` : ""}` : ""; return `${header}${env.prompt}${sentinelNote}`; } // Stop-hook record: the hook writes {text, session_id?, written_at} as JSON. export interface HookRecord { text: string; written_at?: string; [key: string]: unknown; } export function parseHookRecord(raw: string): HookRecord | null { try { const rec = JSON.parse(raw); if (rec && typeof rec.text === "string") return rec as HookRecord; return null; } catch { return null; } } // Completion requires the unique boundary submitted for this request, not merely // a matching marker in scrollback. Missing/truncated provenance stays pending. export function extractSentinelReply(paneText: string, msgId: string, requestBoundary?: string): string | null { if (!requestBoundary) return null; const clean = paneText.replace(/\x1b\[[0-9;]*m/g, ""); const boundaryAt = clean.indexOf(requestBoundary); if (boundaryAt < 0 || clean.indexOf(requestBoundary, boundaryAt + requestBoundary.length) >= 0) return null; const region = clean.slice(boundaryAt + requestBoundary.length); // Strip known Claude TUI prefixes, but preserve Markdown quote text (`>`). const lines = region.split("\n").map(l => l.replace(/^[●❯\s]+/, "").trimEnd()); const sentinel = completionSentinel(msgId); const end = lines.findIndex(l => l === sentinel); if (end < 0 || lines.slice(end + 1).some(l => l.trim() && !/^[─━┏┗┃│]+/.test(l))) return null; const reply = lines.slice(0, end).filter(l => l.trim() && !/^[─━┏┗┃│]+/.test(l)); if (reply.some(l => /< { envelope: T; enqueued_at: string; } export class PromptQueue { private items: Array> = []; private active = false; get depth(): number { return this.items.length + (this.active ? 1 : 0); } push(envelope: T, now: () => string = () => new Date().toISOString()): void { this.items.push({ envelope, enqueued_at: now() }); } // Take the next prompt if none is being processed. Callers MUST call // done() when processing finishes (success or failure). take(): QueuedPrompt | null { if (this.active) return null; const next = this.items.shift(); if (!next) return null; this.active = true; return next; } done(): void { this.active = false; } }