Files
IdeaSDK/frontend/src/features/terminals/useWritePortal.ts
Blomios 27597eb64e feat(permissions): voie projection CLI (LP0→LP3) + checkpoint Codex/input
Jalon vert regroupant deux chantiers entrelacés dans le working tree,
indissociables au niveau fichier mais tous deux verts (cargo test
--workspace + tests frontend permissions au vert).

Permissions — voie « projection CLI » (advisory), complète :
- LP0 domaine pur : modèle PermissionSet/EffectivePermissions, resolve
  deny-wins + postures Allow<Ask<Deny (crates/domain/src/permission.rs).
- LP1 store : FsPermissionStore (.ideai/permissions.json).
- LP2 use cases : Get/Update project, Update agent override, Resolve.
- LP3 projecteurs Claude/Codex (settings.local.json / config.toml),
  câblage launch-path + PermissionProjectorRegistry, nettoyage des
  fichiers Replace orphelins au swap de profil (LP3-4), composition root
  + commandes Tauri, UI PermissionsPanel (projet + override agent).
- ports.rs : PermissionStore + FileSystem::remove_file (cleanup au swap).

Reste ouvert (hors scope, marqué dans le code) : LP4 enforcement OS
airtight (Landlock fichiers) + résumé de permissions injecté.

Inclut aussi le chantier Codex/input/sessions structurées en cours
(McpConfigStrategy, StructuredAdapter, gestion d'input) partageant les
mêmes fichiers (lifecycle.rs, commands.rs, dto.rs, state.rs).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-15 20:39:18 +02:00

254 lines
10 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;
// 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<void> {
return new Promise((resolve) => setTimeout(resolve, ms));
}