/** * useWritePortal — the single write-portal of an agent cell (ARCHITECTURE §20). * * The agent cell hosts the CLI as a **native terminal**: every human keystroke * (Enter included) reaches the PTY through {@link TerminalView}. IdeA never owns * a chat box; it only *observes* a "human line in progress" counter and, when a * delegation is ready, injects its text at a **clean boundary** (human line * empty) through the SAME handle the human keystrokes use — so there is exactly * one physical PTY writer (the front), no race between two writers. * * Responsibilities (the four the cadrage assigns to the portal): * 1. The **human-line counter**: `+1` per printable keystroke, `reset` on Enter * (`\r`/`\n`) and Ctrl-C (`\x03`). Only keystrokes are counted; control * sequences (arrows/escape, CSI…) are ignored so they never falsely mark the * line "non-empty" and block injection forever. * 2. The **local FIFO** of `delegationReady` events received for this agent * (via `system.onDomainEvent`, filtered like {@link useAgentBusy}). * 3. The **handshake (b→e)**: only when a delegation is at the head of the FIFO * AND the human line is empty: * (b) suspend the keystroke relay + raise the overlay; * (c) re-check the counter K; if K>0 write exactly K backspaces `\x7f` * (never Ctrl-U) to erase what the human typed in the micro-window; * (d) `write(text)` WITHOUT `\n`, then after `submitDelayMs ?? 60` ms * `write(submitSequence ?? "\r")`; then ack via `delegationDelivered` * exactly once; * (e) drop the suspension + lower the overlay, with a **2 s floor** from * (b) (anti-flash): if (e) would happen < 2000 ms after (b), the * overlay/suspension are held until 2000 ms. * A delegation that arrives while the line is non-empty simply **waits** in * the FIFO (no write, no refusal, no loss) and is released at the next empty * line. * 4. The boolean **overlay** state, rendered by the hosting cell. * * Hexagonal: the hook talks to ports via DI (`system`, `input`), never to * `invoke()`. It returns a {@link WritePortal} object whose identity is stable * (memoised) so {@link TerminalView} can hold it in a ref. */ import { useEffect, useMemo, useRef, useState } from "react"; import type { DomainEvent } from "@/domain"; import type { TerminalHandle, WritePortal } from "@/ports"; import { useGateways } from "@/app/di"; /** Default submit sequence when the profile omits one (paste-detection esquive). */ const DEFAULT_SUBMIT_SEQUENCE = "\r"; /** Default delay (ms) between text and submit-sequence writes. */ const DEFAULT_SUBMIT_DELAY_MS = 60; /** Anti-flash overlay floor (ms) from step (b). */ const OVERLAY_FLOOR_MS = 2000; /** A pending delegation kept in the local FIFO. */ interface PendingDelegation { ticket: string; text: string; submitSequence?: string; submitDelayMs?: number; } /** What the hook returns: the portal object (for TerminalView) + overlay state. */ export interface UseWritePortalResult { /** The portal object handed to {@link TerminalView} via its `portal` prop. */ portal: WritePortal; /** Whether the "an agent is speaking…" overlay must be shown. */ overlay: boolean; } const encoder = new TextEncoder(); /** * True for a single printable keystroke chunk. We count printable input only; * control bytes (ESC/CSI: arrows, function keys, Ctrl-*) must not bump the * counter, otherwise an arrow keypress would mark the line "non-empty" forever. * * A keystroke chunk from xterm's `onData` is normally a single grapheme, but a * paste can deliver many. We treat the chunk as printable iff it contains **no** * control character (code point < 0x20 or 0x7f). Enter (`\r`/`\n`) and Ctrl-C * (`\x03`) are handled separately as resets before this check. */ function isPrintable(data: string): boolean { if (data.length === 0) return false; for (const ch of data) { const code = ch.codePointAt(0)!; if (code < 0x20 || code === 0x7f) return false; } return true; } export function useWritePortal( projectId: string, agentId: string | null, ): UseWritePortalResult { const { system, input } = useGateways(); const [overlay, setOverlay] = useState(false); // ── Mutable state held in refs (the handshake runs outside React render) ─── const counterRef = useRef(0); // human line counter K const queueRef = useRef([]); // local FIFO const handleRef = useRef(null); const suspendedRef = useRef(false); // relay suspended while injecting const injectingRef = useRef(false); // a handshake is in flight // Stable refs to the gateways used inside the (non-reactive) handshake. const inputRef = useRef(input); inputRef.current = input; const agentIdRef = useRef(agentId); agentIdRef.current = agentId; // The input port needs the project id for the delivery ack. const projectIdRef = useRef(projectId); projectIdRef.current = projectId; // ── (3)+(4): the handshake, attempted whenever a boundary may have opened ── const tryInject = useRef<() => void>(() => {}); tryInject.current = () => { if (injectingRef.current) return; // one handshake at a time const head = queueRef.current[0]; if (!head) return; if (counterRef.current > 0) return; // line not empty ⇒ wait const handle = handleRef.current; if (!handle) return; // no live PTY yet ⇒ wait const agent = agentIdRef.current; const project = projectIdRef.current; if (!agent) return; injectingRef.current = true; const startedAt = Date.now(); // (b) suspend the relay + raise the overlay. suspendedRef.current = true; setOverlay(true); void (async () => { try { // Yield once so any keystroke that landed in the micro-window between // "empty observed" (a) and the suspension taking hold (b) is counted // before we re-check K at step (c). Without this the race window would // be zero and a late keystroke would survive the injection un-erased. await Promise.resolve(); // (c) re-check K; erase exactly K human keystrokes with `\x7f` // (backspace) — never Ctrl-U (which could clear more than the human typed). const k = counterRef.current; if (k > 0) { await handle.write(encoder.encode("\x7f".repeat(k))); counterRef.current = 0; } // (d) write the text WITHOUT a trailing newline, then the submit // sequence after the profile's delay (esquive de la paste-detection). await handle.write(encoder.encode(head.text)); const delay = head.submitDelayMs ?? DEFAULT_SUBMIT_DELAY_MS; await sleep(delay); const submit = head.submitSequence ?? DEFAULT_SUBMIT_SEQUENCE; await handle.write(encoder.encode(submit)); // Dequeue + ack exactly once (best-effort; never throws the handshake). queueRef.current.shift(); try { await inputRef.current.delegationDelivered(project, agent, head.ticket); } catch { /* ack is observability-only; never block the portal */ } } finally { // (e) lower the overlay + resume the relay, with the 2 s anti-flash floor. const elapsed = Date.now() - startedAt; const remaining = OVERLAY_FLOOR_MS - elapsed; if (remaining > 0) await sleep(remaining); suspendedRef.current = false; setOverlay(false); injectingRef.current = false; // A boundary may now be open for the next queued delegation. if (queueRef.current.length > 0) tryInject.current(); } })(); }; // ── (2): subscribe to delegationReady for THIS agent and enqueue ─────────── useEffect(() => { if (!system || !agentId) return; let unsubscribe: (() => void) | undefined; let cancelled = false; void system .onDomainEvent((event: DomainEvent) => { if (event.type !== "delegationReady") return; if (event.agentId !== agentId) return; queueRef.current.push({ ticket: event.ticket, text: event.text, submitSequence: event.submitSequence, submitDelayMs: event.submitDelayMs, }); // A delegation may already be at a clean boundary (line empty) — try now. tryInject.current(); }) .then((un) => { if (cancelled) un(); else unsubscribe = un; }); return () => { cancelled = true; unsubscribe?.(); }; }, [system, agentId]); // ── (1)+(3): the portal object handed to TerminalView (stable identity) ──── const portal = useMemo( () => ({ onHumanData(data: string) { // Reset on Enter / Ctrl-C, increment on a printable keystroke, ignore // control sequences. After a reset the line is empty ⇒ a queued // delegation may now be injectable. if (data === "\r" || data === "\n" || data === "\x03") { counterRef.current = 0; tryInject.current(); return; } if (isPrintable(data)) { counterRef.current += 1; } }, isSuspended() { return suspendedRef.current; }, bindHandle(handle: TerminalHandle) { handleRef.current = handle; // Tell the backend a frontend cell is now mounted for this agent, so the // mediator routes its turns through `delegationReady` (this portal writes) // rather than writing the PTY itself (the headless path for cell-less agents). const agent = agentIdRef.current; if (agent) void inputRef.current.setFrontAttached(agent, true).catch(() => {}); // The PTY just became available — a queued delegation may be injectable. tryInject.current(); }, unbindHandle() { handleRef.current = null; // The cell is gone — let the backend fall back to headless delivery so a // delegation arriving while this agent has no live cell is not lost. const agent = agentIdRef.current; if (agent) void inputRef.current.setFrontAttached(agent, false).catch(() => {}); }, }), [], ); return { portal, overlay }; } /** Promise-based delay used by the handshake (driven by fake timers in tests). */ function sleep(ms: number): Promise { return new Promise((resolve) => setTimeout(resolve, ms)); }