Sauvegarde de l'arbre de travail en cours (persistance P8, conversations C-series, write-portal frontend, médiation d'entrée) avant d'attaquer le support de la délégation inter-agents pour les profils Codex. Le round-trip inter-agent question/réponse est couvert sans tokens par les tests loopback existants (state::mcp_e2e_loopback_tests). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
245 lines
9.6 KiB
TypeScript
245 lines
9.6 KiB
TypeScript
/**
|
|
* 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<PendingDelegation[]>([]); // local FIFO
|
|
const handleRef = useRef<TerminalHandle | null>(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<WritePortal>(
|
|
() => ({
|
|
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;
|
|
// The PTY just became available — a queued delegation may be injectable.
|
|
tryInject.current();
|
|
},
|
|
unbindHandle() {
|
|
handleRef.current = null;
|
|
},
|
|
}),
|
|
[],
|
|
);
|
|
|
|
return { portal, overlay };
|
|
}
|
|
|
|
/** Promise-based delay used by the handshake (driven by fake timers in tests). */
|
|
function sleep(ms: number): Promise<void> {
|
|
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
}
|