merge: main (CLI agent interface #1446) — renumber workflow_settings migration to 112 behind main's cli_sessions(110)/adapter(111), full-workspace literal sweep, i18n union

This commit is contained in:
gsxdsm
2026-06-05 16:18:39 -07:00
170 changed files with 24959 additions and 601 deletions

View File

@@ -69,7 +69,8 @@
"multer": "^2.1.1",
"node-pty": "npm:@homebridge/node-pty-prebuilt-multiarch@^0.13.1",
"react": "^19.2.0",
"react-i18next": "^17.0.8"
"react-i18next": "^17.0.8",
"ws": "^8.18.0"
},
"peerDependencies": {
"@earendil-works/pi-ai": "*",
@@ -96,6 +97,7 @@
"@fusion/pi-llama-cpp": "workspace:*",
"@types/node": "^22.0.0",
"@types/react": "^19.2.0",
"@types/ws": "^8.5.0",
"@vitest/coverage-v8": "^3.1.0",
"cross-env": "^7.0.0",
"esbuild": "^0.25.12",

View File

@@ -495,6 +495,10 @@ vi.mock("@fusion/engine", async (importOriginal) => {
return { stop: vi.fn() };
}
getCliAgentRuntime(): undefined {
return undefined;
}
async onMerge(taskId: string): Promise<unknown> {
return aiMergeTask(this.store, this.cwd, taskId, {
pool: this.pool,

View File

@@ -0,0 +1,407 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import {
attachTerminalSession,
buildWsUrl,
fetchAttachTicket,
DETACH_CHORD_BYTE,
ALT_SCREEN_ENTER,
ALT_SCREEN_LEAVE,
WS_OPEN,
type TerminalWebSocket,
type AttachStdin,
type AttachStdout,
} from "../terminal-attach.js";
// ── Fakes ────────────────────────────────────────────────────────────────────
/** In-memory WS-like transport. Never opens a real socket / never port 4040. */
class FakeWs implements TerminalWebSocket {
readyState = WS_OPEN;
sent: string[] = [];
private handlers = new Map<string, ((...args: unknown[]) => void)[]>();
closed = false;
on(event: string, listener: (...args: unknown[]) => void): void {
const list = this.handlers.get(event) ?? [];
list.push(listener);
this.handlers.set(event, list);
}
send(data: string): void {
this.sent.push(data);
}
close(): void {
if (this.closed) return;
this.closed = true;
this.readyState = 3; // CLOSED
this.emit("close");
}
emit(event: string, ...args: unknown[]): void {
for (const l of this.handlers.get(event) ?? []) l(...args);
}
/** Simulate the server delivering a (JSON) message frame. */
deliver(frame: unknown): void {
this.emit("message", Buffer.from(JSON.stringify(frame), "utf8"));
}
/** Parsed client→server frames. */
parsedSent(): Array<Record<string, unknown>> {
return this.sent.map((s) => JSON.parse(s));
}
}
class FakeStdin implements AttachStdin {
isTTY = true;
isRaw = false;
private listeners: ((chunk: Buffer | string) => void)[] = [];
rawCalls: boolean[] = [];
resumed = false;
on(_event: "data", listener: (chunk: Buffer | string) => void): void {
this.listeners.push(listener);
}
off(_event: "data", listener: (chunk: Buffer | string) => void): void {
this.listeners = this.listeners.filter((l) => l !== listener);
}
setRawMode(mode: boolean): void {
this.rawCalls.push(mode);
this.isRaw = mode;
}
resume(): void {
this.resumed = true;
}
/** Simulate a user keystroke chunk. */
feed(chunk: Buffer | string): void {
for (const l of [...this.listeners]) l(chunk);
}
listenerCount(): number {
return this.listeners.length;
}
}
class FakeStdout implements AttachStdout {
columns = 80;
rows = 24;
writes: string[] = [];
private resizeListeners: (() => void)[] = [];
write(chunk: string): void {
this.writes.push(chunk);
}
on(_event: "resize", listener: () => void): void {
this.resizeListeners.push(listener);
}
off(_event: "resize", listener: () => void): void {
this.resizeListeners = this.resizeListeners.filter((l) => l !== listener);
}
fireResize(cols: number, rows: number): void {
this.columns = cols;
this.rows = rows;
for (const l of [...this.resizeListeners]) l();
}
resizeListenerCount(): number {
return this.resizeListeners.length;
}
all(): string {
return this.writes.join("");
}
}
/** A fetchImpl that always returns a ticket. */
function okTicketFetch(ticket = "TICKET-1"): typeof fetch {
return vi.fn(async () =>
new Response(JSON.stringify({ ticket, expiresAt: new Date().toISOString(), readOnly: false }), {
status: 200,
headers: { "content-type": "application/json" },
}),
) as unknown as typeof fetch;
}
function b64(s: string): string {
return Buffer.from(s, "utf8").toString("base64");
}
interface Harness {
ws: FakeWs;
stdin: FakeStdin;
stdout: FakeStdout;
onDetach: ReturnType<typeof vi.fn>;
}
/**
* Start an attach, drive the WS `open`, and return the harness + handle.
* `await tick()` lets the async ticket fetch resolve.
*/
async function startAttach(
overrides: Partial<Parameters<typeof attachTerminalSession>[0]> = {},
): Promise<Harness & { handle: ReturnType<typeof attachTerminalSession> }> {
const ws = new FakeWs();
const stdin = new FakeStdin();
const stdout = new FakeStdout();
const onDetach = vi.fn();
const handle = attachTerminalSession({
baseUrl: "http://127.0.0.1:9999",
token: "daemon-tok",
sessionId: "sess-1",
stdin,
stdout,
onDetach,
fetchImpl: okTicketFetch(),
wsFactory: () => ws,
ackThresholdBytes: 64,
...overrides,
});
// Let the ticket fetch resolve, then open the socket.
await tick();
ws.emit("open");
return { ws, stdin, stdout, onDetach, handle };
}
const tick = () => new Promise((r) => setTimeout(r, 0));
afterEach(() => {
vi.restoreAllMocks();
});
// ── URL / ticket helpers ──────────────────────────────────────────────────────
describe("buildWsUrl", () => {
it("derives ws:// from http:// and sets sessionId + ticket", () => {
const url = buildWsUrl({ baseUrl: "http://127.0.0.1:4040", sessionId: "s1", ticket: "t1" });
expect(url).toBe("ws://127.0.0.1:4040/api/cli-sessions/ws?sessionId=s1&ticket=t1");
});
it("derives wss:// from https://", () => {
const url = buildWsUrl({ baseUrl: "https://host", sessionId: "s", ticket: "t" });
expect(url.startsWith("wss://host/api/cli-sessions/ws")).toBe(true);
});
});
describe("fetchAttachTicket", () => {
it("POSTs to the attach-ticket route with bearer auth and returns the ticket", async () => {
const fetchImpl = vi.fn(async () =>
new Response(JSON.stringify({ ticket: "TK", readOnly: false }), { status: 200 }),
) as unknown as typeof fetch;
const res = await fetchAttachTicket({
baseUrl: "http://h",
token: "tok",
sessionId: "s 1",
fetchImpl,
});
expect(res.ticket).toBe("TK");
const [url, init] = (fetchImpl as unknown as { mock: { calls: unknown[][] } }).mock.calls[0];
expect(url).toBe("http://h/api/cli-sessions/s%201/attach-ticket");
expect((init as RequestInit).method).toBe("POST");
expect((init as RequestInit).headers).toMatchObject({ authorization: "Bearer tok" });
});
it("throws on non-2xx", async () => {
const fetchImpl = vi.fn(async () => new Response("nope", { status: 404, statusText: "Not Found" })) as unknown as typeof fetch;
await expect(
fetchAttachTicket({ baseUrl: "http://h", sessionId: "s", fetchImpl }),
).rejects.toThrow(/HTTP 404/);
});
});
// ── Passthrough loop ──────────────────────────────────────────────────────────
describe("attachTerminalSession passthrough", () => {
it("enters alt-screen + raw mode on open and sends an initial resize", async () => {
const { stdin, stdout, ws } = await startAttach();
expect(stdout.all()).toContain(ALT_SCREEN_ENTER);
expect(stdin.rawCalls).toContain(true);
expect(stdin.resumed).toBe(true);
const resize = ws.parsedSent().find((f) => f.type === "resize");
expect(resize).toMatchObject({ type: "resize", cols: 80, rows: 24 });
});
it("frames stdin bytes into input messages (base64)", async () => {
const { stdin, ws } = await startAttach();
stdin.feed(Buffer.from("ls -la\r", "utf8"));
const input = ws.parsedSent().find((f) => f.type === "input");
expect(input).toBeDefined();
expect(Buffer.from(input!.data as string, "base64").toString("utf8")).toBe("ls -la\r");
});
it("writes data frames to stdout verbatim", async () => {
const { stdout, ws } = await startAttach();
const payload = "hello \x1b[31mworld\x1b[0m\n";
ws.deliver({ type: "data", data: b64(payload) });
expect(stdout.all()).toContain(payload);
});
it("passes CJK / double-width bytes through verbatim", async () => {
const { stdout, ws } = await startAttach();
const payload = "日本語 ❤ 한국어";
ws.deliver({ type: "data", data: b64(payload) });
expect(stdout.all()).toContain(payload);
});
it("writes scrollback frames to stdout", async () => {
const { stdout, ws } = await startAttach();
ws.deliver({ type: "scrollback", data: b64("prior output\n") });
expect(stdout.all()).toContain("prior output\n");
});
it("propagates host resize as a resize frame", async () => {
const { stdout, ws } = await startAttach();
stdout.fireResize(120, 40);
const resizes = ws.parsedSent().filter((f) => f.type === "resize");
expect(resizes.at(-1)).toMatchObject({ cols: 120, rows: 40 });
});
});
// ── Detach chord ──────────────────────────────────────────────────────────────
describe("detach chord (Ctrl-])", () => {
it("restores state: leaves alt-screen, restores raw mode, closes WS, calls onDetach", async () => {
const { stdin, stdout, ws, onDetach, handle } = await startAttach();
stdin.feed(Buffer.from([DETACH_CHORD_BYTE]));
await handle.done;
expect(stdout.all()).toContain(ALT_SCREEN_LEAVE);
expect(stdin.rawCalls.at(-1)).toBe(false); // restored to prior (false)
expect(ws.closed).toBe(true);
expect(onDetach).toHaveBeenCalledTimes(1);
expect(onDetach).toHaveBeenCalledWith(undefined);
// Listeners removed (refcount back to baseline).
expect(stdin.listenerCount()).toBe(0);
expect(stdout.resizeListenerCount()).toBe(0);
});
it("flushes bytes before the chord, then detaches", async () => {
const { stdin, ws, handle } = await startAttach();
stdin.feed(Buffer.from([0x61, 0x62, DETACH_CHORD_BYTE, 0x63])); // "ab" Ctrl-] "c"
await handle.done;
const inputs = ws.parsedSent().filter((f) => f.type === "input");
expect(inputs).toHaveLength(1);
expect(Buffer.from(inputs[0].data as string, "base64").toString("utf8")).toBe("ab");
});
it("is idempotent — detach() after a chord does not re-fire onDetach", async () => {
const { stdin, onDetach, handle } = await startAttach();
stdin.feed(Buffer.from([DETACH_CHORD_BYTE]));
await handle.done;
handle.detach();
expect(onDetach).toHaveBeenCalledTimes(1);
});
});
// ── Error / drop paths ─────────────────────────────────────────────────────────
describe("WS close mid-attach surfaces error", () => {
it("close before exit → onDetach(error) and terminal restored", async () => {
const { ws, stdout, stdin, onDetach, handle } = await startAttach();
ws.close();
await handle.done;
expect(onDetach).toHaveBeenCalledTimes(1);
expect(onDetach.mock.calls[0][0]).toBeInstanceOf(Error);
expect(stdout.all()).toContain(ALT_SCREEN_LEAVE);
expect(stdin.rawCalls.at(-1)).toBe(false);
});
it("WS error → onDetach(error) and clean restore", async () => {
const { ws, stdout, onDetach, handle } = await startAttach();
ws.emit("error", new Error("boom"));
await handle.done;
expect(onDetach).toHaveBeenCalledTimes(1);
expect((onDetach.mock.calls[0][0] as Error).message).toBe("boom");
expect(stdout.all()).toContain(ALT_SCREEN_LEAVE);
});
it("server `exit` frame ends the attach cleanly (no error)", async () => {
const { ws, onDetach, handle } = await startAttach();
ws.deliver({ type: "exit" });
await handle.done;
expect(onDetach).toHaveBeenCalledWith(undefined);
});
it("failed ticket mint surfaces the error without opening the WS", async () => {
const onDetach = vi.fn();
const wsFactory = vi.fn();
const handle = attachTerminalSession({
baseUrl: "http://127.0.0.1:9999",
sessionId: "s",
stdin: new FakeStdin(),
stdout: new FakeStdout(),
onDetach,
fetchImpl: vi.fn(async () => new Response("x", { status: 500, statusText: "Err" })) as unknown as typeof fetch,
wsFactory: wsFactory as never,
});
await handle.done;
expect(wsFactory).not.toHaveBeenCalled();
expect(onDetach).toHaveBeenCalledTimes(1);
expect(onDetach.mock.calls[0][0]).toBeInstanceOf(Error);
});
});
// ── Output neutralization (full U10 parity set) ─────────────────────────────────
describe("output neutralization before stdout", () => {
it("strips OSC 52 clipboard-write", async () => {
const { stdout, ws } = await startAttach();
const hostile = `before\x1b]52;c;${Buffer.from("stolen").toString("base64")}\x07after`;
ws.deliver({ type: "data", data: b64(hostile) });
const out = stdout.all();
expect(out).toContain("before");
expect(out).toContain("after");
expect(out).not.toContain("52;c;");
});
it("strips a non-http(s) (javascript:) OSC 8 link URI but keeps the text", async () => {
const { stdout, ws } = await startAttach();
const hostile = `\x1b]8;;javascript:alert(1)\x07click me\x1b]8;;\x07`;
ws.deliver({ type: "data", data: b64(hostile) });
const out = stdout.all();
expect(out).toContain("click me");
expect(out).not.toContain("javascript:alert(1)");
});
it("passes an http(s) OSC 8 link through", async () => {
const { stdout, ws } = await startAttach();
const safe = `\x1b]8;;https://example.com\x07link\x1b]8;;\x07`;
ws.deliver({ type: "data", data: b64(safe) });
expect(stdout.all()).toContain("https://example.com");
});
it("strips a DSR device-status query (would forge input)", async () => {
const { stdout, ws } = await startAttach();
const hostile = "x\x1b[6ny"; // DSR cursor-position report
ws.deliver({ type: "data", data: b64(hostile) });
const out = stdout.all();
expect(out).toContain("x");
expect(out).toContain("y");
expect(out).not.toContain("\x1b[6n");
});
it("neutralizes a sequence split across two frames", async () => {
const { stdout, ws } = await startAttach();
// Split an OSC 52 across two data frames.
const part1 = `safe\x1b]52;c;${Buffer.from("secret").toString("base64")}`;
const part2 = `\x07tail`;
ws.deliver({ type: "data", data: b64(part1) });
ws.deliver({ type: "data", data: b64(part2) });
const out = stdout.all();
expect(out).toContain("safe");
expect(out).toContain("tail");
expect(out).not.toContain("52;c;");
});
});
// ── ACK flow control ───────────────────────────────────────────────────────────
describe("ACK flow control", () => {
it("emits an ACK after the threshold bytes are written", async () => {
const { stdout, ws } = await startAttach({ ackThresholdBytes: 64 });
void stdout;
// 100 bytes of benign output → crosses the 64-byte threshold once.
ws.deliver({ type: "data", data: b64("a".repeat(100)) });
const acks = ws.parsedSent().filter((f) => f.type === "ack");
expect(acks).toHaveLength(1);
expect(acks[0].bytes).toBeGreaterThanOrEqual(64);
});
it("does not ACK below the threshold", async () => {
const { ws } = await startAttach({ ackThresholdBytes: 1024 });
ws.deliver({ type: "data", data: b64("short") });
expect(ws.parsedSent().filter((f) => f.type === "ack")).toHaveLength(0);
});
});

View File

@@ -134,6 +134,14 @@ export class DashboardTUI {
waitUntilExit: () => Promise<unknown>;
clear?: () => void;
} & Record<string, unknown> | null = null;
// Captured at start() so a full-screen terminal attach (U14) can unmount Ink,
// hand the TTY to the passthrough loop, then remount the same app on detach.
private renderApp: (() => unknown) | null = null;
private inkRender: ((node: unknown) => typeof this.inkInstance) | null = null;
// True while a full-screen terminal attach owns the TTY; suppresses Ink
// re-render/resize work that would corrupt the passthrough surface.
private terminalAttachActive = false;
// Resize listener attached at start(), detached at stop().
private resizeListener: (() => void) | null = null;
// Debounce timer for resize handling — coalesces tmux/ssh resize bursts.
@@ -705,6 +713,12 @@ export class DashboardTUI {
process.stdout.write("\x1b[?1049h\x1b[H");
}
// Capture the app element factory + render fn so openTerminalAttach() can
// remount the identical tree after a full-screen passthrough detaches.
this.renderApp = () =>
createElement(I18nextProvider, { i18n }, createElement(DashboardApp, { controller: this }));
this.inkRender = (node: unknown) => render(node as Parameters<typeof render>[0]);
this.inkInstance = render(
createElement(I18nextProvider, { i18n }, createElement(DashboardApp, { controller: this })),
);
@@ -899,6 +913,82 @@ export class DashboardTUI {
}
}
/**
* Open a CLI-agent session as a full-screen passthrough (U14). Suspend-and-
* handoff: unmount Ink (releasing its raw-mode / stdin grip), let
* `attachTerminalSession` own the alt-screen + raw mode for the passthrough
* loop, then remount the same Ink app once the user detaches (Ctrl-]) or the
* session ends. Resolves after the TUI has been remounted.
*
* No-op (resolves immediately) when there's no session info / not running.
*/
async openTerminalAttach(sessionId: string, projectId?: string): Promise<void> {
if (!this.isRunning || this.terminalAttachActive) return;
if (!this.renderApp || !this.inkRender) return;
const baseUrl = this.systemInfo?.baseUrl;
if (!baseUrl) return;
const token = this.systemInfo?.authToken;
const { attachTerminalSession } = await import("./terminal-attach.js");
this.terminalAttachActive = true;
// Unmount Ink so it relinquishes raw mode + the stdin 'data' grip; the
// passthrough loop installs its own listeners on the bare stdin/stdout.
// Also drop our mouse listener so wheel reports don't leak into the PTY.
this.uninstallMouseListener();
if (this.inkInstance) {
try {
this.inkInstance.unmount();
} catch {
/* ignore */
}
this.inkInstance = null;
}
// Leave Ink's alt-screen; the passthrough enters its own.
if (process.stdout?.isTTY && typeof process.stdout.write === "function") {
try {
process.stdout.write("\x1b[?1049l");
} catch {
/* ignore */
}
}
await new Promise<void>((resolve) => {
const handle = attachTerminalSession({
baseUrl,
token,
sessionId,
projectId,
stdin: process.stdin as unknown as import("./terminal-attach.js").AttachStdin,
stdout: process.stdout as unknown as import("./terminal-attach.js").AttachStdout,
onDetach: (error) => {
if (error) {
this.error(`Terminal session detached: ${error.message}`, "cli-agent");
}
},
});
void handle.done.finally(() => resolve());
});
// Remount Ink on a clean alt-screen.
this.terminalAttachActive = false;
if (!this.isRunning) return; // stopped while attached — leave the terminal as-is
if (process.stdout?.isTTY && typeof process.stdout.write === "function") {
try {
process.stdout.write("\x1b[?1049h\x1b[H");
} catch {
/* ignore */
}
}
try {
this.inkInstance = this.inkRender(this.renderApp());
} catch {
/* ignore — remount best-effort */
}
this.notify();
}
// ── Private helpers ────────────────────────────────────────────────────────
// Attach a parallel `data` listener that decodes xterm SGR mouse

View File

@@ -0,0 +1,497 @@
/**
* terminal-attach — full-screen passthrough attach to a CLI agent session
* from the Ink TUI (CLI Agent Executor, U14).
*
* Model: SUSPEND-AND-HANDOFF, not embedding. The caller pauses Ink rendering,
* then `attachTerminalSession` takes over the real TTY:
* - enter the alternate screen (`\x1b[?1049h`) and put stdin in raw mode,
* - WS `scrollback`/`data` frames → neutralize (U10 filter) → write to stdout,
* - stdin bytes → WS `input` frames (base64),
* - SIGWINCH / stdout resize → WS `resize` frames,
* - ACK `bytes` consumed after every ~32KB written, for flow control,
* - detach chord Ctrl-] (0x1d) → leave alt-screen, restore raw mode, close WS,
* - WS close/error mid-attach → restore the terminal cleanly + surface via
* `onDetach(error)`.
*
* SECURITY (the riskiest leg): the byte stream is UNTRUSTED. The host terminal
* honors more escape sequences than xterm.js, so every byte written to the host
* TTY is passed through `neutralizeTerminalOutput` FIRST — the identical filter
* the dashboard WS bridge uses (re-exported from `@fusion/dashboard`). OSC 52
* clipboard writes, OSC 8 non-http(s) links, and device-status queries (whose
* auto-responses forge input) are stripped before they ever reach the terminal.
*
* CJK / double-width: bytes pass through verbatim — no width math is needed in a
* passthrough (the host terminal does the width handling).
*
* The WS transport is injectable (`wsFactory`) so tests drive the loop with an
* in-memory WS-like object and NEVER open a real socket (and never touch port
* 4040). The default factory uses the `ws` Node client.
*/
import { WebSocket } from "ws";
import { neutralizeTerminalOutput, flushTerminalOutput } from "@fusion/dashboard";
/** Detach chord: Ctrl-] (GS, 0x1d). Documented + shown in the status hint. */
export const DETACH_CHORD_BYTE = 0x1d;
/** Human-readable label for the detach chord (status hint). */
export const DETACH_CHORD_LABEL = "Ctrl-]";
/** Enter / leave the alternate screen buffer. */
export const ALT_SCREEN_ENTER = "\x1b[?1049h";
export const ALT_SCREEN_LEAVE = "\x1b[?1049l";
/** Emit an ACK after roughly this many bytes are written to stdout. */
export const DEFAULT_ACK_THRESHOLD_BYTES = 32 * 1024;
// ── Frame shapes (mirror packages/dashboard/src/cli-session-ws.ts) ──────────
/** Server → client frames. */
type ServerFrame =
| { type: "scrollback"; data?: string }
| { type: "data"; data?: string }
| { type: "state"; [k: string]: unknown }
| { type: "error"; message?: string; code?: string }
| { type: "exit" };
/** Client → server frames. */
type ClientFrame =
| { type: "input"; data: string }
| { type: "resize"; cols: number; rows: number }
| { type: "ack"; bytes: number };
/**
* The minimal WebSocket surface the passthrough loop uses. The real `ws` client
* satisfies this; tests provide an in-memory implementation.
*/
export interface TerminalWebSocket {
/** Register an event handler. */
on(event: "open", listener: () => void): void;
on(event: "message", listener: (data: unknown) => void): void;
on(event: "close", listener: (code?: number, reason?: unknown) => void): void;
on(event: "error", listener: (err: Error) => void): void;
/** Send a (string) frame. */
send(data: string): void;
/** Close the socket. */
close(code?: number, reason?: string): void;
/** Current ready state; OPEN === 1 (matches the ws/WHATWG constant). */
readyState: number;
}
/** Ready-state constant matching the `ws` client / WHATWG WebSocket. */
export const WS_OPEN = 1;
/** Factory that opens a WS connection to `url` with the given headers. */
export type TerminalWebSocketFactory = (
url: string,
headers: Record<string, string>,
) => TerminalWebSocket;
/** Minimal readable stdin surface (a TTY ReadStream satisfies this). */
export interface AttachStdin {
on(event: "data", listener: (chunk: Buffer | string) => void): void;
off(event: "data", listener: (chunk: Buffer | string) => void): void;
setRawMode?: (mode: boolean) => void;
isRaw?: boolean;
isTTY?: boolean;
resume?: () => void;
pause?: () => void;
}
/** Minimal writable stdout surface (a TTY WriteStream satisfies this). */
export interface AttachStdout {
write(chunk: string): void;
columns?: number;
rows?: number;
on(event: "resize", listener: () => void): void;
off(event: "resize", listener: () => void): void;
}
export interface AttachTerminalSessionOptions {
/** Dashboard base URL, e.g. `http://127.0.0.1:4040`. */
baseUrl: string;
/** Daemon token (Authorization: Bearer …). Optional when auth is disabled. */
token?: string;
/** Session id to attach to. */
sessionId: string;
/** Project id (scopes the attach-ticket mint), if known. */
projectId?: string;
stdin: AttachStdin;
stdout: AttachStdout;
/**
* Called exactly once when the attach ends — cleanly (no arg) or with an error
* (WS drop / failed ticket). The caller resumes Ink rendering here.
*/
onDetach: (error?: Error) => void;
/** Injectable WS factory (default: the `ws` Node client). */
wsFactory?: TerminalWebSocketFactory;
/** Injectable fetch (default: global fetch) for the attach-ticket POST. */
fetchImpl?: typeof fetch;
/** ACK threshold override (bytes). */
ackThresholdBytes?: number;
/** Print a one-line detach hint before entering the alt-screen. */
printHint?: boolean;
}
/** Handle returned by `attachTerminalSession`; lets the caller force-detach. */
export interface AttachHandle {
/** Resolves when the attach fully ends (after terminal restore + onDetach). */
done: Promise<void>;
/** Force a clean detach (e.g. the TUI is quitting). Idempotent. */
detach(): void;
}
interface AttachTicketResponse {
ticket: string;
expiresAt?: string;
readOnly?: boolean;
}
/**
* Fetch a single-use attach ticket for the session. Throws on non-2xx so the
* caller surfaces a clean error and never opens the WS.
*/
export async function fetchAttachTicket(opts: {
baseUrl: string;
token?: string;
sessionId: string;
projectId?: string;
fetchImpl?: typeof fetch;
}): Promise<AttachTicketResponse> {
const fetchFn = opts.fetchImpl ?? fetch;
const url = `${opts.baseUrl.replace(/\/$/, "")}/api/cli-sessions/${encodeURIComponent(
opts.sessionId,
)}/attach-ticket`;
const headers: Record<string, string> = { "content-type": "application/json" };
if (opts.token) headers.authorization = `Bearer ${opts.token}`;
const res = await fetchFn(url, {
method: "POST",
headers,
body: JSON.stringify(opts.projectId ? { projectId: opts.projectId } : {}),
});
if (!res.ok) {
throw new Error(
`Failed to mint attach ticket (HTTP ${res.status} ${res.statusText})`,
);
}
const body = (await res.json()) as AttachTicketResponse;
if (!body || typeof body.ticket !== "string" || body.ticket.length === 0) {
throw new Error("Attach-ticket response missing `ticket`");
}
return body;
}
/** Build the cli-session WS URL with sessionId + ticket query params. */
export function buildWsUrl(opts: {
baseUrl: string;
sessionId: string;
ticket: string;
}): string {
const u = new URL(`${opts.baseUrl.replace(/\/$/, "")}/api/cli-sessions/ws`);
// ws(s):// scheme — derive from http(s).
u.protocol = u.protocol === "https:" ? "wss:" : "ws:";
u.searchParams.set("sessionId", opts.sessionId);
u.searchParams.set("ticket", opts.ticket);
return u.toString();
}
function defaultWsFactory(): TerminalWebSocketFactory {
return (url, headers) => {
const ws = new WebSocket(url, { headers });
return ws as unknown as TerminalWebSocket;
};
}
/**
* Decode a server `data`/`scrollback` frame's base64 payload to a UTF-8 string.
*/
function decodeFrameData(data: string | undefined): string {
if (typeof data !== "string" || data.length === 0) return "";
return Buffer.from(data, "base64").toString("utf8");
}
/**
* Run the full-screen passthrough attach. Returns once the attach has fully
* ended and the terminal has been restored (the same point `onDetach` fires).
*
* Lifecycle is single-shot: every termination path (detach chord, WS close, WS
* error, ticket failure, force `detach()`) funnels through one idempotent
* teardown that restores raw mode, leaves the alt-screen, closes the WS, and
* fires `onDetach` exactly once.
*/
export function attachTerminalSession(
opts: AttachTerminalSessionOptions,
): AttachHandle {
const {
stdin,
stdout,
onDetach,
ackThresholdBytes = DEFAULT_ACK_THRESHOLD_BYTES,
} = opts;
const wsFactory = opts.wsFactory ?? defaultWsFactory();
let settled = false;
let resolveDone: () => void;
const done = new Promise<void>((resolve) => {
resolveDone = resolve;
});
// Terminal state we must restore on teardown.
const priorRaw = stdin.isRaw ?? false;
let enteredAltScreen = false;
let rawModeSet = false;
// Live wiring (set once the WS opens).
let ws: TerminalWebSocket | null = null;
let onStdinData: ((chunk: Buffer | string) => void) | null = null;
let onResize: (() => void) | null = null;
// Outbound neutralization carry (threaded across data frames so a sequence
// split across two frames is still caught).
let carry = "";
// Flow control: bytes written since the last ACK.
let bytesSinceAck = 0;
const sendFrame = (frame: ClientFrame): void => {
if (!ws || ws.readyState !== WS_OPEN) return;
try {
ws.send(JSON.stringify(frame));
} catch {
/* socket closing */
}
};
const ackConsumed = (n: number): void => {
bytesSinceAck += n;
if (bytesSinceAck >= ackThresholdBytes) {
sendFrame({ type: "ack", bytes: bytesSinceAck });
bytesSinceAck = 0;
}
};
// Write a (possibly partial) untrusted chunk to the host TTY through the U10
// neutralizer. `isSnapshot` flushes the carry (scrollback is a complete unit).
const writeNeutralized = (text: string, isSnapshot: boolean): void => {
const result = neutralizeTerminalOutput(text, carry);
let out = result.output;
if (isSnapshot) {
out += flushTerminalOutput(result.carry);
carry = "";
} else {
carry = result.carry;
}
if (out.length === 0) return;
try {
stdout.write(out);
} catch {
/* stdout closing */
}
ackConsumed(Buffer.byteLength(out, "utf8"));
};
const teardown = (error?: Error): void => {
if (settled) return;
settled = true;
// Detach stdin/resize listeners first so no late bytes race the restore.
if (onStdinData) {
try {
stdin.off("data", onStdinData);
} catch {
/* ignore */
}
onStdinData = null;
}
if (onResize) {
try {
stdout.off("resize", onResize);
} catch {
/* ignore */
}
onResize = null;
}
// Restore raw mode to its prior state (only if we changed it).
if (rawModeSet && stdin.setRawMode) {
try {
stdin.setRawMode(priorRaw);
} catch {
/* ignore */
}
}
// Leave the alt-screen so the caller's shell / Ink scrollback is restored.
if (enteredAltScreen) {
try {
stdout.write(ALT_SCREEN_LEAVE);
} catch {
/* ignore */
}
}
// Close the WS (never throws upward).
if (ws) {
try {
ws.close();
} catch {
/* ignore */
}
}
try {
onDetach(error);
} finally {
resolveDone();
}
};
const handleServerFrame = (frame: ServerFrame): void => {
switch (frame.type) {
case "scrollback":
writeNeutralized(decodeFrameData(frame.data), true);
break;
case "data":
writeNeutralized(decodeFrameData(frame.data), false);
break;
case "exit":
teardown();
break;
case "error":
// A server error frame (e.g. read-only) is informational; surface it on
// stdout but don't tear down — the stream may continue.
if (frame.message) {
try {
stdout.write(`\r\n[session] ${frame.message}\r\n`);
} catch {
/* ignore */
}
}
break;
case "state":
default:
break;
}
};
// Enter the alt-screen + raw mode, then wire the loop. We do this BEFORE the
// WS opens so the first scrollback frame lands on a clean alt-screen.
const enterPassthrough = (): void => {
if (opts.printHint !== false) {
try {
stdout.write(
`Attached to session ${opts.sessionId}. Press ${DETACH_CHORD_LABEL} to detach.\r\n`,
);
} catch {
/* ignore */
}
}
try {
stdout.write(ALT_SCREEN_ENTER);
enteredAltScreen = true;
} catch {
/* ignore */
}
if (stdin.setRawMode) {
try {
stdin.setRawMode(true);
rawModeSet = true;
} catch {
/* ignore */
}
}
stdin.resume?.();
// stdin → input frames; detach chord intercepted.
onStdinData = (chunk: Buffer | string): void => {
const buf = typeof chunk === "string" ? Buffer.from(chunk, "utf8") : chunk;
// Detach chord: if Ctrl-] appears, send any bytes before it, then detach.
const idx = buf.indexOf(DETACH_CHORD_BYTE);
if (idx !== -1) {
if (idx > 0) {
sendFrame({ type: "input", data: buf.subarray(0, idx).toString("base64") });
}
teardown();
return;
}
sendFrame({ type: "input", data: buf.toString("base64") });
};
stdin.on("data", onStdinData);
// stdout resize → resize frames.
onResize = (): void => {
const cols = stdout.columns;
const rows = stdout.rows;
if (typeof cols === "number" && typeof rows === "number") {
sendFrame({ type: "resize", cols, rows });
}
};
stdout.on("resize", onResize);
// Send the initial size so the PTY matches the host TTY immediately.
onResize();
};
// ── Kick off: mint ticket, open WS, run the loop ──
(async () => {
let ticket: AttachTicketResponse;
try {
ticket = await fetchAttachTicket({
baseUrl: opts.baseUrl,
token: opts.token,
sessionId: opts.sessionId,
projectId: opts.projectId,
fetchImpl: opts.fetchImpl,
});
} catch (err) {
teardown(err instanceof Error ? err : new Error(String(err)));
return;
}
const url = buildWsUrl({
baseUrl: opts.baseUrl,
sessionId: opts.sessionId,
ticket: ticket.ticket,
});
const headers: Record<string, string> = {};
if (opts.token) headers.authorization = `Bearer ${opts.token}`;
try {
ws = wsFactory(url, headers);
} catch (err) {
teardown(err instanceof Error ? err : new Error(String(err)));
return;
}
ws.on("open", () => {
enterPassthrough();
});
ws.on("message", (data: unknown) => {
let text: string;
if (typeof data === "string") text = data;
else if (Buffer.isBuffer(data)) text = data.toString("utf8");
else if (data instanceof Uint8Array) text = Buffer.from(data).toString("utf8");
else text = String(data);
let frame: ServerFrame;
try {
frame = JSON.parse(text) as ServerFrame;
} catch {
return; // ignore malformed
}
handleServerFrame(frame);
});
ws.on("close", () => {
// A close before any deliberate detach is treated as a clean end if the
// server sent `exit` (already torn down), otherwise as a mid-attach drop.
if (!settled) {
teardown(new Error("Connection closed"));
}
});
ws.on("error", (err: Error) => {
teardown(err instanceof Error ? err : new Error(String(err)));
});
})();
return {
done,
detach: () => teardown(),
};
}

View File

@@ -25,6 +25,9 @@ import {
} from "@fusion/core";
import {
createServer,
AttachTicketStore,
CliInputAttributionLog,
CliConfirmAdvanceRegistry,
GitHubClient,
createSkillsAdapter,
getCliPackageVersion,
@@ -1741,9 +1744,33 @@ export async function runDashboard(port: number, opts: { paused?: boolean; dev?:
// to createServer — routes derived from getPluginRoutes() rely on it.
await phaseTime("pluginLoadingPromise (await)", () => pluginLoadingPromise);
// ── CLI Agent Executor: hub resolver + session transport ─────────────
//
// The hook route validates a per-session token against the project's live
// TelemetryHub; resolve it from that project's engine. The cli-sessions
// transport (REST + WS attach) is supplied from the cwd project's runtime
// (the canonical single-project surface) when the experimental flag is on.
//
const cliAgentHubResolver = (projectId: string | undefined, _sessionId: string) => {
const engine = projectId ? engineManager.getEngine(projectId) : cwdEngine;
return engine?.getCliAgentRuntime()?.bundle.hub;
};
const cwdCliAgentRuntime = cwdEngine?.getCliAgentRuntime();
const cliSessionTransport = cwdCliAgentRuntime
? {
manager: cwdCliAgentRuntime.bundle.manager,
store: cwdCliAgentRuntime.bundle.store,
ticketStore: new AttachTicketStore(),
attributionLog: new CliInputAttributionLog(),
confirmAdvance: new CliConfirmAdvanceRegistry(),
}
: undefined;
app = createServer(store, {
engine: cwdEngine,
engineManager,
cliAgentHubResolver,
cliSessionTransport,
hybridExecutor,
centralCore: centralCoreForEngine,
authStorage: dashboardAuthStorage,

View File

@@ -0,0 +1,211 @@
import { describe, it, expect, beforeAll, beforeEach, afterAll } from "vitest";
import { CliSessionStore } from "../cli-session-store.js";
import { Database } from "../db.js";
import { mkdtempSync } from "node:fs";
import { join } from "node:path";
import { tmpdir } from "node:os";
import { rm } from "node:fs/promises";
function makeTmpDir(): string {
return mkdtempSync(join(tmpdir(), "kb-cli-session-store-test-"));
}
describe("CliSessionStore", () => {
let tmpDir: string;
let fusionDir: string;
let db: Database;
let store: CliSessionStore;
beforeAll(() => {
tmpDir = makeTmpDir();
fusionDir = join(tmpDir, ".fusion");
db = new Database(fusionDir, { inMemory: true });
db.init();
store = new CliSessionStore(fusionDir, db);
});
beforeEach(() => {
db.exec("DELETE FROM cli_sessions");
store.removeAllListeners();
});
afterAll(async () => {
db.close();
await rm(tmpDir, { recursive: true, force: true });
});
it("creates and reads a session record", () => {
const created = store.createSession({
taskId: "FN-100",
purpose: "execute",
projectId: "proj-1",
adapterId: "claude-local",
worktreePath: "/tmp/wt/FN-100",
autonomyPosture: { autoApprove: true, maxResumeAttempts: 3 },
});
expect(created.id).toMatch(/^cli-/);
expect(created.agentState).toBe("starting");
expect(created.terminationReason).toBeNull();
expect(created.resumeAttempts).toBe(0);
expect(created.chatSessionId).toBeNull();
expect(created.autonomyPosture).toEqual({ autoApprove: true, maxResumeAttempts: 3 });
const fetched = store.getSession(created.id);
expect(fetched).toEqual(created);
});
it("persists state transitions", () => {
const s = store.createSession({
taskId: "FN-101",
purpose: "planning",
projectId: "proj-1",
adapterId: "codex-local",
});
const states = ["ready", "busy", "waitingOnInput", "busy", "done"] as const;
for (const state of states) {
const updated = store.updateSession(s.id, { agentState: state });
expect(updated?.agentState).toBe(state);
// Persisted, not just returned.
expect(store.getSession(s.id)?.agentState).toBe(state);
}
});
it("round-trips the native session id", () => {
const s = store.createSession({
taskId: "FN-102",
purpose: "execute",
projectId: "proj-1",
adapterId: "claude-local",
});
expect(s.nativeSessionId).toBeNull();
store.updateSession(s.id, { nativeSessionId: "native-abc-123" });
expect(store.getSession(s.id)?.nativeSessionId).toBe("native-abc-123");
// Reopen via a fresh store instance on the same DB to prove durability.
const reopened = new CliSessionStore(fusionDir, db);
expect(reopened.getSession(s.id)?.nativeSessionId).toBe("native-abc-123");
});
it("updates terminationReason and resumeAttempts atomically with state", () => {
const s = store.createSession({
taskId: "FN-103",
purpose: "validator",
projectId: "proj-1",
adapterId: "claude-local",
});
const updated = store.updateSession(s.id, {
agentState: "dead",
terminationReason: "crashed",
resumeAttempts: 2,
});
expect(updated?.agentState).toBe("dead");
expect(updated?.terminationReason).toBe("crashed");
expect(updated?.resumeAttempts).toBe(2);
const persisted = store.getSession(s.id)!;
expect(persisted.agentState).toBe("dead");
expect(persisted.terminationReason).toBe("crashed");
expect(persisted.resumeAttempts).toBe(2);
});
it("clears terminationReason when set back to null", () => {
const s = store.createSession({
taskId: "FN-104",
purpose: "execute",
projectId: "proj-1",
adapterId: "claude-local",
agentState: "dead",
terminationReason: "killed",
});
expect(s.terminationReason).toBe("killed");
store.updateSession(s.id, { agentState: "starting", terminationReason: null });
const persisted = store.getSession(s.id)!;
expect(persisted.terminationReason).toBeNull();
expect(persisted.agentState).toBe("starting");
});
it("queries sessions by task and by chat entity", () => {
store.createSession({ taskId: "FN-200", purpose: "execute", projectId: "p", adapterId: "a" });
store.createSession({ taskId: "FN-200", purpose: "validator", projectId: "p", adapterId: "a" });
store.createSession({ taskId: "FN-201", purpose: "execute", projectId: "p", adapterId: "a" });
store.createSession({ chatSessionId: "chat-xyz", purpose: "chat", projectId: "p", adapterId: "a" });
expect(store.listByTask("FN-200")).toHaveLength(2);
expect(store.listByTask("FN-201")).toHaveLength(1);
expect(store.listByTask("FN-999")).toHaveLength(0);
const chatSessions = store.listByChatSession("chat-xyz");
expect(chatSessions).toHaveLength(1);
expect(chatSessions[0].purpose).toBe("chat");
});
it("filters by projectId and agentState", () => {
store.createSession({ taskId: "FN-300", purpose: "execute", projectId: "pA", adapterId: "a", agentState: "busy" });
store.createSession({ taskId: "FN-301", purpose: "execute", projectId: "pA", adapterId: "a", agentState: "done" });
store.createSession({ taskId: "FN-302", purpose: "execute", projectId: "pB", adapterId: "a", agentState: "busy" });
expect(store.listSessions({ projectId: "pA" })).toHaveLength(2);
expect(store.listSessions({ projectId: "pA", agentState: "busy" })).toHaveLength(1);
expect(store.listSessions({ agentState: "busy" })).toHaveLength(2);
});
it("rejects an invalid agent state at the store boundary", () => {
const s = store.createSession({
taskId: "FN-400",
purpose: "execute",
projectId: "p",
adapterId: "a",
});
expect(() =>
// @ts-expect-error invalid state value rejected at runtime
store.updateSession(s.id, { agentState: "bogus" }),
).toThrow(/Invalid CLI agent state/);
expect(() =>
// @ts-expect-error invalid state value rejected at runtime
store.createSession({ purpose: "execute", projectId: "p", adapterId: "a", agentState: "nope" }),
).toThrow(/Invalid CLI agent state/);
// The original record was untouched by the failed update.
expect(store.getSession(s.id)?.agentState).toBe("starting");
});
it("rejects an invalid purpose and termination reason at the store boundary", () => {
expect(() =>
// @ts-expect-error invalid purpose rejected at runtime
store.createSession({ purpose: "wat", projectId: "p", adapterId: "a" }),
).toThrow(/Invalid CLI session purpose/);
const s = store.createSession({ taskId: "FN-401", purpose: "execute", projectId: "p", adapterId: "a" });
expect(() =>
// @ts-expect-error invalid termination reason rejected at runtime
store.updateSession(s.id, { terminationReason: "exploded" }),
).toThrow(/Invalid CLI termination reason/);
});
it("emits create/update/delete events", () => {
const events: string[] = [];
store.on("cli-session:created", () => events.push("created"));
store.on("cli-session:updated", () => events.push("updated"));
store.on("cli-session:deleted", () => events.push("deleted"));
const s = store.createSession({ taskId: "FN-500", purpose: "ce", projectId: "p", adapterId: "a" });
store.updateSession(s.id, { agentState: "ready" });
expect(store.deleteSession(s.id)).toBe(true);
expect(store.getSession(s.id)).toBeUndefined();
expect(events).toEqual(["created", "updated", "deleted"]);
});
it("returns undefined when updating a missing session and false when deleting one", () => {
expect(store.updateSession("cli-missing", { agentState: "ready" })).toBeUndefined();
expect(store.deleteSession("cli-missing")).toBe(false);
});
});

View File

@@ -715,7 +715,8 @@ describe("schema migration", () => {
const row = db.prepare("SELECT deletedAt FROM tasks WHERE id = 'FN-legacy'").get() as { deletedAt: string | null };
expect(row.deletedAt).toBeNull();
expect(db.getSchemaVersion()).toBe(110);
expect(db.getSchemaVersion()).toBe(112);
expect(db.getSchemaVersion()).toBe(112);
db.close();
});
@@ -748,7 +749,8 @@ describe("schema migration", () => {
{ id: "WS-001", mode: "prompt", gateMode: "advisory" },
{ id: "WS-002", mode: "script", gateMode: "advisory" },
]);
expect(db.getSchemaVersion()).toBe(110);
expect(db.getSchemaVersion()).toBe(112);
expect(db.getSchemaVersion()).toBe(112);
db.close();
});
@@ -798,7 +800,8 @@ describe("schema migration", () => {
reviewerContextRetryCount: 0,
reviewerFallbackRetryCount: 0,
});
expect(db.getSchemaVersion()).toBe(110);
expect(db.getSchemaVersion()).toBe(112);
expect(db.getSchemaVersion()).toBe(112);
db.close();
});
@@ -827,7 +830,8 @@ describe("schema migration", () => {
const columns = db.prepare("PRAGMA table_info(milestones)").all() as Array<{ name: string }>;
expect(columns.map((column) => column.name)).toContain("acceptanceCriteria");
expect(db.getSchemaVersion()).toBe(110);
expect(db.getSchemaVersion()).toBe(112);
expect(db.getSchemaVersion()).toBe(112);
db.close();
});
@@ -868,7 +872,8 @@ describe("schema migration", () => {
const missionColumns = db.prepare("PRAGMA table_info(missions)").all() as Array<{ name: string }>;
expect(missionColumns.map((column) => column.name)).toContain("autoMerge");
expect(db.getSchemaVersion()).toBe(110);
expect(db.getSchemaVersion()).toBe(112);
expect(db.getSchemaVersion()).toBe(112);
db.close();
});
@@ -902,7 +907,8 @@ describe("schema migration", () => {
{ id: "WS-002", mode: "script", enabled: 1, gateMode: "advisory" },
{ id: "WS-003", mode: "prompt", enabled: 0, gateMode: "advisory" },
]);
expect(db.getSchemaVersion()).toBe(110);
expect(db.getSchemaVersion()).toBe(112);
expect(db.getSchemaVersion()).toBe(112);
db.close();
});
@@ -939,7 +945,8 @@ describe("schema migration", () => {
const indexes = db.prepare("PRAGMA index_list(mission_goals)").all() as Array<{ name: string }>;
expect(indexes.some((index) => index.name === "idxMissionGoalsGoalId")).toBe(true);
expect(db.getSchemaVersion()).toBe(110);
expect(db.getSchemaVersion()).toBe(112);
expect(db.getSchemaVersion()).toBe(112);
db.close();
});
@@ -1000,7 +1007,7 @@ describe("schema migration", () => {
expect(customFieldsColumn).toBeDefined();
expect(customFieldsColumn?.dflt_value).toBe("'{}'");
expect(db.getSchemaVersion()).toBe(110);
expect(db.getSchemaVersion()).toBe(112);
db.close();
});
@@ -1025,6 +1032,44 @@ describe("schema migration", () => {
const tables = db.prepare("SELECT name FROM sqlite_master WHERE type='table'").all() as Array<{ name: string }>;
expect(tables.map((row) => row.name)).toContain("workflow_settings");
const columns = db.prepare("PRAGMA table_info(workflow_settings)").all() as Array<{
name: string;
pk: number;
dflt_value: string | null;
}>;
expect(columns.map((column) => column.name)).toEqual(["workflowId", "projectId", "values", "updatedAt"]);
expect(columns.filter((column) => column.pk > 0).map((column) => column.name).sort()).toEqual(["projectId", "workflowId"]);
const valuesColumn = columns.find((column) => column.name === "values");
expect(valuesColumn?.dflt_value).toBe("'{}'");
const indexes = db.prepare("PRAGMA index_list(workflow_settings)").all() as Array<{ name: string }>;
expect(indexes.some((index) => index.name === "idx_workflow_settings_project")).toBe(true);
expect(db.getSchemaVersion()).toBe(112);
db.close();
});
it("adds cli_sessions table + indexes when migrating from schema version 108", () => {
const db = new Database(fusionDir);
db.exec("CREATE TABLE IF NOT EXISTS __meta (key TEXT PRIMARY KEY, value TEXT)");
db.exec("INSERT INTO __meta (key, value) VALUES ('schemaVersion', '108')");
db.exec("INSERT INTO __meta (key, value) VALUES ('lastModified', '1000')");
db.exec(`
CREATE TABLE IF NOT EXISTS tasks (
id TEXT PRIMARY KEY,
description TEXT NOT NULL,
"column" TEXT NOT NULL,
createdAt TEXT NOT NULL,
updatedAt TEXT NOT NULL
)
`);
db.init();
// The new per-(workflowId, projectId) setting-value table exists.
const tables = db.prepare("SELECT name FROM sqlite_master WHERE type='table'").all() as Array<{ name: string }>;
expect(tables.map((row) => row.name)).toContain("workflow_settings");
const columns = db.prepare("PRAGMA table_info(workflow_settings)").all() as Array<{
name: string;
pk: number;
@@ -1050,7 +1095,81 @@ describe("schema migration", () => {
const indexes = db.prepare("PRAGMA index_list(workflow_settings)").all() as Array<{ name: string }>;
expect(indexes.some((index) => index.name === "idx_workflow_settings_project")).toBe(true);
expect(db.getSchemaVersion()).toBe(110);
// The durable CLI-session record table exists.
const cliTables = db.prepare("SELECT name FROM sqlite_master WHERE type='table'").all() as Array<{ name: string }>;
expect(cliTables.map((row) => row.name)).toContain("cli_sessions");
const cliSessionColumns = db
.prepare("PRAGMA table_info(cli_sessions)")
.all() as Array<{ name: string }>;
expect(cliSessionColumns.map((column) => column.name)).toEqual([
"id",
"taskId",
"chatSessionId",
"purpose",
"projectId",
"adapterId",
"agentState",
"terminationReason",
"nativeSessionId",
"resumeAttempts",
"autonomyPosture",
"worktreePath",
"createdAt",
"updatedAt",
]);
const cliSessionIndexes = db
.prepare("PRAGMA index_list(cli_sessions)")
.all() as Array<{ name: string }>;
const indexNames = cliSessionIndexes.map((index) => index.name);
expect(indexNames).toContain("idx_cli_sessions_taskId");
expect(indexNames).toContain("idx_cli_sessions_chatSessionId");
expect(indexNames).toContain("idx_cli_sessions_project_state");
expect(db.getSchemaVersion()).toBe(112);
db.close();
});
it("adds cliExecutorAdapterId to chat_sessions when migrating from schema version 109", () => {
const db = new Database(fusionDir);
db.exec("CREATE TABLE IF NOT EXISTS __meta (key TEXT PRIMARY KEY, value TEXT)");
db.exec("INSERT INTO __meta (key, value) VALUES ('schemaVersion', '109')");
db.exec("INSERT INTO __meta (key, value) VALUES ('lastModified', '1000')");
db.exec(`
CREATE TABLE IF NOT EXISTS chat_sessions (
id TEXT PRIMARY KEY,
agentId TEXT NOT NULL,
title TEXT,
status TEXT NOT NULL DEFAULT 'active',
projectId TEXT,
modelProvider TEXT,
modelId TEXT,
createdAt TEXT NOT NULL,
updatedAt TEXT NOT NULL,
cliSessionFile TEXT,
inFlightGeneration TEXT
)
`);
db.init();
const columns = db
.prepare("PRAGMA table_info(chat_sessions)")
.all() as Array<{ name: string }>;
expect(columns.map((column) => column.name)).toContain("cliExecutorAdapterId");
expect(db.getSchemaVersion()).toBe(112);
db.close();
});
it("creates cli_sessions on a fresh database (fresh-create path)", () => {
const db = new Database(fusionDir);
db.init();
const tables = db.prepare("SELECT name FROM sqlite_master WHERE type='table'").all() as Array<{ name: string }>;
expect(tables.map((row) => row.name)).toContain("cli_sessions");
expect(db.getSchemaVersion()).toBe(112);
db.close();
});
@@ -1107,20 +1226,23 @@ describe("schema migration", () => {
.get() as { migrated_fragment_id: string | null };
expect(stepRow.migrated_fragment_id).toBeNull();
expect(db.getSchemaVersion()).toBe(110);
expect(db.getSchemaVersion()).toBe(112);
expect(db.getSchemaVersion()).toBe(112);
db.close();
});
it("migration 109 is idempotent on re-init", () => {
const db = new Database(fusionDir);
db.init();
expect(db.getSchemaVersion()).toBe(110);
expect(db.getSchemaVersion()).toBe(112);
expect(db.getSchemaVersion()).toBe(112);
db.close();
// Re-open the same on-disk DB: already at 109, the 109 block must be a no-op.
const reopened = new Database(fusionDir);
reopened.init();
expect(reopened.getSchemaVersion()).toBe(110);
expect(reopened.getSchemaVersion()).toBe(112);
expect(reopened.getSchemaVersion()).toBe(112);
const workflowColumns = reopened.prepare("PRAGMA table_info(workflows)").all() as Array<{ name: string }>;
expect(workflowColumns.filter((c) => c.name === "kind")).toHaveLength(1);
const stepColumns = reopened.prepare("PRAGMA table_info(workflow_steps)").all() as Array<{ name: string }>;

View File

@@ -334,7 +334,8 @@ describe("Database", () => {
});
it("seeds schema version", () => {
expect(db.getSchemaVersion()).toBe(110);
expect(db.getSchemaVersion()).toBe(112);
expect(db.getSchemaVersion()).toBe(112);
});
it("includes tokenUsageCacheWriteTokens on freshly initialized tasks table", () => {
@@ -393,7 +394,8 @@ describe("Database", () => {
it("is idempotent - calling init() twice does not fail", () => {
expect(() => db.init()).not.toThrow();
expect(db.getSchemaVersion()).toBe(110);
expect(db.getSchemaVersion()).toBe(112);
expect(db.getSchemaVersion()).toBe(112);
});
it("does not overwrite existing config on re-init", () => {
// Update the config
@@ -1463,7 +1465,8 @@ describe("schema migrations", () => {
db.init();
// Verify version bumped to 29 (includes v1→v2 through v26→v29)
expect(db.getSchemaVersion()).toBe(110);
expect(db.getSchemaVersion()).toBe(112);
expect(db.getSchemaVersion()).toBe(112);
// Verify new columns exist and existing data is intact
const cols = db.prepare("PRAGMA table_info(tasks)").all() as Array<{ name: string }>;
@@ -1488,11 +1491,16 @@ describe("schema migrations", () => {
const db = new Database(fusionDir);
db.init();
expect(db.getSchemaVersion()).toBe(110);
expect(db.getSchemaVersion()).toBe(112);
// Re-init should not fail
db.init();
expect(db.getSchemaVersion()).toBe(110);
expect(db.getSchemaVersion()).toBe(112);
expect(db.getSchemaVersion()).toBe(112);
// Re-init should not fail
db.init();
expect(db.getSchemaVersion()).toBe(112);
db.close();
});
@@ -1527,7 +1535,8 @@ describe("schema migrations", () => {
db.init();
expect(db.getSchemaVersion()).toBe(110);
expect(db.getSchemaVersion()).toBe(112);
expect(db.getSchemaVersion()).toBe(112);
const cols = db.prepare("PRAGMA table_info(tasks)").all() as Array<{ name: string }>;
expect(cols.map((col) => col.name)).toContain("priority");
@@ -1568,7 +1577,8 @@ describe("schema migrations", () => {
db.init();
expect(db.getSchemaVersion()).toBe(110);
expect(db.getSchemaVersion()).toBe(112);
expect(db.getSchemaVersion()).toBe(112);
const cols = db.prepare("PRAGMA table_info(tasks)").all() as Array<{ name: string }>;
const colNames = cols.map((col) => col.name);
@@ -1640,7 +1650,8 @@ describe("schema migrations", () => {
db.init();
expect(db.getSchemaVersion()).toBe(110);
expect(db.getSchemaVersion()).toBe(112);
expect(db.getSchemaVersion()).toBe(112);
const cols = db.prepare("PRAGMA table_info(tasks)").all() as Array<{ name: string }>;
const colNames = cols.map((col) => col.name);
@@ -1880,7 +1891,8 @@ describe("schema migrations", () => {
db.init();
expect(db.getSchemaVersion()).toBe(110);
expect(db.getSchemaVersion()).toBe(112);
expect(db.getSchemaVersion()).toBe(112);
const cols = db.prepare("PRAGMA table_info(chat_messages)").all() as Array<{ name: string }>;
expect(cols.map((col) => col.name)).toContain("attachments");
@@ -1954,7 +1966,8 @@ describe("schema migrations", () => {
db.init();
expect(db.getSchemaVersion()).toBe(110);
expect(db.getSchemaVersion()).toBe(112);
expect(db.getSchemaVersion()).toBe(112);
const tables = db.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name = 'agentRatings'").all() as Array<{ name: string }>;
expect(tables).toEqual([{ name: "agentRatings" }]);
@@ -1978,7 +1991,8 @@ describe("schema migrations", () => {
db.init();
expect(db.getSchemaVersion()).toBe(110);
expect(db.getSchemaVersion()).toBe(112);
expect(db.getSchemaVersion()).toBe(112);
const tables = db.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name = 'mission_events'").all() as Array<{ name: string }>;
expect(tables).toEqual([{ name: "mission_events" }]);
@@ -2082,7 +2096,8 @@ describe("schema migrations", () => {
db.init();
// Verify version bumped to 29
expect(db.getSchemaVersion()).toBe(110);
expect(db.getSchemaVersion()).toBe(112);
expect(db.getSchemaVersion()).toBe(112);
// Verify new columns exist and existing data is intact
const cols = db.prepare("PRAGMA table_info(tasks)").all() as Array<{ name: string }>;
@@ -2301,7 +2316,8 @@ describe("schema migrations", () => {
localDb.init();
expect(localDb.getSchemaVersion()).toBe(110);
expect(localDb.getSchemaVersion()).toBe(112);
expect(localDb.getSchemaVersion()).toBe(112);
const columns = localDb.prepare("PRAGMA table_info(tasks)").all() as Array<{ name: string }>;
expect(columns.map((column) => column.name)).toContain("tokenUsageCacheWriteTokens");
@@ -2612,7 +2628,8 @@ describe("createDatabase factory", () => {
const db = createDatabase(fusionDir);
db.init();
expect(db.getSchemaVersion()).toBe(110);
expect(db.getSchemaVersion()).toBe(112);
expect(db.getSchemaVersion()).toBe(112);
expect(db.getLastModified()).toBeGreaterThan(0);
db.close();
@@ -2766,7 +2783,8 @@ describe("migration v77 task token budget columns", () => {
migrated = new Database(fusion);
migrated.init();
expect(migrated.getSchemaVersion()).toBe(110);
expect(migrated.getSchemaVersion()).toBe(112);
expect(migrated.getSchemaVersion()).toBe(112);
const rows = migrated.prepare("PRAGMA table_info(tasks)").all() as Array<{ name: string }>;
const names = new Set(rows.map((row) => row.name));
expect(names.has("tokenBudgetSoftAlertedAt")).toBe(true);
@@ -2797,7 +2815,8 @@ describe("migration v106 adds tasks.transitionPending (FN-1417)", () => {
const fresh = new Database(fusion);
try {
fresh.init();
expect(fresh.getSchemaVersion()).toBe(110);
expect(fresh.getSchemaVersion()).toBe(112);
expect(fresh.getSchemaVersion()).toBe(112);
const names = new Set(
(fresh.prepare("PRAGMA table_info(tasks)").all() as Array<{ name: string }>).map((r) => r.name),
);
@@ -2825,7 +2844,8 @@ describe("migration v106 adds tasks.transitionPending (FN-1417)", () => {
migrated = new Database(fusion);
migrated.init();
expect(migrated.getSchemaVersion()).toBe(110);
expect(migrated.getSchemaVersion()).toBe(112);
expect(migrated.getSchemaVersion()).toBe(112);
const names = new Set(
(migrated.prepare("PRAGMA table_info(tasks)").all() as Array<{ name: string }>).map((r) => r.name),
);
@@ -2851,7 +2871,8 @@ describe("migration v107 adds workflow_run_branches + index (FN-1417)", () => {
const fresh = new Database(fusion);
try {
fresh.init();
expect(fresh.getSchemaVersion()).toBe(110);
expect(fresh.getSchemaVersion()).toBe(112);
expect(fresh.getSchemaVersion()).toBe(112);
const table = fresh
.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name = 'workflow_run_branches'")
.get() as { name: string } | undefined;
@@ -2885,7 +2906,8 @@ describe("migration v107 adds workflow_run_branches + index (FN-1417)", () => {
migrated = new Database(fusion);
migrated.init();
expect(migrated.getSchemaVersion()).toBe(110);
expect(migrated.getSchemaVersion()).toBe(112);
expect(migrated.getSchemaVersion()).toBe(112);
const table = migrated
.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name = 'workflow_run_branches'")
.get() as { name: string } | undefined;
@@ -2926,7 +2948,8 @@ describe("migration v67 drops orphan project auth tables", () => {
migrated = new Database(fusion);
migrated.init();
expect(migrated.getSchemaVersion()).toBe(110);
expect(migrated.getSchemaVersion()).toBe(112);
expect(migrated.getSchemaVersion()).toBe(112);
const tables = migrated
.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name LIKE 'project_auth_%'")
.all() as Array<{ name: string }>;
@@ -2953,7 +2976,8 @@ describe("migration v67 drops orphan project auth tables", () => {
try {
fresh.init();
expect(fresh.getSchemaVersion()).toBe(110);
expect(fresh.getSchemaVersion()).toBe(112);
expect(fresh.getSchemaVersion()).toBe(112);
const tables = fresh
.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name LIKE 'project_auth_%'")
.all() as Array<{ name: string }>;

View File

@@ -0,0 +1,134 @@
/**
* cliAgents global-settings slice (U15): round-trip with defaults merge +
* invalid-dropped-at-the-write-boundary behavior.
*/
import { mkdtemp, rm } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { GlobalSettingsStore } from "../global-settings.js";
import { sanitizeCliAgentsSettings, sanitizeCliAgentSettings } from "../settings-schema.js";
describe("sanitizeCliAgentSettings (write-boundary validation)", () => {
it("keeps valid fields and trims strings", () => {
expect(
sanitizeCliAgentSettings({
commandOverride: " /opt/claude ",
extraArgs: [" --foo ", "", "bar"],
envAdditions: ["MY_VAR", " ", "OTHER"],
autonomyMode: "elevated",
}),
).toEqual({
commandOverride: "/opt/claude",
extraArgs: ["--foo", "bar"],
envAdditions: ["MY_VAR", "OTHER"],
autonomyMode: "elevated",
});
});
it("drops unknown fields and invalid values", () => {
expect(
sanitizeCliAgentSettings({
commandOverride: 42,
extraArgs: "not-an-array",
envAdditions: [1, 2, 3],
autonomyMode: "godmode",
bogus: "x",
}),
).toBeUndefined();
});
it("drops empty-after-trim command override", () => {
expect(sanitizeCliAgentSettings({ commandOverride: " " })).toBeUndefined();
});
});
describe("sanitizeCliAgentsSettings", () => {
it("drops unknown adapter ids", () => {
const out = sanitizeCliAgentsSettings({
"claude-code": { autonomyMode: "elevated" },
"totally-made-up": { autonomyMode: "elevated" },
});
expect(Object.keys(out)).toEqual(["claude-code"]);
});
it("returns empty object for non-objects", () => {
expect(sanitizeCliAgentsSettings(null)).toEqual({});
expect(sanitizeCliAgentsSettings([1, 2])).toEqual({});
expect(sanitizeCliAgentsSettings("x")).toEqual({});
});
it("omits adapter entries that sanitize to nothing", () => {
const out = sanitizeCliAgentsSettings({
codex: { autonomyMode: "garbage" },
pi: { extraArgs: ["--ok"] },
});
expect(out).toEqual({ pi: { extraArgs: ["--ok"] } });
});
});
describe("GlobalSettingsStore cliAgents round-trip", () => {
let dir: string;
let store: GlobalSettingsStore;
beforeEach(async () => {
dir = await mkdtemp(join(tmpdir(), "fusion-cli-agents-"));
store = new GlobalSettingsStore(dir);
await store.init();
});
afterEach(async () => {
await rm(dir, { recursive: true, force: true });
});
it("defaults cliAgents to an empty object", async () => {
const settings = await store.getSettings();
expect(settings.cliAgents).toEqual({});
});
it("persists a valid adapter config across a fresh read", async () => {
await store.updateSettings({
cliAgents: {
"claude-code": {
commandOverride: "/usr/local/bin/claude",
extraArgs: ["--verbose"],
autonomyMode: "elevated",
envAdditions: ["HTTP_PROXY"],
},
},
});
store.invalidateCache();
const reread = await store.getSettings();
expect(reread.cliAgents).toEqual({
"claude-code": {
commandOverride: "/usr/local/bin/claude",
extraArgs: ["--verbose"],
autonomyMode: "elevated",
envAdditions: ["HTTP_PROXY"],
},
});
});
it("drops invalid adapter ids and fields at the write boundary", async () => {
await store.updateSettings({
cliAgents: {
// unknown adapter id → dropped
"evil-adapter": { autonomyMode: "elevated" },
// valid adapter, junk autonomyMode dropped, valid extraArgs kept
codex: { autonomyMode: "yolo", extraArgs: ["--model=gpt"] },
} as never,
});
store.invalidateCache();
const reread = await store.getSettings();
expect(reread.cliAgents).toEqual({ codex: { extraArgs: ["--model=gpt"] } });
});
it("merges per-adapter without dropping unrelated global keys", async () => {
await store.updateSettings({ themeMode: "light" });
await store.updateSettings({ cliAgents: { pi: { extraArgs: ["--tools=read"] } } });
store.invalidateCache();
const reread = await store.getSettings();
expect(reread.themeMode).toBe("light");
expect(reread.cliAgents).toEqual({ pi: { extraArgs: ["--tools=read"] } });
});
});

View File

@@ -90,7 +90,7 @@ describe("goals schema", () => {
expect(table?.name).toBe("goals");
});
it("reports schema version 110", () => {
expect(db.getSchemaVersion()).toBe(110);
it("reports schema version 101", () => {
expect(db.getSchemaVersion()).toBe(112);
});
});

View File

@@ -1000,7 +1000,7 @@ describe("Migration: pre-33 DB upgrade", () => {
// Step 1: Create a fresh database at v33 (runs all migrations up to 33)
const db1 = createDatabase(legacyDir);
db1.init();
expect(db1.getSchemaVersion()).toBe(110);
expect(db1.getSchemaVersion()).toBe(112);
db1.close();
// Step 2: Manually downgrade to version 32 and drop insight tables
@@ -1035,7 +1035,7 @@ describe("Migration: pre-33 DB upgrade", () => {
expect(tableNamesBefore).not.toContain("project_insight_runs");
// Now run init — this triggers the v32→v33 migration
db3.init();
expect(db3.getSchemaVersion()).toBe(110);
expect(db3.getSchemaVersion()).toBe(112);
// Step 4: Verify insight tables exist after migration
const tablesAfter = db3.prepare(
@@ -1066,12 +1066,12 @@ describe("Migration: pre-33 DB upgrade", () => {
try {
const db1 = createDatabase(testDir);
db1.init();
expect(db1.getSchemaVersion()).toBe(110);
expect(db1.getSchemaVersion()).toBe(112);
db1.close();
const db2 = createDatabase(testDir);
expect(() => db2.init()).not.toThrow();
expect(db2.getSchemaVersion()).toBe(110);
expect(db2.getSchemaVersion()).toBe(112);
db2.close();
} finally {
rmSync(testDir, { recursive: true, force: true });
@@ -1085,7 +1085,7 @@ describe("Migration: pre-33 DB upgrade", () => {
// Step 1: Create a fresh DB and run migrations
const db1 = createDatabase(compatDir);
db1.init();
expect(db1.getSchemaVersion()).toBe(110);
expect(db1.getSchemaVersion()).toBe(112);
// Step 2: Strip lifecycle and cancelledAt columns by recreating the
// table without them. This simulates a DB that was created before the

View File

@@ -38,7 +38,7 @@ describe("TaskStore merge request record + completion handoff marker", () => {
.all() as Array<{ name: string }>;
expect(tableRows).toEqual([{ name: "completion_handoff_markers" }, { name: "merge_requests" }]);
expect(db.getSchemaVersion()).toBe(110);
expect(db.getSchemaVersion()).toBe(112);
});
it("upserts merge request records", async () => {

View File

@@ -3745,8 +3745,8 @@ describe("MissionStore", () => {
// ── Loop State & Validator Run Schema Tests ───────────────────────────
describe("Loop State & Validator Run Schema (v31)", () => {
it("schema version is 110 after migration", () => {
expect(db.getSchemaVersion()).toBe(110);
it("schema version is 101 after migration", () => {
expect(db.getSchemaVersion()).toBe(112);
});
it("mission_features table has loop state columns", () => {

View File

@@ -0,0 +1,38 @@
import { describe, it, expect } from "vitest";
import { redactSecrets } from "../redact-secrets.js";
// Parity fixtures mirror the original ACP plugin's process-manager tests so the
// shared implementation produces identical behavior (Risk S8).
describe("redactSecrets (shared @fusion/core)", () => {
it("redacts bearer tokens", () => {
const out = redactSecrets("Authorization: Bearer sk-live-ABCDEFG1234567890abcdef");
expect(out).not.toContain("sk-live-ABCDEFG1234567890abcdef");
expect(out).toContain("[REDACTED]");
});
it("redacts key=/token= assignments", () => {
const out = redactSecrets("api_key=abcdef0123456789 token=ZZZ987654321");
expect(out).not.toContain("abcdef0123456789");
expect(out).not.toContain("ZZZ987654321");
});
it("redacts long opaque hex/base64 secrets", () => {
const out = redactSecrets("value 0123456789abcdef0123456789abcdef done");
expect(out).not.toContain("0123456789abcdef0123456789abcdef");
});
it("leaves benign text intact", () => {
expect(redactSecrets("hello world")).toBe("hello world");
});
it("redacts standalone sk-/ghp_/AKIA opaque tokens", () => {
const out = redactSecrets("ghp_ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789");
expect(out).toBe("[REDACTED]");
});
it("redacts quoted secret assignments", () => {
const out = redactSecrets('client_secret="topsecretvalue123"');
expect(out).not.toContain("topsecretvalue123");
expect(out).toContain("[REDACTED]");
});
});

View File

@@ -583,8 +583,8 @@ describe("Run Audit", () => {
expect(indexNames).toContain("idxRunAuditEventsTimestamp");
});
it("schema version is bumped to 109", () => {
expect(db.getSchemaVersion()).toBe(110);
it("schema version is bumped to 40", () => {
expect(db.getSchemaVersion()).toBe(112);
});
});
});

View File

@@ -60,7 +60,7 @@ describe("TaskStore merge queue", () => {
expect.arrayContaining(["idx_mergeQueue_lease_ready", "idx_mergeQueue_leaseExpiresAt"]),
);
expect(store.getDatabase().getSchemaVersion()).toBe(110);
expect(store.getDatabase().getSchemaVersion()).toBe(112);
});
it("migrates a legacy v88 database and preserves task rows", async () => {

View File

@@ -51,7 +51,7 @@ describe("TaskStore task documents", () => {
expect(tableNames.has("task_documents")).toBe(true);
expect(tableNames.has("task_document_revisions")).toBe(true);
expect(db.getSchemaVersion()).toBe(110);
expect(db.getSchemaVersion()).toBe(112);
const index = db
.prepare(

View File

@@ -86,6 +86,7 @@ interface ChatSessionRow {
updatedAt: string;
cliSessionFile: string | null;
inFlightGeneration: string | null;
cliExecutorAdapterId: string | null;
}
/** Database row shape for chat_messages. */
@@ -161,6 +162,7 @@ export class ChatStore extends EventEmitter<ChatStoreEvents> {
updatedAt: row.updatedAt,
cliSessionFile: row.cliSessionFile ?? null,
inFlightGeneration: fromJson<ChatInFlightGenerationState>(row.inFlightGeneration) ?? null,
cliExecutorAdapterId: row.cliExecutorAdapterId ?? null,
};
}
@@ -254,11 +256,12 @@ export class ChatStore extends EventEmitter<ChatStoreEvents> {
updatedAt: now,
cliSessionFile: null,
inFlightGeneration: null,
cliExecutorAdapterId: input.cliExecutorAdapterId ?? null,
};
this.db.prepare(`
INSERT INTO chat_sessions (id, agentId, title, status, projectId, modelProvider, modelId, createdAt, updatedAt, inFlightGeneration)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
INSERT INTO chat_sessions (id, agentId, title, status, projectId, modelProvider, modelId, createdAt, updatedAt, inFlightGeneration, cliExecutorAdapterId)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`).run(
session.id,
session.agentId,
@@ -270,6 +273,7 @@ export class ChatStore extends EventEmitter<ChatStoreEvents> {
session.createdAt,
session.updatedAt,
null,
session.cliExecutorAdapterId,
);
this.db.bumpLastModified();
@@ -466,6 +470,27 @@ export class ChatStore extends EventEmitter<ChatStoreEvents> {
this.db.bumpLastModified();
}
/**
* Set (or clear) the cli-agent adapter that backs this chat session (U12).
* When set, the chat is CLI-backed: composer sends route through the inject
* path and adapter transcript events map to chat_messages rows. Emits a
* session update so the client can switch to the CLI-backed rendering path.
*
* @param id - Session ID
* @param adapterId - cli-agent adapter id, or null to revert to the provider path
*/
setCliExecutorAdapterId(id: string, adapterId: string | null): ChatSession | undefined {
const existing = this.getSession(id);
if (!existing) return undefined;
this.db
.prepare("UPDATE chat_sessions SET cliExecutorAdapterId = ?, updatedAt = ? WHERE id = ?")
.run(adapterId, new Date().toISOString(), id);
this.db.bumpLastModified();
const updated = this.getSession(id)!;
this.emit("chat:session:updated", updated);
return updated;
}
setInFlightGeneration(id: string, inFlightGeneration: ChatInFlightGenerationState | null): ChatSession | undefined {
const existing = this.getSession(id);
if (!existing) return undefined;

View File

@@ -68,6 +68,13 @@ export interface ChatSession {
* for sessions that have never produced an assistant reply.
*/
cliSessionFile: string | null;
/**
* cli-agent adapter id backing this chat session (CLI Agent Executor, U12).
* When non-null the chat is CLI-backed: composer sends inject into a live
* CLI session and adapter transcript events map to chat_messages rows. Null
* means the chat uses the standard model-provider path.
*/
cliExecutorAdapterId: string | null;
/** Durable in-flight assistant snapshot used to recover streaming UI after refresh. */
inFlightGeneration: ChatInFlightGenerationState | null;
}
@@ -160,6 +167,8 @@ export interface ChatSessionCreateInput {
modelProvider?: string | null;
/** Optional model ID override */
modelId?: string | null;
/** Optional cli-agent adapter id; when set the chat is CLI-backed (U12) */
cliExecutorAdapterId?: string | null;
}
/**

View File

@@ -0,0 +1,336 @@
/**
* CliSessionStore - Data layer for durable CLI agent session records
* (CLI Agent Executor, U1).
*
* Manages CRUD for the `cli_sessions` table: the long-lived record that
* survives executor restarts so a session can be reasoned about, resumed,
* or reaped from its persisted state.
*
* Follows the same patterns as ChatStore:
* - EventEmitter for change notifications.
* - SQLite for structured data storage.
* - JSON columns for nested data (autonomyPosture).
* - Validation at the store boundary: invalid enum values are rejected.
*/
import { EventEmitter } from "node:events";
import { randomUUID } from "node:crypto";
import type { Database } from "./db.js";
import { fromJson, toJsonNullable } from "./db.js";
import {
isCliAgentState,
isCliSessionPurpose,
isCliTerminationReason,
type CliAgentState,
type CliAutonomyPosture,
type CliSession,
type CliSessionCreateInput,
type CliSessionPurpose,
type CliSessionUpdateInput,
type CliTerminationReason,
} from "./cli-session-types.js";
// ── Event Types ─────────────────────────────────────────────────────────
export interface CliSessionStoreEvents {
/** Emitted when a CLI session record is created. */
"cli-session:created": [session: CliSession];
/** Emitted when a CLI session record is updated. */
"cli-session:updated": [session: CliSession];
/** Emitted when a CLI session record is deleted. */
"cli-session:deleted": [sessionId: string];
}
// ── Row Interface ────────────────────────────────────────────────────────
/** Database row shape for cli_sessions. */
interface CliSessionRow {
id: string;
taskId: string | null;
chatSessionId: string | null;
purpose: string;
projectId: string;
adapterId: string;
agentState: string;
terminationReason: string | null;
nativeSessionId: string | null;
resumeAttempts: number;
autonomyPosture: string | null;
worktreePath: string | null;
createdAt: string;
updatedAt: string;
}
// ── CliSessionStore Class ────────────────────────────────────────────────
export class CliSessionStore extends EventEmitter<CliSessionStoreEvents> {
constructor(
private fusionDir: string,
private db: Database,
) {
super();
this.setMaxListeners(100);
}
// ── Row-to-Object Converter ──────────────────────────────────────────
private rowToSession(row: CliSessionRow): CliSession {
return {
id: row.id,
taskId: row.taskId ?? null,
chatSessionId: row.chatSessionId ?? null,
purpose: row.purpose as CliSessionPurpose,
projectId: row.projectId,
adapterId: row.adapterId,
agentState: row.agentState as CliAgentState,
terminationReason: (row.terminationReason as CliTerminationReason | null) ?? null,
nativeSessionId: row.nativeSessionId ?? null,
resumeAttempts: row.resumeAttempts ?? 0,
autonomyPosture: fromJson<CliAutonomyPosture>(row.autonomyPosture) ?? null,
worktreePath: row.worktreePath ?? null,
createdAt: row.createdAt,
updatedAt: row.updatedAt,
};
}
// ── Boundary validation ──────────────────────────────────────────────
private assertAgentState(value: unknown): asserts value is CliAgentState {
if (!isCliAgentState(value)) {
throw new Error(`Invalid CLI agent state: ${JSON.stringify(value)}`);
}
}
private assertPurpose(value: unknown): asserts value is CliSessionPurpose {
if (!isCliSessionPurpose(value)) {
throw new Error(`Invalid CLI session purpose: ${JSON.stringify(value)}`);
}
}
private assertTerminationReason(
value: unknown,
): asserts value is CliTerminationReason | null {
if (value === null || value === undefined) return;
if (!isCliTerminationReason(value)) {
throw new Error(`Invalid CLI termination reason: ${JSON.stringify(value)}`);
}
}
// ── CRUD Operations ──────────────────────────────────────────────────
/**
* Create a new CLI session record.
*
* @throws Error if any enum value (purpose / agentState / terminationReason)
* is invalid, or required fields are missing.
*/
createSession(input: CliSessionCreateInput): CliSession {
this.assertPurpose(input.purpose);
const agentState: CliAgentState = input.agentState ?? "starting";
this.assertAgentState(agentState);
this.assertTerminationReason(input.terminationReason ?? null);
if (!input.projectId) {
throw new Error("CLI session requires a projectId");
}
if (!input.adapterId) {
throw new Error("CLI session requires an adapterId");
}
const now = new Date().toISOString();
const id = input.id ?? `cli-${randomUUID().slice(0, 8)}`;
const resumeAttempts = input.resumeAttempts ?? 0;
const session: CliSession = {
id,
taskId: input.taskId ?? null,
chatSessionId: input.chatSessionId ?? null,
purpose: input.purpose,
projectId: input.projectId,
adapterId: input.adapterId,
agentState,
terminationReason: input.terminationReason ?? null,
nativeSessionId: input.nativeSessionId ?? null,
resumeAttempts,
autonomyPosture: input.autonomyPosture ?? null,
worktreePath: input.worktreePath ?? null,
createdAt: now,
updatedAt: now,
};
this.db
.prepare(
`INSERT INTO cli_sessions (
id, taskId, chatSessionId, purpose, projectId, adapterId,
agentState, terminationReason, nativeSessionId, resumeAttempts,
autonomyPosture, worktreePath, createdAt, updatedAt
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
)
.run(
session.id,
session.taskId,
session.chatSessionId,
session.purpose,
session.projectId,
session.adapterId,
session.agentState,
session.terminationReason,
session.nativeSessionId,
session.resumeAttempts,
toJsonNullable(session.autonomyPosture),
session.worktreePath,
session.createdAt,
session.updatedAt,
);
this.db.bumpLastModified();
this.emit("cli-session:created", session);
return session;
}
/** Get a CLI session record by ID. */
getSession(id: string): CliSession | undefined {
const row = this.db
.prepare("SELECT * FROM cli_sessions WHERE id = ?")
.get(id) as unknown as CliSessionRow | undefined;
if (!row) return undefined;
return this.rowToSession(row);
}
/**
* List CLI session records with optional filtering.
*
* @returns Array of sessions ordered by updatedAt DESC.
*/
listSessions(options?: {
taskId?: string;
chatSessionId?: string;
projectId?: string;
agentState?: CliAgentState;
purpose?: CliSessionPurpose;
}): CliSession[] {
const whereClauses: string[] = [];
const params: string[] = [];
if (options?.taskId !== undefined) {
whereClauses.push("taskId = ?");
params.push(options.taskId);
}
if (options?.chatSessionId !== undefined) {
whereClauses.push("chatSessionId = ?");
params.push(options.chatSessionId);
}
if (options?.projectId !== undefined) {
whereClauses.push("projectId = ?");
params.push(options.projectId);
}
if (options?.agentState !== undefined) {
this.assertAgentState(options.agentState);
whereClauses.push("agentState = ?");
params.push(options.agentState);
}
if (options?.purpose !== undefined) {
this.assertPurpose(options.purpose);
whereClauses.push("purpose = ?");
params.push(options.purpose);
}
const whereSql = whereClauses.length > 0 ? `WHERE ${whereClauses.join(" AND ")}` : "";
const rows = this.db
.prepare(`SELECT * FROM cli_sessions ${whereSql} ORDER BY updatedAt DESC`)
.all(...params);
return (rows as unknown as CliSessionRow[]).map((row) => this.rowToSession(row));
}
/** List CLI session records owned by a task. */
listByTask(taskId: string): CliSession[] {
return this.listSessions({ taskId });
}
/** List CLI session records owned by a chat session. */
listByChatSession(chatSessionId: string): CliSession[] {
return this.listSessions({ chatSessionId });
}
/**
* Update a CLI session record.
*
* State, terminationReason, and resumeAttempts are written atomically in a
* single UPDATE statement, so a state transition that also records why the
* session ended and how many resumes were attempted cannot tear.
*
* @throws Error if any provided enum value is invalid.
* @returns The updated session, or undefined if not found.
*/
updateSession(id: string, input: CliSessionUpdateInput): CliSession | undefined {
const existing = this.getSession(id);
if (!existing) return undefined;
if (input.agentState !== undefined) {
this.assertAgentState(input.agentState);
}
if (input.terminationReason !== undefined) {
this.assertTerminationReason(input.terminationReason);
}
const now = new Date().toISOString();
const setClauses: string[] = ["updatedAt = ?"];
const params: (string | number | null)[] = [now];
if (input.taskId !== undefined) {
setClauses.push("taskId = ?");
params.push(input.taskId);
}
if (input.chatSessionId !== undefined) {
setClauses.push("chatSessionId = ?");
params.push(input.chatSessionId);
}
if (input.agentState !== undefined) {
setClauses.push("agentState = ?");
params.push(input.agentState);
}
if (input.terminationReason !== undefined) {
setClauses.push("terminationReason = ?");
params.push(input.terminationReason);
}
if (input.nativeSessionId !== undefined) {
setClauses.push("nativeSessionId = ?");
params.push(input.nativeSessionId);
}
if (input.resumeAttempts !== undefined) {
setClauses.push("resumeAttempts = ?");
params.push(input.resumeAttempts);
}
if (input.autonomyPosture !== undefined) {
setClauses.push("autonomyPosture = ?");
params.push(toJsonNullable(input.autonomyPosture));
}
if (input.worktreePath !== undefined) {
setClauses.push("worktreePath = ?");
params.push(input.worktreePath);
}
params.push(id);
this.db
.prepare(`UPDATE cli_sessions SET ${setClauses.join(", ")} WHERE id = ?`)
.run(...params);
const updated = this.getSession(id)!;
this.db.bumpLastModified();
this.emit("cli-session:updated", updated);
return updated;
}
/** Delete a CLI session record. */
deleteSession(id: string): boolean {
const existing = this.getSession(id);
if (!existing) return false;
this.db.prepare("DELETE FROM cli_sessions WHERE id = ?").run(id);
this.db.bumpLastModified();
this.emit("cli-session:deleted", id);
return true;
}
}

View File

@@ -0,0 +1,196 @@
/**
* CLI agent session type definitions (CLI Agent Executor, U1).
*
* Defines the durable record shape for a CLI agent session — the long-lived
* process that drives a single autonomy unit (a task execution, a planning
* pass, a validator run, a CE run, or an interactive chat). These records
* outlive the in-memory executor so a crashed/restarted Fusion instance can
* reason about, resume, or reap sessions from their persisted state.
*
* Follows the same conventions as chat-types.ts:
* - String-literal unions for enums.
* - Nullable owning-entity references (taskId / chatSessionId).
* - JSON-serialized structured columns (autonomyPosture).
*/
// ── Enums / String Literals ─────────────────────────────────────────────
/**
* Lifecycle state of a CLI agent session.
*
* Transitions (typical): starting → ready → busy ↔ waitingOnInput → done,
* with dead / needsAttention reachable from any active state on failure or
* a condition requiring operator intervention.
*/
export type CliAgentState =
| "starting"
| "ready"
| "busy"
| "waitingOnInput"
| "done"
| "dead"
| "needsAttention";
/** All valid agent states, for runtime validation at the store boundary. */
export const CLI_AGENT_STATES: readonly CliAgentState[] = [
"starting",
"ready",
"busy",
"waitingOnInput",
"done",
"dead",
"needsAttention",
] as const;
/**
* Why a CLI agent session terminated. Null while the session is still live.
*
* Termination taxonomy (KTD):
* - completed — the agent finished its unit of work successfully.
* - userExited — the user/operator deliberately stopped the session.
* - killed — the session was force-terminated (e.g. supervisor reap).
* - crashed — the underlying process exited abnormally / unexpectedly.
* - authFailed — the session ended because credentials/auth were rejected.
* - engineDeath — the owning Fusion engine/process died, orphaning the session.
*/
export type CliTerminationReason =
| "completed"
| "userExited"
| "killed"
| "crashed"
| "authFailed"
| "engineDeath";
/** All valid termination reasons, for runtime validation at the store boundary. */
export const CLI_TERMINATION_REASONS: readonly CliTerminationReason[] = [
"completed",
"userExited",
"killed",
"crashed",
"authFailed",
"engineDeath",
] as const;
/**
* The purpose a CLI agent session serves — which autonomy unit it drives.
*
* - execute — a task execution run.
* - planning — a planning / triage pass.
* - validator — a validator / acceptance run.
* - ce — a compound-engineering run.
* - chat — an interactive chat session.
*/
export type CliSessionPurpose = "execute" | "planning" | "validator" | "ce" | "chat";
/** All valid session purposes, for runtime validation at the store boundary. */
export const CLI_SESSION_PURPOSES: readonly CliSessionPurpose[] = [
"execute",
"planning",
"validator",
"ce",
"chat",
] as const;
// ── Core Types ──────────────────────────────────────────────────────────
/**
* Operator-configured autonomy posture for a session. Stored as JSON.
*
* Kept intentionally open-ended (structured but extensible) so posture
* controls can evolve without a schema migration. Persisted verbatim.
*/
export interface CliAutonomyPosture {
/** Whether the session may proceed without per-step approval. */
autoApprove?: boolean;
/** Maximum number of resume attempts permitted before giving up. */
maxResumeAttempts?: number;
/** Free-form, forward-compatible posture fields. */
[key: string]: unknown;
}
/**
* A durable CLI agent session record.
*
* Exactly one of `taskId` / `chatSessionId` is typically set, matching the
* owning entity for the session's `purpose` (chat → chatSessionId; the rest →
* taskId). Both may be null for sessions not yet attached to an entity.
*/
export interface CliSession {
/** Stable primary key. */
id: string;
/** Owning task ID, when this session drives task work. Null otherwise. */
taskId: string | null;
/** Owning chat session ID, when purpose is "chat". Null otherwise. */
chatSessionId: string | null;
/** What autonomy unit this session drives. */
purpose: CliSessionPurpose;
/** Project this session belongs to. */
projectId: string;
/** Adapter (CLI agent integration) backing the session. */
adapterId: string;
/** Current lifecycle state. */
agentState: CliAgentState;
/** Why the session terminated, or null while live. */
terminationReason: CliTerminationReason | null;
/** Native (adapter/process) session identifier, for resume. Null until known. */
nativeSessionId: string | null;
/** Number of resume attempts made so far. */
resumeAttempts: number;
/** Operator-configured autonomy posture. */
autonomyPosture: CliAutonomyPosture | null;
/** Worktree path the session operates in. */
worktreePath: string | null;
/** When the record was created (ISO 8601). */
createdAt: string;
/** When the record was last updated (ISO 8601). */
updatedAt: string;
}
/** Input for creating a CLI session record. */
export interface CliSessionCreateInput {
/** Optional explicit ID; generated when omitted. */
id?: string;
taskId?: string | null;
chatSessionId?: string | null;
purpose: CliSessionPurpose;
projectId: string;
adapterId: string;
/** Initial state; defaults to "starting" when omitted. */
agentState?: CliAgentState;
terminationReason?: CliTerminationReason | null;
nativeSessionId?: string | null;
resumeAttempts?: number;
autonomyPosture?: CliAutonomyPosture | null;
worktreePath?: string | null;
}
/** Partial updates to a CLI session record. */
export interface CliSessionUpdateInput {
taskId?: string | null;
chatSessionId?: string | null;
agentState?: CliAgentState;
terminationReason?: CliTerminationReason | null;
nativeSessionId?: string | null;
resumeAttempts?: number;
autonomyPosture?: CliAutonomyPosture | null;
worktreePath?: string | null;
}
// ── Validation helpers ───────────────────────────────────────────────────
/** Narrow an unknown value to a valid CliAgentState. */
export function isCliAgentState(value: unknown): value is CliAgentState {
return typeof value === "string" && (CLI_AGENT_STATES as readonly string[]).includes(value);
}
/** Narrow an unknown value to a valid CliTerminationReason. */
export function isCliTerminationReason(value: unknown): value is CliTerminationReason {
return (
typeof value === "string" && (CLI_TERMINATION_REASONS as readonly string[]).includes(value)
);
}
/** Narrow an unknown value to a valid CliSessionPurpose. */
export function isCliSessionPurpose(value: unknown): value is CliSessionPurpose {
return typeof value === "string" && (CLI_SESSION_PURPOSES as readonly string[]).includes(value);
}

View File

@@ -149,7 +149,7 @@ export function probeFts5(db: DatabaseSync): boolean {
// ── Schema Definition ────────────────────────────────────────────────
const SCHEMA_VERSION = 110;
const SCHEMA_VERSION = 112;
export { SCHEMA_VERSION };
@@ -1252,6 +1252,23 @@ export const MIGRATION_ONLY_TABLE_SCHEMAS: Record<string, Record<string, string>
updatedAt: "TEXT NOT NULL",
cliSessionFile: "TEXT",
inFlightGeneration: "TEXT",
cliExecutorAdapterId: "TEXT",
},
cli_sessions: {
id: "TEXT PRIMARY KEY",
taskId: "TEXT",
chatSessionId: "TEXT",
purpose: "TEXT NOT NULL",
projectId: "TEXT NOT NULL",
adapterId: "TEXT NOT NULL",
agentState: "TEXT NOT NULL DEFAULT 'starting'",
terminationReason: "TEXT",
nativeSessionId: "TEXT",
resumeAttempts: "INTEGER NOT NULL DEFAULT 0",
autonomyPosture: "TEXT",
worktreePath: "TEXT",
createdAt: "TEXT NOT NULL",
updatedAt: "TEXT NOT NULL",
},
chat_messages: {
id: "TEXT PRIMARY KEY",
@@ -4311,12 +4328,10 @@ export class Database {
});
}
// Migration 109: Workflow editor consolidation (workflow-editor-consolidation
// U1, KTD-1). Adds workflows.kind (fragment vs workflow discriminator;
// existing rows default to 'workflow') and workflow_steps.migrated_fragment_id
// (nullable marker stamping a step that has been migrated into a fragment, so
// the lazy step migration is idempotent). Additive-only, idempotent
// (addColumnIfMissing guards); no backfill.
// Migration 109: Workflow editor consolidation. Adds workflows.kind
// (fragment vs workflow discriminator; existing rows default 'workflow')
// and workflow_steps.migrated_fragment_id (idempotent lazy step migration).
// Additive-only, idempotent (addColumnIfMissing guards); no backfill.
if (version < 109) {
this.applyMigration(109, () => {
this.addColumnIfMissing("workflows", "kind", "TEXT NOT NULL DEFAULT 'workflow'");
@@ -4324,16 +4339,56 @@ export class Database {
});
}
// Migration 110: Workflow setting values (workflow-settings U2, KTD-2).
// Migration 110: Durable CLI agent session records (CLI Agent Executor U1).
// cli_sessions — one row per long-lived CLI agent session. agentState ∈
// starting|ready|busy|waitingOnInput|done|dead|needsAttention; terminationReason
// ∈ completed|userExited|killed|crashed|authFailed|engineDeath; purpose ∈
// execute|planning|validator|ce|chat. Additive-only, idempotent.
if (version < 110) {
this.applyMigration(110, () => {
this.db.exec(`
CREATE TABLE IF NOT EXISTS cli_sessions (
id TEXT PRIMARY KEY,
taskId TEXT,
chatSessionId TEXT,
purpose TEXT NOT NULL,
projectId TEXT NOT NULL,
adapterId TEXT NOT NULL,
agentState TEXT NOT NULL DEFAULT 'starting',
terminationReason TEXT,
nativeSessionId TEXT,
resumeAttempts INTEGER NOT NULL DEFAULT 0,
autonomyPosture TEXT,
worktreePath TEXT,
createdAt TEXT NOT NULL,
updatedAt TEXT NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_cli_sessions_taskId ON cli_sessions(taskId);
CREATE INDEX IF NOT EXISTS idx_cli_sessions_chatSessionId ON cli_sessions(chatSessionId);
CREATE INDEX IF NOT EXISTS idx_cli_sessions_project_state ON cli_sessions(projectId, agentState);
`);
});
}
// Migration 111: per-chat-session cli-agent adapter selection (U12).
if (version < 111) {
this.applyMigration(111, () => {
if (this.hasTable("chat_sessions")) {
this.addColumnIfMissing("chat_sessions", "cliExecutorAdapterId", "TEXT");
}
});
}
// Migration 112: Workflow setting values (workflow-settings U2, KTD-2).
// Adds workflow_settings — one row per (workflowId, projectId) carrying a JSON
// map of setting values declared by the workflow's IR. Values are validated by
// the store write authority against the named workflow's declarations; built-in
// workflow ids are accepted for value writes even though their declarations are
// non-editable. Additive-only, idempotent (table-exists guard); no backfill.
// (Authored as 109 on the feature branch; renumbered to 110 when main's
// editor-consolidation migration landed first with the same number.)
if (version < 110) {
this.applyMigration(110, () => {
// (Authored as 109 on the feature branch; renumbered as mainline migrations
// land first — currently 112.)
if (version < 112) {
this.applyMigration(112, () => {
this.db.exec(`
CREATE TABLE IF NOT EXISTS workflow_settings (
workflowId TEXT NOT NULL,

View File

@@ -19,6 +19,7 @@ import { mkdir, readFile, writeFile, rename, chmod } from "node:fs/promises";
import { existsSync, mkdirSync, renameSync } from "node:fs";
import type { GlobalSettings } from "./types.js";
import { DEFAULT_GLOBAL_SETTINGS } from "./types.js";
import { sanitizeCliAgentsSettings } from "./settings-schema.js";
function getHomeDir(): string {
return process.env.HOME || process.env.USERPROFILE || homedir();
@@ -193,6 +194,11 @@ export class GlobalSettingsStore {
// null → delete this key from the merged object
// This effectively makes it fall through to the default
delete merged[key];
} else if (key === "cliAgents") {
// Validation at the write boundary (U15, Global Settings convention):
// unknown adapter ids and invalid fields are dropped before persist so
// a malformed `cliAgents` payload can never reach launch resolution.
merged[key] = sanitizeCliAgentsSettings(value);
} else {
// normal value → set it
merged[key] = value;

View File

@@ -1,5 +1,5 @@
export { COLUMNS, DEFAULT_COLUMN, isColumn, normalizeColumn, COLUMN_LABELS, COLUMN_DESCRIPTIONS, VALID_TRANSITIONS, DEFAULT_SETTINGS, DEFAULT_GLOBAL_SETTINGS, DEFAULT_PROJECT_SETTINGS, GLOBAL_SETTINGS_KEYS, PROJECT_SETTINGS_KEYS, isGlobalSettingsKey, isProjectSettingsKey, isMergeRequestContractShadowEnabled, resolvePersistAgentThinkingLog, THINKING_LEVELS, THEME_MODES, COLOR_THEMES, SUPPORTED_LOCALES, DEFAULT_LOCALE, isLocale, WORKFLOW_STEP_TEMPLATES, AGENT_PERMISSIONS, PERMANENT_AGENT_ACTION_CATEGORIES, AGENT_PERMISSION_POLICY_ACTION_CATEGORIES, AGENT_PROVISIONING_APPROVAL_MODES, SANDBOX_PROVISIONING_APPROVAL_MODES, AGENT_PERMISSION_POLICY_PRESET_IDS, LEGACY_AGENT_PERMISSION_POLICY_ACTION_CATEGORY_ALIASES, APPROVAL_REQUEST_STATUSES, APPROVAL_REQUEST_AUDIT_EVENT_TYPES, normalizeApprovalRequestActionCategory, isValidApprovalRequestTransition, agentToConfigSnapshot, diffConfigSnapshots, isEphemeralAgent, hasAgentIdentity, CheckoutConflictError, DEFAULT_HEARTBEAT_PROCEDURE_PATH, getDefaultHeartbeatProcedurePath, EXECUTION_MODES, DEFAULT_EXECUTION_MODE, TASK_PRIORITIES, DEFAULT_TASK_PRIORITY, HIGH_FANOUT_BLOCKER_TODO_THRESHOLD, STALE_HIGH_FANOUT_BLOCKER_AGE_THRESHOLD_MS, DASHBOARD_USER_ID, normalizeMessageParticipant, validateMessageMetadata, validateDockerNodeConfig, sanitizeDockerNodeConfigForResponse, normalizeMergeIntegrationWorktreeMode, normalizeMergeAdvanceAutoSyncMode, MERGE_ADVANCE_AUTO_SYNC_MODES, normalizeMergeConflictStrategy, normalizeMergeStrategyOverlapBehavior, normalizePostMergeAuditMode, POST_MERGE_AUDIT_MODES, normalizeMergeAuditAutoRecovery, MERGE_AUDIT_AUTO_RECOVERY_MODES, normalizeMergerMode, MERGER_MODES, normalizeAutoRecovery, AUTO_RECOVERY_MODES, buildResearchDocumentKey, REPO_OVERRIDE_RE, SHARED_STATE_SNAPSHOT_VERSION } from "./types.js";
export type { Column, ColumnId, IssueInfo, IssueState, TaskSourceIssue, PrInfo, PrConflictState, PrConflictDiagnostics, PrCheckState, PrCheckStatus, PrStatus, BranchGroup, BranchGroupCreateInput, BranchGroupUpdate, BranchGroupPrState, Task, TaskTokenUsage, TaskAttachment, TaskComment, TaskCommentInput, TaskDocument, TaskDocumentRevision, TaskDocumentCreateInput, TaskDocumentWithTask, TaskCreateInput, MeshReplicatedTaskCreatePayload, MeshReplicatedTaskApplyResult, TaskSource, SourceType, TaskDetail, RetrySummary, InboxTask, TodoList, TodoItem, TodoListCreateInput, TodoListUpdateInput, TodoItemCreateInput, TodoItemUpdateInput, TodoListWithItems, AgentLogEntry, AgentLogType, AgentRole, BoardConfig, DistributedTaskIdReserveInput, DistributedTaskIdReserveResult, DistributedTaskIdCommitInput, DistributedTaskIdCommitResult, DistributedTaskIdAbortInput, DistributedTaskIdAbortResult, DistributedTaskIdStateInput, DistributedTaskIdStateResult, AutostashOrphanRecord, AutostashOutcome, MergeDetails, MergeResult, MergeIntegrationWorktreeMode, MergeAdvanceAutoSyncMode, MergeConflictStrategy, CanonicalMergeConflictStrategy, MergeStrategyOverlapBehavior, PostMergeAuditMode, MergeAuditAutoRecoveryMode, MergerMode, MergerSettings, AutoRecoveryMode, AutoRecoveryFailureClass, AutoRecoverySettings, DirectMergeCommitStrategy, Settings, GlobalSettings, ProjectSettings, SecretsEnvConfig, WebSearchBackend, ResearchEnabledSources, ResearchGlobalDefaults, ResearchProjectLimits, ResearchProjectSettings, SandboxBackendName, SandboxFailureMode, SandboxPolicy, SandboxProjectSettings, EvalFollowUpPolicy, EvalProjectSettings, ResolvedEvalSettings, SettingsScope, DaemonTokenSettings, TaskStep, StepStatus, TaskLogEntry, RunMutationContext, ActivityLogEntry, ActivityEventType, ThinkingLevel, ThemeMode, ColorTheme, Locale, ExecutionMode, TaskPriority, MergeQueueEntry, MergeQueueEnqueueOptions, MergeQueueAcquireOptions, MergeQueueReleaseOutcome, MergeRequestState, MergeRequestRecord, CompletionHandoffMarker, HandoffEvidence, HandoffToReviewOptions, UnavailableNodePolicy, OwningNodeHandoffPolicy, PlanningQuestion, PlanningSummary, PlanningResponse, PlanningQuestionType, ArchivedTaskEntry, BatchStatusRequest, BatchStatusResponse, BatchStatusEntry, BatchStatusResult, GithubIssueAction, ModelPreset, WorkflowStep, WorkflowStepMode, WorkflowStepGateMode, WorkflowStepPhase, WorkflowStepInput, WorkflowStepResult, WorkflowStepTemplate, Agent, OrgTreeNode, AgentState, AgentDetail, AgentCreateInput, AgentUpdateInput, AgentApiKey, AgentApiKeyCreateResult, AgentCapability, AgentPromptTemplate, AgentPromptsConfig, AgentPermission, PermanentAgentActionCategory, PermanentAgentSensitiveActionCategory, PermanentAgentGatingContext, AgentPermissionPolicy, AgentPermissionPolicyRules, AgentPermissionPolicyActionCategory, AgentProvisioningApprovalMode, SandboxProvisioningApprovalMode, LegacyAgentPermissionPolicyActionCategory, ApprovalRequestActionCategoryInput, ApprovalRequestActionCategory, AgentPermissionPolicyDisposition, AgentPermissionPolicyPresetId, ApprovalRequestStatus, ApprovalRequestAuditEventType, ApprovalRequestActorSnapshot, ApprovalRequestTargetAction, ApprovalRequestAuditEvent, ApprovalRequest, ApprovalRequestCreateInput, ApprovalRequestDecisionInput, ApprovalRequestCompletionInput, ApprovalRequestListInput, TaskAssignSource, AgentAccessState, AgentHeartbeatConfig, AgentBudgetConfig, AgentBudgetStatus, InstructionsBundleConfig, MessageResponseMode, AgentHeartbeatEvent, AgentHeartbeatRun, BlockedStateSnapshot, HeartbeatInvocationSource, AgentTaskSession, AgentRating, AgentRatingSummary, AgentRatingInput, AgentConfigSnapshot, RevisionFieldDiff, AgentConfigRevision, AgentStats, ReflectionTrigger, ReflectionMetrics, AgentReflection, AgentPerformanceSummary, NtfyNotificationEvent, NotificationEvent, NotificationPayload, NotificationProviderConfig, CustomProvider, SteeringComment, ParticipantType, MessageType, Message, MessageCreateInput, MessageFilter, MessageMetadata, MessageReplyReference, Mailbox, CheckoutLease, CheckoutClaimPrecondition, TaskClaimRow, CentralClaimStore, RunAuditDomain, RunAuditEvent, RunAuditEventInput, RunAuditEventFilter, AgentMemoryInclusionMode, HeartbeatPromptTemplate, HeartbeatScopeDisciplineMode, WorktrunkSettings, WorktrunkOnFailure, TaskBranchContext } from "./types.js";
export { COLUMNS, DEFAULT_COLUMN, isColumn, normalizeColumn, COLUMN_LABELS, COLUMN_DESCRIPTIONS, VALID_TRANSITIONS, DEFAULT_SETTINGS, DEFAULT_GLOBAL_SETTINGS, DEFAULT_PROJECT_SETTINGS, GLOBAL_SETTINGS_KEYS, PROJECT_SETTINGS_KEYS, isGlobalSettingsKey, isProjectSettingsKey, isMergeRequestContractShadowEnabled, resolvePersistAgentThinkingLog, THINKING_LEVELS, THEME_MODES, COLOR_THEMES, SUPPORTED_LOCALES, DEFAULT_LOCALE, isLocale, WORKFLOW_STEP_TEMPLATES, AGENT_PERMISSIONS, PERMANENT_AGENT_ACTION_CATEGORIES, AGENT_PERMISSION_POLICY_ACTION_CATEGORIES, AGENT_PROVISIONING_APPROVAL_MODES, SANDBOX_PROVISIONING_APPROVAL_MODES, AGENT_PERMISSION_POLICY_PRESET_IDS, LEGACY_AGENT_PERMISSION_POLICY_ACTION_CATEGORY_ALIASES, APPROVAL_REQUEST_STATUSES, APPROVAL_REQUEST_AUDIT_EVENT_TYPES, normalizeApprovalRequestActionCategory, isValidApprovalRequestTransition, agentToConfigSnapshot, diffConfigSnapshots, isEphemeralAgent, hasAgentIdentity, CheckoutConflictError, DEFAULT_HEARTBEAT_PROCEDURE_PATH, getDefaultHeartbeatProcedurePath, EXECUTION_MODES, DEFAULT_EXECUTION_MODE, TASK_PRIORITIES, DEFAULT_TASK_PRIORITY, HIGH_FANOUT_BLOCKER_TODO_THRESHOLD, STALE_HIGH_FANOUT_BLOCKER_AGE_THRESHOLD_MS, DASHBOARD_USER_ID, normalizeMessageParticipant, validateMessageMetadata, validateDockerNodeConfig, sanitizeDockerNodeConfigForResponse, normalizeMergeIntegrationWorktreeMode, normalizeMergeAdvanceAutoSyncMode, MERGE_ADVANCE_AUTO_SYNC_MODES, normalizeMergeConflictStrategy, normalizeMergeStrategyOverlapBehavior, normalizePostMergeAuditMode, POST_MERGE_AUDIT_MODES, normalizeMergeAuditAutoRecovery, MERGE_AUDIT_AUTO_RECOVERY_MODES, normalizeMergerMode, MERGER_MODES, normalizeAutoRecovery, AUTO_RECOVERY_MODES, buildResearchDocumentKey, REPO_OVERRIDE_RE, SHARED_STATE_SNAPSHOT_VERSION, sanitizeCliAgentSettings, sanitizeCliAgentsSettings, CLI_AGENT_ADAPTER_IDS, CLI_AGENT_AUTONOMY_MODES } from "./types.js";
export type { Column, ColumnId, IssueInfo, IssueState, TaskSourceIssue, PrInfo, PrConflictState, PrConflictDiagnostics, PrCheckState, PrCheckStatus, PrStatus, BranchGroup, BranchGroupCreateInput, BranchGroupUpdate, BranchGroupPrState, Task, TaskTokenUsage, TaskAttachment, TaskComment, TaskCommentInput, TaskDocument, TaskDocumentRevision, TaskDocumentCreateInput, TaskDocumentWithTask, TaskCreateInput, MeshReplicatedTaskCreatePayload, MeshReplicatedTaskApplyResult, TaskSource, SourceType, TaskDetail, RetrySummary, InboxTask, TodoList, TodoItem, TodoListCreateInput, TodoListUpdateInput, TodoItemCreateInput, TodoItemUpdateInput, TodoListWithItems, AgentLogEntry, AgentLogType, AgentRole, BoardConfig, DistributedTaskIdReserveInput, DistributedTaskIdReserveResult, DistributedTaskIdCommitInput, DistributedTaskIdCommitResult, DistributedTaskIdAbortInput, DistributedTaskIdAbortResult, DistributedTaskIdStateInput, DistributedTaskIdStateResult, AutostashOrphanRecord, AutostashOutcome, MergeDetails, MergeResult, MergeIntegrationWorktreeMode, MergeAdvanceAutoSyncMode, MergeConflictStrategy, CanonicalMergeConflictStrategy, MergeStrategyOverlapBehavior, PostMergeAuditMode, MergeAuditAutoRecoveryMode, MergerMode, MergerSettings, AutoRecoveryMode, AutoRecoveryFailureClass, AutoRecoverySettings, DirectMergeCommitStrategy, Settings, GlobalSettings, ProjectSettings, SecretsEnvConfig, WebSearchBackend, ResearchEnabledSources, ResearchGlobalDefaults, ResearchProjectLimits, ResearchProjectSettings, SandboxBackendName, SandboxFailureMode, SandboxPolicy, SandboxProjectSettings, EvalFollowUpPolicy, EvalProjectSettings, ResolvedEvalSettings, SettingsScope, DaemonTokenSettings, TaskStep, StepStatus, TaskLogEntry, RunMutationContext, ActivityLogEntry, ActivityEventType, ThinkingLevel, ThemeMode, ColorTheme, Locale, ExecutionMode, TaskPriority, MergeQueueEntry, MergeQueueEnqueueOptions, MergeQueueAcquireOptions, MergeQueueReleaseOutcome, MergeRequestState, MergeRequestRecord, CompletionHandoffMarker, HandoffEvidence, HandoffToReviewOptions, UnavailableNodePolicy, OwningNodeHandoffPolicy, PlanningQuestion, PlanningSummary, PlanningResponse, PlanningQuestionType, ArchivedTaskEntry, BatchStatusRequest, BatchStatusResponse, BatchStatusEntry, BatchStatusResult, GithubIssueAction, ModelPreset, WorkflowStep, WorkflowStepMode, WorkflowStepGateMode, WorkflowStepPhase, WorkflowStepInput, WorkflowStepResult, WorkflowStepTemplate, Agent, OrgTreeNode, AgentState, AgentDetail, AgentCreateInput, AgentUpdateInput, AgentApiKey, AgentApiKeyCreateResult, AgentCapability, AgentPromptTemplate, AgentPromptsConfig, AgentPermission, PermanentAgentActionCategory, PermanentAgentSensitiveActionCategory, PermanentAgentGatingContext, AgentPermissionPolicy, AgentPermissionPolicyRules, AgentPermissionPolicyActionCategory, AgentProvisioningApprovalMode, SandboxProvisioningApprovalMode, LegacyAgentPermissionPolicyActionCategory, ApprovalRequestActionCategoryInput, ApprovalRequestActionCategory, AgentPermissionPolicyDisposition, AgentPermissionPolicyPresetId, ApprovalRequestStatus, ApprovalRequestAuditEventType, ApprovalRequestActorSnapshot, ApprovalRequestTargetAction, ApprovalRequestAuditEvent, ApprovalRequest, ApprovalRequestCreateInput, ApprovalRequestDecisionInput, ApprovalRequestCompletionInput, ApprovalRequestListInput, TaskAssignSource, AgentAccessState, AgentHeartbeatConfig, AgentBudgetConfig, AgentBudgetStatus, InstructionsBundleConfig, MessageResponseMode, AgentHeartbeatEvent, AgentHeartbeatRun, BlockedStateSnapshot, HeartbeatInvocationSource, AgentTaskSession, AgentRating, AgentRatingSummary, AgentRatingInput, AgentConfigSnapshot, RevisionFieldDiff, AgentConfigRevision, AgentStats, ReflectionTrigger, ReflectionMetrics, AgentReflection, AgentPerformanceSummary, NtfyNotificationEvent, NotificationEvent, NotificationPayload, NotificationProviderConfig, CustomProvider, SteeringComment, ParticipantType, MessageType, Message, MessageCreateInput, MessageFilter, MessageMetadata, MessageReplyReference, Mailbox, CheckoutLease, CheckoutClaimPrecondition, TaskClaimRow, CentralClaimStore, RunAuditDomain, RunAuditEvent, RunAuditEventInput, RunAuditEventFilter, AgentMemoryInclusionMode, HeartbeatPromptTemplate, HeartbeatScopeDisciplineMode, WorktrunkSettings, WorktrunkOnFailure, TaskBranchContext, CliAgentSettings } from "./types.js";
export { AGENT_VALID_TRANSITIONS, DUPLICATE_OF_METADATA_KEY } from "./types.js";
export {
resolveEntryPointBranchAssignment,
@@ -16,6 +16,7 @@ export type {
EntryPointBranchAssignment,
} from "./branch-assignment.js";
export { customProviderRegistryKey } from "./custom-provider-key.js";
export { redactSecrets } from "./redact-secrets.js";
export { MOCK_PROVIDER_ID } from "./mock-provider-constants.js";
export type { MockProviderId, MockSessionPurpose } from "./mock-provider-constants.js";
export {
@@ -79,6 +80,9 @@ export type {
WorkflowSettingType,
WorkflowSettingOption,
WorkflowSettingRender,
// CLI Agent Executor (U7): node-config executor typing.
WorkflowNodeExecutorKind,
WorkflowNodeExecutorConfig,
} from "./workflow-ir-types.js";
export {
instanceNodeId,
@@ -1592,6 +1596,25 @@ export type {
} from "./chat-types.js";
export { ChatStore } from "./chat-store.js";
export type { ChatStoreEvents } from "./chat-store.js";
export {
CLI_AGENT_STATES,
CLI_TERMINATION_REASONS,
CLI_SESSION_PURPOSES,
isCliAgentState,
isCliTerminationReason,
isCliSessionPurpose,
} from "./cli-session-types.js";
export type {
CliAgentState,
CliTerminationReason,
CliSessionPurpose,
CliAutonomyPosture,
CliSession,
CliSessionCreateInput,
CliSessionUpdateInput,
} from "./cli-session-types.js";
export { CliSessionStore } from "./cli-session-store.js";
export type { CliSessionStoreEvents } from "./cli-session-store.js";
export {
choosePreferredStoredCredential,
extractClaudeCliStoredCredential,

View File

@@ -0,0 +1,31 @@
/**
* Shared secret-redaction helper.
*
* Pure string logic that strips token-like / auth patterns from text so auth
* errors and process output don't leak verbatim into logs or buffers. Best
* effort: covers bearer tokens, `Authorization:` header values,
* `key=`/`token=`/`secret=` assignments, and long base64/hex secrets.
*/
/**
* Redact token-like / auth patterns from `text`.
*/
export function redactSecrets(text: string): string {
return (
text
// Authorization: Bearer <token> / Authorization: <token>
.replace(/(authorization\s*[:=]\s*)(bearer\s+)?[^\s,;"']+/gi, "$1$2[REDACTED]")
// Bearer <token>
.replace(/\b(bearer)\s+[A-Za-z0-9._\-+/=]+/gi, "$1 [REDACTED]")
// key=... token=... secret=... password=... apikey=... (quoted or bare)
.replace(
/\b((?:api[_-]?key|key|token|secret|password|passwd|pwd|access[_-]?token|refresh[_-]?token|client[_-]?secret)\s*[:=]\s*)("?)[^\s,;"']+\2/gi,
"$1$2[REDACTED]$2",
)
// sk-/ghp_/github_pat_/xoxb-/AKIA-style long opaque tokens
.replace(/\b(sk-|ghp_|gho_|github_pat_|xox[abpr]-|AKIA)[A-Za-z0-9_-]{8,}/g, "[REDACTED]")
// standalone long base64/hex secrets (>=32 chars)
.replace(/\b[A-Za-z0-9+/]{40,}={0,2}\b/g, "[REDACTED]")
.replace(/\b[0-9a-fA-F]{32,}\b/g, "[REDACTED]")
);
}

View File

@@ -1,4 +1,4 @@
import type { GlobalSettings, ProjectSettings, Settings } from "./types.js";
import type { CliAgentSettings, GlobalSettings, ProjectSettings, Settings } from "./types.js";
export interface MergeRequestContractShadowSettingsSource {
mergeRequestContractShadowEnabled?: boolean;
@@ -230,6 +230,7 @@ export const DEFAULT_GLOBAL_SETTINGS = {
},
owningNodeHandoffPolicy: "reassign-to-local",
experimentalFeatures: {},
cliAgents: {},
} satisfies CompleteSettings<GlobalSettings>;
/** Default values for project-level settings. */
@@ -238,6 +239,7 @@ export const DEFAULT_PROJECT_SETTINGS = {
globalPauseReason: undefined,
defaultWorkflowId: undefined,
approvedWorkflowCliCommands: undefined,
approvedCliAutonomyAdapters: undefined,
enginePaused: false,
maxConcurrent: 2,
maxTriageConcurrent: 2,
@@ -562,3 +564,81 @@ export function resolvePersistAgentThinkingLog(
if (typeof settings?.persistAgentThinkingLog === "boolean") return settings.persistAgentThinkingLog;
return false;
}
// ── CLI-agent settings sanitization (U15) ───────────────────────────────────
/** Adapter ids accepted in `cliAgents`. Unknown ids are dropped at the write
* boundary so a settings file cannot carry config for non-existent adapters. */
export const CLI_AGENT_ADAPTER_IDS = Object.freeze([
"claude-code",
"codex",
"droid",
"pi",
"generic",
] as const);
/** Autonomy modes accepted in a `CliAgentSettings` entry. */
export const CLI_AGENT_AUTONOMY_MODES = Object.freeze(["default", "elevated"] as const);
function sanitizeStringArray(value: unknown): string[] | undefined {
if (!Array.isArray(value)) return undefined;
const cleaned = value
.filter((v): v is string => typeof v === "string")
.map((v) => v.trim())
.filter((v) => v.length > 0);
return cleaned.length > 0 ? cleaned : undefined;
}
/**
* Sanitize a single adapter's launch settings (U15). Drops unknown fields and
* invalid values; returns `undefined` when nothing survives (so the caller can
* omit an empty entry). Pure — no I/O.
*
* Validation rules:
* - `commandOverride`: non-empty trimmed string, else dropped.
* - `extraArgs` / `envAdditions`: arrays of non-empty trimmed strings, else dropped.
* - `autonomyMode`: one of CLI_AGENT_AUTONOMY_MODES, else dropped (falls back to
* the adapter baseline at resolution time).
*/
export function sanitizeCliAgentSettings(value: unknown): CliAgentSettings | undefined {
if (!value || typeof value !== "object" || Array.isArray(value)) return undefined;
const input = value as Record<string, unknown>;
const out: CliAgentSettings = {};
if (typeof input.commandOverride === "string") {
const trimmed = input.commandOverride.trim();
if (trimmed.length > 0) out.commandOverride = trimmed;
}
const extraArgs = sanitizeStringArray(input.extraArgs);
if (extraArgs) out.extraArgs = extraArgs;
const envAdditions = sanitizeStringArray(input.envAdditions);
if (envAdditions) out.envAdditions = envAdditions;
if (
typeof input.autonomyMode === "string" &&
(CLI_AGENT_AUTONOMY_MODES as readonly string[]).includes(input.autonomyMode)
) {
out.autonomyMode = input.autonomyMode as CliAgentSettings["autonomyMode"];
}
return Object.keys(out).length > 0 ? out : undefined;
}
/**
* Sanitize the whole `cliAgents` map at the write boundary (U15). Drops unknown
* adapter ids and any entry that sanitizes to nothing. Returns a fresh object;
* always returns an object (possibly empty) so the field round-trips cleanly.
*/
export function sanitizeCliAgentsSettings(value: unknown): Record<string, CliAgentSettings> {
if (!value || typeof value !== "object" || Array.isArray(value)) return {};
const input = value as Record<string, unknown>;
const out: Record<string, CliAgentSettings> = {};
for (const adapterId of CLI_AGENT_ADAPTER_IDS) {
if (!(adapterId in input)) continue;
const entry = sanitizeCliAgentSettings(input[adapterId]);
if (entry) out[adapterId] = entry;
}
return out;
}

View File

@@ -13664,6 +13664,50 @@ ${stepsSection}`;
} as unknown as Partial<Settings>);
}
/** Whether a CLI-agent adapter has been approved for ELEVATED autonomy in this
* project (CLI Agent Executor, U15). Mirrors the raw-command approval
* precedent; approval is per-project + per-adapter and stored in project
* settings (`approvedCliAutonomyAdapters`). */
async isCliAutonomyApproved(adapterId: string): Promise<boolean> {
const trimmed = adapterId.trim();
if (!trimmed) return false;
const settings = await this.getSettings();
const approved = (settings as { approvedCliAutonomyAdapters?: string[] }).approvedCliAutonomyAdapters;
return Array.isArray(approved) && approved.includes(trimmed);
}
/** Record approval for elevated CLI-agent autonomy for an adapter. Idempotent.
* The approving principal in v1 is the daemon-token holder (route-level). */
async approveCliAutonomy(adapterId: string): Promise<void> {
const trimmed = adapterId.trim();
if (!trimmed) throw new Error("Adapter id is required");
const settings = await this.getSettings();
const approved = (settings as { approvedCliAutonomyAdapters?: string[] }).approvedCliAutonomyAdapters ?? [];
if (approved.includes(trimmed)) return;
await this.updateSettings({
approvedCliAutonomyAdapters: [...approved, trimmed],
} as unknown as Partial<Settings>);
}
/** Revoke a previously-granted elevated-autonomy approval. Idempotent. */
async revokeCliAutonomy(adapterId: string): Promise<void> {
const trimmed = adapterId.trim();
if (!trimmed) return;
const settings = await this.getSettings();
const approved = (settings as { approvedCliAutonomyAdapters?: string[] }).approvedCliAutonomyAdapters ?? [];
if (!approved.includes(trimmed)) return;
await this.updateSettings({
approvedCliAutonomyAdapters: approved.filter((a) => a !== trimmed),
} as unknown as Partial<Settings>);
}
/** List adapters approved for elevated autonomy in this project. */
async listApprovedCliAutonomyAdapters(): Promise<string[]> {
const settings = await this.getSettings();
const approved = (settings as { approvedCliAutonomyAdapters?: string[] }).approvedCliAutonomyAdapters;
return Array.isArray(approved) ? [...approved] : [];
}
/** Read the workflow currently selected for a task, if any. */
/**
* Synchronously resolve the parsed WorkflowIr that governs a task's columns

View File

@@ -2967,6 +2967,39 @@ export interface GlobalSettings {
*
* Default: {} (empty object — no experimental features enabled). */
experimentalFeatures?: Record<string, boolean>;
/** Per-adapter CLI-agent launch configuration (CLI Agent Executor, U15).
* Keyed by adapter id (e.g. `"claude-code"`, `"codex"`, `"generic"`). Each
* entry carries operator overrides layered over the adapter's shipped
* defaults: a command override, extra args, an autonomy mode, and env
* allowlist additions. Validated + sanitized at the write boundary
* (`sanitizeCliAgentsSettings`); invalid entries/fields are dropped.
*
* Note: elevation expressed through ANY of these channels (autonomy mode,
* extra args, env additions, a non-default command override) is gated by a
* stored per-project approval at launch — see `@fusion/engine`'s
* `resolveEffectivePosture`. These settings only describe *intent*; the
* engine resolves and enforces posture. Default: {} (no overrides). */
cliAgents?: Record<string, CliAgentSettings>;
}
/** Operator launch config for one CLI-agent adapter (U15). Values are layered
* over the adapter's shipped defaults at launch. All fields optional; an empty
* object means "use shipped defaults". */
export interface CliAgentSettings {
/** Override for the binary path/name to invoke. A non-default value is treated
* as privileged (routes through the autonomy approval gate). */
commandOverride?: string;
/** Extra args appended after the adapter's computed base args. Free-form; the
* engine's elevation detector scans these for bypass markers. */
extraArgs?: string[];
/** Autonomy mode above the adapter baseline. `"default"` is the baseline (no
* elevation); `"elevated"` requests bypass-permissions-style autonomy and is
* gated. Kept as a string enum so adapters can map it to their own flags. */
autonomyMode?: "default" | "elevated";
/** Additional env var KEYS to forward from the parent process to the child.
* Names only (never values); the engine copies these from `process.env`.
* Service credentials (`FUSION_*`) are always excluded regardless. */
envAdditions?: string[];
}
export type RemoteAccessProvider = "tailscale" | "cloudflare";
@@ -3056,6 +3089,12 @@ export interface ProjectSettings {
* (trust-on-first-use). A node's command must appear here before it runs;
* named scripts (settings.scripts) never require approval. */
approvedWorkflowCliCommands?: string[];
/** CLI-agent adapter ids the project owner has approved for ELEVATED autonomy
* (CLI Agent Executor, U15). An adapter must appear here before a launch whose
* resolved posture is elevated (bypass-permissions-style) is permitted; an
* unapproved elevation fails the launch with a typed error. Approving
* principal in v1: the daemon-token holder (the single workspace owner). */
approvedCliAutonomyAdapters?: string[];
/** Engine pause (soft pause): when true, the scheduler and triage
* processor stop dispatching **new** work (scheduling, triage
* specification, and auto-merge), but currently running agent sessions
@@ -3916,6 +3955,10 @@ export {
isProjectSettingsKey,
isMergeRequestContractShadowEnabled,
resolvePersistAgentThinkingLog,
sanitizeCliAgentSettings,
sanitizeCliAgentsSettings,
CLI_AGENT_ADAPTER_IDS,
CLI_AGENT_AUTONOMY_MODES,
} from "./settings-schema.js";
export interface BoardConfig {

View File

@@ -26,6 +26,50 @@ export interface WorkflowIrNode {
config?: Record<string, unknown>;
}
/**
* Executor kinds selectable on a prompt/execute node's `config.executor` (CLI
* Agent Executor, U7). The engine reads `config.executor` as an open string; this
* union documents the recognized values and `WorkflowNodeExecutorConfig` the
* fields each one consumes. `config` itself stays an open `Record` so unknown
* keys remain forward-compatible.
*
* - `model` (default): run the prompt on the configured/override model.
* - `agent` : run as a named agent (adopt its model + persona).
* - `skill` : invoke a named skill with the prompt as input.
* - `cli` : run a named project script with the prompt via env.
* - `cli-agent` : drive a CLI coding agent (Claude Code / Codex / Droid / Pi /
* generic) in an engine-owned PTY for the execute step. Honors
* cancel/abort/re-entry semantics and positive-completion gating.
*/
export type WorkflowNodeExecutorKind = "model" | "agent" | "skill" | "cli" | "cli-agent";
/**
* The cli-agent slice of a workflow node's `config`. These ride on the open
* `WorkflowIrNode.config` record (read at U7's executor seam); they are NOT a
* separate column. The resolved values are SNAPSHOTTED at session launch — a
* mid-run edit to the node config applies to the next run only.
*/
export interface WorkflowNodeExecutorConfig {
/** Selected executor kind for this node. */
executor?: WorkflowNodeExecutorKind;
/** cli-agent: adapter id to drive the session (resolved against the registry). */
cliAdapterId?: string;
/**
* cli-agent: autonomy posture (drives privileged flags + resume caps). Stored
* verbatim; structured but extensible (mirrors `CliAutonomyPosture`).
*/
cliAutonomy?: {
autoApprove?: boolean;
maxResumeAttempts?: number;
[key: string]: unknown;
};
/**
* cli-agent: notification settings for waiting-on-input events on this node
* (origin R2/R11). Opaque to the engine seam; forwarded to the dispatch.
*/
cliNotify?: Record<string, unknown>;
}
export interface WorkflowIrEdge {
from: string;
to: string;

View File

@@ -8546,10 +8546,36 @@ export function reorderTodoItems(listId: string, itemIds: string[], projectId?:
// ── AI Sessions (Background Tasks) ─────────────────────────────────────────
/**
* Needs-attention variants for a CLI agent session (CLI Agent Executor, U11).
* Each carries pinned banner copy + action verbs:
* - userExited → Advance / Retry / Cancel task
* - authFailed → Re-authenticate / Retry
* - resume-exhausted → Relaunch fresh / Cancel task
*/
export type CliNeedsAttentionVariant = "userExited" | "authFailed" | "resume-exhausted";
export interface AiSessionSummary {
id: string;
type: "planning" | "subtask" | "mission_interview" | "milestone_interview" | "slice_interview";
status: "draft" | "generating" | "awaiting_input" | "complete" | "error";
type:
| "planning"
| "subtask"
| "mission_interview"
| "milestone_interview"
| "slice_interview"
| "cli-agent";
status:
| "draft"
| "generating"
| "awaiting_input"
| "complete"
| "error"
| "waiting_on_input"
| "needs_attention";
/** For cli-agent sessions: which needs-attention variant (drives pinned copy/actions). */
cliVariant?: CliNeedsAttentionVariant;
/** Underlying CLI session id, for action wiring (confirm-advance / re-auth / etc.). */
cliSessionId?: string;
title: string;
/** Server-derived preview of the in-progress initialPlan; only set for draft planning sessions. */
preview?: string;

View File

@@ -1,6 +1,6 @@
import "./BackgroundTasksIndicator.css";
import { useState, useRef, useEffect, useMemo } from "react";
import { Lightbulb, Layers, Target, Loader2, HelpCircle, X, Lock, AlertCircle } from "lucide-react";
import { Lightbulb, Layers, Target, Terminal, Loader2, HelpCircle, X, Lock, AlertCircle } from "lucide-react";
import { useTranslation } from "react-i18next";
import type { AiSessionSummary } from "../api";
import { useAiSessionSync } from "../hooks/useAiSessionSync";
@@ -21,6 +21,7 @@ const TYPE_ICONS = {
mission_interview: Target,
milestone_interview: Target,
slice_interview: Target,
"cli-agent": Terminal,
} as const;
export function BackgroundTasksIndicator({
@@ -49,6 +50,7 @@ export function BackgroundTasksIndicator({
mission_interview: t("backgroundTasks.typeLabel.missionInterview", "Mission Interview"),
milestone_interview: t("backgroundTasks.typeLabel.milestoneInterview", "Milestone Interview"),
slice_interview: t("backgroundTasks.typeLabel.sliceInterview", "Slice Interview"),
"cli-agent": t("backgroundTasks.typeLabel.cliAgent", "CLI Agent"),
}),
[t],
);

View File

@@ -37,6 +37,7 @@ import { AgentMentionPopup } from "./AgentMentionPopup";
import { AgentAvatar } from "./AgentAvatar";
import { FileMentionPopup } from "./FileMentionPopup";
import { CreateRoomModal } from "./CreateRoomModal";
import { CliChatSurface, type CliChatTier } from "./CliChatSurface";
import { useFileMention } from "../hooks/useFileMention";
import { useModelsCache } from "../hooks/useModelsCache";
import { useDiscoveredSkillsCache } from "../hooks/useDiscoveredSkillsCache";
@@ -2623,6 +2624,300 @@ export function ChatView({ projectId, addToast, experimentalFeatures }: ChatView
containerEl.scrollTo({ top, behavior: prefersReducedMotion ? "auto" : "smooth" });
}, []);
// ── CLI-backed chat mount (U12) ──────────────────────────────────────────
// When the active chat session selects a cli-agent executor, the message-pane
// + composer region is delegated to <CliChatSurface> (transcript + raw-terminal
// toggle for hybrid/native adapters, terminal-only for the generic adapter).
// The transcript renderer and composer renderer are the EXISTING ChatView JSX
// passed through as thunks so there is no parallel message/composer UI.
const cliAdapterId = activeSession?.cliExecutorAdapterId ?? null;
const cliChatActive = Boolean(cliAdapterId);
// Generic adapter has no structured transcript → terminal-only; every other
// bundled adapter exposes a transcript and gets the toggle (the authoritative
// tier is resolved server-side; this only needs the generic vs. non-generic
// split that drives the toggle's presence).
const cliChatTier: CliChatTier = cliAdapterId === "generic" ? "generic" : "hybrid";
// Terminal attach id: the native session linkage when known, else the chat id.
const cliTerminalSessionId = activeSession?.cliSessionFile || activeSession?.id || "";
// The session message pane and composer, captured once so both the normal
// provider path and the CLI-backed path (CliChatSurface thunks) render the
// exact same JSX — no parallel message/composer UI.
const renderSessionMessagesPane = () => (
<div className="chat-messages" ref={messagesContainerRef} onScroll={updateScrollState}>
<div ref={loadMoreSentinelRef} className="chat-load-more-sentinel">
{hasMoreMessages && messagesLoading && (
<div className="chat-loading-older">{t("chat.loadingOlderMessages", "Loading older messages…")}</div>
)}
</div>
{isStreaming ? (
<>
{messages.map((message) => (
<ChatMessageItem
key={message.id}
message={message}
forcePlain={showAllAsPlain}
agentName={agentName}
hideAssistantIdentity={hideAssistantIdentity}
showAssistantModelTag={showAssistantModelTag}
activeModelTag={activeModelTag}
activeModelProvider={activeModelProvider}
activeSessionId={activeSession?.id ?? null}
mentionAgentsByName={mentionAgentsByName}
roomContext={null}
copyAction={showProviderResponseCopy && message.role === "assistant" ? renderCopyAction(message.id, message.content) : undefined}
onScrollToTop={handleScrollMessageToTop}
/>
))}
<div className="chat-message chat-message--assistant chat-message--streaming">
{!hideAssistantIdentity && (
<div className="chat-message-avatar">
{activeModelProvider ? <ProviderIcon provider={activeModelProvider} size="sm" /> : <Bot size={14} />}
<span>{agentName}</span>
{showAssistantModelTag && <span className="chat-model-tag">{activeModelTag}</span>}
</div>
)}
{streamingText ? (
renderAssistantContent(streamingText, showAllAsPlain)
) : (
<div className="chat-message-content chat-message-content--waiting">
{streamingThinking ? t("chat.thinkingStatus", "Thinking…") : t("chat.connectingStatus", "Connecting…")}
</div>
)}
{showProviderResponseCopy && streamingText && renderCopyAction("__streaming__", streamingText, "chat-copy-response-streaming")}
{renderToolCalls(streamingToolCalls, t)}
{streamingThinking && (
<details className="chat-message-thinking">
<summary>{t("chat.thinking", "Thinking")}</summary>
<pre className="chat-message-thinking-content">{linkifyFilePaths(streamingThinking)}</pre>
</details>
)}
<div className="chat-typing-indicator">
<span />
<span />
<span />
</div>
</div>
</>
) : messagesLoading ? (
<div className="chat-empty-state">{t("chat.loadingMessages", "Loading messages...")}</div>
) : messages.length === 0 && !activeSession ? (
renderEmptyState()
) : messages.length === 0 && activeSession ? (
<div className="chat-empty-state">{t("chat.noMessagesYet", "No messages yet. Start the conversation!")}</div>
) : (
<>
{messages.map((message) => (
<ChatMessageItem
key={message.id}
message={message}
forcePlain={showAllAsPlain}
agentName={agentName}
hideAssistantIdentity={hideAssistantIdentity}
showAssistantModelTag={showAssistantModelTag}
activeModelTag={activeModelTag}
activeModelProvider={activeModelProvider}
activeSessionId={activeSession?.id ?? null}
mentionAgentsByName={mentionAgentsByName}
roomContext={null}
copyAction={showProviderResponseCopy && message.role === "assistant" ? renderCopyAction(message.id, message.content) : undefined}
onScrollToTop={handleScrollMessageToTop}
/>
))}
</>
)}
<div ref={messagesEndRef} />
</div>
);
const renderSessionComposerPane = () => (
<div className="chat-input-area">
<input
ref={fileInputRef}
type="file"
accept="image/*,.txt,.json,.yaml,.yml,.log,.csv,.xml,.md"
multiple
style={{ display: "none" }}
onChange={(event) => {
handleAttachmentFiles(event.target.files);
event.target.value = "";
}}
/>
{showSkillMenu && (
<div className="chat-skill-menu" data-testid="chat-skill-menu" role="listbox" aria-label={t("chat.skillSuggestions", "Skill suggestions")}>
{skillsLoading ? (
<div className="chat-skill-menu-empty">{t("chat.loadingSkills", "Loading skills…")}</div>
) : filteredSkills.length === 0 ? (
<div className="chat-skill-menu-empty">
{skillFilter ? t("chat.noSkillsFound", "No skills found") : t("chat.noSkillsAvailable", "No skills available")}
</div>
) : (
filteredSkills.map((skill, index) => (
<button
key={skill.id}
type="button"
role="option"
aria-selected={index === highlightedSkillIndex}
className={`chat-skill-menu-item${index === highlightedSkillIndex ? " chat-skill-menu-item--highlighted" : ""}`}
onMouseDown={(e) => e.preventDefault()}
onMouseEnter={() => setHighlightedSkillIndex(index)}
onClick={() => handleSkillSelect(skill)}
>
<span className="chat-skill-menu-item-name">{skill.name}</span>
<span className="chat-skill-menu-item-description" title={skill.relativePath}>
{skill.relativePath}
</span>
</button>
))
)}
</div>
)}
{pendingAttachments.length > 0 && (
<div className="chat-attachment-previews" data-testid="chat-attachment-previews">
{pendingAttachments.map((attachment, index) => (
<div
key={attachment.previewUrl || `${attachment.file.name}-${index}`}
className="chat-attachment-preview"
data-testid={`chat-attachment-preview-${index}`}
>
{attachment.previewUrl ? (
<img src={attachment.previewUrl} alt={attachment.file.name} />
) : (
<span className="chat-attachment-preview-name">{attachment.file.name}</span>
)}
<button
type="button"
className="chat-attachment-remove"
onClick={() => removeAttachment(index)}
data-testid={`chat-attachment-remove-${index}`}
aria-label={`Remove ${attachment.file.name}`}
>
×
</button>
</div>
))}
</div>
)}
<div className="chat-input-row">
<button
type="button"
className="btn-icon chat-attach-btn"
data-testid="chat-attach-btn"
aria-label={t("chat.attachFiles", "Attach files")}
onClick={() => fileInputRef.current?.click()}
>
<Paperclip size={16} />
</button>
<div
className={`chat-input-wrapper${isDragOver ? " chat-input-wrapper--dragover" : ""}`}
onDragOver={(event) => {
event.preventDefault();
setIsDragOver(true);
}}
onDragLeave={() => setIsDragOver(false)}
onDrop={(event) => {
event.preventDefault();
setIsDragOver(false);
handleAttachmentFiles(event.dataTransfer.files);
}}
>
<textarea
ref={handleComposerRef}
className="chat-input-textarea"
placeholder={t("chat.typeMessage", "Type a message...")}
value={messageInput}
onChange={handleInputChange}
onKeyDown={handleInputKeyDown}
onKeyUp={handleInputKeyUp}
onClick={handleInputSelectionChange}
onBlur={handleInputBlur}
onFocus={handleInputFocus}
onPaste={handlePaste}
onTouchStart={(event) => {
if (typeof window === "undefined") return;
if (window.innerWidth > 768) return;
if (!isIOS()) return;
if (document.activeElement === event.currentTarget) return;
event.preventDefault();
event.currentTarget.focus({ preventScroll: true });
}}
rows={1}
data-testid="chat-input"
/>
<AgentMentionPopup
agents={mentionAgents}
filter={mentionFilter}
highlightedIndex={mentionHighlightIndex}
visible={mentionPopupVisible}
onSelect={handleMentionSelect}
position="below"
roomMemberIds={roomContext?.memberIds}
roomName={roomContext?.roomName}
/>
<FileMentionPopup
visible={fileMention.mentionActive && !mentionPopupVisible}
position={fileMentionPosition}
tasks={fileMention.tasks}
files={fileMention.files}
selectedIndex={fileMention.selectedIndex}
onSelectTask={(task) => {
insertHashMention(fileMention.selectTask(task, messageInput), `#${task.id}`);
}}
onSelectFile={(file) => {
insertHashMention(fileMention.selectFile(file, messageInput), `#${file.path}`);
}}
loading={fileMention.loading}
/>
{pendingMessage && (
<div className="chat-pending-message" data-testid="chat-pending-indicator">
<span>{t("chat.queuedMessage", "Queued: {{preview}}", { preview: pendingPreview })}</span>
<button
type="button"
className="chat-pending-message-dismiss"
aria-label={t("chat.dismissQueuedMessage", "Dismiss queued message")}
data-testid="chat-pending-dismiss"
onClick={clearPendingMessage}
>
×
</button>
</div>
)}
</div>
{isStreaming ? (
<button
className="chat-input-stop"
onClick={stopStreaming}
aria-label={t("chat.stopGeneration", "Stop generation")}
data-testid="chat-stop-btn"
>
<Square size={14} />
</button>
) : (
<button
type="button"
className="chat-input-send"
onPointerDown={(event) => {
if (event.pointerType && event.pointerType !== "mouse") {
event.preventDefault();
}
}}
onMouseDown={(event) => {
event.preventDefault();
}}
onClick={() => {
void handleSend();
}}
disabled={!messageInput.trim() && pendingAttachments.length === 0}
data-testid="chat-send-btn"
style={{ touchAction: "manipulation" }}
>
<Send size={16} />
</button>
)}
</div>
</div>
);
return (
<div className="chat-view">
{/* Sidebar */}
@@ -3216,301 +3511,33 @@ export function ChatView({ projectId, addToast, experimentalFeatures }: ChatView
</div>
)}
{/* Messages */}
<div className="chat-messages" ref={messagesContainerRef} onScroll={updateScrollState}>
<div ref={loadMoreSentinelRef} className="chat-load-more-sentinel">
{hasMoreMessages && messagesLoading && (
<div className="chat-loading-older">{t("chat.loadingOlderMessages", "Loading older messages…")}</div>
)}
</div>
{isStreaming ? (
<>
{messages.map((message) => (
<ChatMessageItem
key={message.id}
message={message}
forcePlain={showAllAsPlain}
agentName={agentName}
hideAssistantIdentity={hideAssistantIdentity}
showAssistantModelTag={showAssistantModelTag}
activeModelTag={activeModelTag}
activeModelProvider={activeModelProvider}
activeSessionId={activeSession?.id ?? null}
mentionAgentsByName={mentionAgentsByName}
roomContext={null}
copyAction={showProviderResponseCopy && message.role === "assistant" ? renderCopyAction(message.id, message.content) : undefined}
onScrollToTop={handleScrollMessageToTop}
/>
))}
<div className="chat-message chat-message--assistant chat-message--streaming">
{!hideAssistantIdentity && (
<div className="chat-message-avatar">
{activeModelProvider ? <ProviderIcon provider={activeModelProvider} size="sm" /> : <Bot size={14} />}
<span>{agentName}</span>
{showAssistantModelTag && <span className="chat-model-tag">{activeModelTag}</span>}
</div>
)}
{streamingText ? (
renderAssistantContent(streamingText, showAllAsPlain)
) : (
<div className="chat-message-content chat-message-content--waiting">
{streamingThinking ? t("chat.thinkingStatus", "Thinking…") : t("chat.connectingStatus", "Connecting…")}
</div>
)}
{showProviderResponseCopy && streamingText && renderCopyAction("__streaming__", streamingText, "chat-copy-response-streaming")}
{renderToolCalls(streamingToolCalls, t)}
{streamingThinking && (
<details className="chat-message-thinking">
<summary>{t("chat.thinking", "Thinking")}</summary>
<pre className="chat-message-thinking-content">{linkifyFilePaths(streamingThinking)}</pre>
</details>
)}
<div className="chat-typing-indicator">
<span />
<span />
<span />
</div>
</div>
</>
) : messagesLoading ? (
<div className="chat-empty-state">{t("chat.loadingMessages", "Loading messages...")}</div>
) : messages.length === 0 && !activeSession ? (
renderEmptyState()
) : messages.length === 0 && activeSession ? (
<div className="chat-empty-state">{t("chat.noMessagesYet", "No messages yet. Start the conversation!")}</div>
) : (
<>
{messages.map((message) => (
<ChatMessageItem
key={message.id}
message={message}
forcePlain={showAllAsPlain}
agentName={agentName}
hideAssistantIdentity={hideAssistantIdentity}
showAssistantModelTag={showAssistantModelTag}
activeModelTag={activeModelTag}
activeModelProvider={activeModelProvider}
activeSessionId={activeSession?.id ?? null}
mentionAgentsByName={mentionAgentsByName}
roomContext={null}
copyAction={showProviderResponseCopy && message.role === "assistant" ? renderCopyAction(message.id, message.content) : undefined}
onScrollToTop={handleScrollMessageToTop}
/>
))}
</>
)}
<div ref={messagesEndRef} />
</div>
{isUserScrolling && (
<button
type="button"
className="btn btn-sm chat-jump-to-latest"
data-testid="chat-jump-to-latest"
onClick={() => scrollToBottom("fab-click")}
>
<ChevronDown size={14} />
{t("chat.latest", "Latest")}
</button>
)}
{/* Input */}
{activeSession && (
<div className="chat-input-area">
<input
ref={fileInputRef}
type="file"
accept="image/*,.txt,.json,.yaml,.yml,.log,.csv,.xml,.md"
multiple
style={{ display: "none" }}
onChange={(event) => {
handleAttachmentFiles(event.target.files);
event.target.value = "";
}}
/>
{showSkillMenu && (
<div className="chat-skill-menu" data-testid="chat-skill-menu" role="listbox" aria-label={t("chat.skillSuggestions", "Skill suggestions")}>
{skillsLoading ? (
<div className="chat-skill-menu-empty">{t("chat.loadingSkills", "Loading skills…")}</div>
) : filteredSkills.length === 0 ? (
<div className="chat-skill-menu-empty">
{skillFilter ? t("chat.noSkillsFound", "No skills found") : t("chat.noSkillsAvailable", "No skills available")}
</div>
) : (
filteredSkills.map((skill, index) => (
<button
key={skill.id}
type="button"
role="option"
aria-selected={index === highlightedSkillIndex}
className={`chat-skill-menu-item${index === highlightedSkillIndex ? " chat-skill-menu-item--highlighted" : ""}`}
onMouseDown={(e) => e.preventDefault()}
onMouseEnter={() => setHighlightedSkillIndex(index)}
onClick={() => handleSkillSelect(skill)}
>
<span className="chat-skill-menu-item-name">{skill.name}</span>
<span className="chat-skill-menu-item-description" title={skill.relativePath}>
{skill.relativePath}
</span>
</button>
))
)}
</div>
)}
{pendingAttachments.length > 0 && (
<div className="chat-attachment-previews" data-testid="chat-attachment-previews">
{pendingAttachments.map((attachment, index) => (
<div
key={attachment.previewUrl || `${attachment.file.name}-${index}`}
className="chat-attachment-preview"
data-testid={`chat-attachment-preview-${index}`}
>
{attachment.previewUrl ? (
<img src={attachment.previewUrl} alt={attachment.file.name} />
) : (
<span className="chat-attachment-preview-name">{attachment.file.name}</span>
)}
<button
type="button"
className="chat-attachment-remove"
onClick={() => removeAttachment(index)}
data-testid={`chat-attachment-remove-${index}`}
aria-label={`Remove ${attachment.file.name}`}
>
×
</button>
</div>
))}
</div>
)}
<div className="chat-input-row">
{/* Messages + composer. CLI-backed chat sessions delegate this
region to <CliChatSurface> (transcript/raw-terminal toggle +
queued composer); generic-tier adapters render terminal-only. */}
{cliChatActive ? (
<CliChatSurface
cliSessionId={cliTerminalSessionId}
tier={cliChatTier}
projectId={projectId}
renderTranscript={renderSessionMessagesPane}
renderComposer={() => (activeSession ? renderSessionComposerPane() : null)}
/>
) : (
<>
{renderSessionMessagesPane()}
{isUserScrolling && (
<button
type="button"
className="btn-icon chat-attach-btn"
data-testid="chat-attach-btn"
aria-label={t("chat.attachFiles", "Attach files")}
onClick={() => fileInputRef.current?.click()}
className="btn btn-sm chat-jump-to-latest"
data-testid="chat-jump-to-latest"
onClick={() => scrollToBottom("fab-click")}
>
<Paperclip size={16} />
<ChevronDown size={14} />
{t("chat.latest", "Latest")}
</button>
<div
className={`chat-input-wrapper${isDragOver ? " chat-input-wrapper--dragover" : ""}`}
onDragOver={(event) => {
event.preventDefault();
setIsDragOver(true);
}}
onDragLeave={() => setIsDragOver(false)}
onDrop={(event) => {
event.preventDefault();
setIsDragOver(false);
handleAttachmentFiles(event.dataTransfer.files);
}}
>
<textarea
ref={handleComposerRef}
className="chat-input-textarea"
placeholder={t("chat.typeMessage", "Type a message...")}
value={messageInput}
onChange={handleInputChange}
onKeyDown={handleInputKeyDown}
onKeyUp={handleInputKeyUp}
onClick={handleInputSelectionChange}
onBlur={handleInputBlur}
onFocus={handleInputFocus}
onPaste={handlePaste}
onTouchStart={(event) => {
if (typeof window === "undefined") return;
if (window.innerWidth > 768) return;
// iOS-only: see comment on the other chat-input touchstart
// handler above. On Android, preventDefault blocks the
// soft keyboard from opening.
if (!isIOS()) return;
if (document.activeElement === event.currentTarget) return;
event.preventDefault();
event.currentTarget.focus({ preventScroll: true });
}}
rows={1}
data-testid="chat-input"
/>
<AgentMentionPopup
agents={mentionAgents}
filter={mentionFilter}
highlightedIndex={mentionHighlightIndex}
visible={mentionPopupVisible}
onSelect={handleMentionSelect}
position="below"
roomMemberIds={roomContext?.memberIds}
roomName={roomContext?.roomName}
/>
<FileMentionPopup
visible={fileMention.mentionActive && !mentionPopupVisible}
position={fileMentionPosition}
tasks={fileMention.tasks}
files={fileMention.files}
selectedIndex={fileMention.selectedIndex}
onSelectTask={(task) => {
insertHashMention(fileMention.selectTask(task, messageInput), `#${task.id}`);
}}
onSelectFile={(file) => {
insertHashMention(fileMention.selectFile(file, messageInput), `#${file.path}`);
}}
loading={fileMention.loading}
/>
{pendingMessage && (
<div className="chat-pending-message" data-testid="chat-pending-indicator">
<span>{t("chat.queuedMessage", "Queued: {{preview}}", { preview: pendingPreview })}</span>
<button
type="button"
className="chat-pending-message-dismiss"
aria-label={t("chat.dismissQueuedMessage", "Dismiss queued message")}
data-testid="chat-pending-dismiss"
onClick={clearPendingMessage}
>
×
</button>
</div>
)}
</div>
{isStreaming ? (
<button
className="chat-input-stop"
onClick={stopStreaming}
aria-label={t("chat.stopGeneration", "Stop generation")}
data-testid="chat-stop-btn"
>
<Square size={14} />
</button>
) : (
<button
type="button"
className="chat-input-send"
// Keep keyboard up when sending. preventDefault fires on
// pointerdown for touch pointers (BEFORE iOS blurs the
// textarea — the synthesized mousedown is too late on
// iOS), and on mousedown for desktop. Crucially we do NOT
// call preventDefault on touchstart and we do NOT run the
// action here — both of those broke quick taps. Click
// still fires from the iOS touch sequence and runs the
// action reliably.
onPointerDown={(event) => {
if (event.pointerType && event.pointerType !== "mouse") {
event.preventDefault();
}
}}
onMouseDown={(event) => {
event.preventDefault();
}}
onClick={() => {
void handleSend();
}}
disabled={!messageInput.trim() && pendingAttachments.length === 0}
data-testid="chat-send-btn"
style={{ touchAction: "manipulation" }}
>
<Send size={16} />
</button>
)}
</div>
</div>
)}
{activeSession && renderSessionComposerPane()}
</>
)}
</div>
)}

View File

@@ -0,0 +1,125 @@
// CliChatSurface — the CLI-backed chat rendering surface (CLI Agent Executor, U12).
//
// ChatView delegates to this component when the active chat session selects a
// cli-agent executor. It encapsulates the three KTD behaviors that distinguish
// a CLI-backed chat from a provider chat:
//
// 1. Hybrid (native/hybrid tier): render the durable transcript as today PLUS a
// transcript ↔ terminal toggle. Raw-terminal mode swaps the message list for
// <SessionTerminal> and HIDES the composer (the terminal owns input);
// toggling back restores the transcript and composer.
// 2. Generic tier: terminal-ONLY. No toggle, no transcript pane — the affordance
// is absent, not empty (screen-output parsing for a structured transcript is
// out of scope for generic CLIs).
// 3. Composer queued state: while the underlying CLI session is busy, sends are
// queued with a visible indicator. The flush decision is owned server-side
// (CliChatSessionRunner, which re-fetches authoritative state — the
// stale-isGenerating learning); this component only surfaces the queued count.
//
// Rendering of the transcript message list itself stays with ChatView's existing
// renderer (passed in as `renderTranscript`) so there is no parallel message UI.
import React, { useState, type ReactNode } from "react";
import { useTranslation } from "react-i18next";
import { Terminal as TerminalIcon, MessageSquare } from "lucide-react";
import { SessionTerminal, type SessionTerminalProps } from "./SessionTerminal";
/** Adapter capability tier — drives whether a transcript view exists at all. */
export type CliChatTier = "native" | "hybrid" | "generic";
export interface CliChatSurfaceProps {
/** Live CLI session id to attach the terminal to. */
cliSessionId: string;
/** Adapter tier. Generic → terminal-only (no toggle, no transcript). */
tier: CliChatTier;
projectId?: string;
/** Renders the existing ChatView transcript message list. */
renderTranscript: () => ReactNode;
/** Renders the existing ChatView composer (hidden in raw-terminal mode). */
renderComposer: () => ReactNode;
/** Number of composer messages queued behind a busy session (0 = none). */
queuedCount?: number;
/** Extra props forwarded to SessionTerminal (posture, settings link, etc.). */
terminalProps?: Partial<Omit<SessionTerminalProps, "sessionId" | "projectId">>;
}
type SurfaceView = "transcript" | "terminal";
export function CliChatSurface({
cliSessionId,
tier,
projectId,
renderTranscript,
renderComposer,
queuedCount = 0,
terminalProps,
}: CliChatSurfaceProps) {
const { t } = useTranslation("app");
const isGeneric = tier === "generic";
// Generic tier is terminal-only; hybrid/native default to the transcript view.
const [view, setView] = useState<SurfaceView>(isGeneric ? "terminal" : "transcript");
// Generic tier: render the terminal directly, no toggle, no composer, no
// transcript pane. The terminal owns all input.
if (isGeneric) {
return (
<div className="cli-chat-surface cli-chat-surface--generic" data-tier="generic">
<SessionTerminal sessionId={cliSessionId} projectId={projectId} {...terminalProps} />
</div>
);
}
const showTerminal = view === "terminal";
return (
<div className="cli-chat-surface" data-tier={tier} data-view={view}>
<div className="cli-chat-surface__toolbar" role="tablist" aria-label={t("cliChat.viewToggleLabel", "Chat view")}>
<button
type="button"
role="tab"
aria-selected={!showTerminal}
className={`cli-chat-surface__tab${!showTerminal ? " is-active" : ""}`}
onClick={() => setView("transcript")}
>
<MessageSquare size={14} aria-hidden="true" />
<span>{t("cliChat.transcriptTab", "Transcript")}</span>
</button>
<button
type="button"
role="tab"
aria-selected={showTerminal}
className={`cli-chat-surface__tab${showTerminal ? " is-active" : ""}`}
onClick={() => setView("terminal")}
>
<TerminalIcon size={14} aria-hidden="true" />
<span>{t("cliChat.terminalTab", "Terminal")}</span>
</button>
</div>
<div className="cli-chat-surface__body">
{showTerminal ? (
// Raw-terminal mode: the message list is swapped out and the terminal
// owns input. Composer is hidden below.
<SessionTerminal sessionId={cliSessionId} projectId={projectId} {...terminalProps} />
) : (
renderTranscript()
)}
</div>
{/* Composer is hidden in raw-terminal mode — the terminal owns input. */}
{!showTerminal && (
<div className="cli-chat-surface__composer">
{queuedCount > 0 && (
<div className="cli-chat-surface__queued" role="status" aria-live="polite">
{t("cliChat.queued", "{{count}} message queued — will send when the agent is ready", {
count: queuedCount,
})}
</div>
)}
{renderComposer()}
</div>
)}
</div>
);
}
export default CliChatSurface;

View File

@@ -1,22 +1,35 @@
import "./SessionNotificationBanner.css";
import { useEffect, useMemo, useState } from "react";
import { useTranslation } from "react-i18next";
import { AlertCircle, Lightbulb, Layers, Target, X } from "lucide-react";
import type { AiSessionSummary } from "../api";
import { AlertCircle, Lightbulb, Layers, Target, Terminal, X } from "lucide-react";
import type { AiSessionSummary, CliNeedsAttentionVariant } from "../api";
type CliActionId = "advance" | "retry" | "cancel" | "reauthenticate" | "relaunch";
interface SessionNotificationBannerProps {
sessions: AiSessionSummary[];
onResumeSession: (session: AiSessionSummary) => void;
onDismissSession: (id: string) => void;
onDismissAll: () => void;
/**
* CLI agent needs-attention / confirm-advance actions (CLI Agent Executor,
* U11). `advance` wires the userExited "Advance" verb + generic-tier
* confirm-advance; the others map to existing endpoints where present, else
* are no-op callbacks marked TODO-wire by the caller.
*/
onCliAction?: (session: AiSessionSummary, action: CliActionId) => void;
}
// `cli-agent` extends the previously-closed union: a SINGLE Terminal icon for
// all adapters (reusing the banner without this entry crashes on the unknown
// type — the union-regression the U11 tests guard).
const TYPE_ICONS = {
planning: Lightbulb,
subtask: Layers,
mission_interview: Target,
milestone_interview: Target,
slice_interview: Target,
"cli-agent": Terminal,
} as const;
const TYPE_LABEL_KEYS: Record<keyof typeof TYPE_ICONS, { key: string; defaultVal: string }> = {
@@ -25,6 +38,38 @@ const TYPE_LABEL_KEYS: Record<keyof typeof TYPE_ICONS, { key: string; defaultVal
mission_interview: { key: "sessionBanner.typeLabel.missionInterview", defaultVal: "Mission Interview" },
milestone_interview: { key: "sessionBanner.typeLabel.milestoneInterview", defaultVal: "Milestone Interview" },
slice_interview: { key: "sessionBanner.typeLabel.sliceInterview", defaultVal: "Slice Interview" },
"cli-agent": { key: "sessionBanner.typeLabel.cliAgent", defaultVal: "CLI Agent" },
};
/** Action verb defaults (i18n) for each pinned needs-attention variant. */
const CLI_ACTION_LABELS: Record<CliActionId, { key: string; defaultVal: string }> = {
advance: { key: "sessionBanner.cli.advance", defaultVal: "Advance" },
retry: { key: "sessionBanner.cli.retry", defaultVal: "Retry" },
cancel: { key: "sessionBanner.cli.cancelTask", defaultVal: "Cancel task" },
reauthenticate: { key: "sessionBanner.cli.reauthenticate", defaultVal: "Re-authenticate" },
relaunch: { key: "sessionBanner.cli.relaunch", defaultVal: "Relaunch fresh" },
};
/** Pinned copy + ordered actions per needs-attention variant (U11). */
const CLI_VARIANT_SPEC: Record<
CliNeedsAttentionVariant,
{ messageKey: string; messageDefault: string; actions: CliActionId[] }
> = {
userExited: {
messageKey: "sessionBanner.cli.userExited",
messageDefault: "Agent exited before completing",
actions: ["advance", "retry", "cancel"],
},
authFailed: {
messageKey: "sessionBanner.cli.authFailed",
messageDefault: "CLI authentication failed",
actions: ["reauthenticate", "retry"],
},
"resume-exhausted": {
messageKey: "sessionBanner.cli.resumeExhausted",
messageDefault: "Couldn't resume the session",
actions: ["relaunch", "cancel"],
},
};
const STORAGE_KEY = "fusion:session-banner-dismissed";
@@ -66,6 +111,21 @@ function persistDismissed(map: Map<string, number>): void {
}
}
/**
* Statuses that warrant a banner entry. Extended for CLI agent sessions:
* `waiting_on_input` (F2) and `needs_attention` (pinned variants) join the
* existing `awaiting_input` / `error`. A CLI session returning to `busy`
* (no longer in this set) clears the banner entry — covering F2.
*/
function isNotifyingStatus(status: AiSessionSummary["status"]): boolean {
return (
status === "awaiting_input" ||
status === "error" ||
status === "waiting_on_input" ||
status === "needs_attention"
);
}
// Map of sessionId → epoch-ms timestamp at which the user dismissed the
// banner for that session. The banner re-shows the session only when the
// session's `updatedAt` advances strictly past the recorded dismissal time
@@ -78,6 +138,7 @@ export function SessionNotificationBanner({
onResumeSession,
onDismissSession,
onDismissAll,
onCliAction,
}: SessionNotificationBannerProps) {
const { t } = useTranslation("app");
const [dismissRevision, setDismissRevision] = useState(0);
@@ -98,7 +159,7 @@ export function SessionNotificationBanner({
for (const [id, dismissedAtMs] of dismissedIds) {
const session = sessionById.get(id);
if (!session) continue;
const stillNotifying = session.status === "awaiting_input" || session.status === "error";
const stillNotifying = isNotifyingStatus(session.status);
if (!stillNotifying) {
dismissedIds.delete(id);
pruned = true;
@@ -117,7 +178,7 @@ export function SessionNotificationBanner({
const sessionsNeedingInput = useMemo(
() =>
sessions.filter((session) => {
if (session.status !== "awaiting_input" && session.status !== "error") return false;
if (!isNotifyingStatus(session.status)) return false;
const dismissedAtMs = dismissedIds.get(session.id);
if (dismissedAtMs === undefined) return true;
return parseUpdatedAtMs(session.updatedAt) > dismissedAtMs;
@@ -129,8 +190,14 @@ export function SessionNotificationBanner({
return null;
}
const awaitingInputCount = sessionsNeedingInput.filter((s) => s.status === "awaiting_input").length;
const errorCount = sessionsNeedingInput.filter((s) => s.status === "error").length;
// CLI `waiting_on_input` rolls into the "needs input" count; `needs_attention`
// rolls into the "failed" count for the summary header.
const awaitingInputCount = sessionsNeedingInput.filter(
(s) => s.status === "awaiting_input" || s.status === "waiting_on_input",
).length;
const errorCount = sessionsNeedingInput.filter(
(s) => s.status === "error" || s.status === "needs_attention",
).length;
let headerText = "";
if (awaitingInputCount > 0 && errorCount > 0) {
@@ -207,6 +274,64 @@ export function SessionNotificationBanner({
{sessionsNeedingInput.map((session) => {
const Icon = TYPE_ICONS[session.type];
const isError = session.status === "error";
const variantSpec =
session.type === "cli-agent" && session.cliVariant
? CLI_VARIANT_SPEC[session.cliVariant]
: null;
// Pinned needs-attention variant: per-variant copy + ordered actions.
if (variantSpec) {
return (
<article
className="session-notification-banner__item session-notification-banner__item--cli session-notification-banner__item--error"
key={session.id}
data-session-type={session.type}
data-session-status={session.status}
data-cli-variant={session.cliVariant}
>
<div className="session-notification-banner__item-main">
<Icon size={16} className="session-notification-banner__type-icon" aria-hidden="true" />
<div className="session-notification-banner__text">
<p className="session-notification-banner__title" title={session.title}>{session.title}</p>
<p className="session-notification-banner__meta">
{t(variantSpec.messageKey, variantSpec.messageDefault)}
</p>
</div>
</div>
<div className="session-notification-banner__actions">
{variantSpec.actions.map((action) => (
<button
key={action}
className="session-notification-banner__resume"
data-cli-action={action}
onClick={() => {
// "advance" wires confirm-advance; other verbs hit
// existing endpoints or remain TODO-wire no-ops upstream.
onCliAction?.(session, action);
if (action === "cancel" || action === "advance") {
dismissLocally(session);
onDismissSession(session.id);
}
}}
>
{t(CLI_ACTION_LABELS[action].key, CLI_ACTION_LABELS[action].defaultVal)}
</button>
))}
<button
className="session-notification-banner__dismiss"
onClick={() => {
dismissLocally(session);
onDismissSession(session.id);
}}
aria-label={t("sessionBanner.dismissItem", "Dismiss {{title}}", { title: session.title })}
>
<X size={14} aria-hidden="true" />
</button>
</div>
</article>
);
}
return (
<article

View File

@@ -0,0 +1,276 @@
/* SessionTerminal (CLI Agent Executor, U11) — canonical tokens only. */
.cli-session-terminal {
display: flex;
flex-direction: column;
min-height: 0;
height: 100%;
background: var(--terminal-bg, var(--bg));
border: 1px solid var(--border);
border-radius: var(--radius-md);
overflow: hidden;
}
.cli-session-terminal__header {
display: flex;
align-items: center;
gap: var(--space-sm);
padding: var(--space-xs) var(--space-sm);
background: var(--surface);
border-bottom: 1px solid var(--border);
flex-wrap: wrap;
}
.cli-session-terminal__posture-wrap {
position: relative;
}
.cli-posture-chip {
display: inline-flex;
align-items: center;
gap: var(--space-xs);
padding: 2px var(--space-sm);
font-size: 0.75rem;
line-height: 1.4;
color: var(--text);
background: var(--card);
border: 1px solid var(--border);
border-radius: var(--radius-sm);
cursor: pointer;
transition: background var(--transition-fast, 0.12s) ease;
}
.cli-posture-chip:hover {
background: var(--card-hover);
}
.cli-posture-chip--elevated {
color: var(--warning, var(--color-warning));
border-color: var(--warning, var(--color-warning));
}
.cli-posture-chip__mode {
color: var(--text-muted);
}
.cli-posture-chip__flag {
font-weight: 600;
}
.cli-posture-tooltip {
position: absolute;
top: calc(100% + var(--space-xs));
left: 0;
z-index: 10;
min-width: 220px;
padding: var(--space-sm);
background: var(--card);
border: 1px solid var(--border);
border-radius: var(--radius-md);
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.25);
}
.cli-posture-tooltip__title {
margin: 0 0 var(--space-xs);
font-size: 0.75rem;
font-weight: 600;
color: var(--text);
}
.cli-posture-tooltip__list {
margin: 0;
padding-left: var(--space-md);
font-size: 0.75rem;
color: var(--text-muted);
}
.cli-posture-tooltip__settings {
display: inline-flex;
align-items: center;
gap: var(--space-xs);
margin-top: var(--space-sm);
padding: 2px var(--space-sm);
font-size: 0.75rem;
color: var(--accent, var(--color-primary));
background: transparent;
border: 1px solid var(--border);
border-radius: var(--radius-sm);
cursor: pointer;
}
.cli-session-terminal__readonly-badge,
.cli-session-terminal__replay-badge {
display: inline-flex;
align-items: center;
gap: var(--space-xs);
padding: 2px var(--space-sm);
font-size: 0.7rem;
color: var(--text-muted);
background: var(--card);
border: 1px solid var(--border);
border-radius: var(--radius-sm);
}
.cli-session-terminal__replay-badge[data-replay-mode="ended"] {
color: var(--text-muted);
}
.cli-session-terminal__viewport {
flex: 1 1 auto;
min-height: 0;
padding: var(--space-xs);
background: var(--terminal-bg, var(--bg));
}
.cli-session-terminal__advance-strip {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--space-sm);
padding: var(--space-sm);
background: var(--surface);
border-top: 1px solid var(--border);
flex-wrap: wrap;
}
.cli-session-terminal__advance-copy {
font-size: 0.8125rem;
color: var(--text);
}
.cli-session-terminal__advance-actions {
display: flex;
gap: var(--space-xs);
}
.cli-session-terminal__advance-btn {
padding: 4px var(--space-md);
font-size: 0.8125rem;
color: var(--button-primary-text, var(--accent-text));
background: var(--button-primary-bg, var(--accent));
border: 1px solid transparent;
border-radius: var(--radius-sm);
cursor: pointer;
}
.cli-session-terminal__advance-btn:disabled {
opacity: 0.6;
cursor: default;
}
.cli-session-terminal__advance-btn--secondary {
color: var(--text);
background: var(--card);
border-color: var(--border);
}
/* ── Mobile input model (U13) ──────────────────────────────────────────────
* Visible input field + accessory key bar. xterm's hidden-textarea input is
* unreliable on mobile (KTD), so the bar is the primary input surface. The
* bar is a fixed footer that lifts above the virtual keyboard when it opens
* (driven by useMobileKeyboard's keyboardOverlap, applied inline).
*/
.cli-session-terminal__mobile-bar {
position: sticky;
bottom: 0;
z-index: 5;
display: flex;
flex-direction: column;
gap: var(--space-xs);
padding: var(--space-xs) var(--space-sm)
calc(var(--space-sm) + env(safe-area-inset-bottom, 0px));
background: var(--surface);
border-top: 1px solid var(--border);
}
.cli-session-terminal__mobile-bar--keyboard-open {
position: fixed;
left: 0;
right: 0;
/* `bottom` is set inline to keyboardOverlap so the bar clears the keyboard. */
padding-bottom: var(--space-sm);
}
.cli-session-terminal__key-row {
display: flex;
gap: var(--space-xs);
overflow-x: auto;
-webkit-overflow-scrolling: touch;
scrollbar-width: none;
}
.cli-session-terminal__key-row::-webkit-scrollbar {
display: none;
}
.cli-terminal-key {
flex: 0 0 auto;
min-width: 40px;
min-height: 36px;
padding: var(--space-xs) var(--space-sm);
font-family: ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, monospace;
font-size: 0.8125rem;
color: var(--text);
background: var(--card);
border: 1px solid var(--border);
border-radius: var(--radius-sm);
cursor: pointer;
touch-action: manipulation;
}
.cli-terminal-key:active {
background: var(--card-hover);
}
.cli-terminal-key--ctrl.cli-terminal-key--active {
color: var(--accent-text, var(--button-primary-text));
background: var(--accent, var(--color-primary));
border-color: var(--accent, var(--color-primary));
}
.cli-terminal-key--ctrlc {
color: var(--warning, var(--color-warning));
border-color: var(--warning, var(--color-warning));
}
.cli-session-terminal__input-row {
display: flex;
gap: var(--space-xs);
}
.cli-session-terminal__mobile-input {
flex: 1 1 auto;
min-width: 0;
min-height: 38px;
padding: var(--space-xs) var(--space-sm);
font-size: 16px; /* >=16px avoids iOS focus zoom */
color: var(--text);
background: var(--input-bg, var(--bg));
border: 1px solid var(--border);
border-radius: var(--radius-sm);
}
.cli-session-terminal__mobile-input:focus {
outline: none;
border-color: var(--accent, var(--color-primary));
}
.cli-session-terminal__mobile-send {
flex: 0 0 auto;
min-height: 38px;
padding: var(--space-xs) var(--space-md);
font-size: 0.8125rem;
color: var(--button-primary-text, var(--accent-text));
background: var(--button-primary-bg, var(--accent));
border: 1px solid transparent;
border-radius: var(--radius-sm);
cursor: pointer;
touch-action: manipulation;
}
/* On mobile the terminal viewport is read-mostly; the bar drives input. */
@media (max-width: 768px), (max-height: 480px) {
.cli-session-terminal__viewport {
-webkit-overflow-scrolling: touch;
}
}

View File

@@ -0,0 +1,730 @@
import "./SessionTerminal.css";
import "@xterm/xterm/css/xterm.css";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import { Terminal as TerminalIcon, ShieldAlert, Settings, Eye } from "lucide-react";
import type { Terminal as XTerm, ITerminalAddon } from "@xterm/xterm";
import { appendTokenQuery } from "../auth";
import { api } from "../api";
import { useMobileKeyboard } from "../hooks/useMobileKeyboard";
/**
* SessionTerminal (CLI Agent Executor, U11) — shared xterm terminal for a CLI
* agent session. Lazy-loads xterm + fit/webgl/unicode11 addons (kept out of the
* main bundle), bridges to the U10 WebSocket attach channel with ACK flow
* control, and renders the posture chip / read-only badge / confirm-advance
* strip / replay states described in the U11 visibility matrix.
*
* The WS bridge:
* 1. POST /api/cli-sessions/:id/attach-ticket → { ticket }
* 2. open WS /api/cli-sessions/ws?sessionId=&ticket= (fn_token carried on URL)
* 3. base64 scrollback/data → term.write; term.onData → input frames
* 4. fit + debounced ResizeObserver → resize frames
* 5. ACK {type:"ack",bytes} via term.write callbacks (~32KB cadence)
*/
/** ACK cadence — ACK roughly every 32KB of consumed output. */
const ACK_THRESHOLD_BYTES = 32 * 1024;
const RESIZE_DEBOUNCE_MS = 100;
/**
* Canonical mobile breakpoint (matches the repo CSS convention). Landscape
* phones exceed 768px wide, so the height clause covers them too.
*/
const MOBILE_MEDIA_QUERY = "(max-width: 768px), (max-height: 480px)";
/**
* Control sequences emitted by the accessory key bar (U13). These are
* deliberate user keystrokes routed straight to the session input path —
* exempt from U2's injected-text neutralization (which governs composed /
* injected strings, not real keystrokes).
*/
const SEQ_ESC = "\x1b"; // 0x1B
const SEQ_TAB = "\x09"; // 0x09
const SEQ_CTRL_C = "\x03"; // 0x03
const SEQ_ARROW_UP = "\x1b[A"; // CSI A
const SEQ_ARROW_DOWN = "\x1b[B"; // CSI B
const SEQ_ARROW_RIGHT = "\x1b[C"; // CSI C
const SEQ_ARROW_LEFT = "\x1b[D"; // CSI D
/**
* Resolve the control byte for a sticky-Ctrl + key combination. Ctrl maps a
* letter to its control code (A→0x01 … Z→0x1A): code = (toUpper(ch) & 0x1f).
* Returns null for keys that have no meaningful Ctrl combination.
*/
function ctrlCombo(key: string): string | null {
if (key.length !== 1) return null;
const upper = key.toUpperCase();
const code = upper.charCodeAt(0);
if (code >= 0x40 && code <= 0x5f) {
// @ A-Z [ \ ] ^ _ → 0x00-0x1F
return String.fromCharCode(code & 0x1f);
}
return null;
}
/** Reactive mobile-viewport detection via the repo breakpoint convention. */
function useIsMobileViewport(): boolean {
const [isMobile, setIsMobile] = useState<boolean>(() => {
if (typeof window === "undefined" || typeof window.matchMedia !== "function") {
return false;
}
return window.matchMedia(MOBILE_MEDIA_QUERY).matches;
});
useEffect(() => {
if (typeof window === "undefined" || typeof window.matchMedia !== "function") {
return;
}
const mql = window.matchMedia(MOBILE_MEDIA_QUERY);
const onChange = () => setIsMobile(mql.matches);
onChange();
// Safari < 14 only has addListener/removeListener.
if (typeof mql.addEventListener === "function") {
mql.addEventListener("change", onChange);
return () => mql.removeEventListener("change", onChange);
}
mql.addListener(onChange);
return () => mql.removeListener(onChange);
}, []);
return isMobile;
}
/** The posture surfaced on the session record (denormalized at launch, U15). */
export interface SessionTerminalPosture {
/** Adapter display name (single Terminal icon for all adapters). */
adapterName: string;
/** Resolved autonomy mode label (e.g. "auto-approve", "default"). */
mode?: string;
/**
* Whether the resolved argv+env elevates above the adapter baseline. When
* true the chip renders in warning color with a shield naming the flag.
*/
elevated?: boolean;
/** The elevated flag(s), named on the chip / tooltip when elevated. */
elevatedFlags?: string[];
/** Resolved posture lines shown in the click tooltip. */
resolved?: string[];
}
/** Replay/live mode for the terminal viewport. */
export type SessionTerminalMode = "live" | "idle" | "ended";
export interface SessionTerminalProps {
sessionId: string;
/** When true, term.onData is dropped (one-shot / replay sessions). */
readOnly?: boolean;
posture?: SessionTerminalPosture;
/** Drives the replay header: live | "session idle" | "session ended". */
mode?: SessionTerminalMode;
projectId?: string;
/** Generic-tier idle confirm-advance strip — POST confirm-advance on Advance. */
onConfirmAdvance?: (decision: "advance" | "not-yet") => void | Promise<void>;
/** Whether the confirm-advance strip is offered (generic-tier idle). */
showConfirmAdvance?: boolean;
/** Settings deep link for the posture chip tooltip. */
onOpenAdapterSettings?: () => void;
}
interface AttachTicketResponse {
ticket: string;
expiresAt: string;
readOnly: boolean;
}
/** Build the WS URL for the cli-sessions attach channel (mirrors useTerminal). */
function buildCliWsUrl(sessionId: string, ticket: string): string {
if (typeof window === "undefined") return "";
const protocol = window.location.protocol === "https:" ? "wss:" : "ws:";
const base =
`${protocol}//${window.location.host}/api/cli-sessions/ws` +
`?sessionId=${encodeURIComponent(sessionId)}&ticket=${encodeURIComponent(ticket)}`;
return appendTokenQuery(base);
}
function decodeBase64ToString(b64: string): string {
if (typeof window !== "undefined" && typeof window.atob === "function") {
// atob → binary string → UTF-8 decode.
const binary = window.atob(b64);
const bytes = new Uint8Array(binary.length);
for (let i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i);
return new TextDecoder("utf-8").decode(bytes);
}
return Buffer.from(b64, "base64").toString("utf8");
}
export function SessionTerminal({
sessionId,
readOnly = false,
posture,
mode = "live",
projectId,
onConfirmAdvance,
showConfirmAdvance = false,
onOpenAdapterSettings,
}: SessionTerminalProps) {
const { t } = useTranslation("app");
const containerRef = useRef<HTMLDivElement | null>(null);
const xtermRef = useRef<XTerm | null>(null);
const fitAddonRef = useRef<ITerminalAddon | null>(null);
const wsRef = useRef<WebSocket | null>(null);
const [postureTooltipOpen, setPostureTooltipOpen] = useState(false);
const [advanceDismissed, setAdvanceDismissed] = useState(false);
const [advancePending, setAdvancePending] = useState(false);
// ── Mobile input model (U13) ───────────────────────────────────────────────
const isMobile = useIsMobileViewport();
// Only arm keyboard tracking on mobile (the hook no-ops off-mobile anyway).
const { keyboardOpen, keyboardOverlap } = useMobileKeyboard({ enabled: isMobile });
const inputRef = useRef<HTMLInputElement | null>(null);
const [mobileInput, setMobileInput] = useState("");
// Sticky Ctrl: tap Ctrl, then the next tapped key combines into a control
// sequence (Ctrl-C → 0x03, Ctrl-D → 0x04, Ctrl-Z → 0x1A).
const [ctrlSticky, setCtrlSticky] = useState(false);
/** Write raw bytes to the session input path (mobile bar + submit). */
const sendInput = useCallback((data: string) => {
if (!data) return;
const ws = wsRef.current;
if (ws?.readyState === WebSocket.OPEN) {
ws.send(JSON.stringify({ type: "input", data }));
}
}, []);
/**
* Emit one accessory-bar key. If sticky Ctrl is active and the key has a
* Ctrl combination, send the combined control byte and clear the modifier;
* otherwise send the literal sequence. Keeps the input focused (the caller's
* pointerdown preventDefault stops the blur).
*/
const emitBarKey = useCallback(
(seq: string) => {
if (ctrlSticky) {
const combined = ctrlCombo(seq);
setCtrlSticky(false);
if (combined) {
sendInput(combined);
return;
}
}
sendInput(seq);
},
[ctrlSticky, sendInput],
);
/** iOS composer pattern: keep focus on the visible input when tapping a key. */
const keepFocus = useCallback((e: { preventDefault: () => void }) => {
e.preventDefault();
}, []);
const handleMobileSubmit = useCallback(
(e?: { preventDefault?: () => void }) => {
e?.preventDefault?.();
// User-typed text + Enter — deliberate input, no neutralization.
if (mobileInput) sendInput(mobileInput);
sendInput("\r");
setMobileInput("");
},
[mobileInput, sendInput],
);
/**
* Input onChange. When sticky Ctrl is armed, the next typed character is
* captured as a Ctrl combination (Ctrl-D `0x04`, Ctrl-Z `0x1A`, …) instead of
* landing in the field — this is how Ctrl-letter chords beyond the bar's
* dedicated Ctrl-C are reached on mobile. Otherwise the value updates
* normally for free-text + Enter submit.
*/
const handleMobileInputChange = useCallback(
(next: string) => {
if (ctrlSticky && next.length > mobileInput.length) {
// The newly-typed character is the last one appended.
const ch = next.slice(mobileInput.length, mobileInput.length + 1);
const combined = ctrlCombo(ch);
setCtrlSticky(false);
if (combined) {
sendInput(combined);
return; // swallow — do not echo the raw key into the field
}
}
setMobileInput(next);
},
[ctrlSticky, mobileInput, sendInput],
);
// Re-arm the strip whenever a fresh idle window is offered.
useEffect(() => {
if (showConfirmAdvance) setAdvanceDismissed(false);
}, [showConfirmAdvance, sessionId]);
// ── xterm lifecycle + WS bridge ──────────────────────────────────────────
useEffect(() => {
if (!sessionId || typeof window === "undefined") return;
let disposed = false;
let resizeObserver: ResizeObserver | null = null;
let resizeTimer: ReturnType<typeof setTimeout> | null = null;
let unackedBytes = 0;
const sendResize = (cols: number, rows: number) => {
const ws = wsRef.current;
if (ws?.readyState === WebSocket.OPEN) {
ws.send(JSON.stringify({ type: "resize", cols, rows }));
}
};
const ackBytes = (n: number) => {
unackedBytes += n;
if (unackedBytes < ACK_THRESHOLD_BYTES) return;
const ws = wsRef.current;
if (ws?.readyState === WebSocket.OPEN) {
ws.send(JSON.stringify({ type: "ack", bytes: unackedBytes }));
}
unackedBytes = 0;
};
const init = async () => {
// 1. Mint a single-use attach ticket via the app API helper.
let ticketRes: AttachTicketResponse;
try {
ticketRes = await api<AttachTicketResponse>(
`/cli-sessions/${encodeURIComponent(sessionId)}/attach-ticket`,
{ method: "POST", body: JSON.stringify(projectId ? { projectId } : {}) },
);
} catch {
return; // surfaced via the "disconnected" state header below
}
if (disposed) return;
// 2. Lazy-load xterm + addons (out of the main bundle).
const [{ Terminal }, { FitAddon }, { Unicode11Addon }] = await Promise.all([
import("@xterm/xterm"),
import("@xterm/addon-fit"),
import("@xterm/addon-unicode11"),
]);
if (disposed || !containerRef.current) return;
const term = new Terminal({
convertEol: false,
cursorBlink: !readOnly && mode === "live",
disableStdin: readOnly,
scrollback: 10000,
// Defensive: do NOT register an OSC 52 (clipboard-write) handler. The
// server-side neutralizer (U10) strips it; we add no client handling.
fontFamily:
'ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, monospace',
fontSize: 13,
});
const fitAddon = new FitAddon();
term.loadAddon(fitAddon);
const unicode11 = new Unicode11Addon();
term.loadAddon(unicode11);
term.unicode.activeVersion = "11";
term.open(containerRef.current);
xtermRef.current = term;
fitAddonRef.current = fitAddon as unknown as ITerminalAddon;
// WebGL renderer with context-loss fallback to the DOM renderer.
try {
const { WebglAddon } = await import("@xterm/addon-webgl");
if (!disposed) {
const webgl = new WebglAddon();
webgl.onContextLoss(() => {
try {
webgl.dispose();
} catch {
/* fall back to DOM renderer */
}
});
term.loadAddon(webgl);
}
} catch {
/* WebGL unavailable — DOM renderer is the default fallback */
}
try {
(fitAddon as unknown as { fit: () => void }).fit();
} catch {
/* container not measurable yet */
}
// term.onData → input frames (skip entirely when read-only).
if (!readOnly) {
term.onData((data: string) => {
const ws = wsRef.current;
if (ws?.readyState === WebSocket.OPEN) {
ws.send(JSON.stringify({ type: "input", data }));
}
});
}
// Debounced ResizeObserver → resize frames.
resizeObserver = new ResizeObserver(() => {
if (resizeTimer) clearTimeout(resizeTimer);
resizeTimer = setTimeout(() => {
try {
(fitAddon as unknown as { fit: () => void }).fit();
sendResize(term.cols, term.rows);
} catch {
/* ignore transient measure failures */
}
}, RESIZE_DEBOUNCE_MS);
});
resizeObserver.observe(containerRef.current);
// 3. Open the WS attach channel.
const ws = new WebSocket(buildCliWsUrl(sessionId, ticketRes.ticket));
wsRef.current = ws;
ws.onopen = () => {
sendResize(term.cols, term.rows);
};
ws.onmessage = (event) => {
let msg: { type?: string; data?: string };
try {
msg = JSON.parse(typeof event.data === "string" ? event.data : "");
} catch {
return;
}
switch (msg.type) {
case "scrollback":
case "data": {
if (typeof msg.data !== "string") return;
const text = decodeBase64ToString(msg.data);
const byteLen = text.length;
// ACK once xterm has flushed the chunk to the screen.
term.write(text, () => ackBytes(byteLen));
break;
}
// state / error / exit frames are advisory; the SSE channel and the
// mode prop drive header copy. We intentionally do not mutate the
// viewport on them.
default:
break;
}
};
};
void init();
return () => {
disposed = true;
if (resizeTimer) clearTimeout(resizeTimer);
if (resizeObserver) resizeObserver.disconnect();
const ws = wsRef.current;
if (ws) {
ws.onopen = null;
ws.onmessage = null;
ws.onclose = null;
ws.onerror = null;
try {
ws.close();
} catch {
/* already closing */
}
wsRef.current = null;
}
const term = xtermRef.current;
if (term) {
try {
term.dispose();
} catch {
/* ignore */
}
xtermRef.current = null;
}
fitAddonRef.current = null;
};
}, [sessionId, readOnly, mode, projectId]);
const replayLabel = useMemo(() => {
if (mode === "idle") return t("cliTerminal.replayIdle", "Session idle");
if (mode === "ended") return t("cliTerminal.replayEnded", "Session ended");
return null;
}, [mode, t]);
const handleAdvance = useCallback(async () => {
if (!onConfirmAdvance) return;
setAdvancePending(true);
try {
await onConfirmAdvance("advance");
setAdvanceDismissed(true);
} finally {
setAdvancePending(false);
}
}, [onConfirmAdvance]);
const handleNotYet = useCallback(async () => {
if (onConfirmAdvance) await onConfirmAdvance("not-yet");
// "Not yet" stays in execute and re-arms the idle timer (server-side); the
// strip hides until the next idle window re-offers it.
setAdvanceDismissed(true);
}, [onConfirmAdvance]);
const elevated = Boolean(posture?.elevated);
const flagSummary = posture?.elevatedFlags?.join(", ");
return (
<div
className={`cli-session-terminal${isMobile ? " cli-session-terminal--mobile" : ""}${
isMobile && keyboardOpen ? " cli-session-terminal--keyboard-open" : ""
}`}
data-mode={mode}
data-read-only={readOnly}
data-mobile={isMobile}
data-keyboard-open={isMobile && keyboardOpen}
>
<header className="cli-session-terminal__header">
{posture && (
<div className="cli-session-terminal__posture-wrap">
<button
type="button"
className={`cli-posture-chip${elevated ? " cli-posture-chip--elevated" : ""}`}
data-elevated={elevated}
aria-expanded={postureTooltipOpen}
onClick={() => setPostureTooltipOpen((v) => !v)}
>
{elevated ? (
<ShieldAlert size={13} aria-hidden="true" />
) : (
<TerminalIcon size={13} aria-hidden="true" />
)}
<span className="cli-posture-chip__name">{posture.adapterName}</span>
{posture.mode && (
<span className="cli-posture-chip__mode">{posture.mode}</span>
)}
{elevated && flagSummary && (
<span className="cli-posture-chip__flag">{flagSummary}</span>
)}
</button>
{postureTooltipOpen && (
<div className="cli-posture-tooltip" role="tooltip">
<p className="cli-posture-tooltip__title">
{t("cliTerminal.postureResolved", "Resolved posture")}
</p>
<ul className="cli-posture-tooltip__list">
{(posture.resolved ?? []).map((line, i) => (
<li key={i}>{line}</li>
))}
{(posture.resolved ?? []).length === 0 && (
<li>{posture.mode ?? t("cliTerminal.postureBaseline", "Baseline")}</li>
)}
</ul>
{onOpenAdapterSettings && (
<button
type="button"
className="cli-posture-tooltip__settings"
onClick={() => {
setPostureTooltipOpen(false);
onOpenAdapterSettings();
}}
>
<Settings size={12} aria-hidden="true" />
{t("cliTerminal.adapterSettings", "Adapter settings")}
</button>
)}
</div>
)}
</div>
)}
{readOnly && (
<span className="cli-session-terminal__readonly-badge">
<Eye size={12} aria-hidden="true" />
{t("cliTerminal.readOnly", "Read-only")}
</span>
)}
{replayLabel && (
<span className="cli-session-terminal__replay-badge" data-replay-mode={mode}>
{replayLabel}
</span>
)}
</header>
<div
className="cli-session-terminal__viewport"
ref={containerRef}
data-testid="cli-terminal-viewport"
/>
{showConfirmAdvance && !advanceDismissed && (
<div className="cli-session-terminal__advance-strip" role="region">
<span className="cli-session-terminal__advance-copy">
{t(
"cliTerminal.advancePrompt",
"This session looks idle — advance to review?",
)}
</span>
<div className="cli-session-terminal__advance-actions">
<button
type="button"
className="cli-session-terminal__advance-btn"
disabled={advancePending}
onClick={handleAdvance}
>
{t("cliTerminal.advance", "Advance")}
</button>
<button
type="button"
className="cli-session-terminal__advance-btn cli-session-terminal__advance-btn--secondary"
disabled={advancePending}
onClick={handleNotYet}
>
{t("cliTerminal.notYet", "Not yet")}
</button>
</div>
</div>
)}
{isMobile && !readOnly && (
<div
className={`cli-session-terminal__mobile-bar${
keyboardOpen ? " cli-session-terminal__mobile-bar--keyboard-open" : ""
}`}
data-testid="cli-terminal-mobile-bar"
style={
// Lift the fixed footer above the virtual keyboard when it's open.
keyboardOpen ? { bottom: `${keyboardOverlap}px` } : undefined
}
>
<div
className="cli-session-terminal__key-row"
data-testid="cli-terminal-key-bar"
>
<button
type="button"
className={`cli-terminal-key cli-terminal-key--ctrl${
ctrlSticky ? " cli-terminal-key--active" : ""
}`}
data-testid="cli-key-ctrl"
aria-label={t("cliTerminal.mobileKeyCtrl", "Sticky Ctrl modifier")}
aria-pressed={ctrlSticky}
onPointerDown={keepFocus}
onMouseDown={keepFocus}
onClick={() => setCtrlSticky((v) => !v)}
>
Ctrl
</button>
<button
type="button"
className="cli-terminal-key"
data-testid="cli-key-esc"
aria-label={t("cliTerminal.mobileKeyEsc", "Send Escape")}
onPointerDown={keepFocus}
onMouseDown={keepFocus}
onClick={() => emitBarKey(SEQ_ESC)}
>
Esc
</button>
<button
type="button"
className="cli-terminal-key"
data-testid="cli-key-tab"
aria-label={t("cliTerminal.mobileKeyTab", "Send Tab")}
onPointerDown={keepFocus}
onMouseDown={keepFocus}
onClick={() => emitBarKey(SEQ_TAB)}
>
Tab
</button>
<button
type="button"
className="cli-terminal-key cli-terminal-key--ctrlc"
data-testid="cli-key-ctrl-c"
aria-label={t("cliTerminal.mobileKeyCtrlC", "Send Ctrl-C")}
onPointerDown={keepFocus}
onMouseDown={keepFocus}
onClick={() => {
// Dedicated shortcut: always Ctrl-C, regardless of sticky state.
setCtrlSticky(false);
sendInput(SEQ_CTRL_C);
}}
>
^C
</button>
<button
type="button"
className="cli-terminal-key"
data-testid="cli-key-arrow-up"
aria-label={t("cliTerminal.mobileKeyArrowUp", "Cursor up")}
onPointerDown={keepFocus}
onMouseDown={keepFocus}
onClick={() => emitBarKey(SEQ_ARROW_UP)}
>
↑
</button>
<button
type="button"
className="cli-terminal-key"
data-testid="cli-key-arrow-down"
aria-label={t("cliTerminal.mobileKeyArrowDown", "Cursor down")}
onPointerDown={keepFocus}
onMouseDown={keepFocus}
onClick={() => emitBarKey(SEQ_ARROW_DOWN)}
>
↓
</button>
<button
type="button"
className="cli-terminal-key"
data-testid="cli-key-arrow-left"
aria-label={t("cliTerminal.mobileKeyArrowLeft", "Cursor left")}
onPointerDown={keepFocus}
onMouseDown={keepFocus}
onClick={() => emitBarKey(SEQ_ARROW_LEFT)}
>
←
</button>
<button
type="button"
className="cli-terminal-key"
data-testid="cli-key-arrow-right"
aria-label={t("cliTerminal.mobileKeyArrowRight", "Cursor right")}
onPointerDown={keepFocus}
onMouseDown={keepFocus}
onClick={() => emitBarKey(SEQ_ARROW_RIGHT)}
>
→
</button>
</div>
<form
className="cli-session-terminal__input-row"
onSubmit={handleMobileSubmit}
>
<input
ref={inputRef}
type="text"
className="cli-session-terminal__mobile-input"
data-testid="cli-terminal-mobile-input"
value={mobileInput}
placeholder={t(
"cliTerminal.mobileInputPlaceholder",
"Type to send to the session…",
)}
autoCapitalize="off"
autoCorrect="off"
autoComplete="off"
spellCheck={false}
onChange={(e) => handleMobileInputChange(e.target.value)}
/>
<button
type="submit"
className="cli-session-terminal__mobile-send"
data-testid="cli-terminal-mobile-send"
aria-label={t("cliTerminal.mobileSend", "Send")}
// iOS pattern: act on click, preventDefault on pointer/mouse down
// so the input doesn't blur (which dismisses the keyboard).
onPointerDown={keepFocus}
onMouseDown={keepFocus}
onClick={() => handleMobileSubmit()}
>
{t("cliTerminal.mobileSend", "Send")}
</button>
</form>
</div>
)}
</div>
);
}

View File

@@ -223,6 +223,7 @@ const SETTINGS_SECTIONS: SettingsSection[] = [
{ id: "notifications", label: "Notifications", labelKey: "settings.nav.notifications", scope: "global" },
{ id: "node-sync", label: "Node Sync", labelKey: "settings.nav.nodeSync", scope: "global" },
{ id: "global-models", label: "Models", labelKey: "settings.nav.globalModels", scope: "global" },
{ id: "cli-agents", label: "CLI Agents", labelKey: "settings.nav.cliAgents", scope: "global" },
{ id: "research-global", label: "Research Defaults", labelKey: "settings.nav.researchGlobal", scope: "global" },
{ id: "remote", label: "Remote Access & Node Sync", labelKey: "settings.nav.remote", scope: "global" },
{ id: "experimental", label: "Experimental Features", labelKey: "settings.nav.experimental", scope: "global" },
@@ -353,6 +354,233 @@ interface SettingsModalProps {
onOpenWorkflowSettings?: () => void;
}
/** Adapter descriptor served by GET /api/cli-agents (U15). */
interface CliAdapterDescriptorView {
id: string;
name: string;
tier: "native" | "hybrid" | "generic";
defaultCommand: string | null;
}
interface CliAgentSettingsEntry {
commandOverride?: string;
extraArgs?: string[];
autonomyMode?: "default" | "elevated";
envAdditions?: string[];
}
/**
* Per-adapter CLI-agent launch settings section (U15). Reads the adapter catalog
* + persisted settings + per-project autonomy approval state, and lets the
* operator edit command override / extra args / env additions / autonomy mode.
* Switching an adapter to elevated autonomy goes through an explicit
* confirmation flow before the per-project approval is granted.
*/
function CliAgentsSettingsSection({
projectId: _projectId,
addToast,
}: {
projectId?: string;
addToast: (message: string, type?: ToastType) => void;
}) {
const { t } = useTranslation("app");
const { confirm } = useConfirm();
const [adapters, setAdapters] = useState<CliAdapterDescriptorView[]>([]);
const [settings, setSettings] = useState<Record<string, CliAgentSettingsEntry>>({});
const [approved, setApproved] = useState<Record<string, boolean>>({});
const [selectedId, setSelectedId] = useState<string>("");
useEffect(() => {
let cancelled = false;
(async () => {
try {
const [catRes, setRes] = await Promise.all([
fetch("/api/cli-agents"),
fetch("/api/cli-agents/settings"),
]);
const cat = catRes.ok ? await catRes.json() : { adapters: [] };
const set = setRes.ok ? await setRes.json() : { cliAgents: {} };
if (cancelled) return;
const list = (cat.adapters ?? []) as CliAdapterDescriptorView[];
setAdapters(list);
setSettings((set.cliAgents ?? {}) as Record<string, CliAgentSettingsEntry>);
if (list.length > 0) setSelectedId((prev) => prev || list[0].id);
// Approval state is per-adapter; fetch lazily per selection below.
} catch {
// Non-fatal: render the static fallback list.
}
})();
return () => {
cancelled = true;
};
}, []);
useEffect(() => {
if (!selectedId) return;
let cancelled = false;
fetch(`/api/cli-agents/${selectedId}/autonomy`)
.then((r) => (r.ok ? r.json() : null))
.then((data) => {
if (cancelled || !data) return;
setApproved((prev) => ({ ...prev, [selectedId]: Boolean(data.approved) }));
})
.catch(() => {});
return () => {
cancelled = true;
};
}, [selectedId]);
const current = settings[selectedId] ?? {};
const persist = useCallback(
async (adapterId: string, config: CliAgentSettingsEntry) => {
try {
const res = await fetch("/api/cli-agents/settings", {
method: "PUT",
headers: { "content-type": "application/json" },
body: JSON.stringify({ adapterId, config }),
});
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const data = await res.json();
setSettings((data.cliAgents ?? {}) as Record<string, CliAgentSettingsEntry>);
} catch (err) {
addToast(getErrorMessage(err) || t("settings.cliAgents.saveFailed"), "error");
}
},
[addToast, t],
);
const updateCurrent = useCallback(
(patch: Partial<CliAgentSettingsEntry>) => {
if (!selectedId) return;
const next = { ...current, ...patch };
setSettings((prev) => ({ ...prev, [selectedId]: next }));
void persist(selectedId, next);
},
[selectedId, current, persist],
);
const onAutonomyChange = useCallback(
async (mode: "default" | "elevated") => {
if (!selectedId) return;
if (mode === "elevated") {
const ok = await confirm({
title: t("settings.cliAgents.elevatedConfirmTitle"),
message: t("settings.cliAgents.elevatedConfirmBody"),
confirmLabel: t("settings.cliAgents.elevatedConfirmAction"),
danger: true,
});
if (!ok) return;
// Record the per-project approval first, then persist the mode.
try {
const res = await fetch(`/api/cli-agents/${selectedId}/approve-autonomy`, {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ confirm: true }),
});
if (!res.ok) throw new Error(`HTTP ${res.status}`);
setApproved((prev) => ({ ...prev, [selectedId]: true }));
} catch (err) {
addToast(getErrorMessage(err) || t("settings.cliAgents.approveFailed"), "error");
return;
}
}
updateCurrent({ autonomyMode: mode });
},
[selectedId, confirm, t, addToast, updateCurrent],
);
return (
<div data-testid="cli-agents-settings">
<h4 className="settings-section-heading">{t("settings.cliAgents.heading")}</h4>
<p className="settings-section-description">{t("settings.cliAgents.description")}</p>
<div className="form-group">
<label htmlFor="cliAgentAdapter">{t("settings.cliAgents.adapterLabel")}</label>
<select
id="cliAgentAdapter"
value={selectedId}
onChange={(e) => setSelectedId(e.target.value)}
>
{adapters.map((a) => (
<option key={a.id} value={a.id}>
{a.name} ({t(`settings.cliAgents.tier.${a.tier}`)})
</option>
))}
</select>
</div>
{selectedId && (
<>
<div className="form-group">
<label htmlFor="cliAgentCommand">{t("settings.cliAgents.commandLabel")}</label>
<input
id="cliAgentCommand"
type="text"
placeholder={
adapters.find((a) => a.id === selectedId)?.defaultCommand ?? ""
}
value={current.commandOverride ?? ""}
onChange={(e) => updateCurrent({ commandOverride: e.target.value || undefined })}
/>
<p className="settings-field-help">{t("settings.cliAgents.commandHelp")}</p>
</div>
<div className="form-group">
<label htmlFor="cliAgentExtraArgs">{t("settings.cliAgents.extraArgsLabel")}</label>
<input
id="cliAgentExtraArgs"
type="text"
value={(current.extraArgs ?? []).join(" ")}
onChange={(e) =>
updateCurrent({
extraArgs: e.target.value.split(/\s+/).filter((s) => s.length > 0),
})
}
/>
<p className="settings-field-help">{t("settings.cliAgents.extraArgsHelp")}</p>
</div>
<div className="form-group">
<label htmlFor="cliAgentEnv">{t("settings.cliAgents.envLabel")}</label>
<input
id="cliAgentEnv"
type="text"
value={(current.envAdditions ?? []).join(", ")}
onChange={(e) =>
updateCurrent({
envAdditions: e.target.value
.split(",")
.map((s) => s.trim())
.filter((s) => s.length > 0),
})
}
/>
<p className="settings-field-help">{t("settings.cliAgents.envHelp")}</p>
</div>
<div className="form-group">
<label htmlFor="cliAgentAutonomy">{t("settings.cliAgents.autonomyLabel")}</label>
<select
id="cliAgentAutonomy"
value={current.autonomyMode ?? "default"}
onChange={(e) => void onAutonomyChange(e.target.value as "default" | "elevated")}
>
<option value="default">{t("settings.cliAgents.autonomy.default")}</option>
<option value="elevated">{t("settings.cliAgents.autonomy.elevated")}</option>
</select>
<p className="settings-field-help">
{approved[selectedId]
? t("settings.cliAgents.approvedNote")
: t("settings.cliAgents.autonomyHelp")}
</p>
</div>
</>
)}
</div>
);
}
export function SettingsModal({
onClose,
addToast,
@@ -2195,6 +2423,13 @@ export function SettingsModal({
const renderSectionFields = () => {
switch (activeSection) {
case "cli-agents":
return (
<>
{renderScopeBanner()}
<CliAgentsSettingsSection projectId={projectId} addToast={addToast} />
</>
);
case "general":
return (
<GeneralSection

View File

@@ -407,6 +407,25 @@ interface TaskCardProps {
/** Card-placed custom field definitions for this task's workflow (U13/KTD-14).
* Empty/undefined → no field badges render (card byte-identical to today). */
cardFieldDefs?: WorkflowFieldDefinition[];
/**
* CLI agent session state for this task's session (CLI Agent Executor, U11).
* Drives the waiting-on-input / needs-attention card badges, which are
* DISTINCT from staleness/stall badges (which U8 suppresses in these states).
* Undefined when the task has no CLI session → no badge (card unchanged).
*/
cliSessionState?: CliCardState;
}
/** Minimal CLI session shape the card needs for its badges (U11). */
export interface CliCardState {
agentState:
| "starting"
| "ready"
| "busy"
| "waitingOnInput"
| "done"
| "dead"
| "needsAttention";
}
function getTaskPrimaryPrInfo(task: Pick<Task, "prInfo" | "prInfos">): PrInfo | undefined {
@@ -540,6 +559,7 @@ function areTaskCardPropsEqual(previous: TaskCardProps, next: TaskCardProps): bo
previous.taskStuckTimeoutMs === next.taskStuckTimeoutMs &&
previous.prAuthAvailable === next.prAuthAvailable &&
previous.autoMergeEnabled === next.autoMergeEnabled &&
previous.cliSessionState?.agentState === next.cliSessionState?.agentState &&
previous.cardFieldDefs === next.cardFieldDefs &&
(previous.cardFieldDefs == null && next.cardFieldDefs == null
? true
@@ -658,6 +678,7 @@ function TaskCardComponent({
prAuthAvailable,
autoMergeEnabled = false,
cardFieldDefs,
cliSessionState,
}: TaskCardProps) {
const { t } = useTranslation("app");
const columnLabel = useColumnLabel();
@@ -924,6 +945,9 @@ function TaskCardComponent({
const stalledReview = getStalledReviewSignal(task);
const showStalledReview = Boolean(stalledReview && task.column === "in-review" && !isPaused);
const hasInReviewStall = shouldShowInReviewStallBadge(task);
// CLI agent session badges (U11) — distinct from staleness/stall badges.
const cliWaitingOnInput = cliSessionState?.agentState === "waitingOnInput";
const cliNeedsAttention = cliSessionState?.agentState === "needsAttention";
const stallCopy = task.inReviewStall
? getInReviewStallCopy(task.inReviewStall, {
mergeRetries: task.mergeRetries,
@@ -1811,6 +1835,24 @@ function TaskCardComponent({
{stallCopy.badgeLabel}{stallCopy.counter ? ` ${stallCopy.counter}` : ""}
</span>
)}
{cliWaitingOnInput && (
<span
className="card-status-badge card-status-badge--cli-waiting"
data-cli-state="waitingOnInput"
title={t("tasks.cliWaitingOnInputTitle", "The CLI agent is waiting for your input")}
>
{t("tasks.cliWaitingOnInput", "Waiting on input")}
</span>
)}
{cliNeedsAttention && (
<span
className="card-status-badge card-status-badge--cli-attention failed"
data-cli-state="needsAttention"
title={t("tasks.cliNeedsAttentionTitle", "The CLI agent needs your attention")}
>
{t("tasks.cliNeedsAttention", "Needs attention")}
</span>
)}
{hasStalePausedReview && stalePausedReviewCopy && (
<span
className={`card-status-badge card-status-badge--in-review stale-paused-review stale-paused-review--${stalePausedReviewCopy.code}`}

View File

@@ -21,7 +21,7 @@ import {
resolveTaskPlanningModel,
resolveTaskValidatorModel,
} from "@fusion/core";
import { uploadAttachment, deleteAttachment, updateTask, pauseTask, unpauseTask, fetchTaskDetail, fetchSettings, fetchGlobalSettings, requestSpecRevision, rebuildTaskSpec, approvePlan, rejectPlan, refineTask, fetchWorkflowResults, assignTask, fetchAgents, fetchAgent, recoverBranchBinding, refreshPrStatus, fetchBoardWorkflows, updateTaskCustomFields } from "../api";
import { uploadAttachment, deleteAttachment, updateTask, pauseTask, unpauseTask, fetchTaskDetail, fetchSettings, fetchGlobalSettings, requestSpecRevision, rebuildTaskSpec, approvePlan, rejectPlan, refineTask, fetchWorkflowResults, assignTask, fetchAgents, fetchAgent, recoverBranchBinding, refreshPrStatus, fetchBoardWorkflows, updateTaskCustomFields, api } from "../api";
import type { RecoverBranchBindingOutcome, WorkflowFieldDefinition, CustomFieldRejection } from "../api";
import { ApiRequestError } from "../api";
import { TaskFieldsSection } from "./TaskFieldsSection";
@@ -46,6 +46,7 @@ import { BranchGroupCard } from "./BranchGroupCard";
import { PluginSlot } from "./PluginSlot";
import { ProviderIcon } from "./ProviderIcon";
import { subscribeSse } from "../sse-bus";
import type { SessionTerminalMode, SessionTerminalPosture } from "./SessionTerminal";
import { usePluginUiSlots } from "../hooks/usePluginUiSlots";
import { appendTokenQuery } from "../auth";
import { extractDependencyDeleteConflict, extractLineageDeleteConflict } from "../utils/taskDelete";
@@ -281,7 +282,69 @@ function formatDurationCompact(ageMs: number): string {
return `${minutes}m`;
}
type TabId = "definition" | "logs" | "changes" | "review" | "pr" | "comments" | "model" | "workflow" | "documents" | "stats" | "routing" | "retries" | `plugin-${string}`;
type TabId = "definition" | "logs" | "changes" | "review" | "pr" | "comments" | "model" | "workflow" | "documents" | "stats" | "routing" | "retries" | "terminal" | `plugin-${string}`;
// Lazy-load the terminal so xterm + addons stay out of the main bundle (U11).
const LazySessionTerminal = lazy(() =>
import("./SessionTerminal").then((m) => ({ default: m.SessionTerminal })),
);
/** CLI session record fields the terminal tab needs (mirrors @fusion/core CliSession). */
export interface CliSessionSummaryRecord {
id: string;
taskId: string | null;
projectId: string;
adapterId: string;
agentState:
| "starting"
| "ready"
| "busy"
| "waitingOnInput"
| "done"
| "dead"
| "needsAttention";
terminationReason: string | null;
autonomyPosture?: Record<string, unknown> | null;
}
type CliTabVisibility =
| { kind: "hidden" }
| { kind: "live"; readOnly: boolean; mode: SessionTerminalMode; showConfirmAdvance: boolean }
| { kind: "replay"; mode: SessionTerminalMode };
/**
* Tab visibility matrix (U11):
* - starting/ready/busy/waitingOnInput → live terminal
* - one-shot (planning/validator) live → read-only live + badge
* - done (resumable) → replay "session idle"
* - dead/needsAttention (PTY reaped) → replay "session ended"
* - no recorded session → hidden
*/
export function deriveCliTabVisibility(
session: CliSessionSummaryRecord | null,
opts: { oneShot?: boolean; genericIdle?: boolean } = {},
): CliTabVisibility {
if (!session) return { kind: "hidden" };
const live =
session.agentState === "starting" ||
session.agentState === "ready" ||
session.agentState === "busy" ||
session.agentState === "waitingOnInput";
if (live) {
return {
kind: "live",
readOnly: Boolean(opts.oneShot),
mode: "live",
showConfirmAdvance: Boolean(opts.genericIdle),
};
}
if (session.agentState === "done") {
// execute-done but resumable → scrollback replay with a "session idle" header.
return { kind: "replay", mode: "idle" };
}
// dead / needsAttention → PTY reaped → "session ended".
return { kind: "replay", mode: "ended" };
}
export interface TaskDetailModalProps {
task: Task | TaskDetail;
@@ -494,6 +557,9 @@ export function TaskDetailContent({
const columnLabel = useColumnLabel();
const [activeTab, setActiveTab] = useState<TabId>(initialTab === "retries" ? "definition" : initialTab);
// ── CLI agent session (U11) ────────────────────────────────────────────────
const [cliSession, setCliSession] = useState<CliSessionSummaryRecord | null>(null);
// ── Async detail loading ──────────────────────────────────────────────────
// When opened optimistically with a Task (no prompt), fetch the full
// TaskDetail in the background. The modal renders immediately with the
@@ -757,6 +823,56 @@ export function TaskDetailContent({
? pluginTabs.find((tab) => tab.tabId === activeTab) ?? null
: null;
// ── CLI terminal tab visibility + posture (U11) ────────────────────────────
const cliOneShot =
cliSession?.adapterId != null &&
(cliSession?.autonomyPosture?.purpose === "planning" ||
cliSession?.autonomyPosture?.purpose === "validator" ||
cliSession?.autonomyPosture?.readOnly === true);
const cliGenericIdle = cliSession?.autonomyPosture?.genericIdle === true;
const cliTabVisibility = useMemo(
() =>
deriveCliTabVisibility(cliSession, {
oneShot: cliOneShot,
genericIdle: cliGenericIdle,
}),
[cliSession, cliOneShot, cliGenericIdle],
);
const showCliTab = cliTabVisibility.kind !== "hidden";
const cliPosture: SessionTerminalPosture | undefined = useMemo(() => {
if (!cliSession) return undefined;
const p = cliSession.autonomyPosture ?? {};
const flags = Array.isArray(p.elevatedFlags) ? (p.elevatedFlags as string[]) : undefined;
return {
adapterName: (p.adapterName as string) ?? cliSession.adapterId,
mode: (p.mode as string) ?? (p.autoApprove ? "auto-approve" : undefined),
elevated: p.elevated === true,
elevatedFlags: flags,
resolved: Array.isArray(p.resolved) ? (p.resolved as string[]) : undefined,
};
}, [cliSession]);
// Confirm-advance handler — POST /api/cli-sessions/:id/confirm-advance.
const handleConfirmAdvance = useCallback(
async (decision: "advance" | "not-yet") => {
if (!cliSession) return;
try {
await api(`/cli-sessions/${encodeURIComponent(cliSession.id)}/confirm-advance`, {
method: "POST",
body: JSON.stringify({ decision, ...(projectId ? { projectId } : {}) }),
});
} catch {
/* surfaced via the strip's disabled state reset */
}
},
[cliSession, projectId],
);
// If the terminal tab is active but the session disappears, fall back.
useEffect(() => {
if (activeTab === "terminal" && !showCliTab) setActiveTab("definition");
}, [activeTab, showCliTab]);
// Track mount state to avoid setting state on unmounted component
useEffect(() => {
mountedRef.current = true;
@@ -886,6 +1002,76 @@ export function TaskDetailContent({
});
}, [activeTab, task.id, projectId]);
// Load the CLI agent session for this task (drives the terminal tab + matrix).
useEffect(() => {
let cancelled = false;
const search = new URLSearchParams({ taskId: task.id });
if (projectId) search.set("projectId", projectId);
void api<{ sessions: CliSessionSummaryRecord[] }>(`/cli-sessions?${search.toString()}`)
.then((res) => {
if (cancelled) return;
// Most-recent session for the task (the list is store-ordered).
const sessions = res.sessions ?? [];
setCliSession(sessions.length > 0 ? sessions[sessions.length - 1] : null);
})
.catch(() => {
if (!cancelled) setCliSession(null);
});
return () => {
cancelled = true;
};
}, [task.id, projectId]);
// Live CLI session state via SSE — MERGE payload fields onto the record
// (never wholesale-replace: the list fetch carries enriched fields the SSE
// payload omits, e.g. adapterId / autonomyPosture).
useEffect(() => {
const query = projectId ? `?projectId=${encodeURIComponent(projectId)}` : "";
const handleCliState = (e: MessageEvent) => {
try {
const payload = JSON.parse(e.data) as {
sessionId: string;
taskId: string | null;
state: string;
terminationReason?: string | null;
};
if (payload.taskId !== task.id) return;
setCliSession((prev) => {
if (!prev || prev.id !== payload.sessionId) {
// Unknown/new session for this task — keep the enriched record from
// the list fetch as the source of truth; ignore until it loads.
if (!prev) return prev;
}
// The machine "idle"/"resuming" states map onto persisted enums; the
// card/tab only need the persisted set, so coerce here.
const next = { ...prev } as CliSessionSummaryRecord;
if (
payload.state === "starting" ||
payload.state === "ready" ||
payload.state === "busy" ||
payload.state === "waitingOnInput" ||
payload.state === "done" ||
payload.state === "dead" ||
payload.state === "needsAttention"
) {
next.agentState = payload.state;
} else if (payload.state === "idle" || payload.state === "resuming") {
next.agentState = "busy";
}
if (payload.terminationReason !== undefined) {
next.terminationReason = payload.terminationReason ?? null;
}
return next;
});
} catch {
/* skip malformed events */
}
};
return subscribeSse(`/api/events${query}`, {
events: { "cli:session:state": handleCliState },
});
}, [task.id, projectId]);
// Reset dependency search when dropdown closes
useEffect(() => {
if (!showDepDropdown) {
@@ -2825,6 +3011,14 @@ export function TaskDetailContent({
>
{t("taskDetail.tabs.routing", "Routing")}
</button>
{showCliTab && (
<button
className={`detail-tab${activeTab === "terminal" ? " detail-tab-active" : ""}`}
onClick={() => setActiveTab("terminal")}
>
{t("taskDetail.tabs.terminal", "Terminal")}
</button>
)}
{/* Plugin tabs */}
{pluginTabs.map(({ entry, tabId }) => {
return (
@@ -3116,6 +3310,27 @@ export function TaskDetailContent({
onTaskUpdated={onTaskUpdated}
/>
</div>
) : activeTab === "terminal" ? (
<div className="detail-section detail-section--terminal">
{cliSession && cliTabVisibility.kind !== "hidden" ? (
<Suspense fallback={<div className="detail-loading">{t("taskDetail.terminal.loading", "Loading terminal…")}</div>}>
<LazySessionTerminal
sessionId={cliSession.id}
projectId={projectId}
posture={cliPosture}
readOnly={
cliTabVisibility.kind === "replay" ||
(cliTabVisibility.kind === "live" && cliTabVisibility.readOnly)
}
mode={cliTabVisibility.mode}
showConfirmAdvance={
cliTabVisibility.kind === "live" && cliTabVisibility.showConfirmAdvance
}
onConfirmAdvance={handleConfirmAdvance}
/>
</Suspense>
) : null}
</div>
) : (
<>
{/* Summary section - only for done tasks with summary */}

View File

@@ -81,7 +81,23 @@ import { WorkflowSettingsPanel } from "./WorkflowSettingsPanel";
import type { WorkflowFieldDefinition, WorkflowSettingDefinition } from "../api";
import { CustomModelDropdown } from "./CustomModelDropdown";
type ExecutorKind = "model" | "agent" | "skill" | "cli";
type ExecutorKind = "model" | "agent" | "skill" | "cli" | "cli-agent";
/** Adapter descriptor served by GET /api/cli-agents (U15). */
interface CliAdapterDescriptorView {
id: string;
name: string;
tier: "native" | "hybrid" | "generic";
}
/** Static fallback so the picker renders before/without the API fetch. */
const CLI_AGENT_ADAPTER_FALLBACK: CliAdapterDescriptorView[] = [
{ id: "claude-code", name: "Claude Code", tier: "native" },
{ id: "codex", name: "Codex", tier: "hybrid" },
{ id: "droid", name: "Droid", tier: "hybrid" },
{ id: "pi", name: "Pi", tier: "hybrid" },
{ id: "generic", name: "Generic CLI", tier: "generic" },
];
// Mirror of @fusion/core's isBuiltinWorkflowId / BUILTIN_WORKFLOW_ID_PREFIX.
// Inlined because the dashboard app build aliases "@fusion/core" to its
@@ -1793,6 +1809,9 @@ function InnerEditor({
setAgents([]);
}, [projectId]);
const [skills, setSkills] = useState<DiscoveredSkill[]>([]);
// CLI-agent adapter catalog (U15). Falls back to the static list when the API
// fetch fails so the picker is always usable.
const [cliAdapters, setCliAdapters] = useState<CliAdapterDescriptorView[]>(CLI_AGENT_ADAPTER_FALLBACK);
useEffect(() => {
let cancelled = false;
@@ -1859,6 +1878,23 @@ function InnerEditor({
[overrideColumnBinding, agents],
);
useEffect(() => {
if (currentExecutor !== "cli-agent") return;
let cancelled = false;
fetch("/api/cli-agents")
.then((r) => (r.ok ? r.json() : null))
.then((data) => {
if (cancelled || !data?.adapters) return;
setCliAdapters(data.adapters as CliAdapterDescriptorView[]);
})
.catch(() => {
// Keep the static fallback; the picker stays functional.
});
return () => {
cancelled = true;
};
}, [currentExecutor]);
useEffect(() => {
// step-review offers an optional review model picker (KTD-4).
if (selectedNode?.data.kind === "step-review" && models.length === 0) {
@@ -2654,6 +2690,7 @@ function InnerEditor({
<option value="agent">Agent</option>
<option value="skill">Skill</option>
<option value="cli">CLI / script</option>
<option value="cli-agent">{t("workflowEditor.cliAgent.executorOption")}</option>
</select>
</label>
@@ -2781,6 +2818,83 @@ function InnerEditor({
</>
)}
{currentExecutor === "cli-agent" && (
<div data-testid="cli-agent-config">
<label className="wf-field">
<span>{t("workflowEditor.cliAgent.adapterLabel")}</span>
<select
data-testid="cli-agent-adapter"
value={String(selectedNode.data.config?.cliAdapterId ?? "")}
onChange={(e) =>
updateSelectedData({ config: { cliAdapterId: e.target.value || undefined } })
}
>
<option value="">{t("workflowEditor.cliAgent.adapterPlaceholder")}</option>
{cliAdapters.map((a) => (
<option key={a.id} value={a.id}>
{a.name} ({t(`workflowEditor.cliAgent.tier.${a.tier}`)})
</option>
))}
</select>
<span className="wf-inspector-note">
{t("workflowEditor.cliAgent.adapterNote")}
</span>
</label>
<label className="wf-field wf-field--checkbox">
<input
type="checkbox"
data-testid="cli-agent-autonomy"
checked={Boolean(
(selectedNode.data.config?.cliAutonomy as { autoApprove?: boolean } | undefined)
?.autoApprove,
)}
onChange={(e) =>
updateSelectedData({
config: {
cliAutonomy: {
...((selectedNode.data.config?.cliAutonomy as Record<string, unknown>) ?? {}),
autoApprove: e.target.checked,
},
},
})
}
/>
<span>{t("workflowEditor.cliAgent.autonomyLabel")}</span>
</label>
{Boolean(
(selectedNode.data.config?.cliAutonomy as { autoApprove?: boolean } | undefined)
?.autoApprove,
) && (
<p className="wf-inspector-note wf-inspector-note--info">
{t("workflowEditor.cliAgent.autonomyNote")}
</p>
)}
<label className="wf-field">
<span>{t("workflowEditor.cliAgent.notifyLabel")}</span>
<select
data-testid="cli-agent-notify"
value={String(
(selectedNode.data.config?.cliNotify as { mode?: string } | undefined)?.mode ??
"banner",
)}
onChange={(e) =>
updateSelectedData({ config: { cliNotify: { mode: e.target.value } } })
}
>
<option value="banner">{t("workflowEditor.cliAgent.notify.banner")}</option>
<option value="banner+notify">
{t("workflowEditor.cliAgent.notify.bannerNotify")}
</option>
</select>
<span className="wf-inspector-note">
{t("workflowEditor.cliAgent.notifyNote")}
</span>
</label>
</div>
)}
<label className="wf-field wf-field--checkbox">
<input
type="checkbox"

View File

@@ -0,0 +1,152 @@
// ChatView CLI-backed mount test (CLI Agent Executor, U12 completion).
//
// Asserts ChatView delegates the message-pane + composer region to
// <CliChatSurface> when the active chat session carries a `cliExecutorAdapterId`,
// and falls back to the normal provider composer for a regular session.
//
// SessionTerminal is mocked (no xterm / no WS / no PTY / no port 4040) because
// CliChatSurface renders it under the hood.
import { beforeEach, describe, expect, it, vi } from "vitest";
import { fireEvent, render, screen } from "@testing-library/react";
import { ChatView } from "../ChatView";
import * as useChatModule from "../../hooks/useChat";
import * as useChatRoomsModule from "../../hooks/useChatRooms";
import type { ChatSessionInfo, UseChatReturn } from "../../hooks/useChat";
import type { UseChatRoomsResult } from "../../hooks/useChatRooms";
import { _resetInitialViewportHeight } from "../../hooks/useMobileKeyboard";
Element.prototype.scrollIntoView = vi.fn();
vi.mock("../SessionTerminal", () => ({
SessionTerminal: ({ sessionId }: { sessionId: string }) => (
<div data-testid="session-terminal" data-session-id={sessionId}>
terminal
</div>
),
}));
vi.mock("../../hooks/useChat");
vi.mock("../../hooks/useChatRooms");
vi.mock("../../hooks/useNavigationHistory", async (importOriginal) => {
const actual = await importOriginal<typeof import("../../hooks/useNavigationHistory")>();
return {
...actual,
useNavigationHistoryContext: () => ({ pushNav: vi.fn(), replaceCurrent: vi.fn() }),
};
});
vi.mock("../../api", async (importOriginal) => {
const actual = await importOriginal<typeof import("../../api")>();
return {
...actual,
fetchAgents: vi.fn().mockResolvedValue([]),
fetchDiscoveredSkills: vi.fn().mockResolvedValue([]),
fetchTasks: vi.fn().mockResolvedValue([]),
searchFiles: vi.fn().mockResolvedValue({ files: [] }),
};
});
const mockUseChat = vi.mocked(useChatModule.useChat);
const mockUseChatRooms = vi.mocked(useChatRoomsModule.useChatRooms);
function makeSession(overrides: Partial<ChatSessionInfo> = {}): ChatSessionInfo {
return {
id: "sess-1",
agentId: "agent-1",
status: "active",
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
...overrides,
};
}
function chatState(session: ChatSessionInfo): UseChatReturn {
return {
sessions: [session],
activeSession: session,
sessionsLoading: false,
messages: [],
messagesLoading: false,
isStreaming: false,
streamingText: "",
streamingThinking: "",
streamingToolCalls: [],
selectSession: vi.fn(),
createSession: vi.fn(),
archiveSession: vi.fn(),
deleteSession: vi.fn(),
sendMessage: vi.fn(),
stopStreaming: vi.fn(),
pendingMessage: "",
clearPendingMessage: vi.fn(),
loadMoreMessages: vi.fn(),
hasMoreMessages: false,
searchQuery: "",
setSearchQuery: vi.fn(),
filteredSessions: [session],
refreshSessions: vi.fn(),
agentsMap: new Map(),
};
}
const defaultRoomsState: UseChatRoomsResult = {
rooms: [],
roomsLoading: false,
roomsError: null,
activeRoom: null,
activeRoomMembers: [],
messages: [],
messagesLoading: false,
selectRoom: vi.fn(),
createRoom: vi.fn(),
deleteRoom: vi.fn(),
sendRoomMessage: vi.fn().mockResolvedValue(undefined),
refreshRooms: vi.fn(),
};
describe("ChatView CLI-backed session mount", () => {
beforeEach(() => {
_resetInitialViewportHeight();
vi.clearAllMocks();
mockUseChatRooms.mockReturnValue(defaultRoomsState);
});
it("renders CliChatSurface (transcript/terminal toggle) for a cli-backed session", () => {
mockUseChat.mockReturnValue(
chatState(makeSession({ cliExecutorAdapterId: "claude-code", cliSessionFile: "cli-native-1" })),
);
render(<ChatView projectId="proj-123" addToast={vi.fn()} />);
// CliChatSurface renders the transcript/terminal toggle tablist.
expect(screen.getByRole("tab", { name: /transcript/i })).toBeInTheDocument();
expect(screen.getByRole("tab", { name: /terminal/i })).toBeInTheDocument();
// The standard provider send button is NOT rendered as a top-level composer
// affordance for the cli surface's default (transcript) view it wraps the
// existing composer, but the distinguishing CLI toggle is present.
});
it("attaches the terminal to the native cli session id linkage", () => {
mockUseChat.mockReturnValue(
chatState(makeSession({ cliExecutorAdapterId: "claude-code", cliSessionFile: "cli-native-1" })),
);
render(<ChatView projectId="proj-123" addToast={vi.fn()} />);
// Switch to the terminal tab to mount SessionTerminal.
fireEvent.click(screen.getByRole("tab", { name: /terminal/i }));
expect(screen.getByTestId("session-terminal").getAttribute("data-session-id")).toBe("cli-native-1");
});
it("generic-tier cli session renders terminal-only (no toggle)", () => {
mockUseChat.mockReturnValue(chatState(makeSession({ cliExecutorAdapterId: "generic" })));
render(<ChatView projectId="proj-123" addToast={vi.fn()} />);
expect(screen.getByTestId("session-terminal")).toBeInTheDocument();
expect(screen.queryByRole("tab", { name: /transcript/i })).toBeNull();
});
it("renders the normal provider composer for a regular (non-cli) session", () => {
mockUseChat.mockReturnValue(chatState(makeSession({ cliExecutorAdapterId: null })));
render(<ChatView projectId="proj-123" addToast={vi.fn()} />);
// Normal composer present, CLI toggle absent.
expect(screen.getByPlaceholderText("Type a message...")).toBeInTheDocument();
expect(screen.queryByRole("tab", { name: /transcript/i })).toBeNull();
expect(screen.queryByTestId("session-terminal")).toBeNull();
});
});

View File

@@ -0,0 +1,98 @@
// CLI-backed chat surface tests (CLI Agent Executor, U12).
//
// Exercises the transcript ↔ terminal toggle, the terminal-owns-composer rule,
// the generic-tier terminal-only rendering, and the composer queued indicator.
// SessionTerminal is mocked (no xterm / no WS / no PTY / no port 4040) so these
// are pure component-behavior assertions.
import { describe, expect, it, vi } from "vitest";
import { fireEvent, render, screen } from "@testing-library/react";
// Mock SessionTerminal — it lazy-loads xterm and opens a WS; we only need to
// assert presence/absence of the terminal surface.
vi.mock("../SessionTerminal", () => ({
SessionTerminal: ({ sessionId }: { sessionId: string }) => (
<div data-testid="session-terminal" data-session-id={sessionId}>
terminal
</div>
),
}));
import { CliChatSurface } from "../CliChatSurface";
function renderSurface(overrides: Partial<React.ComponentProps<typeof CliChatSurface>> = {}) {
return render(
<CliChatSurface
cliSessionId="cli-1"
tier="hybrid"
projectId="proj-1"
renderTranscript={() => <div data-testid="transcript">transcript-rows</div>}
renderComposer={() => <textarea data-testid="composer" />}
{...overrides}
/>,
);
}
describe("CliChatSurface — hybrid tier toggle", () => {
it("defaults to the transcript view with the composer visible", () => {
renderSurface();
expect(screen.getByTestId("transcript")).toBeTruthy();
expect(screen.getByTestId("composer")).toBeTruthy();
expect(screen.queryByTestId("session-terminal")).toBeNull();
});
it("toggling to terminal swaps the message list for the terminal and HIDES the composer", () => {
renderSurface();
fireEvent.click(screen.getByRole("tab", { name: /terminal/i }));
expect(screen.getByTestId("session-terminal")).toBeTruthy();
// Message list replaced and composer hidden — the terminal owns input.
expect(screen.queryByTestId("transcript")).toBeNull();
expect(screen.queryByTestId("composer")).toBeNull();
});
it("toggling back restores the transcript and composer (one underlying session)", () => {
renderSurface();
fireEvent.click(screen.getByRole("tab", { name: /terminal/i }));
const term = screen.getByTestId("session-terminal");
expect(term.getAttribute("data-session-id")).toBe("cli-1");
fireEvent.click(screen.getByRole("tab", { name: /transcript/i }));
expect(screen.getByTestId("transcript")).toBeTruthy();
expect(screen.getByTestId("composer")).toBeTruthy();
expect(screen.queryByTestId("session-terminal")).toBeNull();
});
it("the terminal attaches to the same cli session id as the toggle reflects", () => {
renderSurface({ cliSessionId: "cli-shared" });
fireEvent.click(screen.getByRole("tab", { name: /terminal/i }));
expect(screen.getByTestId("session-terminal").getAttribute("data-session-id")).toBe(
"cli-shared",
);
});
});
describe("CliChatSurface — generic tier", () => {
it("renders the terminal only: no toggle, no transcript pane, no composer", () => {
renderSurface({ tier: "generic" });
expect(screen.getByTestId("session-terminal")).toBeTruthy();
expect(screen.queryByRole("tab")).toBeNull();
expect(screen.queryByTestId("transcript")).toBeNull();
expect(screen.queryByTestId("composer")).toBeNull();
});
});
describe("CliChatSurface — composer queue indicator", () => {
it("shows a queued indicator when messages are queued behind a busy session", () => {
renderSurface({ queuedCount: 2 });
expect(screen.getByRole("status").textContent).toMatch(/queued/i);
});
it("hides the queued indicator when nothing is queued", () => {
renderSurface({ queuedCount: 0 });
expect(screen.queryByRole("status")).toBeNull();
});
it("does not render the queued indicator in raw-terminal mode (composer hidden)", () => {
renderSurface({ queuedCount: 3 });
fireEvent.click(screen.getByRole("tab", { name: /terminal/i }));
expect(screen.queryByRole("status")).toBeNull();
});
});

View File

@@ -24,6 +24,7 @@ vi.mock("../../api", () => ({
batchUpdateTaskModels: vi.fn(),
fetchNodes: vi.fn().mockResolvedValue([]),
fetchBoardWorkflows: vi.fn().mockResolvedValue({ flagEnabled: false, defaultWorkflowId: "", workflows: [], taskWorkflowIds: {} }),
api: vi.fn().mockResolvedValue({ sessions: [] }),
}));
import { fetchTaskDetail, batchUpdateTaskModels, fetchNodes } from "../../api";

View File

@@ -315,3 +315,108 @@ describe("SessionNotificationBanner", () => {
expect(screen.queryByText("Error Session")).not.toBeInTheDocument();
});
});
// ── CLI agent extensions (CLI Agent Executor, U11) ──────────────────────────
function buildCliSession(overrides: Partial<AiSessionSummary>): AiSessionSummary {
return {
id: overrides.id ?? "cli-1",
type: "cli-agent",
status: overrides.status ?? "waiting_on_input",
title: overrides.title ?? "Implement FN-1",
projectId: overrides.projectId ?? "proj-1",
lockedByTab: null,
updatedAt: overrides.updatedAt ?? new Date().toISOString(),
cliVariant: overrides.cliVariant,
cliSessionId: overrides.cliSessionId ?? "cli-1",
};
}
describe("SessionNotificationBanner — cli-agent (U11)", () => {
beforeEach(() => dismissedIds.clear());
it("renders the cli-agent type without crashing (union regression)", () => {
expect(() =>
render(
<SessionNotificationBanner
sessions={[buildCliSession({ status: "waiting_on_input" })]}
onResumeSession={vi.fn()}
onDismissSession={vi.fn()}
onDismissAll={vi.fn()}
/>,
),
).not.toThrow();
expect(screen.getByText("Implement FN-1")).toBeInTheDocument();
});
it("waiting_on_input surfaces a banner entry; busy clears it (F2)", () => {
const { rerender, container } = render(
<SessionNotificationBanner
sessions={[buildCliSession({ status: "waiting_on_input" })]}
onResumeSession={vi.fn()}
onDismissSession={vi.fn()}
onDismissAll={vi.fn()}
/>,
);
expect(container.querySelector(".session-notification-banner")).toBeTruthy();
rerender(
<SessionNotificationBanner
sessions={[buildCliSession({ status: "generating" as AiSessionSummary["status"] })]}
onResumeSession={vi.fn()}
onDismissSession={vi.fn()}
onDismissAll={vi.fn()}
/>,
);
expect(container.querySelector(".session-notification-banner")).toBeFalsy();
});
it("userExited needs-attention renders pinned copy + Advance/Retry/Cancel task", () => {
const onCliAction = vi.fn();
render(
<SessionNotificationBanner
sessions={[buildCliSession({ status: "needs_attention", cliVariant: "userExited" })]}
onResumeSession={vi.fn()}
onDismissSession={vi.fn()}
onDismissAll={vi.fn()}
onCliAction={onCliAction}
/>,
);
expect(screen.getByText("Agent exited before completing")).toBeInTheDocument();
// All three pinned actions render before any action removes the item.
expect(screen.getByText("Advance")).toBeInTheDocument();
expect(screen.getByText("Retry")).toBeInTheDocument();
expect(screen.getByText("Cancel task")).toBeInTheDocument();
fireEvent.click(screen.getByText("Advance"));
expect(onCliAction).toHaveBeenCalledWith(expect.objectContaining({ id: "cli-1" }), "advance");
});
it("authFailed renders Re-authenticate / Retry", () => {
render(
<SessionNotificationBanner
sessions={[buildCliSession({ status: "needs_attention", cliVariant: "authFailed" })]}
onResumeSession={vi.fn()}
onDismissSession={vi.fn()}
onDismissAll={vi.fn()}
onCliAction={vi.fn()}
/>,
);
expect(screen.getByText("CLI authentication failed")).toBeInTheDocument();
expect(screen.getByText("Re-authenticate")).toBeInTheDocument();
expect(screen.getByText("Retry")).toBeInTheDocument();
});
it("resume-exhausted renders Relaunch fresh / Cancel task", () => {
render(
<SessionNotificationBanner
sessions={[buildCliSession({ status: "needs_attention", cliVariant: "resume-exhausted" })]}
onResumeSession={vi.fn()}
onDismissSession={vi.fn()}
onDismissAll={vi.fn()}
onCliAction={vi.fn()}
/>,
);
expect(screen.getByText("Couldn't resume the session")).toBeInTheDocument();
expect(screen.getByText("Relaunch fresh")).toBeInTheDocument();
expect(screen.getByText("Cancel task")).toBeInTheDocument();
});
});

View File

@@ -0,0 +1,371 @@
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { act, render, screen, fireEvent, waitFor } from "@testing-library/react";
import { _resetInitialViewportHeight } from "../../hooks/useMobileKeyboard";
// ── Mock xterm + addon dynamic imports (jsdom has no canvas/WebGL) ──────────
const mockTerm = {
loadAddon: vi.fn(),
open: vi.fn(),
onData: vi.fn(),
write: vi.fn((_data: string, cb?: () => void) => cb?.()),
dispose: vi.fn(),
unicode: { activeVersion: "6" },
cols: 80,
rows: 24,
};
vi.mock("@xterm/xterm", () => ({ Terminal: vi.fn(() => mockTerm) }));
vi.mock("@xterm/addon-fit", () => ({ FitAddon: vi.fn(() => ({ fit: vi.fn() })) }));
vi.mock("@xterm/addon-unicode11", () => ({ Unicode11Addon: vi.fn(() => ({})) }));
vi.mock("@xterm/addon-webgl", () => ({
WebglAddon: vi.fn(() => ({ onContextLoss: vi.fn(), dispose: vi.fn() })),
}));
const apiMock = vi.fn();
vi.mock("../../api", () => ({ api: (...args: unknown[]) => apiMock(...args) }));
vi.mock("../../auth", () => ({ appendTokenQuery: (u: string) => u }));
// ── Minimal WebSocket stub ──────────────────────────────────────────────────
class FakeWS {
static instances: FakeWS[] = [];
static OPEN = 1;
readyState = 1;
onopen: (() => void) | null = null;
onmessage: ((e: { data: string }) => void) | null = null;
onclose: (() => void) | null = null;
onerror: (() => void) | null = null;
sent: string[] = [];
constructor(public url: string) {
FakeWS.instances.push(this);
}
send(d: string) {
this.sent.push(d);
}
close() {
this.readyState = 3;
}
}
(globalThis as unknown as { WebSocket: typeof FakeWS }).WebSocket = FakeWS;
(globalThis as unknown as { ResizeObserver: unknown }).ResizeObserver = class {
observe() {}
disconnect() {}
};
// ── matchMedia mock: drive the mobile breakpoint convention ─────────────────
let matchMediaMatches = true;
function installMatchMedia(matches: boolean) {
matchMediaMatches = matches;
Object.defineProperty(window, "matchMedia", {
writable: true,
configurable: true,
value: vi.fn((query: string) => ({
matches: matchMediaMatches,
media: query,
onchange: null,
addEventListener: vi.fn(),
removeEventListener: vi.fn(),
addListener: vi.fn(),
removeListener: vi.fn(),
dispatchEvent: vi.fn(),
})),
});
}
import { SessionTerminal } from "../SessionTerminal";
/** Pull the parsed input frames a WS has sent. */
function inputFrames(ws: FakeWS): string[] {
return ws.sent
.map((raw) => JSON.parse(raw))
.filter((m) => m.type === "input")
.map((m) => m.data as string);
}
/** Render and wait for the WS attach channel to open. */
async function renderMobile(props: Record<string, unknown> = {}) {
const utils = render(<SessionTerminal sessionId="s1" {...props} />);
await waitFor(() => expect(FakeWS.instances.length).toBe(1));
return { ...utils, ws: FakeWS.instances[0] };
}
beforeEach(() => {
FakeWS.instances = [];
mockTerm.onData.mockReset();
mockTerm.write.mockClear();
apiMock.mockReset();
apiMock.mockResolvedValue({ ticket: "tkt-1", expiresAt: "", readOnly: false });
installMatchMedia(true); // mobile by default
_resetInitialViewportHeight();
});
afterEach(() => {
vi.clearAllMocks();
});
describe("SessionTerminal (mobile)", () => {
it("renders the mobile input bar + accessory key bar on mobile viewports", async () => {
await renderMobile();
expect(screen.getByTestId("cli-terminal-mobile-bar")).toBeTruthy();
expect(screen.getByTestId("cli-terminal-key-bar")).toBeTruthy();
expect(screen.getByTestId("cli-terminal-mobile-input")).toBeTruthy();
});
it("does not render the mobile bar off-mobile (desktop breakpoint)", async () => {
installMatchMedia(false);
await renderMobile();
expect(screen.queryByTestId("cli-terminal-mobile-bar")).toBeNull();
});
it("does not render the mobile bar when read-only", async () => {
apiMock.mockResolvedValue({ ticket: "tkt-1", expiresAt: "", readOnly: true });
await renderMobile({ readOnly: true });
expect(screen.queryByTestId("cli-terminal-mobile-bar")).toBeNull();
});
// ── Accessory bar control sequences ───────────────────────────────────────
it("Esc key emits 0x1b as an input frame", async () => {
const { ws } = await renderMobile();
fireEvent.click(screen.getByTestId("cli-key-esc"));
expect(inputFrames(ws)).toContain("\x1b");
});
it("Tab key emits 0x09", async () => {
const { ws } = await renderMobile();
fireEvent.click(screen.getByTestId("cli-key-tab"));
expect(inputFrames(ws)).toContain("\x09");
});
it("dedicated Ctrl-C shortcut emits 0x03", async () => {
const { ws } = await renderMobile();
fireEvent.click(screen.getByTestId("cli-key-ctrl-c"));
expect(inputFrames(ws)).toContain("\x03");
});
it("arrow keys emit ANSI CSI cursor sequences", async () => {
const { ws } = await renderMobile();
fireEvent.click(screen.getByTestId("cli-key-arrow-up"));
fireEvent.click(screen.getByTestId("cli-key-arrow-down"));
fireEvent.click(screen.getByTestId("cli-key-arrow-right"));
fireEvent.click(screen.getByTestId("cli-key-arrow-left"));
const frames = inputFrames(ws);
expect(frames).toContain("\x1b[A");
expect(frames).toContain("\x1b[B");
expect(frames).toContain("\x1b[C");
expect(frames).toContain("\x1b[D");
});
// ── Sticky Ctrl modifier ──────────────────────────────────────────────────
it("sticky Ctrl shows an active visual state", async () => {
await renderMobile();
const ctrl = screen.getByTestId("cli-key-ctrl");
expect(ctrl.getAttribute("aria-pressed")).toBe("false");
fireEvent.click(ctrl);
expect(ctrl.getAttribute("aria-pressed")).toBe("true");
expect(ctrl.className).toContain("cli-terminal-key--active");
// Tapping again toggles it back off.
fireEvent.click(ctrl);
expect(ctrl.getAttribute("aria-pressed")).toBe("false");
});
it("sticky Ctrl + c → 0x03, + d → 0x04, + z → 0x1a (combined, swallowed from field)", async () => {
const { ws } = await renderMobile();
const ctrl = screen.getByTestId("cli-key-ctrl");
const input = screen.getByTestId("cli-terminal-mobile-input") as HTMLInputElement;
// Ctrl + c
fireEvent.click(ctrl);
fireEvent.change(input, { target: { value: "c" } });
expect(input.value).toBe(""); // combined, not echoed
expect(ctrl.getAttribute("aria-pressed")).toBe("false"); // cleared
// Ctrl + d
fireEvent.click(ctrl);
fireEvent.change(input, { target: { value: "d" } });
// Ctrl + z
fireEvent.click(ctrl);
fireEvent.change(input, { target: { value: "z" } });
const frames = inputFrames(ws);
expect(frames).toContain("\x03"); // Ctrl-C
expect(frames).toContain("\x04"); // Ctrl-D
expect(frames).toContain("\x1a"); // Ctrl-Z
});
it("without sticky Ctrl, typed letters land in the field (no combine)", async () => {
await renderMobile();
const input = screen.getByTestId("cli-terminal-mobile-input") as HTMLInputElement;
fireEvent.change(input, { target: { value: "d" } });
expect(input.value).toBe("d");
});
it("sticky Ctrl + arrow does not combine (no ctrl combo) but clears modifier", async () => {
const { ws } = await renderMobile();
const ctrl = screen.getByTestId("cli-key-ctrl");
fireEvent.click(ctrl);
fireEvent.click(screen.getByTestId("cli-key-arrow-up"));
// Arrow has no Ctrl combo → literal CSI sequence is sent, modifier clears.
expect(inputFrames(ws)).toContain("\x1b[A");
expect(ctrl.getAttribute("aria-pressed")).toBe("false");
});
// ── Input field submit ────────────────────────────────────────────────────
it("submitting the input field sends the text then \\r", async () => {
const { ws } = await renderMobile();
const input = screen.getByTestId("cli-terminal-mobile-input") as HTMLInputElement;
fireEvent.change(input, { target: { value: "ls -la" } });
fireEvent.click(screen.getByTestId("cli-terminal-mobile-send"));
const frames = inputFrames(ws);
const idx = frames.indexOf("ls -la");
expect(idx).toBeGreaterThanOrEqual(0);
expect(frames[idx + 1]).toBe("\r");
// Field is cleared after submit.
expect(input.value).toBe("");
});
it("input is user keystrokes — text is forwarded verbatim (no neutralization)", async () => {
const { ws } = await renderMobile();
const input = screen.getByTestId("cli-terminal-mobile-input") as HTMLInputElement;
// An escape sequence typed by the user is sent verbatim (deliberate input).
fireEvent.change(input, { target: { value: "echo \x1b[31m" } });
fireEvent.click(screen.getByTestId("cli-terminal-mobile-send"));
expect(inputFrames(ws)).toContain("echo \x1b[31m");
});
// ── iOS composer pattern: bar keys do not blur the input ──────────────────
it("bar key pointerdown preventDefault keeps the input focused", async () => {
await renderMobile();
const esc = screen.getByTestId("cli-key-esc");
const pdEvent = new Event("pointerdown", { bubbles: true, cancelable: true });
esc.dispatchEvent(pdEvent);
expect(pdEvent.defaultPrevented).toBe(true);
});
it("send button pointerdown preventDefault keeps the input focused", async () => {
await renderMobile();
const send = screen.getByTestId("cli-terminal-mobile-send");
const pdEvent = new Event("pointerdown", { bubbles: true, cancelable: true });
send.dispatchEvent(pdEvent);
expect(pdEvent.defaultPrevented).toBe(true);
});
// ── AE6 mobile leg: same live bytes reach term.write ──────────────────────
it("mobile attach renders the same live session bytes (data → term.write)", async () => {
const { ws } = await renderMobile();
const b64 = Buffer.from("live-bytes", "utf8").toString("base64");
ws.onmessage?.({ data: JSON.stringify({ type: "data", data: b64 }) });
await waitFor(() =>
expect(mockTerm.write).toHaveBeenCalledWith("live-bytes", expect.any(Function)),
);
});
it("xterm onData input is still attached on mobile (bar is primary, not exclusive)", async () => {
await renderMobile();
expect(mockTerm.onData).toHaveBeenCalled();
});
});
// ── Keyboard-open (fixed-footer) + pinch-zoom guard ──────────────────────────
describe("SessionTerminal (mobile) — keyboard-open behavior", () => {
let savedVisualViewport: typeof window.visualViewport;
function installVisualViewport({
innerHeight,
vvHeight,
scale = 1,
vvOffsetTop = 0,
}: {
innerHeight: number;
vvHeight: number;
scale?: number;
vvOffsetTop?: number;
}) {
(window as unknown as { ontouchstart: unknown }).ontouchstart = null;
Object.defineProperty(navigator, "maxTouchPoints", { value: 5, configurable: true });
Object.defineProperty(window, "innerWidth", { value: 375, writable: true, configurable: true });
Object.defineProperty(window, "innerHeight", {
value: innerHeight,
writable: true,
configurable: true,
});
const listeners: Record<string, Array<() => void>> = { resize: [], scroll: [] };
const mockVV = {
width: 375,
height: vvHeight,
offsetTop: vvOffsetTop,
offsetLeft: 0,
scale,
addEventListener: vi.fn((event: string, cb: () => void) => {
listeners[event]?.push(cb);
}),
removeEventListener: vi.fn(),
};
Object.defineProperty(window, "visualViewport", {
value: mockVV,
writable: true,
configurable: true,
});
return { listeners, mockVV };
}
beforeEach(() => {
FakeWS.instances = [];
mockTerm.onData.mockReset();
apiMock.mockReset();
apiMock.mockResolvedValue({ ticket: "tkt-1", expiresAt: "", readOnly: false });
installMatchMedia(true);
_resetInitialViewportHeight();
savedVisualViewport = window.visualViewport;
});
afterEach(() => {
Object.defineProperty(window, "visualViewport", {
value: savedVisualViewport,
writable: true,
configurable: true,
});
_resetInitialViewportHeight();
vi.clearAllMocks();
});
it("keyboard-open applies the fixed-footer class so the bar is not occluded", async () => {
installVisualViewport({ innerHeight: 800, vvHeight: 600 });
const input = document.createElement("textarea");
document.body.appendChild(input);
input.focus();
render(<SessionTerminal sessionId="s1" />);
await waitFor(() => expect(FakeWS.instances.length).toBe(1));
await waitFor(() => {
const bar = screen.getByTestId("cli-terminal-mobile-bar");
expect(bar.className).toContain("cli-session-terminal__mobile-bar--keyboard-open");
// Bar lifted above the keyboard by keyboardOverlap (800 - 600 = 200).
expect(bar.style.bottom).toBe("200px");
});
input.remove();
});
it("pinch-zoom (vv.scale > 1) is NOT treated as keyboard-open", async () => {
installVisualViewport({ innerHeight: 800, vvHeight: 600, scale: 2 });
const input = document.createElement("textarea");
document.body.appendChild(input);
input.focus();
render(<SessionTerminal sessionId="s1" />);
await waitFor(() => expect(FakeWS.instances.length).toBe(1));
// Give the keyboard hook a beat to settle; it must stay closed.
await act(async () => {
await new Promise((r) => setTimeout(r, 60));
});
const bar = screen.getByTestId("cli-terminal-mobile-bar");
expect(bar.className).not.toContain("cli-session-terminal__mobile-bar--keyboard-open");
expect(bar.getAttribute("data-keyboard-open")).not.toBe("true");
input.remove();
});
});

View File

@@ -0,0 +1,173 @@
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { render, screen, fireEvent, waitFor } from "@testing-library/react";
// ── Mock xterm + addon dynamic imports (jsdom has no canvas/WebGL) ──────────
const mockTerm = {
loadAddon: vi.fn(),
open: vi.fn(),
onData: vi.fn(),
write: vi.fn((_data: string, cb?: () => void) => cb?.()),
dispose: vi.fn(),
unicode: { activeVersion: "6" },
cols: 80,
rows: 24,
};
vi.mock("@xterm/xterm", () => ({ Terminal: vi.fn(() => mockTerm) }));
vi.mock("@xterm/addon-fit", () => ({ FitAddon: vi.fn(() => ({ fit: vi.fn() })) }));
vi.mock("@xterm/addon-unicode11", () => ({ Unicode11Addon: vi.fn(() => ({})) }));
vi.mock("@xterm/addon-webgl", () => ({
WebglAddon: vi.fn(() => ({ onContextLoss: vi.fn(), dispose: vi.fn() })),
}));
const apiMock = vi.fn();
vi.mock("../../api", () => ({ api: (...args: unknown[]) => apiMock(...args) }));
vi.mock("../../auth", () => ({ appendTokenQuery: (u: string) => u }));
// ── Minimal WebSocket stub ──────────────────────────────────────────────────
class FakeWS {
static instances: FakeWS[] = [];
static OPEN = 1;
readyState = 1;
onopen: (() => void) | null = null;
onmessage: ((e: { data: string }) => void) | null = null;
onclose: (() => void) | null = null;
onerror: (() => void) | null = null;
sent: string[] = [];
constructor(public url: string) {
FakeWS.instances.push(this);
}
send(d: string) {
this.sent.push(d);
}
close() {
this.readyState = 3;
}
}
(globalThis as unknown as { WebSocket: typeof FakeWS }).WebSocket = FakeWS;
(globalThis as unknown as { ResizeObserver: unknown }).ResizeObserver = class {
observe() {}
disconnect() {}
};
import { SessionTerminal } from "../SessionTerminal";
beforeEach(() => {
FakeWS.instances = [];
mockTerm.onData.mockReset();
mockTerm.write.mockClear();
apiMock.mockReset();
apiMock.mockResolvedValue({ ticket: "tkt-1", expiresAt: "", readOnly: false });
});
afterEach(() => {
vi.clearAllMocks();
});
describe("SessionTerminal", () => {
it("mints an attach ticket and opens the WS attach channel", async () => {
render(<SessionTerminal sessionId="s1" />);
await waitFor(() =>
expect(apiMock).toHaveBeenCalledWith(
"/cli-sessions/s1/attach-ticket",
expect.objectContaining({ method: "POST" }),
),
);
await waitFor(() => expect(FakeWS.instances.length).toBe(1));
expect(FakeWS.instances[0].url).toContain("sessionId=s1");
expect(FakeWS.instances[0].url).toContain("ticket=tkt-1");
});
it("decodes base64 scrollback/data into term.write and ACKs", async () => {
render(<SessionTerminal sessionId="s1" />);
await waitFor(() => expect(FakeWS.instances.length).toBe(1));
const ws = FakeWS.instances[0];
const b64 = Buffer.from("hello", "utf8").toString("base64");
ws.onmessage?.({ data: JSON.stringify({ type: "scrollback", data: b64 }) });
await waitFor(() => expect(mockTerm.write).toHaveBeenCalledWith("hello", expect.any(Function)));
});
it("read-only: never registers term.onData (input suppressed)", async () => {
render(<SessionTerminal sessionId="s1" readOnly />);
await waitFor(() => expect(FakeWS.instances.length).toBe(1));
expect(mockTerm.onData).not.toHaveBeenCalled();
});
it("renders the Read-only badge when readOnly", async () => {
render(<SessionTerminal sessionId="s1" readOnly />);
expect(await screen.findByText("Read-only")).toBeTruthy();
});
it("renders the session-ended replay state", () => {
render(<SessionTerminal sessionId="s1" mode="ended" />);
expect(screen.getByText("Session ended")).toBeTruthy();
});
it("renders the session-idle replay state", () => {
render(<SessionTerminal sessionId="s1" mode="idle" />);
expect(screen.getByText("Session idle")).toBeTruthy();
});
it("posture chip: baseline shows adapter name without elevated styling", () => {
render(
<SessionTerminal
sessionId="s1"
posture={{ adapterName: "Claude Code", mode: "default", elevated: false }}
/>,
);
const chip = screen.getByRole("button", { name: /Claude Code/ });
expect(chip.getAttribute("data-elevated")).toBe("false");
expect(chip.className).not.toContain("cli-posture-chip--elevated");
});
it("posture chip: elevated shows warning styling, the flag, and a tooltip", () => {
render(
<SessionTerminal
sessionId="s1"
posture={{
adapterName: "Codex",
elevated: true,
elevatedFlags: ["--dangerously-skip-permissions"],
resolved: ["autonomy: full-auto"],
}}
/>,
);
const chip = screen.getByRole("button", { name: /Codex/ });
expect(chip.getAttribute("data-elevated")).toBe("true");
expect(chip.className).toContain("cli-posture-chip--elevated");
expect(screen.getByText("--dangerously-skip-permissions")).toBeTruthy();
fireEvent.click(chip);
expect(screen.getByRole("tooltip")).toBeTruthy();
expect(screen.getByText("autonomy: full-auto")).toBeTruthy();
});
it("confirm-advance strip: Advance posts advance and hides the strip", async () => {
const onConfirmAdvance = vi.fn().mockResolvedValue(undefined);
render(
<SessionTerminal
sessionId="s1"
mode="live"
showConfirmAdvance
onConfirmAdvance={onConfirmAdvance}
/>,
);
const advance = screen.getByText("Advance");
fireEvent.click(advance);
await waitFor(() => expect(onConfirmAdvance).toHaveBeenCalledWith("advance"));
await waitFor(() => expect(screen.queryByText("Advance")).toBeNull());
});
it("confirm-advance strip: Not yet re-arms (calls callback, hides strip)", async () => {
const onConfirmAdvance = vi.fn().mockResolvedValue(undefined);
render(
<SessionTerminal
sessionId="s1"
mode="live"
showConfirmAdvance
onConfirmAdvance={onConfirmAdvance}
/>,
);
fireEvent.click(screen.getByText("Not yet"));
await waitFor(() => expect(onConfirmAdvance).toHaveBeenCalledWith("not-yet"));
await waitFor(() => expect(screen.queryByText("Not yet")).toBeNull());
});
});

View File

@@ -0,0 +1,103 @@
import React from "react";
import { afterEach, describe, it, expect, vi } from "vitest";
import { render, screen } from "@testing-library/react";
import { TaskCard, type CliCardState } from "../TaskCard";
import type { Task } from "@fusion/core";
vi.mock("lucide-react", () => {
const Stub = () => null;
return new Proxy({}, { get: () => Stub });
});
vi.mock("../ProviderIcon", () => ({
ProviderIcon: ({ provider }: { provider: string }) => <span data-testid={`provider-icon-${provider}`} />,
}));
vi.mock("../../hooks/useTaskDiffStats", () => ({
useTaskDiffStats: () => ({ stats: null, loading: false }),
}));
const badgeUpdatesMock = new Map<string, unknown>();
vi.mock("../../hooks/useBadgeWebSocket", () => ({
useBadgeWebSocket: () => ({
badgeUpdates: badgeUpdatesMock,
isConnected: true,
subscribeToBadge: vi.fn(),
unsubscribeFromBadge: vi.fn(),
}),
}));
vi.mock("../../hooks/useBatchBadgeFetch", () => ({
getFreshBatchData: vi.fn(() => null),
}));
vi.mock("../../api", () => ({
fetchTaskDetail: vi.fn(),
uploadAttachment: vi.fn(),
fetchMission: vi.fn(),
fetchAgent: vi.fn(),
fetchAgents: vi.fn(),
}));
vi.mock("../../hooks/useConfirm", () => ({
useConfirm: () => ({ confirm: vi.fn(), confirmWithChoice: vi.fn() }),
}));
function makeTask(overrides: Partial<Task> = {}): Task {
return {
id: "FN-001",
title: "Test task",
column: "in-progress",
status: undefined as never,
steps: [],
dependencies: [],
description: "",
...overrides,
} as Task;
}
const noop = () => {};
function renderCard(cliSessionState?: CliCardState) {
return render(
<TaskCard
task={makeTask()}
onOpenDetail={noop}
addToast={noop}
cliSessionState={cliSessionState}
/>,
);
}
afterEach(() => {
badgeUpdatesMock.clear();
vi.clearAllMocks();
});
describe("TaskCard CLI agent state badges (U11)", () => {
it("renders the waiting-on-input badge when the session is waitingOnInput", () => {
renderCard({ agentState: "waitingOnInput" });
const badge = screen.getByText("Waiting on input");
expect(badge).toBeTruthy();
expect(badge.getAttribute("data-cli-state")).toBe("waitingOnInput");
});
it("renders the needs-attention badge when the session needsAttention", () => {
renderCard({ agentState: "needsAttention" });
const badge = screen.getByText("Needs attention");
expect(badge).toBeTruthy();
expect(badge.getAttribute("data-cli-state")).toBe("needsAttention");
});
it("busy clears both CLI badges (F2 — answering re-arms to busy)", () => {
renderCard({ agentState: "busy" });
expect(screen.queryByText("Waiting on input")).toBeNull();
expect(screen.queryByText("Needs attention")).toBeNull();
});
it("no cli session → no CLI badges (card unchanged)", () => {
renderCard(undefined);
expect(screen.queryByText("Waiting on input")).toBeNull();
expect(screen.queryByText("Needs attention")).toBeNull();
});
});

View File

@@ -0,0 +1,66 @@
import { describe, it, expect } from "vitest";
import {
deriveCliTabVisibility,
type CliSessionSummaryRecord,
} from "../TaskDetailModal";
function session(
agentState: CliSessionSummaryRecord["agentState"],
): CliSessionSummaryRecord {
return {
id: "cli-1",
taskId: "FN-1",
projectId: "p1",
adapterId: "claude-local",
agentState,
terminationReason: null,
autonomyPosture: null,
};
}
describe("TaskDetailModal terminal-tab visibility matrix (U11)", () => {
it("no recorded session → tab hidden", () => {
expect(deriveCliTabVisibility(null)).toEqual({ kind: "hidden" });
});
it("starting / busy / waitingOnInput → live terminal", () => {
for (const s of ["starting", "ready", "busy", "waitingOnInput"] as const) {
const v = deriveCliTabVisibility(session(s));
expect(v.kind).toBe("live");
if (v.kind === "live") {
expect(v.mode).toBe("live");
expect(v.readOnly).toBe(false);
}
}
});
it("one-shot (planning/validator) live → read-only live terminal", () => {
const v = deriveCliTabVisibility(session("busy"), { oneShot: true });
expect(v.kind).toBe("live");
if (v.kind === "live") expect(v.readOnly).toBe(true);
});
it("generic-tier idle → confirm-advance strip offered on the live terminal", () => {
const v = deriveCliTabVisibility(session("busy"), { genericIdle: true });
expect(v.kind).toBe("live");
if (v.kind === "live") expect(v.showConfirmAdvance).toBe(true);
});
it("execute-done resumable → replay 'session idle'", () => {
expect(deriveCliTabVisibility(session("done"))).toEqual({
kind: "replay",
mode: "idle",
});
});
it("reaped (dead / needsAttention) → replay 'session ended'", () => {
expect(deriveCliTabVisibility(session("dead"))).toEqual({
kind: "replay",
mode: "ended",
});
expect(deriveCliTabVisibility(session("needsAttention"))).toEqual({
kind: "replay",
mode: "ended",
});
});
});

View File

@@ -0,0 +1,149 @@
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { render, screen, waitFor, cleanup, fireEvent } from "@testing-library/react";
import type { WorkflowDefinition } from "@fusion/core";
vi.mock("../../api", () => ({
fetchWorkflows: vi.fn(),
createWorkflow: vi.fn(),
updateWorkflow: vi.fn(),
deleteWorkflow: vi.fn(),
compileWorkflow: vi.fn(),
fetchTraits: vi.fn(),
fetchStepParsers: vi.fn(),
fetchModels: vi.fn(),
fetchAgents: vi.fn(),
fetchDiscoveredSkills: vi.fn(),
}));
import {
fetchWorkflows,
fetchTraits,
fetchStepParsers,
updateWorkflow,
fetchModels,
} from "../../api";
import type { TraitCatalogEntry } from "../../api";
import { WorkflowNodeEditor } from "../WorkflowNodeEditor";
const TRAIT_CATALOG: TraitCatalogEntry[] = [
{ id: "intake", name: "Intake", builtin: true, flags: { intake: true } },
{ id: "complete", name: "Complete", builtin: true, flags: { complete: true } },
];
function promptDef(): WorkflowDefinition {
return {
id: "WF-CLI",
name: "CLI",
description: "",
ir: {
version: "v2",
name: "CLI",
columns: [
{ id: "triage", name: "Triage", traits: [{ trait: "intake" }] },
{ id: "done", name: "Done", traits: [{ trait: "complete" }] },
],
nodes: [
{ id: "start", kind: "start", column: "triage" },
{ id: "step", kind: "prompt", column: "triage", config: { prompt: "do" } },
{ id: "end", kind: "end", column: "done" },
],
edges: [
{ from: "start", to: "step", condition: "success" },
{ from: "step", to: "end", condition: "success" },
],
},
layout: {
start: { x: 0, y: 20 },
step: { x: 120, y: 60 },
end: { x: 360, y: 240 },
},
createdAt: "2026-06-03T00:00:00.000Z",
updatedAt: "2026-06-03T00:00:00.000Z",
};
}
describe("WorkflowNodeEditor — cli-agent executor (U15)", () => {
beforeEach(() => {
vi.mocked(fetchWorkflows).mockResolvedValue([promptDef()]);
vi.mocked(fetchTraits).mockResolvedValue(TRAIT_CATALOG);
vi.mocked(fetchStepParsers).mockResolvedValue([]);
vi.mocked(fetchModels).mockResolvedValue({ models: [] });
vi.mocked(updateWorkflow).mockResolvedValue(promptDef());
// Stub the adapter-catalog fetch.
vi.stubGlobal(
"fetch",
vi.fn(async (url: string) => {
if (typeof url === "string" && url.startsWith("/api/cli-agents")) {
return {
ok: true,
json: async () => ({
adapters: [
{ id: "claude-code", name: "Claude Code", tier: "native" },
{ id: "generic", name: "Generic CLI", tier: "generic" },
],
}),
} as Response;
}
return { ok: false, json: async () => ({}) } as Response;
}),
);
});
afterEach(() => {
cleanup();
vi.clearAllMocks();
vi.unstubAllGlobals();
});
async function selectCliAgent() {
render(<WorkflowNodeEditor isOpen onClose={() => {}} addToast={() => {}} />);
const node = await screen.findByTestId("wf-node-prompt");
fireEvent.click(node);
const executorSel = (await screen.findByText("Executor")).parentElement!.querySelector(
"select",
)! as HTMLSelectElement;
fireEvent.change(executorSel, { target: { value: "cli-agent" } });
return executorSel;
}
it("surfaces adapter + notification fields when cli-agent is selected", async () => {
await selectCliAgent();
expect(await screen.findByTestId("cli-agent-config")).toBeInTheDocument();
expect(screen.getByTestId("cli-agent-adapter")).toBeInTheDocument();
expect(screen.getByTestId("cli-agent-notify")).toBeInTheDocument();
expect(screen.getByTestId("cli-agent-autonomy")).toBeInTheDocument();
});
it("populates the adapter picker with tier labels from the API", async () => {
await selectCliAgent();
const adapterSel = (await screen.findByTestId("cli-agent-adapter")) as HTMLSelectElement;
await waitFor(() => {
expect(adapterSel.querySelectorAll("option").length).toBeGreaterThan(2);
});
const optionText = Array.from(adapterSel.querySelectorAll("option")).map((o) => o.textContent);
expect(optionText.some((t) => t?.includes("Claude Code") && t.includes("native"))).toBe(true);
expect(optionText.some((t) => t?.includes("Generic CLI") && t.includes("generic"))).toBe(true);
});
it("lands the selected adapter + notify config in the node config", async () => {
await selectCliAgent();
const adapterSel = (await screen.findByTestId("cli-agent-adapter")) as HTMLSelectElement;
fireEvent.change(adapterSel, { target: { value: "claude-code" } });
expect(adapterSel.value).toBe("claude-code");
const notifySel = screen.getByTestId("cli-agent-notify") as HTMLSelectElement;
fireEvent.change(notifySel, { target: { value: "banner+notify" } });
expect(notifySel.value).toBe("banner+notify");
// Save and assert the persisted IR carries the cli-agent node config.
fireEvent.click(screen.getByText("Save").closest("button")!);
await waitFor(() => expect(updateWorkflow).toHaveBeenCalled());
const savedIr = vi.mocked(updateWorkflow).mock.calls.at(-1)![1] as {
ir: { nodes: Array<{ id: string; config?: Record<string, unknown> }> };
};
const stepNode = savedIr.ir.nodes.find((n) => n.id === "step")!;
expect(stepNode.config?.executor).toBe("cli-agent");
expect(stepNode.config?.cliAdapterId).toBe("claude-code");
expect(stepNode.config?.cliNotify).toEqual({ mode: "banner+notify" });
});
});

View File

@@ -32,6 +32,14 @@ export interface ChatSessionInfo {
lastMessageAt?: string;
isGenerating?: boolean;
inFlightGeneration?: ChatInFlightGenerationState | null;
/**
* When set, this chat session is driven by a cli-agent executor (U12). The
* message-pane + composer region is delegated to <CliChatSurface> instead of
* the standard provider transcript/composer.
*/
cliExecutorAdapterId?: string | null;
/** Native CLI session id linkage (used as the terminal attach id for resume). */
cliSessionFile?: string | null;
}
// Re-export shared chat types so existing consumers (`import { ChatMessageInfo } from "../hooks/useChat"`)

View File

@@ -112,6 +112,7 @@
"@types/multer": "^2.1.0",
"@xterm/addon-fit": "^0.10.0",
"@xterm/addon-search": "^0.15.0",
"@xterm/addon-unicode11": "^0.8.0",
"@xterm/addon-web-links": "^0.11.0",
"@xterm/addon-webgl": "^0.18.0",
"@xterm/xterm": "^5.5.0",

View File

@@ -5,7 +5,7 @@ const { mockCreateFnAgent } = vi.hoisted(() => ({
}));
vi.mock("@fusion/engine", () => ({
createWorkflowAuthoringTools: vi.fn(() => []),
listCliAdapterDescriptors: () => [],
createFnAgent: mockCreateFnAgent,
}));

View File

@@ -25,7 +25,7 @@ const { mockCreateFnAgent } = vi.hoisted(() => ({
// Mock the engine module to avoid dynamic import issues in tests
vi.mock("@fusion/engine", () => ({
createWorkflowAuthoringTools: vi.fn(() => []),
listCliAdapterDescriptors: () => [],
createFnAgent: mockCreateFnAgent,
}));

View File

@@ -43,7 +43,7 @@ const { mockChatStreamManager, mockSendMessage, mockCancelGeneration, mockBeginG
};
});
vi.mock("@fusion/engine", () => ({ createFnAgent: vi.fn(), createWorkflowAuthoringTools: vi.fn(() => []) }));
vi.mock("@fusion/engine", () => ({ listCliAdapterDescriptors: () => [], createFnAgent: vi.fn(), createWorkflowAuthoringTools: vi.fn(() => []) }));
vi.mock("../planning.js", () => ({
getSession: vi.fn(), cleanupSession: vi.fn(), __setCreateFnAgent: vi.fn(), __resetPlanningState: vi.fn(), setAiSessionStore: vi.fn(), rehydrateFromStore: vi.fn().mockReturnValue(0),
}));

View File

@@ -0,0 +1,375 @@
/**
* CLI-backed chat session runner tests (CLI Agent Executor, U12).
*
* Mocks PTY/adapters entirely: the runner depends only on narrow
* `ChatStoreLike` / `CliSessionManagerLike` seams, so these are exercised with
* in-memory fakes. No real CliSessionManager, no node-pty, no network, no
* port 4040.
*
* ───────────────────────────────────────────────────────────────────────────
* redactSecrets COVERAGE CHARACTERIZATION (U12 deliverable)
* ───────────────────────────────────────────────────────────────────────────
* The shared @fusion/core `redactSecrets` pass runs on ALL transcript text
* before it lands in chat_messages. What it catches today (verified by the
* "redaction" describe block below):
*
* CAUGHT:
* - `Authorization: Bearer <token>` and bare `Authorization: <token>` headers.
* - Free-standing `Bearer <token>` strings.
* - `key=`/`token=`/`secret=`/`password=`/`apikey=`/`access_token=` /
* `refresh_token=`/`client_secret=` assignments (`:` or `=`, quoted or bare)
* — this is the env-dump (KEY=VALUE) coverage.
* - Vendor-prefixed opaque tokens: `sk-…`, `ghp_…`, `gho_…`, `github_pat_…`,
* `xoxb-/xoxa-/xoxp-/xoxr-…`, `AKIA…` (>=8 trailing chars).
* - Standalone long base64 (>=40 chars) and hex (>=32 chars) blobs.
*
* KNOWN GAPS (deferred per plan Risks — deeper heuristics are follow-ups):
* - Generic short secrets with no recognizable prefix/keyword/length.
* - PEM private-key blocks and multi-line credentials are only partially hit
* (line-by-line base64 may exceed the length threshold, but headers leak).
* - JSON `"token": "..."` survives only via the keyword rule, not structurally.
* - Cross-chunk tokens are handled by the ENGINE's TelemetryHub carry-over
* window (chunkCarryChars), NOT by redactSecrets alone — see the
* "token spanning a chunk split" test which models that carry behavior.
* ───────────────────────────────────────────────────────────────────────────
*/
import { describe, it, expect, beforeEach } from "vitest";
import { redactSecrets } from "@fusion/core";
import type {
ChatMessage,
ChatMessageCreateInput,
ChatSession,
} from "@fusion/core";
import {
CliChatSessionRunner,
type ChatStoreLike,
type CliSessionLike,
type CliSessionManagerLike,
type ChatTelemetryEvent,
} from "../cli-chat.js";
// ── Fakes ──────────────────────────────────────────────────────────────────
class FakeChatStore implements ChatStoreLike {
sessions = new Map<string, ChatSession>();
messages: ChatMessage[] = [];
private seq = 0;
putSession(partial: Partial<ChatSession> & { id: string }): ChatSession {
const session: ChatSession = {
id: partial.id,
agentId: "agent-1",
title: null,
status: "active",
projectId: "proj-1",
modelProvider: null,
modelId: null,
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
cliSessionFile: null,
cliExecutorAdapterId: "claude-local",
inFlightGeneration: null,
...partial,
};
this.sessions.set(session.id, session);
return session;
}
getSession(id: string): ChatSession | undefined {
return this.sessions.get(id);
}
addMessage(sessionId: string, input: ChatMessageCreateInput): ChatMessage {
const msg: ChatMessage = {
id: `msg-${++this.seq}`,
sessionId,
role: input.role,
content: input.content ?? "",
thinkingOutput: null,
metadata: input.metadata ?? null,
createdAt: new Date().toISOString(),
};
this.messages.push(msg);
return msg;
}
setCliExecutorAdapterId(id: string, adapterId: string | null): ChatSession | undefined {
const s = this.sessions.get(id);
if (!s) return undefined;
s.cliExecutorAdapterId = adapterId;
return s;
}
// Used by the runner to persist the native session id linkage.
setCliSessionFile(id: string, value: string): void {
const s = this.sessions.get(id);
if (s) s.cliSessionFile = value;
}
messagesFor(sessionId: string): ChatMessage[] {
return this.messages.filter((m) => m.sessionId === sessionId);
}
}
class FakeCliManager implements CliSessionManagerLike {
records = new Map<string, CliSessionLike>();
injected: { sessionId: string; text: string }[] = [];
spawnCalls: unknown[] = [];
private seq = 0;
async spawn(options: Parameters<CliSessionManagerLike["spawn"]>[0]): Promise<CliSessionLike> {
this.spawnCalls.push(options);
const id = options.resume?.sessionId ?? `cli-${++this.seq}`;
const record: CliSessionLike = {
id,
nativeSessionId: options.resume?.nativeSessionId ?? null,
agentState: "ready",
};
this.records.set(id, record);
return record;
}
async inject(sessionId: string, text: string): Promise<void> {
this.injected.push({ sessionId, text });
}
getSession(sessionId: string): CliSessionLike | undefined {
return this.records.get(sessionId);
}
setState(sessionId: string, state: string): void {
const r = this.records.get(sessionId);
if (r) r.agentState = state;
}
}
function makeRunner() {
const store = new FakeChatStore();
const manager = new FakeCliManager();
const runner = new CliChatSessionRunner({ store, manager });
return { store, manager, runner };
}
// ── Session spawn / resume ──────────────────────────────────────────────────
describe("CliChatSessionRunner — session lifecycle", () => {
let ctx: ReturnType<typeof makeRunner>;
beforeEach(() => {
ctx = makeRunner();
});
it("spawns a chat-purpose CLI session in the configured working directory", async () => {
ctx.store.putSession({ id: "chat-1", cliExecutorAdapterId: "claude-local" });
const cliId = await ctx.runner.ensureSession("chat-1", {
projectId: "proj-1",
worktreePath: "/work/dir",
});
expect(cliId).toBeTruthy();
const call = ctx.manager.spawnCalls[0] as Record<string, unknown>;
expect(call.purpose).toBe("chat");
expect(call.chatSessionId).toBe("chat-1");
expect(call.worktreePath).toBe("/work/dir");
expect(call.resume).toBeUndefined();
});
it("resumes via the persisted native session id (cliSessionFile linkage)", async () => {
ctx.store.putSession({ id: "chat-1", cliSessionFile: "native-abc" });
await ctx.runner.ensureSession("chat-1", { projectId: "proj-1" });
const call = ctx.manager.spawnCalls[0] as Record<string, unknown>;
expect(call.resume).toEqual({ sessionId: "chat-1", nativeSessionId: "native-abc" });
});
it("reuses an existing live session instead of respawning", async () => {
ctx.store.putSession({ id: "chat-1" });
const a = await ctx.runner.ensureSession("chat-1", { projectId: "proj-1" });
const b = await ctx.runner.ensureSession("chat-1", { projectId: "proj-1" });
expect(a).toBe(b);
expect(ctx.manager.spawnCalls).toHaveLength(1);
});
it("rejects sessions with no cli-agent executor selected", async () => {
ctx.store.putSession({ id: "chat-1", cliExecutorAdapterId: null });
await expect(ctx.runner.ensureSession("chat-1", { projectId: "proj-1" })).rejects.toThrow(
/no cli-agent executor/,
);
});
});
// ── Transcript mapping (granularity: user/assistant/tool-summary) ───────────
describe("CliChatSessionRunner — transcript mapping", () => {
let ctx: ReturnType<typeof makeRunner>;
let cliId: string;
beforeEach(async () => {
ctx = makeRunner();
ctx.store.putSession({ id: "chat-1" });
cliId = await ctx.runner.ensureSession("chat-1", { projectId: "proj-1" });
});
it("maps a transcript fixture to the expected chat_messages sequence, excluding tool noise", async () => {
// Fixture: busy → assistant chunks → a tool-summary → fine-grained tool
// noise (must be dropped) → done. Models one assistant turn.
const fixture: ChatTelemetryEvent[] = [
{ kind: "busy" },
{ kind: "transcript", text: "Let me check " },
{ kind: "transcript", text: "the config.\n" },
{ kind: "toolActivity", text: "Read(config.json)" }, // NOISE — dropped
{ kind: "outputProgress", text: "...." }, // NOISE — dropped
{ kind: "transcript", toolSummary: "Read config.json (42 lines)" },
{ kind: "idle" }, // NOISE — dropped
{ kind: "transcript", text: "All good." },
{ kind: "done" },
];
for (const ev of fixture) {
await ctx.runner.handleTelemetry("chat-1", ev);
}
const rows = ctx.store.messagesFor("chat-1").map((m) => ({
role: m.role,
content: m.content,
kind: (m.metadata as Record<string, unknown> | null)?.kind,
}));
expect(rows).toEqual([
{ role: "assistant", content: "Read config.json (42 lines)", kind: "tool-summary" },
{ role: "assistant", content: "Let me check the config.\nAll good.", kind: undefined },
]);
});
it("persists native session id on first transcript event carrying it", async () => {
await ctx.runner.handleTelemetry("chat-1", {
kind: "busy",
nativeSessionId: "native-xyz",
});
expect(ctx.store.getSession("chat-1")?.cliSessionFile).toBe("native-xyz");
});
it("transcript rows persist and reload after the session ends (durable store)", async () => {
await ctx.runner.handleTelemetry("chat-1", { kind: "busy" });
await ctx.runner.handleTelemetry("chat-1", { kind: "transcript", text: "Done working." });
await ctx.runner.handleTelemetry("chat-1", { kind: "done" });
// Simulate session end + reload: a fresh runner reading the SAME store.
const reloaded = new CliChatSessionRunner({ store: ctx.store, manager: ctx.manager });
void reloaded;
const rows = ctx.store.messagesFor("chat-1");
expect(rows).toHaveLength(1);
expect(rows[0].content).toBe("Done working.");
});
});
// ── Composer queue (stale-isGenerating learning) ───────────────────────────
describe("CliChatSessionRunner — composer queue", () => {
let ctx: ReturnType<typeof makeRunner>;
let cliId: string;
beforeEach(async () => {
ctx = makeRunner();
ctx.store.putSession({ id: "chat-1" });
cliId = await ctx.runner.ensureSession("chat-1", { projectId: "proj-1" });
});
it("injects immediately when the session is idle", async () => {
ctx.manager.setState(cliId, "ready");
const result = await ctx.runner.send("chat-1", "hello");
expect(result).toBe("sent");
expect(ctx.manager.injected).toEqual([{ sessionId: cliId, text: "hello" }]);
expect(ctx.runner.queuedCount("chat-1")).toBe(0);
});
it("queues with a visible indicator when the session is busy", async () => {
ctx.manager.setState(cliId, "busy");
const result = await ctx.runner.send("chat-1", "while busy");
expect(result).toBe("queued");
expect(ctx.manager.injected).toHaveLength(0);
expect(ctx.runner.queuedCount("chat-1")).toBe(1);
// User message is still persisted even though injection is deferred.
expect(ctx.store.messagesFor("chat-1").some((m) => m.role === "user")).toBe(true);
});
it("flushes on done using a RE-FETCHED authoritative state, not a cached flag", async () => {
ctx.manager.setState(cliId, "busy");
await ctx.runner.send("chat-1", "queued msg");
// The 'done' telemetry says the turn ended; flush must re-read the record.
ctx.manager.setState(cliId, "ready"); // authoritative state now idle
await ctx.runner.handleTelemetry("chat-1", { kind: "done" });
expect(ctx.manager.injected).toEqual([{ sessionId: cliId, text: "queued msg" }]);
expect(ctx.runner.queuedCount("chat-1")).toBe(0);
});
it("does NOT flush if the session turned busy again before the flush (re-fetch wins)", async () => {
ctx.manager.setState(cliId, "busy");
await ctx.runner.send("chat-1", "queued msg");
// 'done' arrives but the authoritative record shows busy again (re-entered turn).
ctx.manager.setState(cliId, "busy");
await ctx.runner.handleTelemetry("chat-1", { kind: "done" });
expect(ctx.manager.injected).toHaveLength(0);
expect(ctx.runner.queuedCount("chat-1")).toBe(1);
});
});
// ── Redaction (characterized coverage) ──────────────────────────────────────
describe("CliChatSessionRunner — redaction before persistence", () => {
let ctx: ReturnType<typeof makeRunner>;
beforeEach(async () => {
ctx = makeRunner();
ctx.store.putSession({ id: "chat-1" });
await ctx.runner.ensureSession("chat-1", { projectId: "proj-1" });
});
it("redacts a bearer token in transcript text before it lands in chat_messages", async () => {
await ctx.runner.handleTelemetry("chat-1", { kind: "busy" });
await ctx.runner.handleTelemetry("chat-1", {
kind: "transcript",
text: "Authorization: Bearer abcDEF123ghiJKL456mnoPQR789stu",
});
await ctx.runner.handleTelemetry("chat-1", { kind: "done" });
const content = ctx.store.messagesFor("chat-1")[0].content;
expect(content).toContain("[REDACTED]");
expect(content).not.toContain("abcDEF123ghiJKL456mnoPQR789stu");
});
it("redacts an env-dump (KEY=VALUE) before persistence", async () => {
await ctx.runner.handleTelemetry("chat-1", { kind: "busy" });
await ctx.runner.handleTelemetry("chat-1", {
kind: "transcript",
text: "API_KEY=sk-livesupersecretvalue9999 TOKEN=ghp_anotherSecretToken12345",
});
await ctx.runner.handleTelemetry("chat-1", { kind: "done" });
const content = ctx.store.messagesFor("chat-1")[0].content;
expect(content).not.toContain("sk-livesupersecretvalue9999");
expect(content).not.toContain("ghp_anotherSecretToken12345");
expect(content).toContain("[REDACTED]");
});
it("catches a token spanning a chunk split via the engine carry-over model", async () => {
// The engine's TelemetryHub keeps a carry tail across chunks so a token
// split as `Bearer ` (chunk A) + `<value>` (chunk B) is redacted at the
// boundary. We model that carry: the adapter delivers the boundary-joined
// text as ONE sanitized transcript event (already redacted upstream), so
// the persisted row never contains the value. Here we assert redactSecrets
// catches the joined form the carry produces.
const chunkA = "here is the Bearer ";
const chunkB = "sk-splitTokenAcrossChunks0000abcd";
const joined = redactSecrets(chunkA + chunkB);
expect(joined).not.toContain("sk-splitTokenAcrossChunks0000abcd");
expect(joined).toContain("[REDACTED]");
// And end-to-end: a transcript event carrying the joined text persists redacted.
await ctx.runner.handleTelemetry("chat-1", { kind: "busy" });
await ctx.runner.handleTelemetry("chat-1", { kind: "transcript", text: chunkA + chunkB });
await ctx.runner.handleTelemetry("chat-1", { kind: "done" });
const content = ctx.store.messagesFor("chat-1")[0].content;
expect(content).not.toContain("sk-splitTokenAcrossChunks0000abcd");
});
it("redacts user composer messages too (users can paste tokens)", async () => {
const cliId = ctx.manager.records.keys().next().value as string;
ctx.manager.setState(cliId, "ready");
await ctx.runner.send("chat-1", "use key=mysupersecretpassword12345 please");
const userMsg = ctx.store.messagesFor("chat-1").find((m) => m.role === "user")!;
expect(userMsg.content).not.toContain("mysupersecretpassword12345");
expect(userMsg.content).toContain("[REDACTED]");
});
});

View File

@@ -0,0 +1,71 @@
/**
* ChatManager.sendMessage cli-agent send-branch (CLI Agent Executor integration).
*
* When a chat session selects a cli-agent executor (`cliExecutorAdapterId`),
* sendMessage must broker the composer text to the injected CliChatSessionRunner
* (ensureSession + send) rather than running the model agent loop. Narrow fakes:
* no real ChatStore, no pi-ai agent, no PTY, no network, no port 4040.
*/
import { describe, it, expect, vi, beforeEach } from "vitest";
import { ChatManager } from "../chat.js";
const mockChatStore = {
getSession: vi.fn(),
createSession: vi.fn(),
addMessage: vi.fn(),
getMessages: vi.fn(),
updateSession: vi.fn(),
setCliSessionFile: vi.fn(),
setInFlightGeneration: vi.fn(),
getRoomMessages: vi.fn(),
};
function makeManager(): ChatManager {
return new ChatManager(mockChatStore as never, "/tmp/test");
}
describe("ChatManager.sendMessage — cli-agent send branch", () => {
beforeEach(() => {
vi.clearAllMocks();
});
it("routes a cli-executor chat session's composer send to runner.send", async () => {
mockChatStore.getSession.mockReturnValue({
id: "chat-cli",
cliExecutorAdapterId: "claude-code",
projectId: "proj-1",
});
const ensureSession = vi.fn(async () => "cli-session-1");
const send = vi.fn(async () => "sent" as const);
const manager = makeManager();
manager.setCliChatRunner({ ensureSession, send }, "proj-1");
await manager.sendMessage("chat-cli", "hello agent");
expect(ensureSession).toHaveBeenCalledWith("chat-cli", { projectId: "proj-1" });
expect(send).toHaveBeenCalledWith("chat-cli", "hello agent");
// The model-agent path persists in-flight generation state; the cli branch
// must NOT touch it.
expect(mockChatStore.setInFlightGeneration).not.toHaveBeenCalled();
});
it("uses the session's projectId when no explicit runner projectId is set", async () => {
mockChatStore.getSession.mockReturnValue({
id: "chat-cli2",
cliExecutorAdapterId: "codex",
projectId: "proj-from-session",
});
const ensureSession = vi.fn(async () => "cli-session-2");
const send = vi.fn(async () => "queued" as const);
const manager = makeManager();
// No projectId passed to setCliChatRunner → falls back to session.projectId.
manager.setCliChatRunner({ ensureSession, send });
await manager.sendMessage("chat-cli2", "queued please");
expect(ensureSession).toHaveBeenCalledWith("chat-cli2", { projectId: "proj-from-session" });
expect(send).toHaveBeenCalledWith("chat-cli2", "queued please");
});
});

View File

@@ -0,0 +1,122 @@
// @vitest-environment node
/**
* CLI Agent Executor server-wiring contract (integration bootstrap).
*
* Proves that a real `createCliAgentRuntime` bundle (over a temp in-memory DB,
* PTY mocked at the loadPty seam) satisfies the shapes the dashboard ServerOptions
* consume:
* - `cliAgentHubResolver(projectId, sessionId)` resolves the project's live
* TelemetryHub from the runtime bundle.
* - `cliSessionTransport` accepts the runtime's manager + store and the
* transport-owned ticket/attribution/confirm singletons, and the
* cli-sessions router mounts against that dep without error.
*
* No real PTY, no network, no port 4040.
*/
import express from "express";
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { mkdtempSync } from "node:fs";
import { join } from "node:path";
import { tmpdir } from "node:os";
import { rm } from "node:fs/promises";
import { Database } from "@fusion/core";
import type { IPty } from "node-pty";
import { createCliAgentRuntime, type BootstrappedCliAgentRuntime } from "@fusion/engine";
import {
AttachTicketStore,
CliInputAttributionLog,
CliConfirmAdvanceRegistry,
} from "../cli-session-transport.js";
import { createCliSessionsRouter } from "../routes/cli-sessions.js";
import { request } from "../test-request.js";
function mockPty(): typeof import("node-pty") {
return {
spawn() {
return {
pid: 1,
onData: () => ({ dispose() {} }),
onExit: () => ({ dispose() {} }),
write() {},
resize() {},
pause() {},
resume() {},
kill() {},
clear() {},
} as unknown as IPty;
},
} as unknown as typeof import("node-pty");
}
describe("cli-agent runtime server wiring", () => {
let runtime: BootstrappedCliAgentRuntime;
let db: Database;
let tmpDir: string;
beforeEach(() => {
tmpDir = mkdtempSync(join(tmpdir(), "fn-cli-wiring-"));
const fusionDir = join(tmpDir, ".fusion");
db = new Database(fusionDir, { inMemory: true });
db.init();
runtime = createCliAgentRuntime({
fusionDir,
db,
projectId: "proj-a",
hookEndpointUrl: "http://127.0.0.1:4040/api/cli-agent/hooks",
managerOptions: { loadPty: async () => mockPty() },
});
});
afterEach(async () => {
runtime.dispose();
db.close();
await rm(tmpDir, { recursive: true, force: true });
});
it("cliAgentHubResolver resolves the project's hub from the runtime bundle", () => {
const engines = new Map([["proj-a", { getCliAgentRuntime: () => runtime }]]);
const cliAgentHubResolver = (projectId: string | undefined, _sessionId: string) => {
const engine = projectId ? engines.get(projectId) : undefined;
return engine?.getCliAgentRuntime()?.bundle.hub;
};
expect(cliAgentHubResolver("proj-a", "cli-1")).toBe(runtime.bundle.hub);
expect(cliAgentHubResolver("missing", "cli-1")).toBeUndefined();
expect(cliAgentHubResolver(undefined, "cli-1")).toBeUndefined();
});
it("cliSessionTransport dep is satisfied by the runtime manager + store, and the router mounts", async () => {
// Seed a session so a transport-backed list route returns it.
runtime.bundle.store.createSession({
adapterId: runtime.bundle.registry.ids()[0],
projectId: "proj-a",
purpose: "execute",
taskId: "FN-1",
worktreePath: "/tmp/wt",
agentState: "busy",
});
const transport = {
manager: runtime.bundle.manager,
store: runtime.bundle.store,
ticketStore: new AttachTicketStore(),
attributionLog: new CliInputAttributionLog(),
confirmAdvance: new CliConfirmAdvanceRegistry(),
};
const app = express();
app.use(express.json());
app.use("/api/cli-sessions", createCliSessionsRouter(transport));
const res = await request(
app as unknown as (req: import("http").IncomingMessage, res: import("http").ServerResponse) => void,
"GET",
"/api/cli-sessions?projectId=proj-a",
);
expect(res.status).toBe(200);
const sessions = res.body.sessions as Array<{ taskId?: string }>;
expect(sessions.some((s) => s.taskId === "FN-1")).toBe(true);
});
});

View File

@@ -0,0 +1,151 @@
// @vitest-environment node
import { describe, expect, it } from "vitest";
import {
neutralizeTerminalOutput,
flushTerminalOutput,
MAX_CARRY_LENGTH,
} from "../cli-session-output-filter.js";
const ESC = "\x1b";
const BEL = "\x07";
const ST = "\x1b\\";
/** Run a single full chunk and return the output (asserting no carry leftover). */
function run(chunk: string): string {
const { output, carry } = neutralizeTerminalOutput(chunk, "");
return output + flushTerminalOutput(carry);
}
describe("neutralizeTerminalOutput", () => {
it("passes plain text through untouched", () => {
expect(run("hello world\n")).toBe("hello world\n");
});
it("strips OSC 52 clipboard-write sequences (BEL-terminated)", () => {
const evil = `before${ESC}]52;c;ZXZpbA==${BEL}after`;
const out = run(evil);
expect(out).toBe("beforeafter");
expect(out).not.toContain("52;");
});
it("strips OSC 52 clipboard-write sequences (ST-terminated)", () => {
const evil = `x${ESC}]52;c;ZGF0YQ==${ST}y`;
expect(run(evil)).toBe("xy");
});
it("strips the URI of an OSC 8 javascript: hyperlink but keeps the text", () => {
const link = `${ESC}]8;;javascript:alert(1)${BEL}Click me${ESC}]8;;${BEL}`;
const out = run(link);
expect(out).not.toContain("javascript:");
expect(out).toContain("Click me");
// The opening link should be present but with an empty URI.
expect(out).toContain(`${ESC}]8;;`);
});
it("passes through OSC 8 https hyperlinks verbatim", () => {
const link = `${ESC}]8;;https://example.com${BEL}Link${ESC}]8;;${BEL}`;
const out = run(link);
expect(out).toContain("https://example.com");
expect(out).toContain("Link");
});
it("strips DSR (device status report) query sequences", () => {
// ESC [ 6 n is a cursor-position report query; the terminal would answer it.
const out = run(`a${ESC}[6nb`);
expect(out).toBe("ab");
});
it("strips DA (device attributes) query sequences", () => {
expect(run(`a${ESC}[cb`)).toBe("ab");
expect(run(`a${ESC}[>cb`)).toBe("ab");
});
it("strips DECRQSS (DCS query) sequences", () => {
const out = run(`a${ESC}P$qm${ST}b`);
expect(out).toBe("ab");
});
it("preserves benign CSI sequences like SGR color", () => {
const colored = `${ESC}[31mred${ESC}[0m`;
expect(run(colored)).toBe(colored);
});
it("preserves cursor movement CSI (not a query)", () => {
const moved = `${ESC}[2J${ESC}[H`;
expect(run(moved)).toBe(moved);
});
it("handles an OSC 52 sequence split across two chunks", () => {
const first = `before${ESC}]52;c;ZXZ`;
const second = `pbA==${BEL}after`;
const r1 = neutralizeTerminalOutput(first, "");
// The unterminated OSC should be withheld in carry, not emitted.
expect(r1.output).toBe("before");
expect(r1.carry).toContain("52;");
const r2 = neutralizeTerminalOutput(second, r1.carry);
expect(r2.output).toBe("after");
expect(r2.output + flushTerminalOutput(r2.carry)).not.toContain("52;");
});
it("handles a DSR query split across two chunks", () => {
const r1 = neutralizeTerminalOutput(`a${ESC}[6`, "");
expect(r1.output).toBe("a");
const r2 = neutralizeTerminalOutput(`nb`, r1.carry);
expect(r2.output).toBe("b");
});
it("withholds a lone trailing ESC as carry", () => {
const r1 = neutralizeTerminalOutput(`hi${ESC}`, "");
expect(r1.output).toBe("hi");
expect(r1.carry).toBe(ESC);
const r2 = neutralizeTerminalOutput(`[31mred`, r1.carry);
expect(r2.output).toBe(`${ESC}[31mred`);
});
it("flushes an unterminated sequence at stream end (no infinite withhold)", () => {
const r = neutralizeTerminalOutput(`text${ESC}]52;c;partial`, "");
expect(r.output).toBe("text");
// flush emits the residual literally rather than losing it forever.
expect(flushTerminalOutput(r.carry)).toContain("partial");
});
it("bounds the carry so an unterminated sequence cannot grow unbounded", () => {
const huge = `${ESC}]52;c;` + "A".repeat(MAX_CARRY_LENGTH + 100);
const r = neutralizeTerminalOutput(huge, "");
expect(r.carry.length).toBeLessThanOrEqual(MAX_CARRY_LENGTH);
});
it("drops (not flushes) an overflowing OSC 52 prefix so it cannot reconstruct across chunks", () => {
// An unterminated OSC 52 grows past MAX_CARRY. The dangerous introducer must
// NOT be emitted as literal — otherwise a terminator in the next chunk would
// recombine at the client into a working OSC 52 clipboard write.
const huge = `${ESC}]52;c;` + "A".repeat(MAX_CARRY_LENGTH + 100);
const r1 = neutralizeTerminalOutput(huge, "");
// Nothing reconstructable was emitted, and the carry was dropped.
expect(r1.output).not.toContain(`${ESC}]`);
expect(r1.output).not.toContain("52;");
expect(r1.carry).toBe("");
// The terminator arriving next has no held introducer to recombine with.
const r2 = neutralizeTerminalOutput(`${BEL}visible`, r1.carry);
const combined = r1.output + r2.output;
expect(combined).not.toContain(`${ESC}]52;`);
expect(r2.output).toContain("visible");
});
it("neutralizes a stream with OSC 52, OSC 8 js link, and a DSR query together", () => {
const stream =
`start${ESC}]52;c;ZXZpbA==${BEL}` +
`${ESC}]8;;javascript:x${BEL}danger${ESC}]8;;${BEL}` +
`${ESC}[6n` +
`end`;
const out = run(stream);
expect(out).not.toContain("52;");
expect(out).not.toContain("javascript:");
expect(out).toContain("start");
expect(out).toContain("danger");
expect(out).toContain("end");
// No bare query passthrough.
expect(out).not.toContain(`${ESC}[6n`);
});
});

View File

@@ -0,0 +1,625 @@
// @vitest-environment node
import http from "node:http";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { WebSocket } from "ws";
import type { CliSession } from "@fusion/core";
import type { CliSessionAttachment } from "@fusion/engine";
import {
setupCliSessionWebSocket,
CLI_SESSION_WS_PATH,
} from "../cli-session-ws.js";
import {
AttachTicketStore,
CliInputAttributionLog,
bridgeCliStateToSse,
type CliSessionManagerLike,
} from "../cli-session-transport.js";
import {
emitCliSessionStateSseEvent,
getCliSessionStateEventsSince,
resetCliSessionStateBufferForTests,
} from "../sse.js";
import { CliSessionStateMachine } from "@fusion/engine";
const DAEMON_TOKEN = "test-daemon-token";
// ── A fake PTY-backed session (no real node-pty) ─────────────────────────────
class FakeAttachment implements CliSessionAttachment {
scrollback: Uint8Array;
private queue: Uint8Array[] = [];
private waiters: ((r: IteratorResult<Uint8Array>) => void)[] = [];
private closed = false;
writes: string[] = [];
resizes: { cols: number; rows: number }[] = [];
detached = false;
constructor(
scrollback: string,
private readonly onWrite: (data: string) => void,
private readonly onResize: (cols: number, rows: number) => void,
) {
this.scrollback = Buffer.from(scrollback, "utf8");
}
pushLive(text: string): void {
const chunk = new Uint8Array(Buffer.from(text, "utf8"));
const waiter = this.waiters.shift();
if (waiter) waiter({ value: chunk, done: false });
else this.queue.push(chunk);
}
close(): void {
if (this.closed) return;
this.closed = true;
while (this.waiters.length) this.waiters.shift()!({ value: undefined, done: true });
}
get stream(): AsyncIterable<Uint8Array> {
return {
[Symbol.asyncIterator]: () => ({
next: (): Promise<IteratorResult<Uint8Array>> => {
const q = this.queue.shift();
if (q !== undefined) return Promise.resolve({ value: q, done: false });
if (this.closed) return Promise.resolve({ value: undefined, done: true });
return new Promise((resolve) => this.waiters.push(resolve));
},
return: (): Promise<IteratorResult<Uint8Array>> => {
this.close();
return Promise.resolve({ value: undefined, done: true });
},
}),
};
}
write(data: string): void {
this.onWrite(data);
this.writes.push(data);
}
resize(cols: number, rows: number): void {
this.onResize(cols, rows);
this.resizes.push({ cols, rows });
}
detach(): void {
this.detached = true;
this.close();
}
}
class FakeManager implements CliSessionManagerLike {
attachments = new Map<string, FakeAttachment[]>();
live = new Set<string>();
ptyInput: string[] = [];
ptyResizes: { cols: number; rows: number }[] = [];
paused = 0;
resumed = 0;
constructor(liveIds: string[]) {
for (const id of liveIds) this.live.add(id);
}
isLive(id: string): boolean {
return this.live.has(id);
}
makeAttachment(id: string, scrollback: string): FakeAttachment {
const att = new FakeAttachment(
scrollback,
(data) => this.ptyInput.push(data),
(cols, rows) => this.ptyResizes.push({ cols, rows }),
);
const list = this.attachments.get(id) ?? [];
list.push(att);
this.attachments.set(id, list);
return att;
}
attach(id: string): CliSessionAttachment {
// Each attach gets its own attachment, but they share the PTY input sink.
return this.makeAttachment(id, this.scrollbackFor(id));
}
private scrollbackFor(id: string): string {
return this.scrollbackById.get(id) ?? "";
}
scrollbackById = new Map<string, string>();
/** Broadcast live bytes to every attachment of a session. */
broadcast(id: string, text: string): void {
for (const att of this.attachments.get(id) ?? []) att.pushLive(text);
}
requestPause(): void {
this.paused += 1;
}
requestResume(): void {
this.resumed += 1;
}
}
function makeSession(overrides: Partial<CliSession> = {}): CliSession {
return {
id: "cli-1",
taskId: "FN-1",
chatSessionId: null,
purpose: "execute",
projectId: "proj-a",
adapterId: "claude-code",
agentState: "busy",
terminationReason: null,
nativeSessionId: null,
resumeAttempts: 0,
autonomyPosture: null,
worktreePath: "/tmp/wt",
createdAt: "2026-06-04T00:00:00.000Z",
updatedAt: "2026-06-04T00:00:00.000Z",
...overrides,
};
}
function makeStore(sessions: CliSession[]) {
const map = new Map(sessions.map((s) => [s.id, s]));
return {
getSession: (id: string) => map.get(id),
listSessions: () => [...map.values()],
};
}
// ── Harness: a real http.Server on an EPHEMERAL port (0 — never 4040) ────────
interface Harness {
port: number;
manager: FakeManager;
ticketStore: AttachTicketStore;
attributionLog: CliInputAttributionLog;
store: ReturnType<typeof makeStore>;
close: () => Promise<void>;
}
async function startHarness(opts?: {
sessions?: CliSession[];
liveIds?: string[];
highWatermarkBytes?: number;
lowWatermarkBytes?: number;
noAuth?: boolean;
}): Promise<Harness> {
const sessions = opts?.sessions ?? [makeSession()];
const store = makeStore(sessions);
const manager = new FakeManager(opts?.liveIds ?? sessions.map((s) => s.id));
const ticketStore = new AttachTicketStore();
const attributionLog = new CliInputAttributionLog();
const server = http.createServer((_req, res) => {
res.statusCode = 426;
res.end();
});
setupCliSessionWebSocket(server, {
manager,
store,
ticketStore,
attributionLog,
daemonToken: DAEMON_TOKEN,
noAuth: opts?.noAuth ?? false,
highWatermarkBytes: opts?.highWatermarkBytes,
lowWatermarkBytes: opts?.lowWatermarkBytes,
});
await new Promise<void>((resolve) => server.listen(0, "127.0.0.1", resolve));
const addr = server.address();
const port = typeof addr === "object" && addr ? addr.port : 0;
expect(port).not.toBe(4040);
return {
port,
manager,
ticketStore,
attributionLog,
store,
close: () =>
new Promise<void>((resolve) => {
server.close(() => resolve());
}),
};
}
function wsUrl(
port: number,
params: Record<string, string>,
): string {
const qs = new URLSearchParams(params).toString();
return `ws://127.0.0.1:${port}${CLI_SESSION_WS_PATH}?${qs}`;
}
/** Connect a WS, supplying an Origin (same-host by default) and token via query. */
function connect(
port: number,
params: Record<string, string>,
headers: Record<string, string> = {},
): WebSocket {
return new WebSocket(wsUrl(port, { fn_token: DAEMON_TOKEN, ...params }), {
headers: { origin: `http://127.0.0.1:${port}`, ...headers },
});
}
function nextMessage(ws: WebSocket, predicate?: (m: any) => boolean): Promise<any> {
return new Promise((resolve, reject) => {
const onMsg = (raw: Buffer) => {
const msg = JSON.parse(raw.toString());
if (!predicate || predicate(msg)) {
ws.off("message", onMsg);
resolve(msg);
}
};
ws.on("message", onMsg);
ws.once("error", reject);
});
}
function waitClose(ws: WebSocket): Promise<{ code: number; reason: string }> {
return new Promise((resolve) => {
ws.once("close", (code, reason) => resolve({ code, reason: reason.toString() }));
});
}
function decode(b64: string): string {
return Buffer.from(b64, "base64").toString("utf8");
}
describe("cli-session WS attach", () => {
let h: Harness;
afterEach(async () => {
if (h) await h.close();
});
async function mintTicket(sessionId: string): Promise<string> {
const { ticket } = h.ticketStore.mint({
sessionId,
projectId: h.store.getSession(sessionId)!.projectId,
readOnly: false,
});
return ticket;
}
it("two concurrent attaches both receive live bytes; input from either reaches the PTY; detach of one keeps the session", async () => {
h = await startHarness();
const t1 = await mintTicket("cli-1");
const t2 = await mintTicket("cli-1");
const a = connect(h.port, { sessionId: "cli-1", ticket: t1 });
const b = connect(h.port, { sessionId: "cli-1", ticket: t2 });
await Promise.all([
nextMessage(a, (m) => m.type === "scrollback"),
nextMessage(b, (m) => m.type === "scrollback"),
]);
const aData = nextMessage(a, (m) => m.type === "data");
const bData = nextMessage(b, (m) => m.type === "data");
h.manager.broadcast("cli-1", "live-output");
expect(decode((await aData).data)).toBe("live-output");
expect(decode((await bData).data)).toBe("live-output");
a.send(JSON.stringify({ type: "input", data: "from-a" }));
b.send(JSON.stringify({ type: "input", data: "from-b" }));
await vi.waitFor(() => {
expect(h.manager.ptyInput).toContain("from-a");
expect(h.manager.ptyInput).toContain("from-b");
});
// Detach a — session must stay live and b keeps receiving.
a.close();
await waitClose(a);
expect(h.manager.isLive("cli-1")).toBe(true);
const bData2 = nextMessage(b, (m) => m.type === "data");
h.manager.broadcast("cli-1", "still-here");
expect(decode((await bData2).data)).toBe("still-here");
b.close();
});
it("rejects upgrade without a daemon token", async () => {
h = await startHarness();
const t = await mintTicket("cli-1");
const ws = new WebSocket(wsUrl(h.port, { sessionId: "cli-1", ticket: t }), {
headers: { origin: `http://127.0.0.1:${h.port}` },
});
const err = await new Promise<Error>((resolve) => ws.once("error", resolve));
expect(err.message).toMatch(/401/);
});
it("rejects a foreign Origin", async () => {
h = await startHarness();
const t = await mintTicket("cli-1");
const ws = connect(
h.port,
{ sessionId: "cli-1", ticket: t },
{ origin: "http://evil.example.com" },
);
const err = await new Promise<Error>((resolve) => ws.once("error", resolve));
expect(err.message).toMatch(/403/);
});
it("rejects an absent browser Origin (Sec-Fetch-Site present)", async () => {
h = await startHarness();
const t = await mintTicket("cli-1");
const ws = new WebSocket(wsUrl(h.port, { fn_token: DAEMON_TOKEN, sessionId: "cli-1", ticket: t }), {
headers: { "sec-fetch-site": "cross-site" },
});
const err = await new Promise<Error>((resolve) => ws.once("error", resolve));
expect(err.message).toMatch(/403/);
});
it("rejects a replayed / consumed ticket", async () => {
h = await startHarness();
const t = await mintTicket("cli-1");
const first = connect(h.port, { sessionId: "cli-1", ticket: t });
await nextMessage(first, (m) => m.type === "scrollback");
// Reuse the same ticket on a new connection — must be rejected.
const second = connect(h.port, { sessionId: "cli-1", ticket: t });
const closed = await waitClose(second);
expect(closed.code).toBe(4401);
first.close();
});
it("rejects a ticket for session A used to attach session B", async () => {
h = await startHarness({
sessions: [makeSession(), makeSession({ id: "cli-2", taskId: "FN-2" })],
});
const tA = await mintTicket("cli-1");
const ws = connect(h.port, { sessionId: "cli-2", ticket: tA });
const closed = await waitClose(ws);
expect(closed.code).toBe(4401);
});
it("rejects a cross-project session id (ticket project mismatch handled; unknown session)", async () => {
h = await startHarness();
const t = await mintTicket("cli-1");
const ws = connect(h.port, { sessionId: "cli-unknown", ticket: t });
const closed = await waitClose(ws);
expect(closed.code).toBe(4004);
});
it("late attacher gets scrollback then live with no duplication", async () => {
h = await startHarness();
h.manager.scrollbackById.set("cli-1", "PRIOR-OUTPUT");
const t = await mintTicket("cli-1");
const ws = connect(h.port, { sessionId: "cli-1", ticket: t });
const sb = await nextMessage(ws, (m) => m.type === "scrollback");
expect(decode(sb.data)).toBe("PRIOR-OUTPUT");
const live = nextMessage(ws, (m) => m.type === "data");
h.manager.broadcast("cli-1", "NEW");
expect(decode((await live).data)).toBe("NEW");
ws.close();
});
it("flow control: slow consumer (no acks) triggers pause at high watermark; acks resume at low watermark", async () => {
h = await startHarness({ highWatermarkBytes: 20, lowWatermarkBytes: 10 });
const t = await mintTicket("cli-1");
const ws = connect(h.port, { sessionId: "cli-1", ticket: t });
await nextMessage(ws, (m) => m.type === "scrollback");
// Push 25 bytes without acking — crosses the 20-byte high watermark.
const d1 = nextMessage(ws, (m) => m.type === "data");
h.manager.broadcast("cli-1", "x".repeat(25));
await d1;
await vi.waitFor(() => expect(h.manager.paused).toBe(1));
expect(h.manager.resumed).toBe(0);
// Ack 20 bytes — outstanding drops to 5 (<= low watermark 10) → resume.
ws.send(JSON.stringify({ type: "ack", bytes: 20 }));
await vi.waitFor(() => expect(h.manager.resumed).toBe(1));
ws.close();
});
it("resize is forwarded (latest-active-client) to the manager", async () => {
h = await startHarness();
const t = await mintTicket("cli-1");
const ws = connect(h.port, { sessionId: "cli-1", ticket: t });
await nextMessage(ws, (m) => m.type === "scrollback");
ws.send(JSON.stringify({ type: "resize", cols: 120, rows: 40 }));
await vi.waitFor(() =>
expect(h.manager.ptyResizes).toContainEqual({ cols: 120, rows: 40 }),
);
ws.close();
});
it("output hardening: OSC 52, OSC 8 javascript: link, and a DSR query are neutralized (incl. split across chunks)", async () => {
h = await startHarness();
const t = await mintTicket("cli-1");
const ws = connect(h.port, { sessionId: "cli-1", ticket: t });
await nextMessage(ws, (m) => m.type === "scrollback");
const ESC = "\x1b";
const BEL = "\x07";
// OSC 52 + OSC 8 js link + DSR — all in one chunk.
const d1 = nextMessage(ws, (m) => m.type === "data");
h.manager.broadcast(
"cli-1",
`A${ESC}]52;c;ZXZpbA==${BEL}${ESC}]8;;javascript:x${BEL}T${ESC}]8;;${BEL}${ESC}[6nB`,
);
const out1 = decode((await d1).data);
expect(out1).not.toContain("52;");
expect(out1).not.toContain("javascript:");
expect(out1).not.toContain(`${ESC}[6n`);
expect(out1).toContain("A");
expect(out1).toContain("T");
expect(out1).toContain("B");
// A sequence split across two broadcasts (chunks).
h.manager.broadcast("cli-1", `C${ESC}]52;c;ZXZ`);
h.manager.broadcast("cli-1", `pbA==${BEL}D`);
// Collect data frames until we see the trailing "D".
let collected = "";
await vi.waitFor(
() =>
new Promise<void>((resolve, reject) => {
const onMsg = (raw: Buffer) => {
const m = JSON.parse(raw.toString());
if (m.type === "data") collected += decode(m.data);
if (collected.includes("D")) {
ws.off("message", onMsg);
resolve();
}
};
ws.on("message", onMsg);
setTimeout(() => reject(new Error("timeout")), 1000);
}),
);
expect(collected).not.toContain("52;");
expect(collected).toContain("C");
expect(collected).toContain("D");
ws.close();
});
it("does not leak an OSC 52 split across the scrollback→live seam", async () => {
const ESC = "\x1b";
const BEL = "\x07";
// The OSC 52 introducer lands at the very TAIL of scrollback (unterminated);
// its terminator arrives in the first LIVE chunk. The carry must thread
// across the seam so the neutralizer sees the full sequence and strips it —
// the scrollback frame must NOT flush the held introducer verbatim.
h = await startHarness();
h.manager.scrollbackById.set("cli-1", `prior-output${ESC}]52;c;ZXZ`);
const t = await mintTicket("cli-1");
const ws = connect(h.port, { sessionId: "cli-1", ticket: t });
const scrollback = await nextMessage(ws, (m) => m.type === "scrollback");
const scrollText = decode(scrollback.data);
// Normal scrollback still renders; the unterminated tail is withheld (carry).
expect(scrollText).toContain("prior-output");
expect(scrollText).not.toContain("52;");
expect(scrollText).not.toContain(`${ESC}]`);
// Deliver the terminator in the first live chunk.
h.manager.broadcast("cli-1", `pbA==${BEL}after`);
let collected = scrollText;
await vi.waitFor(
() =>
new Promise<void>((resolve, reject) => {
const onMsg = (raw: Buffer) => {
const m = JSON.parse(raw.toString());
if (m.type === "data") collected += decode(m.data);
if (collected.includes("after")) {
ws.off("message", onMsg);
resolve();
}
};
ws.on("message", onMsg);
setTimeout(() => reject(new Error("timeout")), 1000);
}),
);
// The full sequence, reassembled across the seam, was neutralized.
expect(collected).not.toContain("52;");
expect(collected).not.toContain(`${ESC}]52`);
expect(collected).toContain("prior-output");
expect(collected).toContain("after");
ws.close();
});
it("read-only session rejects input with an error frame (server-side)", async () => {
h = await startHarness({
sessions: [makeSession({ id: "cli-ro", purpose: "validator" })],
});
const t = await mintTicket("cli-ro");
const ws = connect(h.port, { sessionId: "cli-ro", ticket: t });
await nextMessage(ws, (m) => m.type === "scrollback");
const errFrame = nextMessage(ws, (m) => m.type === "error");
ws.send(JSON.stringify({ type: "input", data: "should-be-blocked" }));
const err = await errFrame;
expect(err.code).toBe("READ_ONLY");
expect(h.manager.ptyInput).not.toContain("should-be-blocked");
ws.close();
});
});
// ── SSE cli:session:state ─────────────────────────────────────────────────────
/** Minimal in-memory store satisfying what the state machine + bridge touch. */
function makeStateStore(session: CliSession) {
let current = { ...session };
return {
getSession: (id: string) => (id === current.id ? current : undefined),
listSessions: () => [current],
updateSession: (id: string, input: Partial<CliSession>) => {
if (id !== current.id) return undefined;
current = { ...current, ...input } as CliSession;
return current;
},
};
}
describe("cli:session:state SSE bridge", () => {
beforeEach(() => resetCliSessionStateBufferForTests());
it("forwards a state transition as one event carrying owning entity + redacted preview", () => {
const session = makeSession({
id: "s-1",
taskId: "FN-9",
projectId: "proj-a",
agentState: "starting",
});
const store = makeStateStore(session);
const machine = new CliSessionStateMachine({
sessionId: "s-1",
store: store as never,
});
const captured: { id: number; payload: any; projectId?: string }[] = [];
const unbridge = bridgeCliStateToSse(machine, {
store: store as never,
getRecentOutput: () => "bearer sk-secret-1234567890 \x1b[31mfoo\x1b[0m",
});
// Seed machine into busy via the legal path, then trigger a transition.
machine.markReady(); // starting → ready
machine.injectPrompt(); // ready → busy
const before = getCliSessionStateEventsSince(0).length;
machine.signalDone(); // busy → done
const events = getCliSessionStateEventsSince(0);
expect(events.length).toBeGreaterThan(before);
const last = events[events.length - 1];
expect(last.payload.sessionId).toBe("s-1");
expect(last.payload.taskId).toBe("FN-9");
expect(last.payload.state).toBe("done");
expect(last.projectId).toBe("proj-a");
// Preview is ANSI-stripped and bounded.
expect(last.payload.lastOutputPreview).not.toContain("\x1b");
unbridge();
void captured;
});
it("engine throttle coalesces rapid transitions into fewer emitted events", async () => {
const session = makeSession({ id: "s-2", projectId: "proj-a", agentState: "starting" });
const store = makeStateStore(session);
const machine = new CliSessionStateMachine({
sessionId: "s-2",
store: store as never,
stateChangeThrottleMs: 500,
});
bridgeCliStateToSse(machine, { store: store as never });
machine.markReady();
machine.injectPrompt();
// Rapid waiting↔busy churn within the throttle window.
machine.signalWaitingOnInput();
machine.signalBusy();
machine.signalWaitingOnInput();
machine.signalBusy();
const emitted = getCliSessionStateEventsSince(0).length;
// Leading-edge + coalesced trailing means far fewer than the ~6 raw transitions.
expect(emitted).toBeLessThan(6);
expect(emitted).toBeGreaterThanOrEqual(1);
});
it("getCliSessionStateEventsSince replays only transitions after a lastEventId", () => {
const a = emitCliSessionStateSseEvent(
{ sessionId: "x", taskId: null, chatSessionId: null, state: "ready", at: "t0" },
"proj-a",
);
const b = emitCliSessionStateSseEvent(
{ sessionId: "x", taskId: null, chatSessionId: null, state: "busy", at: "t1" },
"proj-a",
);
const replay = getCliSessionStateEventsSince(a);
expect(replay.map((e) => e.id)).toEqual([b]);
expect(replay[0].payload.state).toBe("busy");
});
});

View File

@@ -0,0 +1,211 @@
// @vitest-environment node
import express from "express";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { request } from "../test-request.js";
import type { CliSession } from "@fusion/core";
type App = (req: import("http").IncomingMessage, res: import("http").ServerResponse) => void;
function getJson(app: App, path: string) {
return request(app, "GET", path);
}
function postJson(app: App, path: string, body: unknown) {
return request(app, "POST", path, JSON.stringify(body), {
"content-type": "application/json",
});
}
import { createCliSessionsRouter } from "../routes/cli-sessions.js";
import {
AttachTicketStore,
CliInputAttributionLog,
CliConfirmAdvanceRegistry,
type CliSessionManagerLike,
} from "../cli-session-transport.js";
function makeSession(overrides: Partial<CliSession> = {}): CliSession {
return {
id: "cli-1",
taskId: "FN-1",
chatSessionId: null,
purpose: "execute",
projectId: "proj-a",
adapterId: "claude-code",
agentState: "busy",
terminationReason: null,
nativeSessionId: null,
resumeAttempts: 0,
autonomyPosture: null,
worktreePath: "/tmp/wt",
createdAt: "2026-06-04T00:00:00.000Z",
updatedAt: "2026-06-04T00:00:00.000Z",
...overrides,
};
}
function makeStore(sessions: CliSession[]) {
const map = new Map(sessions.map((s) => [s.id, s]));
return {
getSession: (id: string) => map.get(id),
listSessions: (opts?: {
projectId?: string;
taskId?: string;
chatSessionId?: string;
}) =>
[...map.values()].filter(
(s) =>
(opts?.projectId === undefined || s.projectId === opts.projectId) &&
(opts?.taskId === undefined || s.taskId === opts.taskId) &&
(opts?.chatSessionId === undefined || s.chatSessionId === opts.chatSessionId),
),
_map: map,
};
}
function buildApp(opts: {
store: ReturnType<typeof makeStore>;
manager: CliSessionManagerLike;
ticketStore: AttachTicketStore;
attributionLog: CliInputAttributionLog;
confirmAdvance: CliConfirmAdvanceRegistry;
}): express.Express {
const app = express();
app.use(express.json());
app.use(
"/api/cli-sessions",
createCliSessionsRouter({
store: opts.store,
manager: opts.manager,
ticketStore: opts.ticketStore,
attributionLog: opts.attributionLog,
confirmAdvance: opts.confirmAdvance,
}),
);
return app;
}
describe("cli-sessions routes", () => {
let store: ReturnType<typeof makeStore>;
let manager: CliSessionManagerLike;
let injectSpy: ReturnType<typeof vi.fn>;
let ticketStore: AttachTicketStore;
let attributionLog: CliInputAttributionLog;
let confirmAdvance: CliConfirmAdvanceRegistry;
let app: express.Express;
beforeEach(() => {
store = makeStore([
makeSession(),
makeSession({ id: "cli-2", projectId: "proj-b", taskId: "FN-2" }),
makeSession({ id: "cli-ro", purpose: "validator", projectId: "proj-a", taskId: "FN-3" }),
]);
injectSpy = vi.fn().mockResolvedValue(undefined);
manager = {
isLive: () => true,
attach: () => {
throw new Error("not used in route tests");
},
inject: injectSpy,
requestPause: vi.fn(),
requestResume: vi.fn(),
};
ticketStore = new AttachTicketStore();
attributionLog = new CliInputAttributionLog();
confirmAdvance = new CliConfirmAdvanceRegistry();
app = buildApp({ store, manager, ticketStore, attributionLog, confirmAdvance });
});
afterEach(() => {
vi.restoreAllMocks();
});
it("lists sessions filtered by project / task / chat", async () => {
const res = await getJson(app, "/api/cli-sessions?projectId=proj-a");
expect(res.status).toBe(200);
const ids = res.body.sessions.map((s: CliSession) => s.id).sort();
expect(ids).toEqual(["cli-1", "cli-ro"]);
const byTask = await getJson(app, "/api/cli-sessions?taskId=FN-2");
expect(byTask.body.sessions.map((s: CliSession) => s.id)).toEqual(["cli-2"]);
});
it("returns a single session record", async () => {
const res = await getJson(app, "/api/cli-sessions/cli-1");
expect(res.status).toBe(200);
expect(res.body.session.id).toBe("cli-1");
});
it("404s for unknown session", async () => {
const res = await getJson(app, "/api/cli-sessions/nope");
expect(res.status).toBe(404);
});
it("rejects cross-project access (project scope check)", async () => {
const res = await getJson(app, "/api/cli-sessions/cli-1?projectId=proj-b");
expect(res.status).toBe(403);
});
it("mints a single-use attach ticket with expiry", async () => {
const res = await postJson(app, "/api/cli-sessions/cli-1/attach-ticket", {});
expect(res.status).toBe(200);
expect(typeof res.body.ticket).toBe("string");
expect(res.body.ticket.length).toBeGreaterThan(20);
expect(new Date(res.body.expiresAt).getTime()).toBeGreaterThan(Date.now());
expect(res.body.readOnly).toBe(false);
// The ticket consumes exactly once and is bound to its session.
const entry = ticketStore.consume(res.body.ticket, "cli-1");
expect(entry).not.toBeNull();
expect(ticketStore.consume(res.body.ticket, "cli-1")).toBeNull(); // single-use
});
it("marks a read-only (validator) session's ticket as readOnly", async () => {
const res = await postJson(app, "/api/cli-sessions/cli-ro/attach-ticket", {});
expect(res.body.readOnly).toBe(true);
});
it("injects text and records attribution", async () => {
const res = await postJson(app, "/api/cli-sessions/cli-1/inject", { text: "hello agent" });
expect(res.status).toBe(200);
expect(injectSpy).toHaveBeenCalledWith("cli-1", "hello agent");
const log = attributionLog.list("cli-1");
expect(log).toHaveLength(1);
expect(log[0].source).toBe("inject");
});
it("rejects inject on a read-only session (server-side enforcement)", async () => {
const res = await postJson(app, "/api/cli-sessions/cli-ro/inject", { text: "nope" });
expect(res.status).toBe(403);
expect(injectSpy).not.toHaveBeenCalled();
});
it("rejects empty inject body", async () => {
const res = await postJson(app, "/api/cli-sessions/cli-1/inject", { text: "" });
expect(res.status).toBe(400);
});
it("409s inject when session is not live", async () => {
manager.isLive = () => false;
const res = await postJson(app, "/api/cli-sessions/cli-1/inject", { text: "hi" });
expect(res.status).toBe(409);
});
it("records a confirm-advance decision and emits an event", async () => {
const seen: string[] = [];
confirmAdvance.on((info) => seen.push(`${info.sessionId}:${info.decision}`));
const res = await postJson(app, "/api/cli-sessions/cli-1/confirm-advance", {
decision: "advance",
});
expect(res.status).toBe(200);
expect(res.body.decision).toBe("advance");
expect(confirmAdvance.getLatest("cli-1")).toBe("advance");
expect(seen).toContain("cli-1:advance");
});
it("rejects an invalid confirm-advance decision", async () => {
const res = await postJson(app, "/api/cli-sessions/cli-1/confirm-advance", {
decision: "maybe",
});
expect(res.status).toBe(400);
});
});

View File

@@ -21,7 +21,7 @@ const mockErrors = vi.hoisted(() => ({
}));
vi.mock("@fusion/engine", () => ({
createWorkflowAuthoringTools: vi.fn(() => []),
listCliAdapterDescriptors: () => [],
defaultGitOps: vi.fn(() => ({})),
ExperimentFinalizeService: vi.fn(() => ({ previewPlan: previewPlanMock, finalize: finalizeMock })),
ExperimentFinalizeStateError: mockErrors.StateError,

View File

@@ -7,7 +7,7 @@ const { mockCreateFnAgent } = vi.hoisted(() => ({
}));
vi.mock("@fusion/engine", () => ({
createWorkflowAuthoringTools: vi.fn(() => []),
listCliAdapterDescriptors: () => [],
createFnAgent: mockCreateFnAgent,
}));

View File

@@ -7,7 +7,7 @@ const { mockCreateFnAgent } = vi.hoisted(() => ({
}));
vi.mock("@fusion/engine", () => ({
createWorkflowAuthoringTools: vi.fn(() => []),
listCliAdapterDescriptors: () => [],
createFnAgent: mockCreateFnAgent,
}));

View File

@@ -14,7 +14,7 @@ vi.mock("node:child_process", () => ({
}));
vi.mock("@fusion/engine", () => ({
createWorkflowAuthoringTools: vi.fn(() => []),
listCliAdapterDescriptors: () => [],
createFnAgent: vi.fn(async () => ({
session: {
prompt: promptMock,

View File

@@ -33,7 +33,7 @@ vi.mock("@fusion/core", async () => {
});
vi.mock("@fusion/engine", () => ({
createWorkflowAuthoringTools: vi.fn(() => []),
listCliAdapterDescriptors: () => [],
createFnAgent: vi.fn(async () => ({
session: {
state: { messages: [] as Array<{ role: string; content: string }> },

View File

@@ -55,7 +55,7 @@ vi.mock("@fusion/core", async () => {
});
vi.mock("@fusion/engine", () => ({
createWorkflowAuthoringTools: vi.fn(() => []),
listCliAdapterDescriptors: () => [],
executeApprovedAgentProvisioning: vi.fn(async () => undefined),
}));

View File

@@ -38,7 +38,7 @@ class MockApprovalRequestStore {
vi.mock("@fusion/core", async (importOriginal) => ({
...(await importOriginal<typeof import("@fusion/core")>()), ApprovalRequestStore: MockApprovalRequestStore, AgentStore: class { async init() {} async getAgent() { return null; } } }));
vi.mock("@fusion/engine", () => ({
createWorkflowAuthoringTools: vi.fn(() => []),
listCliAdapterDescriptors: () => [],
executeApprovedAgentProvisioning: vi.fn(),
executeApprovedWorktrunkInstall: vi.fn(),
assertNoSecretPlaintext: (metadata?: Record<string, unknown>) => {

View File

@@ -86,7 +86,7 @@ vi.mock("@fusion/core", async (importOriginal) => ({
const executeApprovedWorktrunkInstall = vi.fn(async () => ({ binaryPath: "~/.fusion/bin/wt", source: "installed-release" }));
vi.mock("@fusion/engine", () => ({
createWorkflowAuthoringTools: vi.fn(() => []),
listCliAdapterDescriptors: () => [],
executeApprovedAgentProvisioning,
executeApprovedWorktrunkInstall,
}));

View File

@@ -26,7 +26,7 @@ class MockApprovalRequestStore {
}
vi.mock("@fusion/engine", () => ({
createWorkflowAuthoringTools: vi.fn(() => []),
listCliAdapterDescriptors: () => [],
WORKTRUNK_INSTALL_PATH: "~/.fusion/bin/wt",
WORKTRUNK_PINNED_RELEASE: {
source: "upstream-pending-verification",

View File

@@ -45,7 +45,7 @@ const { mockCreateFnAgent } = vi.hoisted(() => ({
}));
vi.mock("@fusion/engine", () => ({
createWorkflowAuthoringTools: vi.fn(() => []),
listCliAdapterDescriptors: () => [],
createFnAgent: mockCreateFnAgent,
}));

View File

@@ -38,7 +38,7 @@ const { mockCreateFnAgent } = vi.hoisted(() => ({
}));
vi.mock("@fusion/engine", () => ({
createWorkflowAuthoringTools: vi.fn(() => []),
listCliAdapterDescriptors: () => [],
createFnAgent: mockCreateFnAgent,
}));

View File

@@ -41,7 +41,7 @@ const { mockCreateFnAgent } = vi.hoisted(() => ({
}));
vi.mock("@fusion/engine", () => ({
createWorkflowAuthoringTools: vi.fn(() => []),
listCliAdapterDescriptors: () => [],
createFnAgent: mockCreateFnAgent,
createResolvedAgentSession: vi.fn(async () => ({
session: { state: { messages: [] }, prompt: vi.fn(), dispose: vi.fn() },

View File

@@ -38,7 +38,7 @@ const { mockCreateFnAgent } = vi.hoisted(() => ({
}));
vi.mock("@fusion/engine", () => ({
createWorkflowAuthoringTools: vi.fn(() => []),
listCliAdapterDescriptors: () => [],
createFnAgent: mockCreateFnAgent,
}));

View File

@@ -34,7 +34,7 @@ vi.mock("@fusion/core", async () => {
});
vi.mock("@fusion/engine", () => ({
createWorkflowAuthoringTools: vi.fn(() => []),
listCliAdapterDescriptors: () => [],
createFnAgent: vi.fn(async () => ({
session: {
state: { messages: [] as Array<{ role: string; content: string }> },

View File

@@ -9,7 +9,7 @@ const { mockCreateFnAgent } = vi.hoisted(() => ({
}));
vi.mock("@fusion/engine", () => ({
createWorkflowAuthoringTools: vi.fn(() => []),
listCliAdapterDescriptors: () => [],
createFnAgent: mockCreateFnAgent,
}));

View File

@@ -16,8 +16,18 @@ import type { IncomingMessage } from "node:http";
*/
export const TOKEN_QUERY_PARAM = "fn_token";
/** Paths that are exempt from authentication (liveness probes). */
const EXEMPT_PATHS = ["/api/health"];
/**
* Paths exempt from the daemon bearer-token middleware.
*
* - `/api/health` — liveness probes.
* - `/api/cli-agent/hooks` — the CLI-agent hook ingestion route (U17). Hook
* scripts run inside the spawned CLI process and only hold the per-session hook
* token, NOT the daemon bearer token. That route does its OWN authentication:
* it validates the per-session token against the engine-held registry
* (constant-time) and rejects browser-context requests (Origin/Host CSRF
* defense). It must therefore bypass the daemon-token gate, not weaken it.
*/
const EXEMPT_PATHS = ["/api/health", "/api/cli-agent/hooks"];
/**
* Only /api/* paths are gated by this middleware. The SPA shell (index.html,

View File

@@ -741,6 +741,29 @@ export class ChatManager {
private taskStore?: TaskStore,
) {}
/**
* Runner for CLI-agent-backed chat sessions (CLI Agent Executor). When a chat
* session selects a cli-agent executor (`cliExecutorAdapterId`), composer sends
* are brokered to the live PTY through this runner instead of the model agent
* loop. Injected post-construction (the runtime is built per-project at boot,
* after the ChatManager) so the positional ctor stays stable.
*/
private cliChatRunner?: {
ensureSession(chatSessionId: string, opts: { projectId: string; worktreePath?: string | null }): Promise<string>;
send(chatSessionId: string, text: string): Promise<"sent" | "queued">;
};
/** Project id used when the runner spawns a CLI session for a chat. */
private cliChatProjectId?: string;
/** Wire (or clear) the CLI-agent chat runner and its owning project id. */
setCliChatRunner(
runner: ChatManager["cliChatRunner"] | undefined,
projectId?: string,
): void {
this.cliChatRunner = runner;
this.cliChatProjectId = projectId;
}
private queueInFlightGenerationPersist(sessionId: string, snapshot: ChatInFlightGenerationState | null): void {
const existingTimer = this.inFlightPersistTimers.get(sessionId);
if (existingTimer) {
@@ -1398,6 +1421,26 @@ export class ChatManager {
const broadcastOptions = { generationId };
const session = this.chatStore.getSession(sessionId);
// CLI-agent-backed chat: a session that selected a cli-agent executor brokers
// its composer sends to the live PTY (via the runner) rather than running the
// model agent loop. The runner persists the user message + the transcript.
if (session?.cliExecutorAdapterId && this.cliChatRunner) {
const runner = this.cliChatRunner;
try {
await runner.ensureSession(sessionId, {
projectId: this.cliChatProjectId ?? session.projectId ?? "",
});
await runner.send(sessionId, content);
} finally {
const current = this.activeGenerations.get(sessionId);
if (current?.generationId === generationId) {
this.activeGenerations.delete(sessionId);
}
}
return;
}
let agentResult: AgentResult | undefined;
let accumulatedThinking = "";
let accumulatedText = "";

View File

@@ -0,0 +1,332 @@
/**
* CLI-backed chat session runner (CLI Agent Executor, U12).
*
* When a chat session selects a cli-agent executor (`ChatSession.cliExecutorAdapterId`),
* the chat is driven by a long-lived CLI agent process instead of the standard
* model-provider path. This runner is the server-side bridge between that CLI
* session and the durable chat transcript:
*
* - It spawns (or resumes) a `CliSessionManager` session with purpose "chat",
* cwd = the configured working directory (or the project root), persisting the
* native session id back onto the chat session for resume.
* - Composer messages route through the inject path (FIFO, serialized by the
* manager's write queue). While the session is busy, sends queue; the flush
* decision re-fetches authoritative session state from the store rather than
* trusting a cached/streamed busy flag (the stale-isGenerating learning,
* docs/solutions/logic-errors/queued-chat-message-flush-trusts-stale-isgenerating.md).
* - Adapter transcript telemetry events map to `chat_messages` rows at
* user/assistant/tool-summary granularity. Fine-grained tool noise
* (toolActivity, outputProgress, idle) stays in the terminal and is NOT
* persisted — the durable transcript is the readable conversation, not the
* raw scrollback.
*
* Secret hygiene: the shared `redactSecrets` pass runs on transcript text
* BEFORE persistence. Durable chat rows must not become a secret store — CLI
* agents routinely print bearer tokens and env dumps. See the test block in
* packages/dashboard/src/__tests__/chat-cli-sessions.test.ts for the
* characterized coverage of what `redactSecrets` catches and its known gaps.
*
* This module owns no PTY/adapter internals directly: it depends on narrow
* interfaces (`ChatStoreLike`, `CliSessionManagerLike`) so it is unit-testable
* with mocked PTY/adapters per the U12 constraints.
*/
import { redactSecrets } from "@fusion/core";
import type {
ChatMessage,
ChatMessageCreateInput,
ChatSession,
} from "@fusion/core";
// ── Narrow dependency interfaces (testable seams) ──────────────────────────
/** The slice of ChatStore this runner needs. */
export interface ChatStoreLike {
getSession(id: string): ChatSession | undefined;
addMessage(sessionId: string, input: ChatMessageCreateInput): ChatMessage;
setCliExecutorAdapterId(id: string, adapterId: string | null): ChatSession | undefined;
}
/** A durable cli_sessions record (subset used here). */
export interface CliSessionLike {
id: string;
nativeSessionId: string | null;
agentState: string;
}
/** The slice of CliSessionManager this runner needs. */
export interface CliSessionManagerLike {
spawn(options: {
adapterId: string;
projectId: string;
purpose: "chat";
chatSessionId: string;
worktreePath?: string | null;
resume?: { sessionId: string; nativeSessionId: string };
}): Promise<CliSessionLike>;
inject(sessionId: string, text: string): Promise<void>;
/** Authoritative, freshly-read session record (used for flush decisions). */
getSession(sessionId: string): CliSessionLike | undefined;
}
/**
* Sanitized telemetry event shape (mirrors engine's SanitizedTelemetryEvent,
* duplicated as a structural type to avoid a dashboard→engine import edge).
*/
export interface ChatTelemetryEvent {
kind:
| "sessionStart"
| "busy"
| "waitingOnInput"
| "done"
| "idle"
| "toolActivity"
| "outputProgress"
| "transcript";
text?: string;
nativeSessionId?: string;
/** Transcript role hint when the adapter distinguishes turns. */
role?: "user" | "assistant";
/** A tool-summary line (one human-readable line, not raw tool noise). */
toolSummary?: string;
}
/** Busy-equivalent states: composer sends must queue, not flush. */
const BUSY_STATES = new Set(["starting", "busy", "waitingOnInput"]);
export interface CliChatSessionRunnerOptions {
store: ChatStoreLike;
manager: CliSessionManagerLike;
}
/**
* Maps one CLI-backed chat session to its durable transcript and brokers
* composer injection with FIFO queueing.
*/
export class CliChatSessionRunner {
private readonly store: ChatStoreLike;
private readonly manager: CliSessionManagerLike;
/** chatSessionId → live cli session id. */
private readonly cliSessionByChat = new Map<string, string>();
/** chatSessionId → FIFO queue of composer texts awaiting a flush. */
private readonly queue = new Map<string, string[]>();
/** chatSessionId → assistant text being accumulated across transcript chunks. */
private readonly assistantBuffer = new Map<string, string>();
constructor(opts: CliChatSessionRunnerOptions) {
this.store = opts.store;
this.manager = opts.manager;
}
/**
* Ensure a live CLI session exists for the chat, spawning (or resuming via a
* persisted native session id) as needed. Returns the cli session id.
*/
async ensureSession(
chatSessionId: string,
opts: { projectId: string; worktreePath?: string | null },
): Promise<string> {
const existing = this.cliSessionByChat.get(chatSessionId);
if (existing) return existing;
const chat = this.store.getSession(chatSessionId);
if (!chat) throw new Error(`Unknown chat session: ${chatSessionId}`);
const adapterId = chat.cliExecutorAdapterId;
if (!adapterId) {
throw new Error(`Chat session ${chatSessionId} has no cli-agent executor`);
}
// Resume if we previously recorded a native session id (cliSessionFile-style
// linkage; here the native id lives on the cli_sessions record).
const resumeNative = chat.cliSessionFile; // native session id persisted on the chat
const cli = await this.manager.spawn({
adapterId,
projectId: opts.projectId,
purpose: "chat",
chatSessionId,
worktreePath: opts.worktreePath ?? null,
...(resumeNative
? { resume: { sessionId: chatSessionId, nativeSessionId: resumeNative } }
: {}),
});
this.cliSessionByChat.set(chatSessionId, cli.id);
return cli.id;
}
/**
* Send a composer message. If the underlying CLI session is busy (per a
* freshly re-fetched store record — never a cached flag), the text is queued
* with a visible queued state instead of injected. Returns whether the
* message was injected immediately (`"sent"`) or queued (`"queued"`).
*
* The user message is persisted to the transcript immediately in both cases
* so the conversation reflects intent regardless of timing.
*/
async send(chatSessionId: string, text: string): Promise<"sent" | "queued"> {
const cliSessionId = this.cliSessionByChat.get(chatSessionId);
if (!cliSessionId) throw new Error(`No live CLI session for chat ${chatSessionId}`);
// Persist the user's message immediately (redacted — users can paste tokens too).
this.store.addMessage(chatSessionId, {
role: "user",
content: redactSecrets(text),
metadata: { source: "cli-agent", origin: "composer" },
});
if (this.isBusy(cliSessionId)) {
this.enqueue(chatSessionId, text);
return "queued";
}
await this.manager.inject(cliSessionId, text);
return "sent";
}
/**
* Authoritative busy check: re-reads the session record from the manager/store
* so flush decisions never trust a stale SSE/cached `isGenerating` flag.
*/
private isBusy(cliSessionId: string): boolean {
const record = this.manager.getSession(cliSessionId);
if (!record) return false;
return BUSY_STATES.has(record.agentState);
}
private enqueue(chatSessionId: string, text: string): void {
const q = this.queue.get(chatSessionId) ?? [];
q.push(text);
this.queue.set(chatSessionId, q);
}
/** Number of composer messages currently queued for a chat (UI indicator). */
queuedCount(chatSessionId: string): number {
return this.queue.get(chatSessionId)?.length ?? 0;
}
/**
* Attempt to flush one queued composer message. Called when the session
* reports `done`. Re-fetches authoritative state before injecting — if the
* session turned busy again between the SSE event and this call, the flush
* is skipped and the message stays queued (the stale-isGenerating learning).
*/
async flushNext(chatSessionId: string): Promise<boolean> {
const cliSessionId = this.cliSessionByChat.get(chatSessionId);
if (!cliSessionId) return false;
const q = this.queue.get(chatSessionId);
if (!q || q.length === 0) return false;
// Authoritative re-fetch — do NOT trust a cached/streamed flag here.
if (this.isBusy(cliSessionId)) return false;
const text = q.shift()!;
if (q.length === 0) this.queue.delete(chatSessionId);
await this.manager.inject(cliSessionId, text);
return true;
}
/**
* Map a sanitized adapter telemetry event to transcript persistence.
*
* Granularity (KTD): only user / assistant / tool-summary land in
* chat_messages. `toolActivity`, `outputProgress`, and `idle` are terminal
* noise and are dropped here. `redactSecrets` runs on all persisted text.
*
* - `busy` → starts a new assistant turn (flushes any prior buffer).
* - `transcript` (role assistant or unspecified) → accumulates assistant text.
* - `transcript` (role user) → a user-echo turn (rare; adapters that surface it).
* - `transcript` with `toolSummary` → a single tool-summary row (no raw noise).
* - `done` → flushes the accumulated assistant turn, then tries a queue flush.
*
* Returns the chat_messages rows it created (for tests / SSE fan-out is the
* store's responsibility via `chat:message:added`).
*/
async handleTelemetry(
chatSessionId: string,
event: ChatTelemetryEvent,
): Promise<ChatMessage[]> {
const created: ChatMessage[] = [];
// Persist the native session id for resume the first time we learn it.
if (event.nativeSessionId) {
const chat = this.store.getSession(chatSessionId);
if (chat && chat.cliSessionFile !== event.nativeSessionId) {
// Reuse cliSessionFile column as the native-session linkage (KTD:
// cliSessionFile-style column or session metadata). setCliSessionFile is
// internal plumbing; we route through the public setter on the runner's
// store slice when available, else fall through.
(this.store as { setCliSessionFile?: (id: string, v: string) => void }).setCliSessionFile?.(
chatSessionId,
event.nativeSessionId,
);
}
}
switch (event.kind) {
case "busy": {
// New assistant turn begins — flush any stale buffer defensively.
this.flushAssistantBuffer(chatSessionId, created);
this.assistantBuffer.set(chatSessionId, "");
break;
}
case "transcript": {
if (event.toolSummary) {
// One readable tool-summary row. Raw per-call tool noise never reaches here.
const row = this.store.addMessage(chatSessionId, {
role: "assistant",
content: redactSecrets(event.toolSummary),
metadata: { source: "cli-agent", kind: "tool-summary" },
});
created.push(row);
break;
}
const text = event.text ?? "";
if (event.role === "user") {
// Adapter-surfaced user echo — persist as a user row (deduped by caller).
const row = this.store.addMessage(chatSessionId, {
role: "user",
content: redactSecrets(text),
metadata: { source: "cli-agent", origin: "transcript" },
});
created.push(row);
break;
}
// Default: assistant transcript text — accumulate across chunks.
const buf = this.assistantBuffer.get(chatSessionId) ?? "";
this.assistantBuffer.set(chatSessionId, buf + text);
break;
}
case "done": {
this.flushAssistantBuffer(chatSessionId, created);
// Session idle → attempt to flush one queued composer message.
await this.flushNext(chatSessionId);
break;
}
// Terminal-only noise — intentionally NOT persisted to the transcript.
case "toolActivity":
case "outputProgress":
case "idle":
case "sessionStart":
case "waitingOnInput":
break;
}
return created;
}
/** Persist the accumulated assistant turn as one row, if non-empty. */
private flushAssistantBuffer(chatSessionId: string, into: ChatMessage[]): void {
const buf = this.assistantBuffer.get(chatSessionId);
if (buf == null) return;
this.assistantBuffer.delete(chatSessionId);
const trimmed = buf.trim();
if (trimmed.length === 0) return;
const row = this.store.addMessage(chatSessionId, {
role: "assistant",
content: redactSecrets(trimmed),
metadata: { source: "cli-agent" },
});
into.push(row);
}
}

View File

@@ -0,0 +1,253 @@
/**
* cli-session-output-filter — outbound terminal-output hardening
* (CLI Agent Executor, U10).
*
* The CLI agent's terminal byte stream is UNTRUSTED: the agent (or anything it
* runs) controls the bytes, and a co-driving surface (browser xterm, TUI host
* TTY) honors escape sequences. Some sequences let an attacker exfiltrate or
* forge input, so the server-side WS bridge neutralizes them BEFORE forwarding
* a data frame. `neutralizeTerminalOutput` is the single, pure, streaming-safe
* implementation; the TUI (U14) re-imports it so every passthrough applies the
* identical set.
*
* The neutralized set (KTD — output hardening):
* - OSC 52 (clipboard write): `ESC ] 52 ; … BEL|ST` — a remote write to the
* user's clipboard. Stripped entirely.
* - OSC 8 hyperlinks with a non-http/https scheme (e.g. `javascript:`,
* `file:`): `ESC ] 8 ; params ; URI BEL|ST`. The URI is stripped (replaced
* with an empty URI so the hyperlink is closed/neutral) while the visible
* link TEXT that follows is kept. http/https links pass through untouched.
* - Device-status / query sequences whose auto-responses would forge INPUT back
* into the PTY: DSR (`ESC [ … n`), DA1/DA2 (`ESC [ c`, `ESC [ > c`), and
* DECRQSS (`ESC P $ q … ESC \`). A terminal answers these by writing bytes to
* stdin — which converge on the agent's input FIFO — so a malicious stream
* could smuggle keystrokes. Stripped.
*
* Streaming-safe: a sequence may be split across two chunks. The function takes
* (and returns) a small carry buffer holding a trailing partial sequence; the
* caller threads the carry across calls. `flush` emits any residual carry on
* stream end. The carry is bounded so a never-terminated sequence can't grow
* without limit.
*/
const ESC = "\x1b";
const BEL = "\x07";
const ST = "\x1b\\"; // String Terminator (ESC \)
/**
* Maximum bytes held in the carry buffer for an in-progress sequence. A
* sequence longer than this is almost certainly malformed/hostile (a real OSC /
* CSI is short); once exceeded we flush the carry as literal text rather than
* buffer unboundedly.
*/
export const MAX_CARRY_LENGTH = 8 * 1024;
/** Allowed URI schemes for OSC 8 hyperlinks. Everything else has its URI stripped. */
const SAFE_LINK_SCHEME = /^(https?):/i;
export interface NeutralizeResult {
/** The sanitized, safe-to-forward text. */
output: string;
/**
* Trailing bytes withheld because they may be the prefix of a sequence that
* continues in the next chunk. Pass this back in as the next call's `carry`.
*/
carry: string;
}
/**
* Does `text` (already known to start at an ESC) look like it COULD be the start
* of a longer sequence we care about, if more bytes arrive? Used to decide
* whether to withhold a trailing partial as carry. Returns true when the buffer
* is a strict, still-growing prefix of a recognized-but-unterminated sequence.
*/
function isIncompleteSequence(buf: string): boolean {
if (buf === ESC) return true; // lone ESC — could begin anything
// OSC: ESC ] … (terminated by BEL or ST). Incomplete until terminator seen.
if (buf.startsWith(`${ESC}]`)) {
return !buf.includes(BEL) && !buf.includes(ST);
}
// DCS (DECRQSS uses DCS): ESC P … ESC \ (terminated by ST).
if (buf.startsWith(`${ESC}P`)) {
return !buf.includes(ST);
}
// CSI: ESC [ params/intermediates, terminated by a final byte @-~.
if (buf.startsWith(`${ESC}[`)) {
// Final byte is in 0x40–0x7e. Incomplete while we've not seen one yet.
return !/[@-~]/.test(buf.slice(2));
}
// ESC followed by exactly nothing-meaningful-yet: ESC + a single byte that
// could still become "]" / "[" / "P". `${ESC}` alone handled above; a 2-char
// ESC + intermediate is its own complete 2-byte sequence, not incomplete.
if (buf.length === 1) return true;
return false;
}
/**
* Process a fully-terminated OSC body (the text between `ESC ]` and its
* terminator, terminator excluded). Returns the replacement text to emit in
* place of the WHOLE OSC sequence (including terminator handling done by the
* caller).
*/
function neutralizeOsc(body: string): string {
// OSC 52 (clipboard): drop entirely.
if (body.startsWith("52;") || body === "52") {
return "";
}
// OSC 8 hyperlink: ESC ] 8 ; params ; URI
if (body.startsWith("8;")) {
const rest = body.slice(2); // params ; URI
const sep = rest.indexOf(";");
if (sep === -1) {
// Malformed — strip the whole thing to be safe.
return "";
}
const params = rest.slice(0, sep);
const uri = rest.slice(sep + 1);
// The closing OSC 8 (empty URI) is always safe — keep it so link state is
// balanced.
if (uri === "") {
return `${ESC}]8;${params};${ST}`;
}
if (SAFE_LINK_SCHEME.test(uri)) {
// Safe scheme — pass the hyperlink through verbatim.
return `${ESC}]8;${params};${uri}${ST}`;
}
// Unsafe scheme — strip the URI (emit an opening link with empty URI so the
// following visible text still renders, just not as a live link).
return `${ESC}]8;${params};${ST}`;
}
// Any other OSC — pass through verbatim (re-add ST terminator).
return `${ESC}]${body}${ST}`;
}
/**
* Is this a CSI device-status / query sequence whose auto-response forges input?
* `seq` is the full CSI starting at `ESC [` and ending at its final byte.
*/
function isForgingQueryCsi(seq: string): boolean {
const body = seq.slice(2); // params + intermediates + final
const final = body.slice(-1);
const params = body.slice(0, -1);
// DSR — Device Status Report: ESC [ … n (e.g. 5n, 6n cursor-position report).
if (final === "n") return true;
// DA1 — Primary Device Attributes: ESC [ c or ESC [ 0 c
if (final === "c" && !params.startsWith(">") && !params.startsWith("=")) return true;
// DA2/DA3 — Secondary/Tertiary DA: ESC [ > c , ESC [ = c
if (final === "c" && (params.startsWith(">") || params.startsWith("="))) return true;
return false;
}
/**
* Neutralize one chunk of (possibly mid-sequence) terminal output, threading a
* carry buffer so sequences split across chunks are handled. Pure: no I/O, no
* shared state.
*/
export function neutralizeTerminalOutput(chunk: string, carry = ""): NeutralizeResult {
const input = carry + chunk;
let out = "";
let i = 0;
while (i < input.length) {
const ch = input[i];
if (ch !== ESC) {
out += ch;
i += 1;
continue;
}
// We're at an ESC. Examine the remainder.
const rest = input.slice(i);
// ── OSC: ESC ] … BEL|ST ──
if (rest.startsWith(`${ESC}]`)) {
const belIdx = rest.indexOf(BEL);
const stIdx = rest.indexOf(ST);
let endIdx = -1;
let termLen = 0;
if (belIdx !== -1 && (stIdx === -1 || belIdx < stIdx)) {
endIdx = belIdx;
termLen = BEL.length;
} else if (stIdx !== -1) {
endIdx = stIdx;
termLen = ST.length;
}
if (endIdx === -1) {
// Unterminated — withhold as carry (bounded).
break;
}
const body = rest.slice(2, endIdx);
out += neutralizeOsc(body);
i += endIdx + termLen;
continue;
}
// ── DCS (DECRQSS): ESC P … ESC \ ──
if (rest.startsWith(`${ESC}P`)) {
const stIdx = rest.indexOf(ST, 2);
if (stIdx === -1) {
break; // unterminated — carry
}
// DECRQSS is a query (ESC P $ q …). Strip the whole DCS.
i += stIdx + ST.length;
continue;
}
// ── CSI: ESC [ … final(@-~) ──
if (rest.startsWith(`${ESC}[`)) {
const finalMatch = rest.slice(2).search(/[@-~]/);
if (finalMatch === -1) {
break; // unterminated — carry
}
const seq = rest.slice(0, 2 + finalMatch + 1);
if (isForgingQueryCsi(seq)) {
// Strip the query.
} else {
out += seq;
}
i += seq.length;
continue;
}
// ── Lone ESC at end of buffer: could begin a sequence next chunk. ──
if (rest.length === 1) {
break; // carry the ESC
}
// ── Some other 2-byte ESC sequence (ESC <byte>) — pass through. ──
out += rest.slice(0, 2);
i += 2;
}
let newCarry = input.slice(i);
// Bound the carry: if a "sequence" never terminates, don't buffer forever.
if (newCarry.length > MAX_CARRY_LENGTH) {
// If the overflowing carry begins with a recognized dangerous introducer
// (OSC `ESC ]` or DCS `ESC P`), do NOT flush it as literal — emitting the
// raw `ESC ]52;…` prefix would let it recombine with a terminator that
// arrives in a later chunk and reconstruct the hazardous sequence at the
// client. Drop the introducer (and everything held with it) so it can never
// be reassembled. Harmless overflow (anything else) is flushed as before.
if (newCarry.startsWith(`${ESC}]`) || newCarry.startsWith(`${ESC}P`)) {
// Strip the dangerous prefix entirely.
} else {
out += newCarry;
}
newCarry = "";
} else if (newCarry.length > 0 && !isIncompleteSequence(newCarry)) {
// The residual isn't actually a growing prefix — emit it.
out += newCarry;
newCarry = "";
}
return { output: out, carry: newCarry };
}
/**
* Flush a residual carry at stream end. The held bytes were an unterminated
* sequence; emit them as literal text (we never got a terminator, so there's no
* safe interpretation to apply — but withholding forever would lose output).
*/
export function flushTerminalOutput(carry: string): string {
return carry;
}

View File

@@ -0,0 +1,344 @@
/**
* cli-session-transport — shared transport primitives for CLI agent sessions
* (CLI Agent Executor, U10).
*
* Holds the pieces the REST router (cli-sessions.ts) and the WS attach handler
* (server.ts wiring) both depend on:
* - the narrow manager/store/hub interfaces the transport needs (so tests can
* supply a fake CliSessionManager without a real PTY),
* - the short-lived, single-use, session-scoped ATTACH TICKET store,
* - input-source ATTRIBUTION bookkeeping (ticket id → session input log),
* - the generic-tier CONFIRM-ADVANCE flag/event seam,
* - the ORIGIN allowlist check for the WS upgrade.
*
* Attach auth (KTD): the long-lived daemon token alone never authorizes PTY
* WRITE access. A surface must first call the authenticated attach-ticket route
* (daemon-token gated), then present the single-use ticket on the WS upgrade —
* which ALSO re-checks the daemon token and an Origin allowlist. Keystroke
* injection into a privileged agent PTY warrants the stronger posture.
*/
import { randomBytes } from "node:crypto";
import type { CliSession, CliSessionStore } from "@fusion/core";
import {
stripAnsiControl,
type CliSessionAttachment,
type CliStateChange,
} from "@fusion/engine";
import { emitCliSessionStateSseEvent } from "./sse.js";
// ── Narrow interfaces the transport needs ────────────────────────────────────
/**
* The subset of the engine `CliSessionManager` the transport touches. The real
* manager satisfies this; WS tests supply a fake (no PTY) implementing exactly
* these members.
*/
export interface CliSessionManagerLike {
/** Whether a session id is currently live (attachable). */
isLive(sessionId: string): boolean;
/** Attach a client: scrollback + live stream + write/resize/detach. */
attach(sessionId: string): CliSessionAttachment;
/** Inject a composed/engine prompt (neutralized) onto the shared FIFO. */
inject(sessionId: string, text: string): Promise<void>;
/** High-watermark backpressure: pause the PTY. */
requestPause(sessionId: string): void;
/** Low-watermark backpressure release: resume the PTY. */
requestResume(sessionId: string): void;
}
/** Dependencies the transport binds to (engine-owned, supplied at setup). */
export interface CliSessionTransportDeps {
manager: CliSessionManagerLike;
store: Pick<CliSessionStore, "getSession" | "listSessions">;
}
// ── Attach tickets ───────────────────────────────────────────────────────────
/** Default attach-ticket TTL (ms). Short-lived: enough to open a WS, no more. */
export const DEFAULT_ATTACH_TICKET_TTL_MS = 60_000;
interface AttachTicketEntry {
ticket: string;
sessionId: string;
projectId: string;
/** Whether write (input/inject over WS) is permitted (read-only sessions: false). */
readOnly: boolean;
expiresAt: number;
consumed: boolean;
}
/**
* In-memory, single-use, session-scoped attach-ticket store. A ticket is minted
* by the authenticated REST route, then consumed exactly once on the WS upgrade.
* Expired/consumed tickets never validate, and a ticket for session A can never
* attach session B (the session id is bound into the ticket).
*/
export class AttachTicketStore {
private readonly tickets = new Map<string, AttachTicketEntry>();
private readonly ttlMs: number;
private readonly now: () => number;
constructor(opts?: { ttlMs?: number; now?: () => number }) {
this.ttlMs = opts?.ttlMs ?? DEFAULT_ATTACH_TICKET_TTL_MS;
this.now = opts?.now ?? (() => Date.now());
}
/** Mint a ticket for a session. Returns the opaque ticket + its expiry. */
mint(input: {
sessionId: string;
projectId: string;
readOnly: boolean;
}): { ticket: string; expiresAt: number } {
const ticket = randomBytes(24).toString("hex");
const expiresAt = this.now() + this.ttlMs;
this.tickets.set(ticket, {
ticket,
sessionId: input.sessionId,
projectId: input.projectId,
readOnly: input.readOnly,
expiresAt,
consumed: false,
});
return { ticket, expiresAt };
}
/**
* Consume a ticket for a specific session (single-use). Returns the entry on
* success, or null when: unknown, already consumed, expired, or bound to a
* DIFFERENT session than `sessionId`.
*/
consume(ticket: string | null | undefined, sessionId: string): AttachTicketEntry | null {
if (!ticket) return null;
const entry = this.tickets.get(ticket);
if (!entry) return null;
if (entry.consumed) return null;
if (this.now() > entry.expiresAt) {
this.tickets.delete(ticket);
return null;
}
if (entry.sessionId !== sessionId) return null;
entry.consumed = true;
// Keep briefly for attribution, but it can never be reused.
return entry;
}
/** Sweep expired tickets (best-effort housekeeping). */
sweepExpired(): void {
const now = this.now();
for (const [ticket, entry] of this.tickets) {
if (now > entry.expiresAt) this.tickets.delete(ticket);
}
}
}
// ── Input-source attribution ─────────────────────────────────────────────────
export interface CliInputAttributionEntry {
/** The ticket id the input arrived under (the accountability floor in v1). */
ticketId: string;
/** "ws" for WS input frames, "inject" for the REST inject route. */
source: "ws" | "inject";
/** Byte length of the input (not the content — content is not retained). */
byteLength: number;
at: string;
}
/**
* In-memory per-session input-attribution log. v1 has no per-user arbitration,
* so logging which ticket each input frame arrived under is the accountability
* floor for post-incident attribution.
*/
export class CliInputAttributionLog {
private readonly bySession = new Map<string, CliInputAttributionEntry[]>();
private readonly cap: number;
constructor(opts?: { capPerSession?: number }) {
this.cap = opts?.capPerSession ?? 1000;
}
record(sessionId: string, entry: CliInputAttributionEntry): void {
let list = this.bySession.get(sessionId);
if (!list) {
list = [];
this.bySession.set(sessionId, list);
}
list.push(entry);
if (list.length > this.cap) list.splice(0, list.length - this.cap);
}
list(sessionId: string): CliInputAttributionEntry[] {
return [...(this.bySession.get(sessionId) ?? [])];
}
}
// ── Confirm-advance (generic-tier R20 affordance) ────────────────────────────
export type CliConfirmAdvanceListener = (info: {
sessionId: string;
projectId: string;
decision: "advance" | "not-yet";
}) => void;
/**
* The generic-tier "this session looks idle — advance to review?" affordance.
* The engine pipeline layer acts on the event later; for now the transport
* persists the latest decision per session and emits to subscribers (the engine
* seam wires a listener in a later unit).
*/
export class CliConfirmAdvanceRegistry {
private readonly latest = new Map<string, "advance" | "not-yet">();
private readonly listeners = new Set<CliConfirmAdvanceListener>();
on(listener: CliConfirmAdvanceListener): () => void {
this.listeners.add(listener);
return () => this.listeners.delete(listener);
}
record(sessionId: string, projectId: string, decision: "advance" | "not-yet"): void {
this.latest.set(sessionId, decision);
for (const listener of this.listeners) {
listener({ sessionId, projectId, decision });
}
}
getLatest(sessionId: string): "advance" | "not-yet" | undefined {
return this.latest.get(sessionId);
}
}
// ── Read-only enforcement ────────────────────────────────────────────────────
/**
* Whether a session is read-only (one-shot validator/planning, U9). Server-side
* enforcement (not just client) — input/inject is rejected for these.
*
* U9's one-shot sessions (validator/planning/CE) are wired via the engine's
* `runOneShotSession`, which persists the autonomy posture `readOnly` flag on
* the session record. This check honors that flag plus validator/planning
* purposes which are inherently read-only.
*/
export function isReadOnlySession(session: CliSession): boolean {
if (session.autonomyPosture && session.autonomyPosture.readOnly === true) return true;
return session.purpose === "validator" || session.purpose === "planning";
}
// ── Origin allowlist ─────────────────────────────────────────────────────────
/**
* Origin allowlist check for the WS upgrade. Rejects a foreign or absent Origin
* (a privileged keystroke channel must not be CSRF-attachable from another
* page). Allowed:
* - no Origin header AND not a browser context (native clients: TUI, our own WS
* client set no Origin) — these are allowed because they aren't subject to the
* browser same-origin model and authenticate via the daemon token + ticket.
* We detect "browser context" by the presence of `Sec-Fetch-Site` / a
* `User-Agent` claiming a browser; absent those, an absent Origin is a native
* client.
* - an Origin whose host matches the request Host (same-host), or
* - an Origin in the configured extras allowlist.
*
* Per the plan, a FOREIGN or ABSENT Origin from a browser is rejected.
*/
export interface OriginCheckInput {
origin: string | undefined;
host: string | undefined;
/** Sec-Fetch-Site header (present on browser-issued requests). */
secFetchSite?: string | undefined;
/** Extra allowed origins (exact, scheme+host[:port]) from config. */
extraAllowedOrigins?: string[];
}
// ── SSE state bridge ─────────────────────────────────────────────────────────
/** Default cap (chars) on the SSE last-output preview. */
export const DEFAULT_OUTPUT_PREVIEW_CHARS = 200;
/**
* Bound + sanitize a last-output preview for the SSE `cli:session:state` event:
* ANSI/control-stripped, redacted, and capped (~200 chars). The text is already
* expected to be recent output (e.g. the scrollback tail); we strip first, then
* the engine's redaction is applied by the supplier — but we strip here defensively.
*/
export function buildOutputPreview(
raw: string | undefined,
maxChars = DEFAULT_OUTPUT_PREVIEW_CHARS,
): string | undefined {
if (!raw) return undefined;
const stripped = stripAnsiControl(raw).replace(/\s+/g, " ").trim();
if (stripped.length === 0) return undefined;
return stripped.length > maxChars ? stripped.slice(-maxChars) : stripped;
}
/**
* Subscribe to an engine state machine's throttled `onStateChange` and forward
* each transition onto the SSE bus as a `cli:session:state` event. The engine
* applies the ~500ms throttle (via `stateChangeThrottleMs`); this bridge only
* shapes the payload (owning entity + bounded redacted preview) and routes it
* project-scoped.
*
* Returns an unsubscribe handle.
*/
export function bridgeCliStateToSse(
machine: { onStateChange(listener: (change: CliStateChange) => void): () => void },
deps: {
store: Pick<CliSessionStore, "getSession">;
/** Recent (raw) output supplier for the bounded preview (e.g. scrollback tail). */
getRecentOutput?: (sessionId: string) => string | undefined;
},
): () => void {
return machine.onStateChange((change) => {
const session = deps.store.getSession(change.sessionId);
const preview = buildOutputPreview(deps.getRecentOutput?.(change.sessionId));
emitCliSessionStateSseEvent(
{
sessionId: change.sessionId,
taskId: session?.taskId ?? null,
chatSessionId: session?.chatSessionId ?? null,
state: change.state,
terminationReason: change.terminationReason,
lastOutputPreview: preview,
at: change.at,
},
session?.projectId,
);
});
}
export function isOriginAllowed(input: OriginCheckInput): boolean {
const { origin, host, secFetchSite, extraAllowedOrigins } = input;
// No Origin header.
if (!origin) {
// A browser ALWAYS sends Origin on a cross-site WS and sets Sec-Fetch-Site;
// an absent Origin together with a browser signal is suspicious → reject.
if (secFetchSite && secFetchSite !== "none" && secFetchSite !== "same-origin") {
return false;
}
// Native client (TUI / our WS client) — allowed (token + ticket gated).
return true;
}
// Parse the Origin.
let originUrl: URL;
try {
originUrl = new URL(origin);
} catch {
return false; // malformed Origin → reject
}
// Same-host: the Origin's host:port matches the request Host header.
if (host && originUrl.host === host) {
return true;
}
// Extras allowlist (exact scheme+host[:port]).
if (extraAllowedOrigins && extraAllowedOrigins.length > 0) {
const normalized = `${originUrl.protocol}//${originUrl.host}`;
if (extraAllowedOrigins.some((o) => o === normalized || o === origin)) {
return true;
}
}
return false;
}

View File

@@ -0,0 +1,351 @@
/**
* cli-session-ws — distinct WebSocket attach handler for CLI agent sessions
* (CLI Agent Executor, U10).
*
* This is a SEPARATE connection handler from the existing terminal WS
* (`/api/terminal/ws`): it shares only the upgrade gate shape (daemon-token
* auth) and the JSON frame protocol style. The connection body resolves the
* session from the engine's `CliSessionManager` (the explicit async attach
* interface), never the dashboard-local terminal service.
*
* Path: /api/cli-sessions/ws?sessionId=<id>&ticket=<ticket>[&fn_token=<token>]
*
* Upgrade gate (stronger than the terminal WS — this channel injects keystrokes
* into privileged agent PTYs):
* 1. daemon-token auth (authenticateUpgradeRequest), AND
* 2. an Origin allowlist check (reject foreign/absent browser Origin), AND
* 3. a valid, unconsumed, single-use ticket bound to this exact session.
*
* Frame protocol (JSON, mirrors the terminal WS shape):
* server→client: {type:"scrollback", data} (base64 bytes) — once, on connect
* {type:"data", data} (base64 bytes) — live, neutralized
* {type:"state", ...} (optional state hints)
* {type:"error", message} (e.g. read-only input rejected)
* client→server: {type:"input", data} (utf8 keystrokes → PTY write)
* {type:"resize", cols, rows} (latest-active-client resize)
* {type:"ack", bytes} (ACK-credit flow control)
*
* Flow control: the client ACKs bytes it has consumed; the server tracks
* outstanding unacked bytes and, above the high watermark, calls
* `manager.requestPause`; back below the low watermark, `manager.requestResume`.
* The server never buffers PTY bytes — the engine owns the ring + pause/resume.
*
* Output hardening: every live `data` frame is passed through
* `neutralizeTerminalOutput` (carry threaded across chunks) BEFORE send.
*/
import { WebSocketServer, type WebSocket } from "ws";
import type { IncomingMessage, Server as HttpServer } from "node:http";
import { authenticateUpgradeRequest } from "./auth-middleware.js";
import { neutralizeTerminalOutput, flushTerminalOutput } from "./cli-session-output-filter.js";
import {
isOriginAllowed,
isReadOnlySession,
type AttachTicketStore,
type CliInputAttributionLog,
type CliSessionTransportDeps,
} from "./cli-session-transport.js";
/** Default high/low watermark (bytes) for ACK-credit backpressure. */
export const DEFAULT_HIGH_WATERMARK_BYTES = 128 * 1024;
export const DEFAULT_LOW_WATERMARK_BYTES = 16 * 1024;
export const CLI_SESSION_WS_PATH = "/api/cli-sessions/ws";
export interface CliSessionWebSocketOptions extends CliSessionTransportDeps {
ticketStore: AttachTicketStore;
attributionLog: CliInputAttributionLog;
/** Daemon token; when set (and not noAuth) the upgrade requires it. */
daemonToken?: string;
noAuth?: boolean;
/** Extra allowed WS Origins (scheme+host[:port]). */
extraAllowedOrigins?: string[];
highWatermarkBytes?: number;
lowWatermarkBytes?: number;
}
/**
* Attach the CLI-session WebSocket handler to an HTTP server. Adds its OWN
* `upgrade` listener filtered to the cli-sessions path, so it coexists with the
* terminal/badge WS upgrade listeners (each ignores non-matching paths).
*/
export function setupCliSessionWebSocket(
server: HttpServer,
options: CliSessionWebSocketOptions,
): WebSocketServer {
const wss = new WebSocketServer({ noServer: true });
const highWatermark = options.highWatermarkBytes ?? DEFAULT_HIGH_WATERMARK_BYTES;
const lowWatermark = options.lowWatermarkBytes ?? DEFAULT_LOW_WATERMARK_BYTES;
server.on("upgrade", (req, socket, head) => {
const pathname = new URL(req.url || "", `http://${req.headers.host}`).pathname;
if (pathname !== CLI_SESSION_WS_PATH) return;
const reject = (code: number, reason: string) => {
socket.write(`HTTP/1.1 ${code} ${reason}\r\nConnection: close\r\n\r\n`);
socket.destroy();
};
// 1. Daemon-token auth at the upgrade.
if (options.daemonToken && !options.noAuth) {
if (!authenticateUpgradeRequest(options.daemonToken, req)) {
reject(401, "Unauthorized");
return;
}
}
// 2. Origin allowlist.
const originOk = isOriginAllowed({
origin: headerStr(req, "origin"),
host: headerStr(req, "host"),
secFetchSite: headerStr(req, "sec-fetch-site"),
extraAllowedOrigins: options.extraAllowedOrigins,
});
if (!originOk) {
reject(403, "Forbidden");
return;
}
wss.handleUpgrade(req, socket, head, (upgraded) => {
wss.emit("connection", upgraded, req);
});
});
wss.on("connection", (ws: WebSocket, req: IncomingMessage) => {
const url = new URL(req.url || "", `http://${req.headers.host}`);
const sessionId = url.searchParams.get("sessionId");
const ticket = url.searchParams.get("ticket");
if (!sessionId) {
ws.close(4000, "Missing sessionId");
return;
}
const session = options.store.getSession(sessionId);
if (!session) {
ws.close(4004, "Session not found");
return;
}
// 3. Single-use, session-scoped ticket. Consume here (after upgrade) so a
// replayed/consumed ticket, or a ticket for a different session, is rejected.
const consumed = options.ticketStore.consume(ticket, sessionId);
if (!consumed) {
ws.close(4401, "Invalid or expired attach ticket");
return;
}
if (consumed.projectId !== session.projectId) {
ws.close(4503, "Ticket does not match session project");
return;
}
if (!options.manager.isLive(sessionId)) {
ws.close(4409, "Session is not live");
return;
}
const readOnly = isReadOnlySession(session) || consumed.readOnly;
const ticketId = consumed.ticket;
// Attach to the engine manager.
let attachment;
try {
attachment = options.manager.attach(sessionId);
} catch {
ws.close(4500, "Failed to attach");
return;
}
// ── Flow control state ──
let unacked = 0;
let paused = false;
const onSentBytes = (n: number) => {
unacked += n;
if (!paused && unacked >= highWatermark) {
paused = true;
try {
options.manager.requestPause(sessionId);
} catch {
/* ignore */
}
}
};
const onAck = (n: number) => {
if (!Number.isFinite(n) || n <= 0) return;
unacked = Math.max(0, unacked - n);
if (paused && unacked <= lowWatermark) {
paused = false;
try {
options.manager.requestResume(sessionId);
} catch {
/* ignore */
}
}
};
// ── Outbound: scrollback (neutralized) then live stream (neutralized) ──
let carry = "";
const sendData = (bytes: Uint8Array) => {
if (ws.readyState !== ws.OPEN) return;
const text = Buffer.from(bytes).toString("utf8");
const result = neutralizeTerminalOutput(text, carry);
carry = result.carry;
if (result.output.length === 0) return;
const payload = Buffer.from(result.output, "utf8");
try {
ws.send(
JSON.stringify({ type: "data", data: payload.toString("base64") }),
);
onSentBytes(payload.byteLength);
} catch {
/* socket closing */
}
};
// Scrollback replay (run through the same neutralizer so a hostile sequence
// recorded in scrollback is also stripped). Sent as its own frame so the
// client can clear before replay.
{
const scrollText = Buffer.from(attachment.scrollback).toString("utf8");
const result = neutralizeTerminalOutput(scrollText, "");
// Thread the carry across the scrollback→live seam. Do NOT flush the
// scrollback carry verbatim: if a dangerous sequence (e.g. OSC 52) is
// split so its introducer lands at the tail of scrollback and its
// terminator arrives in the first live chunk, flushing the held prefix
// here would let it recombine at the client and reconstruct the hazard.
// Instead we hand the unterminated tail to the live `sendData` carry so
// the neutralizer sees the full sequence and strips it. Only the safe,
// fully-neutralized prefix is sent in the scrollback frame.
carry = result.carry;
try {
ws.send(
JSON.stringify({
type: "scrollback",
data: Buffer.from(result.output, "utf8").toString("base64"),
}),
);
} catch {
/* ignore */
}
}
ws.send(JSON.stringify({ type: "state", state: session.agentState, readOnly }));
// Pump the live byte stream.
let streamClosed = false;
(async () => {
try {
for await (const chunk of attachment.stream) {
if (streamClosed || ws.readyState !== ws.OPEN) break;
sendData(chunk);
}
} catch {
/* stream error — close below */
}
// Flush any residual carry at true stream end. The held bytes are an
// unterminated tail; no further chunk can arrive to recombine with them,
// so emitting them as literal is safe and avoids losing trailing output.
if (!streamClosed && ws.readyState === ws.OPEN && carry.length > 0) {
const tail = flushTerminalOutput(carry);
carry = "";
if (tail.length > 0) {
try {
ws.send(
JSON.stringify({
type: "data",
data: Buffer.from(tail, "utf8").toString("base64"),
}),
);
} catch {
/* ignore */
}
}
}
if (!streamClosed && ws.readyState === ws.OPEN) {
try {
ws.send(JSON.stringify({ type: "exit" }));
} catch {
/* ignore */
}
}
})();
// ── Inbound frames ──
ws.on("message", (raw: Buffer) => {
let msg: Record<string, unknown>;
try {
msg = JSON.parse(raw.toString());
} catch {
return; // ignore malformed
}
switch (msg.type) {
case "input": {
if (typeof msg.data !== "string") return;
if (readOnly) {
try {
ws.send(
JSON.stringify({
type: "error",
code: "READ_ONLY",
message: "Session is read-only — input is not permitted",
}),
);
} catch {
/* ignore */
}
return;
}
attachment.write(msg.data);
options.attributionLog.record(sessionId, {
ticketId,
source: "ws",
byteLength: Buffer.byteLength(msg.data, "utf8"),
at: new Date().toISOString(),
});
break;
}
case "resize": {
if (typeof msg.cols === "number" && typeof msg.rows === "number") {
// Latest-active-client policy is enforced by the manager (latest wins).
attachment.resize(msg.cols, msg.rows);
}
break;
}
case "ack": {
if (typeof msg.bytes === "number") onAck(msg.bytes);
break;
}
}
});
const teardown = () => {
if (streamClosed) return;
streamClosed = true;
try {
attachment.detach(); // NEVER kills the session (other clients keep it).
} catch {
/* ignore */
}
// Release backpressure so a remaining client isn't stuck paused.
if (paused) {
try {
options.manager.requestResume(sessionId);
} catch {
/* ignore */
}
}
};
ws.on("close", teardown);
ws.on("error", teardown);
});
return wss;
}
function headerStr(req: IncomingMessage, name: string): string | undefined {
const v = req.headers[name];
if (typeof v === "string") return v;
if (Array.isArray(v)) return v[0];
return undefined;
}

View File

@@ -66,3 +66,25 @@ export {
} from "./badge-pubsub.js";
export * from "./plugins/index.js";
// CLI-session terminal-output hardening — re-exported so the TUI passthrough
// (packages/cli, U14) applies the IDENTICAL neutralization set as the dashboard
// WS bridge (U10). The host TTY honors more escape sequences than xterm.js, so
// the TUI MUST reuse this single implementation rather than fork it.
export {
neutralizeTerminalOutput,
flushTerminalOutput,
MAX_CARRY_LENGTH,
type NeutralizeResult,
} from "./cli-session-output-filter.js";
// CLI Agent Executor transport dependencies — re-exported so the CLI boot
// (packages/cli dashboard command) can construct the per-session attach-ticket
// store, input-attribution log, and confirm-advance registry that the
// cli-sessions transport routes require, then thread them into ServerOptions.
export {
AttachTicketStore,
CliInputAttributionLog,
CliConfirmAdvanceRegistry,
type CliSessionTransportDeps,
} from "./cli-session-transport.js";

View File

@@ -173,6 +173,8 @@ import { registerRuntimeProviderRoutes } from "./routes/register-runtime-provide
import { registerFnBinaryRoutes } from "./routes/register-fn-binary-routes.js";
import { registerUpdateCheckRoutes } from "./routes/register-update-check-routes.js";
import { registerDiagnosticsRoutes } from "./routes/register-diagnostics-routes.js";
import { registerCliAgentHooksRoute } from "./routes/cli-agent-hooks.js";
import { registerCliAgentSettingsRoutes } from "./routes/cli-agent-settings.js";
import { registerIntegratedRouters, registerIntegratedDevServerRouter } from "./routes/register-integrated-routers.js";
import { registerApprovalRoutes } from "./routes/register-approval-routes.js";
import { registerWorktrunkRoutes } from "./routes/register-worktrunk-routes.js";
@@ -1953,6 +1955,13 @@ export function createApiRoutes(store: TaskStore, options?: ServerOptions): Rout
registerUsageRoutes(routeContext);
registerUpdateCheckRoutes(routeContext);
registerDiagnosticsRoutes(routeContext);
// CLI Agent Executor hook ingestion (U17) — per-session token auth, exempt from
// the daemon bearer-token middleware (hook scripts only hold the session token).
registerCliAgentHooksRoute(routeContext);
// CLI Agent Executor adapter settings + autonomy approval (U15) — daemon-token
// authed like the rest of /api (the approving principal is the token holder).
registerCliAgentSettingsRoutes(routeContext);
// ── Automation / Scheduled Task Routes ────────────────────────────
//

View File

@@ -49,7 +49,7 @@ vi.mock("@fusion/core", async () => {
});
vi.mock("@fusion/engine", () => ({
createWorkflowAuthoringTools: vi.fn(() => []),
listCliAdapterDescriptors: () => [],
createFnAgent: vi.fn(async () => ({ session: { state: { messages: [] }, prompt: vi.fn(), dispose: vi.fn() } })),
createResolvedAgentSession: vi.fn(async () => ({
session: { state: { messages: [] }, prompt: vi.fn(), dispose: vi.fn() },

View File

@@ -0,0 +1,269 @@
// @vitest-environment node
import express from "express";
import { mkdtempSync } from "node:fs";
import { join } from "node:path";
import { tmpdir } from "node:os";
import { rm } from "node:fs/promises";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { CliSessionStore, Database } from "@fusion/core";
import { TelemetryHub } from "@fusion/engine";
import { request as performRequest } from "../../test-request.js";
import {
createCliAgentHooksRouterForTest,
HOOK_PAYLOAD_LIMIT_BYTES,
type CliAgentHookHub,
} from "../cli-agent-hooks.js";
const PATH = "/api/cli-agent/hooks";
const TOKEN_HEADER = "x-fusion-cli-session-token";
const SESSION_HEADER = "x-fusion-cli-session-id";
/** Mount the hook route on a bare express app with a JSON error handler. */
function mount(resolver: (projectId: string | undefined, sessionId: string) => CliAgentHookHub | undefined) {
const router = createCliAgentHooksRouterForTest(resolver);
const app = express();
app.use("/api", router);
// express.json's PayloadTooLargeError surfaces here as 413.
app.use((err: any, _req: express.Request, res: express.Response, _next: express.NextFunction) => {
res.status(err?.statusCode ?? err?.status ?? 500).json({ error: err?.message ?? String(err) });
});
return app;
}
function post(
app: express.Express,
body: string,
headers: Record<string, string> = {},
path = PATH,
) {
return performRequest(app, "POST", path, body, {
"content-type": "application/json",
host: "127.0.0.1",
...headers,
});
}
describe("cli-agent-hooks route (stub hub)", () => {
function stubHub(overrides: Partial<CliAgentHookHub> = {}): CliAgentHookHub & { ingested: Array<{ sessionId: string; event: unknown }> } {
const ingested: Array<{ sessionId: string; event: unknown }> = [];
return {
ingested,
validateToken: (sessionId, token) => token === "good-token" && sessionId === "sess-1",
ingest: (sessionId, event) => {
ingested.push({ sessionId, event });
return event;
},
...overrides,
};
}
it("forwards a valid token + session to the hub", async () => {
const hub = stubHub();
const app = mount(() => hub);
const res = await post(app, JSON.stringify({ session_id: "native-1", hello: "world" }) , {
[TOKEN_HEADER]: "good-token",
[SESSION_HEADER]: "sess-1",
}, `${PATH}?event=Stop`);
expect(res.status).toBe(200);
expect(res.body).toEqual({ ok: true });
expect(hub.ingested).toHaveLength(1);
expect(hub.ingested[0].sessionId).toBe("sess-1");
expect(hub.ingested[0].event).toMatchObject({
kind: "done",
payload: { nativeSessionId: "native-1" },
});
});
it("rejects a missing token with 401", async () => {
const hub = stubHub();
const app = mount(() => hub);
const res = await post(app, "{}", { [SESSION_HEADER]: "sess-1" });
expect(res.status).toBe(401);
expect(hub.ingested).toHaveLength(0);
});
it("rejects a wrong token with 401", async () => {
const hub = stubHub();
const app = mount(() => hub);
const res = await post(app, "{}", {
[TOKEN_HEADER]: "wrong-token",
[SESSION_HEADER]: "sess-1",
});
expect(res.status).toBe(401);
expect(hub.ingested).toHaveLength(0);
});
it("rejects a valid-format token issued for the WRONG session", async () => {
const hub = stubHub();
const app = mount(() => hub);
// good-token only validates for sess-1; present it for sess-2.
const res = await post(app, "{}", {
[TOKEN_HEADER]: "good-token",
[SESSION_HEADER]: "sess-2",
});
expect(res.status).toBe(401);
expect(hub.ingested).toHaveLength(0);
});
it("rejects a request carrying a browser Origin header (CSRF)", async () => {
const hub = stubHub();
const app = mount(() => hub);
const res = await post(app, "{}", {
[TOKEN_HEADER]: "good-token",
[SESSION_HEADER]: "sess-1",
origin: "http://evil.example.com",
});
expect(res.status).toBe(403);
expect(hub.ingested).toHaveLength(0);
});
it("rejects a cross-site Host header", async () => {
const hub = stubHub();
const app = mount(() => hub);
const res = await post(app, "{}", {
[TOKEN_HEADER]: "good-token",
[SESSION_HEADER]: "sess-1",
host: "evil.example.com",
});
expect(res.status).toBe(403);
expect(hub.ingested).toHaveLength(0);
});
it("accepts loopback Host with a port", async () => {
const hub = stubHub();
const app = mount(() => hub);
const res = await post(app, "{}", {
[TOKEN_HEADER]: "good-token",
[SESSION_HEADER]: "sess-1",
host: "127.0.0.1:4040",
});
expect(res.status).toBe(200);
});
it("rejects an oversized payload", async () => {
const hub = stubHub();
const app = mount(() => hub);
const big = JSON.stringify({ blob: "x".repeat(HOOK_PAYLOAD_LIMIT_BYTES + 1024) });
const res = await post(app, big, {
[TOKEN_HEADER]: "good-token",
[SESSION_HEADER]: "sess-1",
});
expect(res.status).toBe(413);
expect(hub.ingested).toHaveLength(0);
});
it("treats an unknown session as a no-op (200), never a crash, when the hub accepts it", async () => {
// A hub that validates any token but whose ingest is a no-op for unknown
// sessions (the real hub's contract). The route returns 200 and never throws.
const hub: CliAgentHookHub = {
validateToken: () => true,
ingest: () => undefined, // unknown session → no-op (returns undefined)
};
const app = mount(() => hub);
const res = await post(app, "{}", {
[TOKEN_HEADER]: "any",
[SESSION_HEADER]: "ghost",
});
expect(res.status).toBe(200);
});
it("returns 401 when no hub is resolvable for the session", async () => {
const app = mount(() => undefined);
const res = await post(app, "{}", {
[TOKEN_HEADER]: "good-token",
[SESSION_HEADER]: "sess-1",
});
expect(res.status).toBe(401);
});
it("returns 200 even when hub.ingest throws (best-effort telemetry)", async () => {
const hub: CliAgentHookHub = {
validateToken: () => true,
ingest: () => {
throw new Error("boom");
},
};
const app = mount(() => hub);
const res = await post(app, "{}", {
[TOKEN_HEADER]: "good-token",
[SESSION_HEADER]: "sess-1",
});
expect(res.status).toBe(200);
});
});
describe("cli-agent-hooks route (real TelemetryHub lifecycle)", () => {
let tmpDir: string;
let db: Database;
let store: CliSessionStore;
beforeEach(() => {
tmpDir = mkdtempSync(join(tmpdir(), "fusion-hook-route-"));
const fusionDir = join(tmpDir, ".fusion");
db = new Database(fusionDir, { inMemory: true });
db.init();
store = new CliSessionStore(fusionDir, db);
});
afterEach(async () => {
db.close();
await rm(tmpDir, { recursive: true, force: true });
});
function seed(agentState = "busy"): string {
return store.createSession({
purpose: "execute",
projectId: "proj",
adapterId: "claude-code",
agentState: agentState as never,
}).id;
}
it("end-to-end: valid token forwards and advances state; lifecycle revokes it", async () => {
const sessionId = seed("busy");
const hub = new TelemetryHub({ store });
const token = hub.issueToken(sessionId);
const app = mount((_proj, sid) => (hub.hasSession(sid) ? (hub as unknown as CliAgentHookHub) : undefined));
// Valid POST → 200, state machine advances to done.
const ok = await post(app, "{}", {
[TOKEN_HEADER]: token,
[SESSION_HEADER]: sessionId,
}, `${PATH}?event=Stop`);
expect(ok.status).toBe(200);
expect(hub.getStateMachine(sessionId)?.getState()).toBe("done");
// Lifecycle: session end invalidates the token.
hub.invalidate(sessionId);
// Replayed POST with the old token → 401 (no hub / not validating).
const replay = await post(app, "{}", {
[TOKEN_HEADER]: token,
[SESSION_HEADER]: sessionId,
}, `${PATH}?event=Stop`);
expect(replay.status).toBe(401);
});
it("after registry rebuild from non-live sessions, old tokens are rejected", async () => {
const sessionId = seed("busy");
const hub1 = new TelemetryHub({ store });
const oldToken = hub1.issueToken(sessionId);
// Simulate engine death mid-session: the session is no longer live.
store.updateSession(sessionId, { agentState: "dead" as never });
// New hub rebuilt from the store mints NO token for the non-live session.
const hub2 = new TelemetryHub({ store });
expect(hub2.hasSession(sessionId)).toBe(false);
const app = mount((_proj, sid) => (hub2.hasSession(sid) ? (hub2 as unknown as CliAgentHookHub) : undefined));
const res = await post(app, "{}", {
[TOKEN_HEADER]: oldToken,
[SESSION_HEADER]: sessionId,
}, `${PATH}?event=Stop`);
expect(res.status).toBe(401);
});
});

View File

@@ -0,0 +1,162 @@
// @vitest-environment node
import express from "express";
import { Router } from "express";
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { request as performRequest } from "../../test-request.js";
import { rethrowAsApiError } from "../../api-error.js";
import { registerCliAgentSettingsRoutes } from "../cli-agent-settings.js";
import type { ApiRoutesContext } from "../types.js";
/**
* Minimal fake TaskStore covering the methods the route touches. Global settings
* (`cliAgents`) and project autonomy approvals live in-memory; `getSettings`
* returns the merged view (global ∪ project) the route reads from.
*/
function makeFakeStore() {
const state = {
cliAgents: {} as Record<string, unknown>,
approvedCliAutonomyAdapters: [] as string[],
};
return {
state,
async getSettings() {
return {
cliAgents: state.cliAgents,
approvedCliAutonomyAdapters: [...state.approvedCliAutonomyAdapters],
};
},
async updateGlobalSettings(patch: { cliAgents?: Record<string, unknown> }) {
if (patch.cliAgents) state.cliAgents = patch.cliAgents;
return state;
},
async isCliAutonomyApproved(adapterId: string) {
return state.approvedCliAutonomyAdapters.includes(adapterId);
},
async approveCliAutonomy(adapterId: string) {
if (!state.approvedCliAutonomyAdapters.includes(adapterId)) {
state.approvedCliAutonomyAdapters.push(adapterId);
}
},
async revokeCliAutonomy(adapterId: string) {
state.approvedCliAutonomyAdapters = state.approvedCliAutonomyAdapters.filter(
(a) => a !== adapterId,
);
},
};
}
function mount(store: ReturnType<typeof makeFakeStore>) {
const router = Router();
router.use(express.json());
const ctx = {
router,
rethrowAsApiError,
getScopedStore: async () => store as never,
getProjectContext: async () => ({ store: store as never, engine: undefined, projectId: "p1" }),
} as unknown as ApiRoutesContext;
registerCliAgentSettingsRoutes(ctx);
const app = express();
app.use("/api", router);
app.use((err: any, _req: express.Request, res: express.Response, _next: express.NextFunction) => {
res.status(err?.statusCode ?? err?.status ?? 500).json({ error: err?.message ?? String(err) });
});
return app;
}
const JSON_HEADERS = { "content-type": "application/json", host: "127.0.0.1" };
describe("cli-agent-settings routes (U15)", () => {
let store: ReturnType<typeof makeFakeStore>;
let app: express.Express;
beforeEach(() => {
store = makeFakeStore();
app = mount(store);
});
afterEach(() => {});
it("GET /api/cli-agents lists adapter descriptors with tier labels", async () => {
const res = await performRequest(app, "GET", "/api/cli-agents", undefined, JSON_HEADERS);
expect(res.status).toBe(200);
const ids = res.body.adapters.map((a: { id: string }) => a.id);
expect(ids).toContain("claude-code");
expect(ids).toContain("generic");
const claude = res.body.adapters.find((a: { id: string }) => a.id === "claude-code");
expect(claude.tier).toBe("native");
const generic = res.body.adapters.find((a: { id: string }) => a.id === "generic");
expect(generic.tier).toBe("generic");
});
it("PUT /api/cli-agents/settings persists a sanitized adapter config", async () => {
const res = await performRequest(
app,
"PUT",
"/api/cli-agents/settings",
JSON.stringify({
adapterId: "codex",
config: { extraArgs: ["--model=gpt"], autonomyMode: "garbage", bogus: 1 },
}),
JSON_HEADERS,
);
expect(res.status).toBe(200);
// autonomyMode "garbage" + bogus field dropped at the core write boundary.
expect(res.body.cliAgents).toEqual({ codex: { extraArgs: ["--model=gpt"] } });
expect(store.state.cliAgents).toEqual({ codex: { extraArgs: ["--model=gpt"] } });
});
it("PUT rejects an unknown adapter id", async () => {
const res = await performRequest(
app,
"PUT",
"/api/cli-agents/settings",
JSON.stringify({ adapterId: "evil", config: {} }),
JSON_HEADERS,
);
expect(res.status).toBe(400);
});
it("autonomy approval round-trip (approve requires confirm)", async () => {
// Initially unapproved.
let res = await performRequest(app, "GET", "/api/cli-agents/claude-code/autonomy", undefined, JSON_HEADERS);
expect(res.body).toEqual({ adapterId: "claude-code", approved: false });
// Approve without confirm → rejected.
res = await performRequest(
app,
"POST",
"/api/cli-agents/claude-code/approve-autonomy",
JSON.stringify({}),
JSON_HEADERS,
);
expect(res.status).toBe(400);
// Approve with confirm → granted.
res = await performRequest(
app,
"POST",
"/api/cli-agents/claude-code/approve-autonomy",
JSON.stringify({ confirm: true }),
JSON_HEADERS,
);
expect(res.status).toBe(200);
expect(store.state.approvedCliAutonomyAdapters).toContain("claude-code");
// Now reads as approved.
res = await performRequest(app, "GET", "/api/cli-agents/claude-code/autonomy", undefined, JSON_HEADERS);
expect(res.body.approved).toBe(true);
// Revoke.
res = await performRequest(
app,
"POST",
"/api/cli-agents/claude-code/revoke-autonomy",
JSON.stringify({}),
JSON_HEADERS,
);
expect(res.status).toBe(200);
expect(store.state.approvedCliAutonomyAdapters).not.toContain("claude-code");
});
});

View File

@@ -28,7 +28,7 @@ vi.mock("@fusion/core", async () => {
});
vi.mock("@fusion/engine", () => ({
createWorkflowAuthoringTools: vi.fn(() => []),
listCliAdapterDescriptors: () => [],
createFnAgent: vi.fn(async () => ({ session: { state: { messages: [] }, prompt: vi.fn(), dispose: vi.fn() } })),
createResolvedAgentSession: vi.fn(async () => ({
session: { state: { messages: [] }, prompt: vi.fn(), dispose: vi.fn() },

View File

@@ -34,7 +34,7 @@ vi.mock("@fusion/core", async () => {
});
vi.mock("@fusion/engine", () => ({
createWorkflowAuthoringTools: vi.fn(() => []),
listCliAdapterDescriptors: () => [],
createFnAgent: vi.fn(async () => ({ session: { state: { messages: [] }, prompt: vi.fn(), dispose: vi.fn() } })),
createResolvedAgentSession: vi.fn(async () => ({
session: { state: { messages: [] }, prompt: vi.fn(), dispose: vi.fn() },

View File

@@ -0,0 +1,231 @@
/**
* CLI-agent hook ingestion route (CLI Agent Executor, U17).
*
* A localhost POST endpoint that authenticates per-session hook POSTs from a
* spawned CLI agent (Claude Code, Codex, Droid, …) and forwards the validated,
* parsed payload IN-PROCESS to the engine-held telemetry hub. The engine has no
* HTTP server — only the dashboard serves HTTP (the Orca pattern, adapted).
*
* Security posture (KTD — hook-endpoint security; localhost is NOT a trust
* boundary: any local process or browser page can reach 127.0.0.1):
*
* 1. Per-session token, constant-time. The request must carry the high-entropy
* per-session hook token AND the session id; the route validates that the
* token was issued for exactly that session against the engine-held registry
* (`hub.validateToken`). A session id alone is NEVER sufficient, and a valid
* token for session B never validates for session A. Comparison is
* constant-time inside the hub registry lookup; the header presence check here
* avoids leaking timing on the cheap path only.
*
* 2. Origin / Host CSRF defense. A browser page on any origin can POST to
* 127.0.0.1, so a forged `Stop`/completion could otherwise advance incomplete
* work or suppress the stall detector. We REJECT any request carrying a
* browser `Origin` header, and any request whose `Host` is not a loopback
* host. Hook scripts are plain `curl` (no Origin); browsers always attach one
* on cross-origin fetch — so this cleanly separates the two.
*
* 3. Payload cap. Oversized bodies are rejected (413) — both at parse time (a
* route-scoped `express.json` limit) and defensively via `Content-Length`.
*
* 4. No daemon bearer token. Hook scripts only hold the per-session token, so
* this path is EXEMPT from the daemon-token middleware (see auth-middleware
* `EXEMPT_PATHS`). It is not unauthenticated — it authenticates with the
* per-session token instead.
*
* 5. Never crash. An unknown / non-live session key is a 200 no-op (the hub's
* `ingest` is itself a no-op for unknown sessions); malformed JSON is a 400;
* nothing here throws into the agent's hook chain.
*/
import { Router, type Request, type Response } from "express";
import express from "express";
import type { ApiRouteRegistrar } from "./types.js";
/** Max accepted hook payload size. Hook payloads are small JSON envelopes. */
export const HOOK_PAYLOAD_LIMIT_BYTES = 256 * 1024;
/** Header carrying the per-session hook token (matches the engine hook scripts). */
const TOKEN_HEADER = "x-fusion-cli-session-token";
/** Header carrying the session id the token must validate for. */
const SESSION_HEADER = "x-fusion-cli-session-id";
/** The minimal hub surface the route depends on (validate + ingest). */
export interface CliAgentHookHub {
validateToken(sessionId: string, token: string | null | undefined): boolean;
ingest(sessionId: string, event: unknown): unknown;
}
/** Loopback hosts the route accepts. Anything else is treated as cross-site. */
function isLoopbackHost(host: string | undefined): boolean {
if (!host) return false;
// Strip a :port suffix (but keep IPv6 brackets intact for the comparison).
const bare = host.replace(/:\d+$/, "").toLowerCase();
return (
bare === "127.0.0.1" ||
bare === "localhost" ||
bare === "[::1]" ||
bare === "::1" ||
bare === "0.0.0.0"
);
}
/** First value of a (possibly array) header, trimmed. */
function headerValue(req: Request, name: string): string | undefined {
const raw = req.headers[name];
const value = Array.isArray(raw) ? raw[0] : raw;
if (typeof value !== "string") return undefined;
const trimmed = value.trim();
return trimmed.length > 0 ? trimmed : undefined;
}
/**
* Map a host CLI hook event name (from the `?event=` query param the scripts add)
* onto a normalized telemetry event kind. Unknown / absent events fall back to a
* generic activity signal, so an unrecognized hook never advances state on its own
* (positive completion gating lives in the state machine, not here).
*/
function normalizeHookEvent(eventName: string | undefined, body: Record<string, unknown>) {
const name = (eventName ?? "").toLowerCase();
// Carry the native session id whenever the payload reports one (Claude:
// `session_id` in every payload) so the hub can persist it.
const nativeSessionId =
typeof body.session_id === "string"
? body.session_id
: typeof body.sessionId === "string"
? body.sessionId
: undefined;
const basePayload: Record<string, unknown> = {};
if (nativeSessionId) basePayload.nativeSessionId = nativeSessionId;
switch (name) {
case "sessionstart":
return { kind: "sessionStart" as const, payload: basePayload };
case "stop":
case "subagentstop":
return { kind: "done" as const, payload: basePayload };
case "notification":
case "permissionrequest":
case "notify":
return {
kind: "waitingOnInput" as const,
payload: { ...basePayload, notification: body },
};
case "pretooluse":
case "posttooluse":
return { kind: "toolActivity" as const, payload: basePayload };
case "userpromptsubmit":
return { kind: "busy" as const, payload: basePayload };
default:
// Unknown / absent event → activity only (re-arms watchdog, never advances).
return { kind: "outputProgress" as const, payload: basePayload };
}
}
export const registerCliAgentHooksRoute: ApiRouteRegistrar = (ctx) => {
const { router } = ctx;
const logger = ctx.runtimeLogger.child("cli-agent-hooks");
// Route-scoped JSON parser with a hard size cap. An oversized body is rejected
// at parse time (express throws a 413 PayloadTooLargeError, surfaced by the
// error handler) before any handler logic runs.
const parseHookBody = express.json({ limit: HOOK_PAYLOAD_LIMIT_BYTES });
const handler = (req: Request, res: Response): void => {
// ── 1. CSRF defense: reject browser-context requests ──────────────────────
// Any request carrying an Origin header came from a browser fetch — a hook
// script never sets one. Reject outright (localhost is not a trust boundary).
if (headerValue(req, "origin") !== undefined) {
res.status(403).json({ error: "Origin not allowed" });
return;
}
// Host must be a loopback host. A cross-site Host (DNS-rebinding style) is
// rejected even absent an Origin header.
if (!isLoopbackHost(headerValue(req, "host"))) {
res.status(403).json({ error: "Host not allowed" });
return;
}
// ── 2. Defensive payload cap on Content-Length ────────────────────────────
const contentLength = Number(req.headers["content-length"] ?? 0);
if (Number.isFinite(contentLength) && contentLength > HOOK_PAYLOAD_LIMIT_BYTES) {
res.status(413).json({ error: "Payload too large" });
return;
}
// ── 3. Identify session + token ───────────────────────────────────────────
const sessionId = headerValue(req, SESSION_HEADER);
const token = headerValue(req, TOKEN_HEADER);
if (!sessionId || !token) {
// Missing credentials — never a no-op (a no-op is reserved for a *valid*
// request against an unknown session). No token == not authenticated.
res.status(401).json({ error: "Missing session token" });
return;
}
// ── 4. Resolve the engine-held hub for this session ───────────────────────
const projectId = ctx.getProjectIdFromRequest(req);
const resolver = ctx.options?.cliAgentHubResolver;
const hub = resolver?.(projectId, sessionId) as CliAgentHookHub | undefined;
// No hub at all (e.g. engine not wired / no live sessions). A forged token
// cannot validate; treat as unauthorized rather than no-op so a wrong token
// is never silently accepted.
if (!hub) {
res.status(401).json({ error: "Invalid session token" });
return;
}
// ── 5. Validate the per-session token (token-belongs-to-session) ──────────
// The hub validates that this exact token was issued for THIS session —
// session id alone is never sufficient, and a valid token for another session
// is rejected. Missing/wrong/expired/invalidated tokens all fail here.
if (!hub.validateToken(sessionId, token)) {
res.status(401).json({ error: "Invalid session token" });
return;
}
// ── 6. Forward the validated payload in-process to the hub ────────────────
const body = (req.body ?? {}) as Record<string, unknown>;
const eventName = typeof req.query.event === "string" ? req.query.event : undefined;
const event = normalizeHookEvent(eventName, body);
try {
// ingest is itself a no-op for unknown/non-live sessions — never crashes.
hub.ingest(sessionId, event);
} catch (error) {
// Telemetry ingestion is best-effort. Log and still return 200 so the
// agent's hook chain is never disturbed by an engine-side hiccup.
logger.warn("hook ingest failed", {
sessionId,
error: error instanceof Error ? error.message : String(error),
});
}
res.status(200).json({ ok: true });
};
// POST only. The route does its own auth (per-session token) and is exempt
// from the daemon bearer-token middleware (see auth-middleware EXEMPT_PATHS).
router.post("/cli-agent/hooks", parseHookBody, handler);
};
/**
* Build a standalone Express router carrying just the hook route — used by the
* route test to mount the handler without the full server. Mirrors the
* production registration (`registerCliAgentHooksRoute`).
*/
export function createCliAgentHooksRouterForTest(
resolver: (projectId: string | undefined, sessionId: string) => CliAgentHookHub | undefined,
logger: { warn: (msg: string, ctx?: unknown) => void } = { warn: () => {} },
): Router {
const router = Router();
registerCliAgentHooksRoute({
router,
options: { cliAgentHubResolver: resolver as never },
getProjectIdFromRequest: (req: Request) =>
typeof req.query.projectId === "string" ? req.query.projectId : undefined,
runtimeLogger: { child: () => logger } as never,
} as never);
return router;
}

View File

@@ -0,0 +1,131 @@
/**
* CLI-agent adapter settings + autonomy-approval routes (CLI Agent Executor, U15).
*
* All routes are daemon-token authed (the standard `/api` middleware — no new
* auth surface; the approving principal in v1 is the daemon-token holder, the
* single workspace owner). Routes:
*
* GET /api/cli-agents — adapter descriptors (tier +
* capability flags) for the
* settings UI + node editor.
* GET /api/cli-agents/settings — per-adapter launch config
* (GlobalSettings.cliAgents).
* PUT /api/cli-agents/settings — replace one adapter's launch
* config (validated at the core
* write boundary).
* GET /api/cli-agents/:adapterId/autonomy — approval state for the project.
* POST /api/cli-agents/:adapterId/approve-autonomy — approve elevated autonomy
* for the adapter in this
* project (idempotent).
* POST /api/cli-agents/:adapterId/revoke-autonomy — revoke approval.
*
* The approval is per-PROJECT + per-adapter, stored in project settings
* (`approvedCliAutonomyAdapters`) and mirrors the raw workflow-CLI-command
* approval precedent (`approveWorkflowCliCommand`).
*/
import { listCliAdapterDescriptors } from "@fusion/engine";
import { sanitizeCliAgentSettings, CLI_AGENT_ADAPTER_IDS } from "@fusion/core";
import { ApiError, badRequest } from "../api-error.js";
import type { ApiRoutesContext } from "./types.js";
/** Static adapter descriptor list (tier + capability flags). Stable per build. */
const ADAPTER_DESCRIPTORS = listCliAdapterDescriptors();
const KNOWN_ADAPTER_IDS = new Set<string>(CLI_AGENT_ADAPTER_IDS);
export function registerCliAgentSettingsRoutes(ctx: ApiRoutesContext): void {
const { router, rethrowAsApiError } = ctx;
// GET /api/cli-agents — adapter catalog (tier labels + capability flags).
router.get("/cli-agents", async (_req, res) => {
res.json({ adapters: ADAPTER_DESCRIPTORS });
});
// GET /api/cli-agents/settings — the per-adapter launch config map.
router.get("/cli-agents/settings", async (req, res) => {
try {
const store = await ctx.getScopedStore(req);
const settings = await store.getSettings();
res.json({ cliAgents: (settings as { cliAgents?: unknown }).cliAgents ?? {} });
} catch (err: unknown) {
if (err instanceof ApiError) throw err;
rethrowAsApiError(err);
}
});
// PUT /api/cli-agents/settings — replace ONE adapter's launch config. The body
// is `{ adapterId, config }`; an empty/invalid config clears the entry. The
// core write boundary sanitizes (`sanitizeCliAgentsSettings`) so invalid fields
// are dropped regardless — this route just scopes the merge to one adapter.
router.put("/cli-agents/settings", async (req, res) => {
try {
const store = await ctx.getScopedStore(req);
const adapterId = String((req.body as { adapterId?: unknown })?.adapterId ?? "").trim();
if (!adapterId || !KNOWN_ADAPTER_IDS.has(adapterId)) {
throw badRequest("Unknown or missing adapterId");
}
const rawConfig = (req.body as { config?: unknown })?.config;
const sanitized = sanitizeCliAgentSettings(rawConfig);
const settings = await store.getSettings();
const prior = { ...(((settings as { cliAgents?: Record<string, unknown> }).cliAgents) ?? {}) };
if (sanitized) {
prior[adapterId] = sanitized;
} else {
delete prior[adapterId];
}
await store.updateGlobalSettings({ cliAgents: prior } as never);
res.json({ cliAgents: prior });
} catch (err: unknown) {
if (err instanceof ApiError) throw err;
rethrowAsApiError(err);
}
});
// GET /api/cli-agents/:adapterId/autonomy — approval state for this project.
router.get("/cli-agents/:adapterId/autonomy", async (req, res) => {
try {
const { store } = await ctx.getProjectContext(req);
const adapterId = req.params.adapterId;
if (!KNOWN_ADAPTER_IDS.has(adapterId)) throw badRequest("Unknown adapterId");
const approved = await store.isCliAutonomyApproved(adapterId);
res.json({ adapterId, approved });
} catch (err: unknown) {
if (err instanceof ApiError) throw err;
rethrowAsApiError(err);
}
});
// POST /api/cli-agents/:adapterId/approve-autonomy — grant elevated autonomy
// for the adapter in this project. Idempotent. Requires an explicit confirm
// flag in the body so a stray POST cannot grant elevation by accident.
router.post("/cli-agents/:adapterId/approve-autonomy", async (req, res) => {
try {
const { store } = await ctx.getProjectContext(req);
const adapterId = req.params.adapterId;
if (!KNOWN_ADAPTER_IDS.has(adapterId)) throw badRequest("Unknown adapterId");
if ((req.body as { confirm?: unknown })?.confirm !== true) {
throw badRequest("Elevated autonomy approval requires explicit confirmation (confirm: true)");
}
await store.approveCliAutonomy(adapterId);
res.json({ adapterId, approved: true });
} catch (err: unknown) {
if (err instanceof ApiError) throw err;
rethrowAsApiError(err);
}
});
// POST /api/cli-agents/:adapterId/revoke-autonomy — revoke approval. Idempotent.
router.post("/cli-agents/:adapterId/revoke-autonomy", async (req, res) => {
try {
const { store } = await ctx.getProjectContext(req);
const adapterId = req.params.adapterId;
if (!KNOWN_ADAPTER_IDS.has(adapterId)) throw badRequest("Unknown adapterId");
await store.revokeCliAutonomy(adapterId);
res.json({ adapterId, approved: false });
} catch (err: unknown) {
if (err instanceof ApiError) throw err;
rethrowAsApiError(err);
}
});
}

View File

@@ -0,0 +1,162 @@
/**
* cli-sessions routes — authenticated REST surface for CLI agent sessions
* (CLI Agent Executor, U10).
*
* Daemon-token gated like every other /api route (the caller mounts this router
* behind the auth middleware). Routes:
* - GET /api/cli-sessions list (by project/task/chat)
* - GET /api/cli-sessions/:id one session record
* - POST /api/cli-sessions/:id/attach-ticket mint a short-lived single-use
* session-scoped attach ticket
* - POST /api/cli-sessions/:id/inject inject text onto the session FIFO
* - POST /api/cli-sessions/:id/confirm-advance generic-tier R20 affordance
*
* Attach tickets (KTD — attach auth): the long-lived daemon token never
* authorizes PTY write access by itself. A surface mints a ticket here (gated by
* the daemon token), then presents it on the WS upgrade (which re-checks the
* token AND an Origin allowlist). Tickets are single-use, ~60s TTL, and bound to
* their session id.
*/
import { Router, type Request, type Response } from "express";
import { badRequest, notFound, ApiError, catchHandler } from "../api-error.js";
import {
type AttachTicketStore,
type CliInputAttributionLog,
type CliConfirmAdvanceRegistry,
type CliSessionTransportDeps,
isReadOnlySession,
} from "../cli-session-transport.js";
export interface CliSessionRoutesOptions extends CliSessionTransportDeps {
ticketStore: AttachTicketStore;
attributionLog: CliInputAttributionLog;
confirmAdvance: CliConfirmAdvanceRegistry;
/** Max inject body length (chars). Bounds a hostile body. */
maxInjectChars?: number;
}
const DEFAULT_MAX_INJECT_CHARS = 64 * 1024;
/** Coerce a route param (may be string | string[]) to a single string. */
function paramId(value: unknown): string {
return Array.isArray(value) ? String(value[0]) : String(value);
}
/** Optional project scoping: when a `projectId` is provided it must match. */
function assertProjectScope(sessionProjectId: string, requested: unknown): void {
if (requested === undefined || requested === null || requested === "") return;
if (typeof requested !== "string" || requested !== sessionProjectId) {
// Cross-project access is a hard rejection (the session id is not enough).
throw new ApiError(403, "Session does not belong to this project");
}
}
export function createCliSessionsRouter(options: CliSessionRoutesOptions): Router {
const { manager, store, ticketStore, attributionLog, confirmAdvance } = options;
const maxInjectChars = options.maxInjectChars ?? DEFAULT_MAX_INJECT_CHARS;
const router = Router();
// ── List ────────────────────────────────────────────────────────────────
router.get(
"/",
catchHandler(async (req: Request, res: Response) => {
const { projectId, taskId, chatSessionId } = req.query;
const sessions = store.listSessions({
projectId: typeof projectId === "string" ? projectId : undefined,
taskId: typeof taskId === "string" ? taskId : undefined,
chatSessionId: typeof chatSessionId === "string" ? chatSessionId : undefined,
});
res.json({ sessions });
}),
);
// ── One ─────────────────────────────────────────────────────────────────
router.get(
"/:id",
catchHandler(async (req: Request, res: Response) => {
const session = store.getSession(paramId(req.params.id));
if (!session) throw notFound("Session not found");
assertProjectScope(session.projectId, req.query.projectId);
res.json({ session });
}),
);
// ── Attach ticket ─────────────────────────────────────────────────────────
router.post(
"/:id/attach-ticket",
catchHandler(async (req: Request, res: Response) => {
const session = store.getSession(paramId(req.params.id));
if (!session) throw notFound("Session not found");
assertProjectScope(session.projectId, req.body?.projectId ?? req.query.projectId);
const readOnly = isReadOnlySession(session);
const { ticket, expiresAt } = ticketStore.mint({
sessionId: session.id,
projectId: session.projectId,
readOnly,
});
res.json({
ticket,
expiresAt: new Date(expiresAt).toISOString(),
readOnly,
});
}),
);
// ── Inject ────────────────────────────────────────────────────────────────
router.post(
"/:id/inject",
catchHandler(async (req: Request, res: Response) => {
const session = store.getSession(paramId(req.params.id));
if (!session) throw notFound("Session not found");
assertProjectScope(session.projectId, req.body?.projectId ?? req.query.projectId);
const text = req.body?.text;
if (typeof text !== "string" || text.length === 0) {
throw badRequest("Missing or empty `text`");
}
if (text.length > maxInjectChars) {
throw badRequest(`\`text\` exceeds max length (${maxInjectChars})`);
}
// Server-side read-only enforcement (not just client).
if (isReadOnlySession(session)) {
throw new ApiError(403, "Session is read-only — input is not permitted");
}
if (!manager.isLive(session.id)) {
throw new ApiError(409, "Session is not live");
}
await manager.inject(session.id, text);
// Attribution: record the input source on the session's input log. The
// REST inject path has no attach ticket; attribute it to the daemon token.
attributionLog.record(session.id, {
ticketId: "rest:inject",
source: "inject",
byteLength: Buffer.byteLength(text, "utf8"),
at: new Date().toISOString(),
});
res.json({ ok: true });
}),
);
// ── Confirm-advance (generic-tier R20 affordance) ──────────────────────────
router.post(
"/:id/confirm-advance",
catchHandler(async (req: Request, res: Response) => {
const session = store.getSession(paramId(req.params.id));
if (!session) throw notFound("Session not found");
assertProjectScope(session.projectId, req.body?.projectId ?? req.query.projectId);
const decisionRaw = req.body?.decision ?? "advance";
if (decisionRaw !== "advance" && decisionRaw !== "not-yet") {
throw badRequest("`decision` must be 'advance' or 'not-yet'");
}
confirmAdvance.record(session.id, session.projectId, decisionRaw);
res.json({ ok: true, decision: decisionRaw });
}),
);
return router;
}

View File

@@ -56,9 +56,12 @@ import {
rehydrateFromStore as rehydrateMilestoneSliceSessions,
} from "./milestone-slice-interview.js";
import { ChatManager } from "./chat.js";
import { CliChatSessionRunner } from "./cli-chat.js";
import { stopAllDevServers } from "./dev-server-routes.js";
import type { SkillsAdapter } from "./skills-adapter.js";
import { createAuthMiddleware, authenticateUpgradeRequest, getDaemonToken } from "./auth-middleware.js";
import { setupCliSessionWebSocket } from "./cli-session-ws.js";
import { createCliSessionsRouter } from "./routes/cli-sessions.js";
import { validateRemoteAuthToken } from "./remote-auth.js";
import { getCliPackageVersion } from "./cli-package-version.js";
import {
@@ -194,6 +197,18 @@ export interface ServerOptions {
engineManager?: import("@fusion/engine").ProjectEngineManager;
/** Optional HybridExecutor orchestration context for multi-project runtime plumbing. */
hybridExecutor?: import("@fusion/engine").HybridExecutor;
/**
* Resolver for the engine-held CLI-agent telemetry hub (U17 hook route).
* Given a request's projectId (if any) and the target session id, returns the
* in-process TelemetryHub that owns that session's token registry, or undefined
* when no hub / session is live. The hook route validates the per-session token
* against this hub and forwards validated payloads to `hub.ingest`. Injected
* here (rather than reached through the engine) so the engine↔dashboard wiring
* can be supplied by later units and stubbed in tests. */
cliAgentHubResolver?: (
projectId: string | undefined,
sessionId: string,
) => import("@fusion/engine").TelemetryHub | undefined;
/** Shared CentralCore instance used by the engine manager.
* Routes that mutate central runtime state should use this instance so
* in-process listeners (for example global concurrency changes) are notified. */
@@ -223,6 +238,19 @@ export interface ServerOptions {
};
/** Optional AiSessionStore — if not provided, one is created from the default store's database */
aiSessionStore?: AiSessionStore;
/**
* Optional CLI agent session transport dependencies (CLI Agent Executor, U10).
* When provided, the server mounts the cli-sessions REST routes and the
* distinct `/api/cli-sessions/ws` attach handler. Wiring the engine-owned
* CliSessionManager/store into this dep happens in a later unit; until then
* the transport is inert unless explicitly supplied (e.g. in tests).
*/
cliSessionTransport?: import("./cli-session-transport.js").CliSessionTransportDeps & {
ticketStore: import("./cli-session-transport.js").AttachTicketStore;
attributionLog: import("./cli-session-transport.js").CliInputAttributionLog;
confirmAdvance: import("./cli-session-transport.js").CliConfirmAdvanceRegistry;
extraAllowedOrigins?: string[];
};
/** Optional MissionAutopilot for autonomous mission progression */
missionAutopilot?: {
watchMission(missionId: string): void;
@@ -1110,6 +1138,73 @@ export function createServer(store: TaskStore, options?: ServerOptions): ReturnT
store,
);
// CLI Agent Executor — chat surface wiring. When the cli-session transport is
// supplied (the runtime is live), broker cli-backed chat sends to the PTY and
// route the project hub's sanitized telemetry into the runner's transcript
// handler. The listener is keyed per-session inside one closure so it composes
// safely even if other taps exist.
if (options?.cliSessionTransport && options.cliAgentHubResolver) {
try {
const cliTransportStore = options.cliSessionTransport.store;
// The transport's `manager` is typed for the attach/inject transport slice;
// the chat runner additionally needs `spawn`. The concrete engine
// CliSessionManager provides both — widen via a structural cast to the
// spawn/inject slice the runner consumes.
const spawnInject = options.cliSessionTransport.manager as unknown as {
spawn: (opts: {
adapterId: string;
projectId: string;
purpose: "chat";
chatSessionId: string;
worktreePath?: string | null;
resume?: { sessionId: string; nativeSessionId: string };
}) => Promise<{ id: string; nativeSessionId: string | null; agentState: string }>;
inject: (sessionId: string, text: string) => Promise<void>;
};
// The runner needs spawn/inject (manager) + a fresh session record getter
// (store). Compose the slice the runner expects so flush decisions read
// authoritative records.
const cliChatRunner = new CliChatSessionRunner({
store: chatStore,
manager: {
spawn: (opts) => spawnInject.spawn(opts),
inject: (sessionId, text) => spawnInject.inject(sessionId, text),
getSession: (sessionId) => {
const r = cliTransportStore.getSession(sessionId);
return r
? { id: r.id, nativeSessionId: r.nativeSessionId, agentState: r.agentState }
: undefined;
},
},
});
chatManager.setCliChatRunner(cliChatRunner, options.engine?.getProjectId?.());
const hub = options.cliAgentHubResolver(undefined, "");
if (hub) {
hub.setEventListener((cliSessionId, event) => {
// Per-session routing inside one listener: map the CLI session id to its
// owning chat session (only chat-purpose sessions carry chatSessionId);
// non-chat sessions (task/validator) are ignored here.
const record = cliTransportStore.getSession(cliSessionId);
const chatSessionId = record?.chatSessionId;
if (!chatSessionId) return;
void cliChatRunner
.handleTelemetry(chatSessionId, {
kind: event.kind,
text: event.text,
nativeSessionId: event.nativeSessionId,
})
.catch(() => {
// best-effort: a transcript-handler throw must never break ingest.
});
});
}
} catch (err) {
runtimeLogger.warn?.("CLI-agent chat runner wiring failed", {
message: err instanceof Error ? err.message : String(err),
});
}
}
const runAiSessionCleanup = (maxAgeMs: number, source: "initial" | "scheduled") => {
const result = aiSessionStore.cleanupStaleSessions(maxAgeMs);
runtimeLogger.info("AI session cleanup summary", {
@@ -1436,6 +1531,21 @@ export function createServer(store: TaskStore, options?: ServerOptions): ReturnT
});
app.use("/api", apiRouter);
// CLI agent session REST routes (U10). Daemon-token gated by the app-level
// auth middleware. Mounted only when transport deps are supplied.
if (options?.cliSessionTransport) {
app.use(
"/api/cli-sessions",
createCliSessionsRouter({
manager: options.cliSessionTransport.manager,
store: options.cliSessionTransport.store,
ticketStore: options.cliSessionTransport.ticketStore,
attributionLog: options.cliSessionTransport.attributionLog,
confirmAdvance: options.cliSessionTransport.confirmAdvance,
}),
);
}
// API 404 Handler - Return JSON for unmatched API routes (instead of falling through to SPA)
app.use("/api", (_req: express.Request, res: express.Response) => {
sendErrorResponse(res, 404, "Not found");
@@ -1549,6 +1659,20 @@ export function createServer(store: TaskStore, options?: ServerOptions): ReturnT
const websocketOptions = { ...options, runtimeLogger };
setupTerminalWebSocket(dashboardApp, server as HttpServer, store, websocketOptions);
setupBadgeWebSocket(dashboardApp, server as HttpServer, store, websocketOptions);
// CLI agent session attach WS (U10) — distinct handler, shares only the
// upgrade-gate shape with the terminal WS. Mounted only when transport
// deps are supplied (engine wiring lands in a later unit).
if (options?.cliSessionTransport) {
setupCliSessionWebSocket(server as HttpServer, {
manager: options.cliSessionTransport.manager,
store: options.cliSessionTransport.store,
ticketStore: options.cliSessionTransport.ticketStore,
attributionLog: options.cliSessionTransport.attributionLog,
daemonToken: getDaemonToken(options),
noAuth: options?.noAuth,
extraAllowedOrigins: options.cliSessionTransport.extraAllowedOrigins,
});
}
}
return server as HttpServer;

View File

@@ -281,6 +281,72 @@ export function emitPluginCustomSseEvent(
}
}
/**
* CLI agent session state transitions (CLI Agent Executor, U10). The engine
* state machine's `onStateChange` (throttled ~500ms in the engine) is bridged
* into this seam by `setupCliSessionTransport`; every open SSE stream forwards
* a matching (project-scoped) `cli:session:state` event so cards / banners
* update without touching the byte stream.
*
* Events are also appended to a small module-level ring buffer with monotonic
* ids so a client reconnecting with `Last-Event-ID` replays the transitions it
* missed (the byte stream is on a separate WS channel; this is state only).
*/
export interface CliSessionStateSsePayload {
sessionId: string;
taskId: string | null;
chatSessionId: string | null;
/** Machine state (may be the transient "resuming"). */
state: string;
terminationReason?: string | null;
/** Bounded (~200 chars), ANSI-stripped, redacted preview of recent output. */
lastOutputPreview?: string;
at: string;
}
type CliSessionStateSseListener = (
id: number,
payload: CliSessionStateSsePayload,
projectId?: string,
) => void;
const cliSessionStateSseListeners = new Set<CliSessionStateSseListener>();
/** Module-level ring buffer of cli-session-state events for lastEventId replay. */
const cliSessionStateBuffer: { id: number; payload: CliSessionStateSsePayload; projectId?: string }[] =
[];
let cliSessionStateNextId = 1;
const CLI_SESSION_STATE_BUFFER_CAP = 200;
export function emitCliSessionStateSseEvent(
payload: CliSessionStateSsePayload,
projectId?: string,
): number {
const id = cliSessionStateNextId++;
cliSessionStateBuffer.push({ id, payload, projectId });
if (cliSessionStateBuffer.length > CLI_SESSION_STATE_BUFFER_CAP) {
cliSessionStateBuffer.splice(0, cliSessionStateBuffer.length - CLI_SESSION_STATE_BUFFER_CAP);
}
for (const listener of cliSessionStateSseListeners) {
listener(id, payload, projectId);
}
return id;
}
/** Buffered cli-session-state events with id > lastEventId (for reconnect replay). */
export function getCliSessionStateEventsSince(
lastEventId: number,
): { id: number; payload: CliSessionStateSsePayload; projectId?: string }[] {
if (!Number.isFinite(lastEventId)) return [...cliSessionStateBuffer];
return cliSessionStateBuffer.filter((entry) => entry.id > lastEventId);
}
/** Test seam: reset the cli-session-state buffer between tests. */
export function resetCliSessionStateBufferForTests(): void {
cliSessionStateBuffer.length = 0;
cliSessionStateNextId = 1;
}
/**
* Normalized plugin lifecycle payload emitted via SSE.
* This is the stable contract the UI can reconcile.
@@ -650,6 +716,11 @@ export function createSSE(
send(`event: plugin:custom\ndata: ${JSON.stringify({ pluginId, event, payload })}\n\n`);
};
const onCliSessionStateEvent: CliSessionStateSseListener = (id, payload, eventProjectId) => {
if (projectId && eventProjectId && eventProjectId !== projectId) return;
send(`id: ${id}\nevent: cli:session:state\ndata: ${JSON.stringify(payload)}\n\n`);
};
// --- Chat store event handlers ---
const onChatSessionCreated = (session: unknown) => {
send(`event: chat:session:created\ndata: ${JSON.stringify(session)}\n\n`);
@@ -801,6 +872,7 @@ export function createSSE(
approvalSseListeners.delete(onApprovalEvent);
workflowSseListeners.delete(onWorkflowEvent);
pluginCustomSseListeners.delete(onPluginCustomEvent);
cliSessionStateSseListeners.delete(onCliSessionStateEvent);
if (chatStore) {
chatStore.off("chat:session:created", onChatSessionCreated);
chatStore.off("chat:session:updated", onChatSessionUpdated);
@@ -949,6 +1021,30 @@ export function createSSE(
approvalSseListeners.add(onApprovalEvent);
workflowSseListeners.add(onWorkflowEvent);
pluginCustomSseListeners.add(onPluginCustomEvent);
cliSessionStateSseListeners.add(onCliSessionStateEvent);
// Replay any cli-session-state transitions missed since the client's
// Last-Event-ID (reconnect recovery — the byte stream is on a separate WS
// channel, so only state events are replayed here).
{
const headerVal = _req.headers?.["last-event-id"];
const lastEventIdRaw =
(typeof headerVal === "string"
? headerVal
: Array.isArray(headerVal)
? headerVal[0]
: undefined) ??
(typeof _req.query?.lastEventId === "string" ? _req.query.lastEventId : undefined);
const lastEventId = lastEventIdRaw !== undefined ? Number(lastEventIdRaw) : NaN;
if (Number.isFinite(lastEventId)) {
for (const entry of getCliSessionStateEventsSince(lastEventId)) {
if (projectId && entry.projectId && entry.projectId !== projectId) continue;
send(
`id: ${entry.id}\nevent: cli:session:state\ndata: ${JSON.stringify(entry.payload)}\n\n`,
);
}
}
}
registerManagedConnection({
id: connectionId,

View File

@@ -11,176 +11,10 @@ import { EventEmitter } from "events";
import * as os from "os";
import * as path from "path";
import * as fs from "node:fs";
import { createRequire } from "node:module";
import { join, dirname } from "node:path";
// Detect if we're running as a Bun-compiled binary
// @ts-expect-error - Bun global is only available in Bun runtime
const isBunBinary = typeof Bun !== "undefined" && !!Bun.embeddedFiles;
// Lazy-loaded node-pty module (only loaded when terminal is actually used)
let ptyModule: typeof import("node-pty") | null = null;
let ptyLoadError: Error | null = null;
const require = createRequire(import.meta.url);
/**
* Find the staged native assets directory for Bun-compiled binaries.
* Looks for runtime/<platform-arch>/ next to the binary.
*
* NOTE: The fs.existsSync() calls in this function run during service initialization
* (when terminal is first used). This is acceptable as it only executes once per
* service lifetime, not per-request.
*/
function getNativePrebuildName(): string {
const platform = process.platform === "darwin" ? "darwin" :
process.platform === "linux" ? "linux" :
process.platform === "win32" ? "win32" : "unknown";
const arch = process.arch === "arm64" ? "arm64" :
process.arch === "x64" ? "x64" : "unknown";
return `${platform}-${arch}`;
}
function findInstalledNodePtyNativeDir(): string | null {
try {
const packageJsonPath = require.resolve("node-pty/package.json");
const pkgRoot = dirname(packageJsonPath);
// @homebridge/node-pty-prebuilt-multiarch (aliased as node-pty) places the binary
// in build/Release/pty.node after prebuild-install runs at install time.
// Prefer this location as it is the fork's standard output path.
const releaseDir = join(pkgRoot, "build", "Release");
if (fs.existsSync(join(releaseDir, "pty.node"))) {
return releaseDir;
}
// Fallback: check the old prebuilds/<plat-arch>/ layout (upstream node-pty style).
const prebuildDir = join(pkgRoot, "prebuilds", getNativePrebuildName());
if (fs.existsSync(join(prebuildDir, "pty.node"))) {
return prebuildDir;
}
return null;
} catch {
return null;
}
}
function ensureNodePtyNativePermissions(): void {
if (process.platform === "win32") {
return;
}
const candidateDirs = new Set<string>();
const envNativeDir = process.env.NODE_PTY_SPAWN_HELPER_DIR || process.env.FUSION_NATIVE_ASSETS_PATH;
if (envNativeDir) {
candidateDirs.add(envNativeDir);
}
const stagedNativeDir = findStagedNativeDir();
if (stagedNativeDir) {
candidateDirs.add(stagedNativeDir);
}
const installedNativeDir = findInstalledNodePtyNativeDir();
if (installedNativeDir) {
candidateDirs.add(installedNativeDir);
}
for (const nativeDir of candidateDirs) {
const helperPath = join(nativeDir, "spawn-helper");
const nativeModulePath = join(nativeDir, "pty.node");
try {
fs.chmodSync(helperPath, 0o755);
} catch {
// Best-effort permission repair; helper may not exist in some layouts.
}
try {
fs.chmodSync(nativeModulePath, 0o755);
} catch (err) {
// Keep diagnostics for the native module path since missing/invalid perms
// here are more likely to prevent PTY startup.
console.warn("[terminal] Failed to repair node-pty native permissions:", {
nativeDir,
error: err instanceof Error ? err.message : String(err),
});
}
}
}
function findStagedNativeDir(): string | null {
const prebuildName = getNativePrebuildName();
// Check FUSION_RUNTIME_DIR env var first
if (process.env.FUSION_RUNTIME_DIR) {
const envPath = join(process.env.FUSION_RUNTIME_DIR, prebuildName);
if (fs.existsSync(join(envPath, "pty.node"))) {
return envPath;
}
}
// Look next to the executable
const execDir = dirname(process.execPath);
const nextToBinary = join(execDir, "runtime", prebuildName);
if (fs.existsSync(join(nextToBinary, "pty.node"))) {
return nextToBinary;
}
return null;
}
async function loadPtyModule(): Promise<typeof import("node-pty")> {
ensureNodePtyNativePermissions();
if (ptyModule) {
return ptyModule;
}
if (ptyLoadError) {
throw ptyLoadError;
}
// For Bun-compiled binary, set up native paths before loading
if (isBunBinary) {
const nativeDir = findStagedNativeDir();
if (nativeDir) {
// Set spawn-helper directory
if (process.platform !== "win32") {
process.env.NODE_PTY_SPAWN_HELPER_DIR = nativeDir;
}
// Store reference for debugging
process.env.FUSION_NATIVE_ASSETS_PATH = nativeDir;
// Try to pre-load the native module using process.dlopen
// This can help when the normal require() path fails
const nativePath = join(nativeDir, "pty.node");
if (fs.existsSync(nativePath)) {
try {
const nativeModule: { exports?: unknown } = { exports: {} };
// process.dlopen is a Node internal API
process.dlopen(nativeModule, nativePath);
console.log("[terminal] Pre-loaded native module via dlopen");
} catch (dlopenErr) {
// dlopen failed - log but continue, normal import might still work
console.log("[terminal] dlopen pre-load failed (continuing):", dlopenErr);
}
}
}
}
try {
// Standard import path - the native-patch setup should have created
// the necessary symlink structure for node-pty to find the module
const mod = await import("node-pty");
ptyModule = mod;
return ptyModule as typeof import("node-pty");
} catch (err) {
ptyLoadError = err instanceof Error ? err : new Error(String(err));
throw ptyLoadError;
}
}
// The node-pty native-asset loader (lazy-load, prebuild resolution, dlopen
// fallback, and permission repair) lives in @fusion/engine so PTY owners share
// one implementation. See packages/engine/src/pty-native.ts.
import { loadPtyModule } from "@fusion/engine";
// Maximum scrollback buffer size (characters)
const MAX_SCROLLBACK_SIZE = 50000; // ~50KB per terminal

View File

@@ -44,6 +44,7 @@
"@earendil-works/pi-coding-agent": "^0.78.0",
"cron-parser": "^5.5.0",
"esbuild": "^0.25.12",
"node-pty": "npm:@homebridge/node-pty-prebuilt-multiarch@^0.13.1",
"proper-lockfile": "^4.1.2",
"typebox": "^1.0.0"
},

View File

@@ -0,0 +1,396 @@
import "./executor-test-helpers.js";
import { beforeEach, afterEach, describe, expect, it, vi } from "vitest";
import { join } from "node:path";
import { tmpdir } from "node:os";
// node:fs is mocked by executor-test-helpers; use node:fs/promises (unmocked) for
// real temp-dir + hook-script I/O.
import { mkdtemp, rm } from "node:fs/promises";
import { Database, CliSessionStore } from "@fusion/core";
import type { IPty } from "node-pty";
import { TaskExecutor, type CliAgentRuntime } from "../executor.js";
import { resetExecutorMocks } from "./executor-test-helpers.js";
import { CliSessionManager } from "../cli-agent/session-manager.js";
import { TelemetryHub } from "../cli-agent/telemetry-hub.js";
import { CliAdapterRegistry, type CliAgentAdapter } from "../cli-agent/adapter.js";
type Listener = (...args: any[]) => void;
// ── Mock PTY ────────────────────────────────────────────────────────────────
interface MockPty extends IPty {
written: string[];
killed: boolean;
killSignal: string | undefined;
emitData(data: string): void;
emitExit(exitCode: number, signal?: number): void;
}
interface MockState {
ptys: MockPty[];
}
function makeMockPtyModule(state: MockState): typeof import("node-pty") {
return {
spawn() {
let dataCb: ((d: string) => void) | undefined;
let exitCb: ((e: { exitCode: number; signal?: number }) => void) | undefined;
const mock: MockPty = {
pid: 3000 + state.ptys.length,
cols: 80,
rows: 24,
process: "mock",
handleFlowControl: false,
written: [],
killed: false,
killSignal: undefined,
onData: (cb: (d: string) => void) => {
dataCb = cb;
return { dispose() {} };
},
onExit: (cb: (e: { exitCode: number; signal?: number }) => void) => {
exitCb = cb;
return { dispose() {} };
},
on() {},
write(data: string) {
mock.written.push(data);
},
resize() {},
clear() {},
kill(signal?: string) {
mock.killed = true;
mock.killSignal = signal;
exitCb?.({ exitCode: 0, signal: signal === "SIGKILL" ? 9 : undefined });
},
pause() {},
resume() {},
emitData(d: string) {
dataCb?.(d);
},
emitExit(exitCode: number, signal?: number) {
exitCb?.({ exitCode, signal });
},
} as any;
state.ptys.push(mock);
return mock as unknown as IPty;
},
} as unknown as typeof import("node-pty");
}
function scriptedAdapter(): CliAgentAdapter {
return {
id: "scripted",
name: "Scripted",
capabilities: { nativeDone: true, nativeWaiting: true, transcriptSource: "hooks", supportsResume: true },
buildLaunch: () => ({ command: "scripted", args: [] }),
buildEnvAllowlist: () => ["PATH"],
createReadinessDetector: () => {
let ready = false;
return {
observe(chunk: string) {
if (chunk.includes("READY")) ready = true;
return ready;
},
};
},
formatInjection: (text) => ({ payload: text.endsWith("\r") ? text : `${text}\r` }),
};
}
// ── Store stub satisfying the runGraphCustomNode/cli-agent code paths ──────────
function createStore(task: any) {
const listeners = new Map<string, Set<Listener>>();
const logs: string[] = [];
return {
logs,
store: {
on: vi.fn((event: string, listener: Listener) => {
const set = listeners.get(event) ?? new Set<Listener>();
set.add(listener);
listeners.set(event, set);
}),
off: vi.fn(),
getTask: vi.fn().mockImplementation(async () => task),
logEntry: vi.fn().mockImplementation(async (_id: string, msg: string) => {
logs.push(msg);
}),
updateTask: vi.fn().mockResolvedValue(undefined),
getSettings: vi.fn().mockResolvedValue({ globalPause: false, enginePaused: false }),
listTasks: vi.fn().mockResolvedValue([]),
} as any,
};
}
describe("cli-agent executor seam (U7)", () => {
let tmpDir: string;
let fusionDir: string;
let db: Database;
let cliStore: CliSessionStore;
let registry: CliAdapterRegistry;
let manager: CliSessionManager;
let hub: TelemetryHub;
let state: MockState;
let worktree: string;
beforeEach(async () => {
resetExecutorMocks();
vi.clearAllMocks();
tmpDir = await mkdtemp(join(tmpdir(), "kb-cli-exec-"));
fusionDir = join(tmpDir, ".fusion");
worktree = join(tmpDir, "wt");
db = new Database(fusionDir, { inMemory: true });
db.init();
cliStore = new CliSessionStore(fusionDir, db);
registry = new CliAdapterRegistry();
registry.register(scriptedAdapter());
state = { ptys: [] };
manager = new CliSessionManager({ registry, store: cliStore, loadPty: async () => makeMockPtyModule(state) });
hub = new TelemetryHub({ store: cliStore });
});
afterEach(async () => {
manager.dispose();
db.close();
await rm(tmpDir, { recursive: true, force: true });
});
function runtime(): CliAgentRuntime {
return {
manager,
hub,
registry,
store: cliStore,
projectId: "proj",
hookEndpointUrl: "http://127.0.0.1:4040/api/cli-agent/hooks",
hookDirRoot: tmpDir,
};
}
function makeExecutor(task: any) {
const { store, logs } = createStore(task);
const executor = new TaskExecutor(store, tmpDir, { cliAgentRuntime: runtime() });
return { executor, store, logs };
}
const cliNode = {
id: "execute",
kind: "prompt" as const,
config: { executor: "cli-agent", cliAdapterId: "scripted", prompt: "implement the feature" },
};
const taskDetail = () => ({
id: "FN-100",
column: "in-progress",
worktree,
prompt: "implement the feature",
steps: [],
currentStep: 0,
});
function lastPty() {
return state.ptys[state.ptys.length - 1];
}
// ── AE1 / F1 ────────────────────────────────────────────────────────────────
it("AE1: cli-agent node spawns in worktree, injects prompt after readiness, native done advances, PTY reaped", async () => {
const { executor } = makeExecutor(taskDetail());
const resultP = (executor as any).runGraphCustomNode(cliNode, taskDetail(), {});
// Wait for spawn.
await vi.waitFor(() => expect(state.ptys).toHaveLength(1));
// Readiness → injection.
lastPty().emitData("READY\r\n");
await vi.waitFor(() => expect(lastPty().written.some((w) => w.includes("implement the feature"))).toBe(true));
// Resolve the live session via the hub (the registered session id).
const sessions = cliStore.listByTask("FN-100");
expect(sessions).toHaveLength(1);
const sid = sessions[0].id;
// Injection drives ready→busy on the machine asynchronously; wait for busy.
await vi.waitFor(() => expect(hub.getStateMachine(sid)?.getState()).toBe("busy"));
hub.ingest(sid, { kind: "done" });
const result = await resultP;
expect(result.outcome).toBe("success");
expect(result.value).toBe("cli-agent-done");
// Reaped at handoff.
expect(lastPty().killed).toBe(true);
expect(manager.isLive(sid)).toBe(false);
expect(cliStore.getSession(sid)?.terminationReason).toBe("completed");
});
// ── AE5 ──────────────────────────────────────────────────────────────────────
it("AE5: user input mid-busy doesn't break tracking; subsequent done still advances", async () => {
const { executor } = makeExecutor(taskDetail());
const resultP = (executor as any).runGraphCustomNode(cliNode, taskDetail(), {});
await vi.waitFor(() => expect(state.ptys).toHaveLength(1));
lastPty().emitData("READY\r\n");
const sid = await vi.waitFor(() => {
const s = cliStore.listByTask("FN-100");
expect(s).toHaveLength(1);
return s[0].id;
});
// The injection drives the ready→busy machine transition asynchronously;
// wait until the machine has reached busy before exercising mid-busy input.
await vi.waitFor(() => expect(hub.getStateMachine(sid)?.getState()).toBe("busy"));
hub.ingest(sid, { kind: "sessionStart" });
hub.ingest(sid, { kind: "busy" });
// Mid-busy user keystrokes via the manager (deliberate control input).
manager.write(sid, "hint\r");
hub.ingest(sid, { kind: "toolActivity" });
expect(hub.getStateMachine(sid)?.getState()).toBe("busy");
hub.ingest(sid, { kind: "done" });
const result = await resultP;
expect(result.outcome).toBe("success");
});
// ── Hard cancel via the abort path ────────────────────────────────────────────
it("hard cancel: abort path SIGKILLs the cli session, marks killed (not resume-eligible), releases slot", async () => {
const { executor } = makeExecutor(taskDetail());
const resultP = (executor as any).runGraphCustomNode(cliNode, taskDetail(), {});
await vi.waitFor(() => expect(state.ptys).toHaveLength(1));
lastPty().emitData("READY\r\n");
const sid = await vi.waitFor(() => {
const s = cliStore.listByTask("FN-100");
expect(s).toHaveLength(1);
return s[0].id;
});
hub.ingest(sid, { kind: "sessionStart" });
hub.ingest(sid, { kind: "busy" });
// The cli session is registered as an active surface.
expect((executor as any).activeCliTaskSessions.has("FN-100")).toBe(true);
expect(manager.activeCount()).toBe(1);
// moveTask(in-progress→todo) hard cancel routes here.
await executor.awaitAbortInFlightTaskWork("FN-100", "parent moved from in-progress to todo", {
userCanceled: true,
});
const result = await resultP;
expect(result.outcome).toBe("failure");
expect(result.value).toBe("cli-agent-killed");
expect(lastPty().killed).toBe(true);
expect(lastPty().killSignal).toBe("SIGKILL");
expect(manager.activeCount()).toBe(0);
expect((executor as any).activeCliTaskSessions.has("FN-100")).toBe(false);
expect(cliStore.getSession(sid)?.terminationReason).toBe("killed");
});
// ── Re-entry launches fresh (prior live session killed) ──────────────────────
it("re-entry: a fresh run kills the prior live session and spawns a new PTY", async () => {
const { executor } = makeExecutor(taskDetail());
// First run, left live (no done).
void (executor as any).runGraphCustomNode(cliNode, taskDetail(), {});
await vi.waitFor(() => expect(state.ptys).toHaveLength(1));
lastPty().emitData("READY\r\n");
const firstId = await vi.waitFor(() => {
const s = cliStore.listByTask("FN-100");
expect(s.length).toBeGreaterThanOrEqual(1);
return s[0].id;
});
expect(manager.isLive(firstId)).toBe(true);
// Let the first run's async injection settle (it drives the machine to busy
// and would otherwise overwrite the killed reason mid-race).
await vi.waitFor(() => expect(hub.getStateMachine(firstId)?.getState()).toBe("busy"));
// Drop the first run's active handle to simulate a graph re-entry without abort.
(executor as any).activeCliTaskSessions.delete("FN-100");
// Second run (RETHINK re-entry) — kills the prior live session, spawns fresh.
const secondP = (executor as any).runGraphCustomNode(cliNode, taskDetail(), {});
await vi.waitFor(() => expect(state.ptys).toHaveLength(2));
expect(manager.isLive(firstId)).toBe(false);
expect(cliStore.getSession(firstId)?.terminationReason).toBe("killed");
lastPty().emitData("READY\r\n");
const second = cliStore.listByTask("FN-100").find((s) => s.id !== firstId)!;
await vi.waitFor(() => expect(hub.getStateMachine(second.id)?.getState()).toBe("busy"));
hub.ingest(second.id, { kind: "done" });
const result = await secondP;
expect(result.outcome).toBe("success");
});
// ── Ceiling produces a typed surfaced value, not a hang ──────────────────────
it("ceiling: spawn at the PTY pool ceiling produces a surfaced cli-agent-at-capacity value", async () => {
const limited = new CliSessionManager({
registry,
store: cliStore,
concurrencyCeiling: 1,
loadPty: async () => makeMockPtyModule(state),
});
try {
// Consume the only slot with a directly-spawned session.
await limited.spawn({ adapterId: "scripted", projectId: "proj", purpose: "execute", worktreePath: worktree });
const { store, logs } = createStore(taskDetail());
const executor = new TaskExecutor(store, tmpDir, {
cliAgentRuntime: { ...runtime(), manager: limited },
});
const result = await (executor as any).runGraphCustomNode(cliNode, taskDetail(), {});
expect(result.outcome).toBe("failure");
expect(result.value).toBe("cli-agent-at-capacity");
expect(logs.some((l) => l.includes("ceiling"))).toBe(true);
} finally {
limited.dispose();
}
});
// ── Missing config / runtime surface as clear errors ─────────────────────────
it("missing cliAdapterId surfaces a clear config error (not a stall)", async () => {
const { executor } = makeExecutor(taskDetail());
const node = { id: "x", kind: "prompt" as const, config: { executor: "cli-agent", prompt: "go" } };
const result = await (executor as any).runGraphCustomNode(node, taskDetail(), {});
expect(result.outcome).toBe("failure");
expect(result.value).toBe("cli-agent-adapter-missing");
});
it("absent runtime surfaces cli-agent-runtime-unavailable", async () => {
const { store } = createStore(taskDetail());
const executor = new TaskExecutor(store, tmpDir, {}); // no cliAgentRuntime
const result = await (executor as any).runGraphCustomNode(cliNode, taskDetail(), {});
expect(result.outcome).toBe("failure");
expect(result.value).toBe("cli-agent-runtime-unavailable");
});
it("no worktree surfaces no-worktree-for-write-node", async () => {
const noWt = { ...taskDetail(), worktree: undefined };
const { store } = createStore(noWt);
const executor = new TaskExecutor(store, tmpDir, { cliAgentRuntime: runtime() });
const result = await (executor as any).runGraphCustomNode(cliNode, noWt, {});
expect(result.outcome).toBe("failure");
expect(result.value).toBe("no-worktree-for-write-node");
});
// ── Node-config edit mid-run keeps the launch-time snapshot ───────────────────
it("node-config edit mid-run does not re-spawn or change the live session", async () => {
const { executor } = makeExecutor(taskDetail());
const node = {
id: "execute",
kind: "prompt" as const,
config: { executor: "cli-agent", cliAdapterId: "scripted", prompt: "v1 prompt" },
};
const resultP = (executor as any).runGraphCustomNode(node, taskDetail(), {});
await vi.waitFor(() => expect(state.ptys).toHaveLength(1));
lastPty().emitData("READY\r\n");
await vi.waitFor(() => expect(lastPty().written.some((w) => w.includes("v1 prompt"))).toBe(true));
// Edit the node config object mid-run.
node.config.prompt = "v2 prompt";
const sid = cliStore.listByTask("FN-100")[0].id;
await vi.waitFor(() => expect(hub.getStateMachine(sid)?.getState()).toBe("busy"));
hub.ingest(sid, { kind: "done" });
await resultP;
// Exactly one PTY, and it only ever saw the launch-time prompt (no re-spawn).
expect(state.ptys).toHaveLength(1);
expect(lastPty().written.some((w) => w.includes("v2 prompt"))).toBe(false);
});
});

View File

@@ -0,0 +1,153 @@
import { describe, it, expect } from "vitest";
import {
mapParsedToVerdict,
oneShotResultToVerdict,
normalizeVerdictToken,
inferVerdictFromProse,
runCliAgentValidation,
} from "../cli-agent-validator.js";
import type {
OneShotResult,
RunOneShotOptions,
} from "../cli-agent/one-shot-session.js";
function success(parsed: Record<string, unknown>, text = ""): OneShotResult {
return { ok: true, sessionId: "s1", parsed, text, rawOutput: JSON.stringify(parsed) };
}
describe("verdict token normalization", () => {
it("maps synonyms to the contract set", () => {
expect(normalizeVerdictToken("APPROVE")).toBe("pass");
expect(normalizeVerdictToken("passed")).toBe("pass");
expect(normalizeVerdictToken("REVISE")).toBe("fail");
expect(normalizeVerdictToken("blocked")).toBe("blocked");
expect(normalizeVerdictToken("nonsense")).toBeNull();
});
});
describe("mapParsedToVerdict — per-adapter shapes → verdicts", () => {
it("claude-shaped pass (is_error:false + verdict)", () => {
const v = mapParsedToVerdict({ type: "result", verdict: "pass", is_error: false }, "");
expect(v.status).toBe("pass");
});
it("claude-shaped error flag is authoritative", () => {
const v = mapParsedToVerdict({ is_error: true, result: "crashed" }, "");
expect(v.status).toBe("error");
});
it("boolean passed:false → fail", () => {
const v = mapParsedToVerdict({ passed: false, summary: "missing X" }, "");
expect(v.status).toBe("fail");
expect(v.summary).toBe("missing X");
});
it("explicit blocked flag → blocked with reason", () => {
const v = mapParsedToVerdict({ blocked: true, reason: "needs creds" }, "");
expect(v.status).toBe("blocked");
expect(v.blockedReason).toBe("needs creds");
});
it("status token + assertions array", () => {
const v = mapParsedToVerdict(
{
status: "fail",
assertions: [
{ assertionId: "a1", passed: true },
{ id: "a2", passed: false, message: "nope" },
],
},
"",
);
expect(v.status).toBe("fail");
expect(v.assertions).toHaveLength(2);
expect(v.assertions[1]).toEqual({ assertionId: "a2", passed: false, message: "nope" });
});
it("prose-only pass inference", () => {
expect(inferVerdictFromProse("All assertions pass.")).toBe("pass");
const v = mapParsedToVerdict({}, "All assertions pass.");
expect(v.status).toBe("pass");
});
it("MALFORMED / undecidable → error, NEVER pass", () => {
const v = mapParsedToVerdict({ irrelevant: 1 }, "the agent rambled without a verdict");
expect(v.status).toBe("error");
});
});
describe("oneShotResultToVerdict — failures map to error", () => {
it("nonzero exit → error with stderr in summary", () => {
const v = oneShotResultToVerdict({
ok: false,
reason: "nonzero-exit",
sessionId: "s1",
exitCode: 1,
stderr: "segfault",
message: "exited with code 1",
});
expect(v.status).toBe("error");
expect(v.summary).toContain("segfault");
});
it("unparseable → error (never silent pass)", () => {
const v = oneShotResultToVerdict({
ok: false,
reason: "unparseable",
sessionId: "s1",
exitCode: 0,
stderr: "garbage",
message: "no decodable result",
});
expect(v.status).toBe("error");
});
it("success with pass verdict → pass", () => {
expect(oneShotResultToVerdict(success({ verdict: "pass" })).status).toBe("pass");
});
});
describe("runCliAgentValidation — seam threads purpose:validator and maps verdict", () => {
it("invokes runner with validator purpose and returns the verdict", async () => {
let seenPurpose: string | undefined;
const fakeRun = async (opts: RunOneShotOptions): Promise<OneShotResult> => {
seenPurpose = opts.purpose;
return success({ verdict: "pass", summary: "looks good" }, "looks good");
};
const verdict = await runCliAgentValidation(
{
manager: {} as RunOneShotOptions["manager"],
adapterId: "claude-code",
projectId: "p",
prompt: "validate",
cwd: "/tmp",
},
fakeRun as never,
);
expect(seenPurpose).toBe("validator");
expect(verdict.status).toBe("pass");
expect(verdict.summary).toBe("looks good");
});
it("runner failure surfaces as error verdict", async () => {
const fakeRun = async (): Promise<OneShotResult> => ({
ok: false,
reason: "spawn-failed",
sessionId: null,
exitCode: null,
stderr: "",
message: "ENOENT claude",
});
const verdict = await runCliAgentValidation(
{
manager: {} as RunOneShotOptions["manager"],
adapterId: "claude-code",
projectId: "p",
prompt: "validate",
cwd: "/tmp",
},
fakeRun as never,
);
expect(verdict.status).toBe("error");
});
});

View File

@@ -2,9 +2,61 @@ import { describe, expect, it, vi } from "vitest";
import type { PlanningQuestion, PlanningResponse } from "@fusion/core";
import {
createInteractiveAiSessionWith,
runCliAgentPlanning,
type InteractiveAgentResult,
type InteractiveAgentSession,
} from "../interactive-ai-session.js";
import type {
OneShotResult,
RunOneShotOptions,
} from "../cli-agent/one-shot-session.js";
describe("runCliAgentPlanning (U9 one-shot planning seam)", () => {
const baseOpts = {
manager: {} as RunOneShotOptions["manager"],
adapterId: "claude-code",
projectId: "p",
prompt: "plan it",
cwd: "/tmp",
};
it("maps one-shot output to the SAME PlanningResponse shape a model run produces", async () => {
let seenPurpose: string | undefined;
const fakeRun = async (opts: RunOneShotOptions): Promise<OneShotResult> => {
seenPurpose = opts.purpose;
const summary = {
title: "Do X",
description: "Plan to do X",
suggestedSize: "M",
suggestedDependencies: [],
keyDeliverables: ["X"],
};
return {
ok: true,
sessionId: "s1",
parsed: {},
text: JSON.stringify({ type: "complete", data: summary }),
rawOutput: "",
};
};
const resp: PlanningResponse = await runCliAgentPlanning(baseOpts, fakeRun as never);
expect(seenPurpose).toBe("planning");
expect(resp.type).toBe("complete");
if (resp.type === "complete") expect(resp.data.title).toBe("Do X");
});
it("throws on a failed one-shot (never returns a fabricated plan)", async () => {
const fakeRun = async (): Promise<OneShotResult> => ({
ok: false,
reason: "unparseable",
sessionId: "s1",
exitCode: 0,
stderr: "",
message: "no result",
});
await expect(runCliAgentPlanning(baseOpts, fakeRun as never)).rejects.toThrow(/planning/i);
});
});
/**
* A scripted fake agent: each `prompt()` advances through a queue of canned

View File

@@ -0,0 +1,92 @@
import { describe, it, expect, beforeEach, afterEach } from "vitest";
import * as fs from "node:fs";
import * as os from "node:os";
import { join } from "node:path";
import {
getNativePrebuildName,
findStagedNativeDir,
ensureNodePtyNativePermissions,
} from "../pty-native.js";
const SAVED_ENV = {
FUSION_RUNTIME_DIR: process.env.FUSION_RUNTIME_DIR,
NODE_PTY_SPAWN_HELPER_DIR: process.env.NODE_PTY_SPAWN_HELPER_DIR,
FUSION_NATIVE_ASSETS_PATH: process.env.FUSION_NATIVE_ASSETS_PATH,
};
let tmpRoot: string;
beforeEach(() => {
tmpRoot = fs.mkdtempSync(join(os.tmpdir(), "pty-native-"));
delete process.env.FUSION_RUNTIME_DIR;
delete process.env.NODE_PTY_SPAWN_HELPER_DIR;
delete process.env.FUSION_NATIVE_ASSETS_PATH;
});
afterEach(() => {
fs.rmSync(tmpRoot, { recursive: true, force: true });
for (const [k, v] of Object.entries(SAVED_ENV)) {
if (v === undefined) delete process.env[k];
else process.env[k] = v;
}
});
/** Create a fixture `<root>/<prebuildName>/pty.node` (+ spawn-helper) directory. */
function makeStagedDir(root: string, opts: { broken?: boolean } = {}): string {
const dir = join(root, getNativePrebuildName());
fs.mkdirSync(dir, { recursive: true });
const nativePath = join(dir, "pty.node");
const helperPath = join(dir, "spawn-helper");
fs.writeFileSync(nativePath, "fake-native");
fs.writeFileSync(helperPath, "fake-helper");
if (opts.broken) {
// Strip executable + write/read bits to simulate a broken-mode install.
fs.chmodSync(nativePath, 0o400);
fs.chmodSync(helperPath, 0o400);
}
return dir;
}
describe("getNativePrebuildName", () => {
it("returns a <platform>-<arch> token", () => {
const name = getNativePrebuildName();
expect(name).toMatch(/^(darwin|linux|win32|unknown)-(arm64|x64|unknown)$/);
});
});
describe("findStagedNativeDir (packaged-binary mode)", () => {
it("resolves the staged dir via FUSION_RUNTIME_DIR fixture", () => {
const staged = makeStagedDir(tmpRoot);
process.env.FUSION_RUNTIME_DIR = tmpRoot;
expect(findStagedNativeDir()).toBe(staged);
});
it("returns null when no staged pty.node is present", () => {
process.env.FUSION_RUNTIME_DIR = tmpRoot; // empty, no pty.node
expect(findStagedNativeDir()).toBeNull();
});
});
describe("ensureNodePtyNativePermissions (permission repair)", () => {
// chmod semantics don't apply on win32; skip there.
const maybe = process.platform === "win32" ? it.skip : it;
maybe("repairs broken modes on a fixture native dir to 0o755", () => {
const dir = makeStagedDir(tmpRoot, { broken: true });
process.env.FUSION_RUNTIME_DIR = tmpRoot;
const nativePath = join(dir, "pty.node");
const helperPath = join(dir, "spawn-helper");
// Precondition: not executable.
expect(fs.statSync(nativePath).mode & 0o111).toBe(0);
ensureNodePtyNativePermissions();
expect(fs.statSync(nativePath).mode & 0o777).toBe(0o755);
expect(fs.statSync(helperPath).mode & 0o777).toBe(0o755);
});
maybe("is a no-op (does not throw) when no candidate dirs exist", () => {
expect(() => ensureNodePtyNativePermissions()).not.toThrow();
});
});

Some files were not shown because too many files have changed in this diff Show More