feat(engine): add CliAgentAdapter interface and CliSessionManager (U2)

Engine-owned PTY lifecycle for CLI agent sessions:
- adapter.ts: CliAgentAdapter interface (launch/env-allowlist builders,
  capability flags, readiness detection, injection formatter, resume builder,
  telemetry wiring) + CliAdapterRegistry with typed unknown/duplicate errors.
- session-manager.ts: CliSessionManager owning node-pty processes via the U16
  shared loader. Byte-bounded scrollback ring (default ~512KB), single
  serialized write queue shared by injections + user input (FIFO, deferral in
  quiet windows), latest-active-client resize, scoped-SIGKILL process registry
  on process exit (never port 4040), explicit async attach interface
  (scrollback + AsyncIterable<Uint8Array> + write/resize/detach),
  requestPause/requestResume watermark hooks, separate concurrency pool with
  typed CliConcurrencyLimitError at the ceiling.
- Security: bracketed paste only when ?2004h observed; unconditional control-char
  neutralization on the raw path; user keystrokes bypass neutralization.
- Persists lifecycle into the U1 CliSessionStore (create on spawn, update
  state/termination).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
gsxdsm
2026-06-04 23:04:13 -07:00
parent 202083dcff
commit 4fc1a9dd46
4 changed files with 1795 additions and 0 deletions

View File

@@ -0,0 +1,103 @@
import { describe, it, expect } from "vitest";
import {
CliAdapterRegistry,
DuplicateCliAdapterError,
UnknownCliAdapterError,
type CliAgentAdapter,
} from "../adapter.js";
function makeAdapter(id: string, overrides: Partial<CliAgentAdapter> = {}): CliAgentAdapter {
return {
id,
name: `Adapter ${id}`,
capabilities: {
nativeDone: true,
nativeWaiting: true,
transcriptSource: "hooks",
supportsResume: true,
},
buildLaunch: () => ({ command: id, args: [] }),
buildEnvAllowlist: () => [],
createReadinessDetector: () => ({ observe: () => true }),
formatInjection: (text) => ({ payload: `${text}\r` }),
...overrides,
};
}
describe("CliAdapterRegistry", () => {
it("registers and retrieves an adapter by id", () => {
const registry = new CliAdapterRegistry();
const adapter = makeAdapter("claude-code");
registry.register(adapter);
expect(registry.get("claude-code")).toBe(adapter);
expect(registry.has("claude-code")).toBe(true);
expect(registry.ids()).toEqual(["claude-code"]);
expect(registry.all()).toEqual([adapter]);
});
it("throws UnknownCliAdapterError for an unregistered id", () => {
const registry = new CliAdapterRegistry();
expect(() => registry.get("nope")).toThrow(UnknownCliAdapterError);
try {
registry.get("nope");
} catch (err) {
expect((err as UnknownCliAdapterError).code).toBe("UNKNOWN_CLI_ADAPTER");
expect((err as UnknownCliAdapterError).adapterId).toBe("nope");
}
});
it("tryGet returns undefined instead of throwing", () => {
const registry = new CliAdapterRegistry();
expect(registry.tryGet("nope")).toBeUndefined();
expect(registry.has("nope")).toBe(false);
});
it("rejects duplicate registration of the same id", () => {
const registry = new CliAdapterRegistry();
registry.register(makeAdapter("codex"));
expect(() => registry.register(makeAdapter("codex"))).toThrow(DuplicateCliAdapterError);
try {
registry.register(makeAdapter("codex"));
} catch (err) {
expect((err as DuplicateCliAdapterError).code).toBe("DUPLICATE_CLI_ADAPTER");
}
});
it("supports multiple adapters with distinct ids", () => {
const registry = new CliAdapterRegistry();
registry.register(makeAdapter("claude-code"));
registry.register(makeAdapter("codex"));
registry.register(
makeAdapter("generic", {
capabilities: {
nativeDone: false,
nativeWaiting: false,
transcriptSource: "none",
supportsResume: false,
},
}),
);
expect(registry.ids().sort()).toEqual(["claude-code", "codex", "generic"]);
expect(registry.get("generic").capabilities.nativeDone).toBe(false);
expect(registry.get("claude-code").capabilities.nativeDone).toBe(true);
});
it("adapters declare honest capability flags read off the registry", () => {
const registry = new CliAdapterRegistry();
registry.register(
makeAdapter("hybrid", {
capabilities: {
nativeDone: true,
nativeWaiting: false, // codex hybrid caveat
transcriptSource: "jsonl",
supportsResume: true,
},
}),
);
const caps = registry.get("hybrid").capabilities;
expect(caps.nativeWaiting).toBe(false);
expect(caps.transcriptSource).toBe("jsonl");
});
});

View File

@@ -0,0 +1,626 @@
import { describe, it, expect, beforeEach, afterEach } from "vitest";
import { mkdtempSync } from "node:fs";
import { writeFileSync, chmodSync } from "node:fs";
import { join } from "node:path";
import { tmpdir } from "node:os";
import { rm } from "node:fs/promises";
import { Database, CliSessionStore } from "@fusion/core";
import type { IPty } from "node-pty";
import {
CliSessionManager,
CliConcurrencyLimitError,
neutralizeInjection,
DEFAULT_SCROLLBACK_BYTES,
} from "../session-manager.js";
import { CliAdapterRegistry, type CliAgentAdapter } from "../adapter.js";
const textDecoder = new TextDecoder();
// ── Mock PTY at the loadPtyModule seam ─────────────────────────────────────
//
// A scripted in-memory PTY records every byte written, lets the test push
// synthetic output (driving readiness + bracketed-paste detection), and tracks
// kill/resize/pause/resume. This gives deterministic byte-level assertions for
// the security-critical paths (neutralization, FIFO, paste mode) without timing
// flakiness; a separate test exercises the real node-pty.
interface MockPty extends IPty {
written: string[];
killed: boolean;
killSignal: string | undefined;
resized: { cols: number; rows: number }[];
paused: boolean;
spawnEnv: { [key: string]: string };
emitData(data: string): void;
emitExit(exitCode: number, signal?: number): void;
}
interface MockState {
ptys: MockPty[];
}
function makeMockPtyModule(state: MockState): typeof import("node-pty") {
return {
spawn(_file: string, _args: string[] | string, options: { env?: { [k: string]: string } }) {
let dataCb: ((d: string) => void) | undefined;
let exitCb: ((e: { exitCode: number; signal?: number }) => void) | undefined;
const mock: MockPty = {
pid: 1000 + state.ptys.length,
cols: 80,
rows: 24,
process: "mock",
handleFlowControl: false,
written: [],
killed: false,
killSignal: undefined,
resized: [],
paused: false,
spawnEnv: (options.env ?? {}) as { [k: string]: string },
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(cols: number, rows: number) {
mock.resized.push({ cols, rows });
},
clear() {},
kill(signal?: string) {
mock.killed = true;
mock.killSignal = signal;
},
pause() {
mock.paused = true;
},
resume() {
mock.paused = false;
},
emitData(d: string) {
dataCb?.(d);
},
emitExit(exitCode: number, signal?: number) {
exitCb?.({ exitCode, signal });
},
} as unknown as MockPty;
state.ptys.push(mock);
return mock as unknown as IPty;
},
} as unknown as typeof import("node-pty");
}
// ── Test adapter ───────────────────────────────────────────────────────────
function makeAdapter(overrides: Partial<CliAgentAdapter> = {}): CliAgentAdapter {
return {
id: "test-cli",
name: "Test CLI",
capabilities: {
nativeDone: true,
nativeWaiting: true,
transcriptSource: "hooks",
supportsResume: true,
},
buildLaunch: () => ({ command: "test-cli", args: ["--interactive"] }),
buildEnvAllowlist: () => ["PATH", "HOME"],
// Ready as soon as we see the "READY" marker.
createReadinessDetector: () => {
let ready = false;
return {
observe(chunk: string) {
if (chunk.includes("READY")) ready = true;
return ready;
},
};
},
// Trailing carriage return submits the injection.
formatInjection: (text) => ({ payload: `${text}\r` }),
buildResume: (ctx) => ({ command: "test-cli", args: ["--resume", ctx.nativeSessionId] }),
...overrides,
};
}
// ── Harness ──────────────────────────────────────────────────────────────
interface Harness {
manager: CliSessionManager;
registry: CliAdapterRegistry;
store: CliSessionStore;
state: MockState;
db: Database;
tmpDir: string;
}
function makeHarness(opts?: {
ceiling?: number;
scrollbackBytes?: number;
injectionQuietWindowMs?: number;
adapter?: CliAgentAdapter;
}): Harness {
const tmpDir = mkdtempSync(join(tmpdir(), "kb-cli-sm-test-"));
const fusionDir = join(tmpDir, ".fusion");
const db = new Database(fusionDir, { inMemory: true });
db.init();
const store = new CliSessionStore(fusionDir, db);
const registry = new CliAdapterRegistry();
registry.register(opts?.adapter ?? makeAdapter());
const state: MockState = { ptys: [] };
const manager = new CliSessionManager({
registry,
store,
concurrencyCeiling: opts?.ceiling,
scrollbackBytes: opts?.scrollbackBytes,
injectionQuietWindowMs: opts?.injectionQuietWindowMs,
loadPty: async () => makeMockPtyModule(state),
});
return { manager, registry, store, state, db, tmpDir };
}
async function spawnSession(h: Harness, extra?: Record<string, unknown>) {
return h.manager.spawn({
adapterId: "test-cli",
projectId: "proj-1",
purpose: "execute",
taskId: "FN-1",
worktreePath: h.tmpDir,
...extra,
});
}
function allWritten(pty: MockPty): string {
return pty.written.join("");
}
// ── Tests ────────────────────────────────────────────────────────────────
describe("CliSessionManager (scripted PTY)", () => {
let harnesses: Harness[] = [];
afterEach(async () => {
for (const h of harnesses) {
h.manager.dispose();
h.db.close();
await rm(h.tmpDir, { recursive: true, force: true });
}
harnesses = [];
});
function newHarness(opts?: Parameters<typeof makeHarness>[0]): Harness {
const h = makeHarness(opts);
harnesses.push(h);
return h;
}
it("happy path: spawn → readiness → inject once ready → output in ring → clean teardown kills child", async () => {
const h = newHarness();
const record = await spawnSession(h);
expect(record.agentState).toBe("starting");
const pty = h.state.ptys[0];
// Inject before ready: must wait for readiness, no write yet.
const injectP = h.manager.inject(record.id, "do the thing");
await Promise.resolve();
expect(allWritten(pty)).toBe("");
// Child emits readiness.
pty.emitData("welcome\r\nREADY> ");
await injectP;
expect(allWritten(pty)).toBe("do the thing\r");
// Output lands in ring (visible via attach scrollback).
pty.emitData("working...\r\n");
const att = h.manager.attach(record.id);
expect(textDecoder.decode(att.scrollback)).toContain("working...");
att.detach();
// Persisted state advanced to ready.
expect(h.store.getSession(record.id)?.agentState).toBe("ready");
// Clean teardown kills the child (scoped SIGKILL).
h.manager.kill(record.id);
expect(pty.killed).toBe(true);
expect(pty.killSignal).toBe("SIGKILL");
expect(h.manager.activeCount()).toBe(0);
const after = h.store.getSession(record.id);
expect(after?.agentState).toBe("dead");
expect(after?.terminationReason).toBe("killed");
});
it("injection serialization: user write queued mid-injection never interleaves; two injections FIFO", async () => {
const h = newHarness();
const record = await spawnSession(h);
const pty = h.state.ptys[0];
pty.emitData("READY");
// Queue two injections and a user write between them (synchronously).
const i1 = h.manager.inject(record.id, "first");
h.manager.write(record.id, "U"); // user keystroke
const i2 = h.manager.inject(record.id, "second");
await Promise.all([i1, i2]);
// FIFO across the shared queue: first injection, then the user keystroke,
// then the second injection — never byte-interleaved.
expect(pty.written).toEqual(["first\r", "U", "second\r"]);
});
it("injection deferred while output streaming, dispatched in a quiet window", async () => {
const h = newHarness({ injectionQuietWindowMs: 30 });
const record = await spawnSession(h);
const pty = h.state.ptys[0];
pty.emitData("READY");
const injectP = h.manager.inject(record.id, "deferred");
// Output keeps arriving — injection must wait.
pty.emitData("chunk-a");
await new Promise((r) => setTimeout(r, 10));
pty.emitData("chunk-b");
expect(allWritten(pty)).toBe(""); // still deferred
await injectP; // resolves once quiet window elapses
expect(allWritten(pty)).toBe("deferred\r");
});
it("bracketed paste only when ?2004h observed; raw otherwise", async () => {
const h = newHarness();
const record = await spawnSession(h);
const pty = h.state.ptys[0];
// Raw path first (no bracketed paste negotiated).
pty.emitData("READY");
await h.manager.inject(record.id, "raw msg");
expect(pty.written.at(-1)).toBe("raw msg\r");
expect(allWritten(pty)).not.toContain("\x1b[200~");
// Child enables bracketed paste.
pty.emitData("\x1b[?2004h");
await h.manager.inject(record.id, "pasted msg");
const last = pty.written.at(-1)!;
expect(last).toContain("\x1b[200~pasted msg\x1b[201~");
// Child disables it again → back to raw.
pty.emitData("\x1b[?2004l");
await h.manager.inject(record.id, "raw again");
expect(pty.written.at(-1)).toBe("raw again\r");
});
it("control-char neutralization on raw path: \\x03,\\x04,ESC never reach PTY as control", async () => {
const h = newHarness();
const record = await spawnSession(h);
const pty = h.state.ptys[0];
pty.emitData("READY");
// Injected text laden with Ctrl-C, Ctrl-D, and an ESC sequence.
await h.manager.inject(record.id, "safe\x03\x04before\x1b[31mafter\nnext");
const written = pty.written.at(-1)!;
// No raw control bytes survived (except the intended trailing submit \r and
// the \n→\r conversion).
expect(written).not.toContain("\x03");
expect(written).not.toContain("\x04");
expect(written).not.toContain("\x1b");
// Text content preserved; the ESC sequence's bytes are stripped.
expect(written).toContain("safebefore");
expect(written).toContain("after");
expect(written).toContain("next");
});
it("user keystrokes bypass neutralization (deliberate control input)", async () => {
const h = newHarness();
const record = await spawnSession(h);
const pty = h.state.ptys[0];
pty.emitData("READY");
// A user pressing Ctrl-C is deliberate control input and must pass through.
const att = h.manager.attach(record.id);
att.write("\x03");
await new Promise((r) => setTimeout(r, 0));
expect(allWritten(pty)).toContain("\x03");
att.detach();
});
it("concurrency ceiling=2: third rejected with typed error; slot released on teardown", async () => {
const h = newHarness({ ceiling: 2 });
const r1 = await spawnSession(h);
const r2 = await spawnSession(h);
expect(h.manager.activeCount()).toBe(2);
await expect(spawnSession(h)).rejects.toBeInstanceOf(CliConcurrencyLimitError);
// Release a slot.
h.manager.kill(r1.id);
expect(h.manager.activeCount()).toBe(1);
// Now a third spawn succeeds.
const r3 = await spawnSession(h);
expect(h.manager.activeCount()).toBe(2);
expect(r2.id).not.toBe(r3.id);
});
it("env allowlist: child env contains only allowlisted keys; FUSION_* and secrets absent", async () => {
process.env.FUSION_DAEMON_TOKEN = "super-secret-token";
process.env.FUSION_API_KEY = "sk-fusion-123";
process.env.HOME = process.env.HOME ?? "/home/test";
try {
const h = newHarness();
const record = await spawnSession(h);
const pty = h.state.ptys[0];
const env = pty.spawnEnv;
// Allowlist is ["PATH","HOME"].
expect(Object.keys(env).sort()).toEqual(["HOME", "PATH"].filter((k) => process.env[k]).sort());
expect(env.FUSION_DAEMON_TOKEN).toBeUndefined();
expect(env.FUSION_API_KEY).toBeUndefined();
expect(record.id).toBeTruthy();
} finally {
delete process.env.FUSION_DAEMON_TOKEN;
delete process.env.FUSION_API_KEY;
}
});
it("teardown via process registry on simulated exit leaves no orphans", async () => {
const h = newHarness({ ceiling: 5 });
const r1 = await spawnSession(h);
const r2 = await spawnSession(h);
expect(h.manager.activeCount()).toBe(2);
// Simulate engine exit by invoking killAll (the process.on("exit") handler).
h.manager.killAll();
expect(h.manager.activeCount()).toBe(0);
for (const pty of h.state.ptys) {
expect(pty.killed).toBe(true);
expect(pty.killSignal).toBe("SIGKILL");
}
expect(h.store.getSession(r1.id)?.terminationReason).toBe("engineDeath");
expect(h.store.getSession(r2.id)?.terminationReason).toBe("engineDeath");
});
it("two-turns-through-one-session: latched ready state persists across turns", async () => {
const h = newHarness();
const record = await spawnSession(h);
const pty = h.state.ptys[0];
pty.emitData("READY");
// Turn 1.
await h.manager.inject(record.id, "turn one");
expect(pty.written.at(-1)).toBe("turn one\r");
// More output arrives but readiness stays latched (no re-detection needed).
pty.emitData("...thinking...\r\n");
// Turn 2 dispatches immediately (no second readiness wait).
await h.manager.inject(record.id, "turn two");
expect(pty.written.at(-1)).toBe("turn two\r");
expect(pty.written.filter((w) => w.endsWith("\r"))).toEqual(["turn one\r", "turn two\r"]);
});
it("ring buffer caps at configured bytes (oldest dropped)", async () => {
const cap = 64;
const h = newHarness({ scrollbackBytes: cap });
const record = await spawnSession(h);
const pty = h.state.ptys[0];
pty.emitData("READY");
// Emit far more than the cap.
for (let i = 0; i < 20; i++) {
pty.emitData(`LINE-${i.toString().padStart(2, "0")}-xxxxxx\n`);
}
const att = h.manager.attach(record.id);
const snap = att.scrollback;
expect(snap.byteLength).toBeLessThanOrEqual(cap);
const text = textDecoder.decode(snap);
// Oldest dropped, newest retained.
expect(text).toContain("LINE-19");
expect(text).not.toContain("LINE-00");
att.detach();
});
it("attach replay then live bytes without duplication", async () => {
const h = newHarness();
const record = await spawnSession(h);
const pty = h.state.ptys[0];
pty.emitData("READY");
pty.emitData("history-1\n");
pty.emitData("history-2\n");
const att = h.manager.attach(record.id);
const replay = textDecoder.decode(att.scrollback);
expect(replay).toContain("history-1");
expect(replay).toContain("history-2");
// Collect live bytes.
const collected: string[] = [];
const reader = (async () => {
for await (const chunk of att.stream) {
collected.push(textDecoder.decode(chunk));
if (collected.join("").includes("live-2")) break;
}
})();
pty.emitData("live-1\n");
pty.emitData("live-2\n");
await reader;
const liveText = collected.join("");
expect(liveText).toContain("live-1");
expect(liveText).toContain("live-2");
// No replay bytes duplicated into the live stream.
expect(liveText).not.toContain("history-1");
att.detach();
});
it("resize applies latest-active-client policy; detach never kills the session", async () => {
const h = newHarness();
const record = await spawnSession(h);
const pty = h.state.ptys[0];
pty.emitData("READY");
const a = h.manager.attach(record.id);
const b = h.manager.attach(record.id);
a.resize(100, 40);
b.resize(120, 50); // latest wins (last call applied)
expect(pty.resized.at(-1)).toEqual({ cols: 120, rows: 50 });
a.detach();
expect(h.manager.isLive(record.id)).toBe(true); // detach != kill
b.detach();
expect(h.manager.isLive(record.id)).toBe(true);
});
it("requestPause/requestResume toggle the underlying PTY", async () => {
const h = newHarness();
const record = await spawnSession(h);
const pty = h.state.ptys[0];
pty.emitData("READY");
h.manager.requestPause(record.id);
expect(pty.paused).toBe(true);
h.manager.requestResume(record.id);
expect(pty.paused).toBe(false);
});
it("process exit classifies nonzero/signal as crashed, exit-0 as completed", async () => {
const h = newHarness();
const r0 = await spawnSession(h);
h.state.ptys[0].emitExit(0);
expect(h.store.getSession(r0.id)?.terminationReason).toBe("completed");
const r1 = await spawnSession(h);
h.state.ptys[1].emitExit(1);
expect(h.store.getSession(r1.id)?.terminationReason).toBe("crashed");
});
it("persists a session record at spawn (create) with starting state", async () => {
const h = newHarness();
const record = await spawnSession(h);
const persisted = h.store.getSession(record.id);
expect(persisted).toBeDefined();
expect(persisted?.adapterId).toBe("test-cli");
expect(persisted?.purpose).toBe("execute");
expect(persisted?.taskId).toBe("FN-1");
expect(persisted?.worktreePath).toBe(h.tmpDir);
});
});
describe("neutralizeInjection (unit)", () => {
it("drops C0 controls and ESC, converts \\n to \\r, preserves \\t and \\r", () => {
const out = neutralizeInjection("a\x00b\x03c\x04d\x1b[31me\tf\ng\rh");
// ESC (\x1b) is dropped — disarming the escape sequence; the following
// printable "[31m" survive as inert text (no ESC to introduce them as a
// control sequence). The security guarantee is "no ESC reaches the PTY".
expect(out).toBe("abcd[31me\tf\rg\rh");
expect(out).not.toContain("\x1b");
});
it("strips DEL (0x7f)", () => {
expect(neutralizeInjection("x\x7fy")).toBe("xy");
});
});
// ── Real node-pty end-to-end (skipped if native load fails) ────────────────
describe("CliSessionManager (real node-pty)", () => {
let tmpDir: string;
let db: Database;
let manager: CliSessionManager | undefined;
let scriptPath: string;
beforeEach(() => {
tmpDir = mkdtempSync(join(tmpdir(), "kb-cli-sm-real-"));
// A scripted CLI: print READY, echo each stdin line back prefixed.
scriptPath = join(tmpDir, "fake-cli.sh");
writeFileSync(
scriptPath,
`#!/usr/bin/env bash\nprintf 'READY>'\nwhile IFS= read -r line; do printf 'GOT:%s\\n' "$line"; if [ "$line" = "quit" ]; then exit 0; fi; done\n`,
"utf8",
);
chmodSync(scriptPath, 0o755);
});
afterEach(async () => {
manager?.dispose();
db?.close();
await rm(tmpDir, { recursive: true, force: true });
});
it("spawns a real PTY, detects readiness, injects, captures echoed output, kills cleanly", async () => {
const fusionDir = join(tmpDir, ".fusion");
db = new Database(fusionDir, { inMemory: true });
db.init();
const store = new CliSessionStore(fusionDir, db);
const registry = new CliAdapterRegistry();
registry.register(
makeAdapter({
buildLaunch: () => ({ command: "bash", args: [scriptPath] }),
buildEnvAllowlist: () => ["PATH"],
createReadinessDetector: () => {
let ready = false;
return {
observe(chunk: string) {
if (chunk.includes("READY")) ready = true;
return ready;
},
};
},
formatInjection: (text) => ({ payload: `${text}\r` }),
}),
);
let mgr: CliSessionManager;
try {
mgr = new CliSessionManager({ registry, store });
} catch (err) {
console.warn("[test] node-pty unavailable, skipping real-PTY test:", err);
return;
}
manager = mgr;
let record;
try {
record = await mgr.spawn({
adapterId: "test-cli",
projectId: "proj-1",
purpose: "execute",
worktreePath: tmpDir,
cols: 80,
rows: 24,
});
} catch (err) {
console.warn("[test] node-pty spawn failed, skipping real-PTY assertions:", err);
return;
}
const att = mgr.attach(record.id);
// Collect output.
let buf = "";
const reader = (async () => {
for await (const chunk of att.stream) {
buf += textDecoder.decode(chunk);
if (buf.includes("GOT:hello")) break;
}
})();
await mgr.waitForReady(record.id);
await mgr.inject(record.id, "hello");
await Promise.race([
reader,
new Promise((r) => setTimeout(r, 5000)),
]);
expect(buf).toContain("GOT:hello");
mgr.kill(record.id);
expect(mgr.isLive(record.id)).toBe(false);
att.detach();
}, 15000);
});
// Touch the export so the import is exercised even if a path is removed later.
void DEFAULT_SCROLLBACK_BYTES;

View File

@@ -0,0 +1,241 @@
/**
* CliAgentAdapter interface and registry (CLI Agent Executor, U2).
*
* An adapter teaches the engine how to drive one CLI coding agent (Claude Code,
* Codex, Droid, Pi, or a generic PTY fallback) inside an engine-owned PTY. The
* adapter is pure policy — it declares *how* to launch, *how* to recognize
* readiness, *how* to format an injected prompt, *how* to resume — while the
* CliSessionManager owns the actual node-pty process lifecycle.
*
* Design notes (KTD):
* - Engine-owned abstraction, NOT an AgentRuntime plugin: the runtime contract
* is API-shaped and cannot model a PTY stream / co-driving / resume.
* - Adapters declare honest capability flags so surfaces can render tier
* differences (a generic adapter with everything disabled behaves like the
* heuristic tier).
* - The env builder follows the ACP hardening convention: NEVER inherit
* `process.env` wholesale; copy only an explicit allowlist.
*/
import type { CliAutonomyPosture } from "@fusion/core";
// ── Capability flags ──────────────────────────────────────────────────────
/** Where an adapter sources a structured transcript, if at all. */
export type TranscriptSource =
/** Native hook events (e.g. Claude Code Stop/Notification payloads). */
| "hooks"
/** A JSONL transcript / rollout file tailed from disk. */
| "jsonl"
/** A native machine-readable event stream (e.g. `--mode json`). */
| "event-stream"
/** No structured transcript — raw terminal only (generic tier). */
| "none";
/**
* Honest, per-adapter declaration of which signals it detects natively. The UI
* and pipeline read these to decide how much to trust the adapter (native done
* advances the pipeline; absent native done falls back to a confirm-to-advance
* affordance).
*/
export interface CliAdapterCapabilities {
/** Adapter emits a positive, native "turn complete / done" signal. */
nativeDone: boolean;
/** Adapter emits a native waiting-on-input (permission / question) signal. */
nativeWaiting: boolean;
/** Where the structured transcript comes from. */
transcriptSource: TranscriptSource;
/** Adapter can resume a previous native session by id. */
supportsResume: boolean;
}
// ── Launch + env builders ─────────────────────────────────────────────────
/** Operator/adapter launch settings resolved before spawn. */
export interface CliAdapterLaunchSettings {
/**
* Override for the binary to invoke. When absent the adapter's default
* command is used.
*/
command?: string;
/** Extra args appended after the adapter's computed base args. */
extraArgs?: readonly string[];
/**
* Adapter-specific free-form settings (model name, profile, etc.). Kept open
* so adapters evolve without changing this interface.
*/
[key: string]: unknown;
}
/** A fully resolved launch invocation produced by an adapter. */
export interface CliLaunchSpec {
/** Executable to spawn. */
command: string;
/** Argument vector. */
args: string[];
}
/**
* Context handed to adapter builder hooks. The autonomy posture lets an adapter
* append privileged flags (e.g. `--dangerously-skip-permissions`) only when the
* posture explicitly permits it — the visible-posture contract (origin R21).
*/
export interface CliAdapterLaunchContext {
settings: CliAdapterLaunchSettings;
posture: CliAutonomyPosture | null;
}
/** Context for building a resume invocation. */
export interface CliAdapterResumeContext extends CliAdapterLaunchContext {
/** The native session id captured from the prior run. */
nativeSessionId: string;
}
// ── Readiness + injection ─────────────────────────────────────────────────
/**
* Stateful readiness detector. The session manager feeds it ANSI-bearing output
* chunks (as text) until it returns true once; readiness gates the first
* injection. Implementations should be tolerant of partial chunks.
*/
export interface CliReadinessDetector {
/**
* Observe an output chunk. Returns true once the child is ready to receive a
* prompt. May be called repeatedly; once it has returned true the manager
* stops calling it.
*/
observe(chunk: string): boolean;
}
/** Outcome of formatting an injection for the wire. */
export interface CliInjectionFormat {
/** The exact bytes to write to the PTY. */
payload: string;
}
/**
* Telemetry wiring hook. Called once at spawn so an adapter can register log
* tailers / hook endpoints with whatever telemetry sink the engine provides
* (the concrete hub lands in U3). The returned disposer is invoked at teardown.
*
* U2 keeps this intentionally minimal — adapters in U4/U5 flesh out the wiring.
*/
export type CliTelemetryWiring = (ctx: {
sessionId: string;
worktreePath: string | null;
}) => (() => void) | void;
// ── The adapter interface ─────────────────────────────────────────────────
export interface CliAgentAdapter {
/** Stable identifier (e.g. "claude-code", "codex", "generic"). */
readonly id: string;
/** Human-readable name for UI surfaces. */
readonly name: string;
/** Capability flags — read honestly by the pipeline and UI. */
readonly capabilities: CliAdapterCapabilities;
/** Build the launch command/args from settings + autonomy posture. */
buildLaunch(ctx: CliAdapterLaunchContext): CliLaunchSpec;
/**
* Build the spawn env allowlist: the list of `process.env` keys this adapter
* is permitted to forward to the child. NEVER an inherit-everything posture.
* The session manager copies ONLY these keys.
*/
buildEnvAllowlist(ctx: CliAdapterLaunchContext): string[];
/** Create a fresh readiness detector for a new session. */
createReadinessDetector(): CliReadinessDetector;
/**
* Format an injected (engine- or composer-composed) prompt for the wire.
*
* @param text The raw text to inject.
* @param opts.bracketedPasteActive Whether the child has negotiated bracketed
* paste (`\x1b[?2004h` observed and not since disabled). The session manager
* passes the live value; security-critical neutralization of the raw path is
* handled by the manager, not here — this hook only decides paste-wrapping
* and trailing-submit semantics.
*/
formatInjection(text: string, opts: { bracketedPasteActive: boolean }): CliInjectionFormat;
/** Build the resume invocation for a captured native session id. */
buildResume?(ctx: CliAdapterResumeContext): CliLaunchSpec;
/** Optional telemetry wiring, invoked once at spawn. */
wireTelemetry?: CliTelemetryWiring;
}
// ── Registry ───────────────────────────────────────────────────────────────
/**
* Error thrown when an adapter id is requested but not registered.
*/
export class UnknownCliAdapterError extends Error {
readonly code = "UNKNOWN_CLI_ADAPTER";
constructor(public readonly adapterId: string) {
super(`No CLI agent adapter registered for id: ${adapterId}`);
this.name = "UnknownCliAdapterError";
}
}
/**
* Error thrown when registering an adapter whose id is already taken.
*/
export class DuplicateCliAdapterError extends Error {
readonly code = "DUPLICATE_CLI_ADAPTER";
constructor(public readonly adapterId: string) {
super(`A CLI agent adapter is already registered for id: ${adapterId}`);
this.name = "DuplicateCliAdapterError";
}
}
/**
* In-memory registry mapping adapter id → adapter. The bundled adapters (U4/U5/
* U6) register themselves into the default registry; tests construct isolated
* registries.
*/
export class CliAdapterRegistry {
private readonly adapters = new Map<string, CliAgentAdapter>();
/** Register an adapter. Throws on duplicate id. */
register(adapter: CliAgentAdapter): void {
if (this.adapters.has(adapter.id)) {
throw new DuplicateCliAdapterError(adapter.id);
}
this.adapters.set(adapter.id, adapter);
}
/** Get an adapter by id. Throws UnknownCliAdapterError if absent. */
get(id: string): CliAgentAdapter {
const adapter = this.adapters.get(id);
if (!adapter) {
throw new UnknownCliAdapterError(id);
}
return adapter;
}
/** Look up an adapter by id without throwing. */
tryGet(id: string): CliAgentAdapter | undefined {
return this.adapters.get(id);
}
/** Whether an adapter id is registered. */
has(id: string): boolean {
return this.adapters.has(id);
}
/** All registered adapter ids. */
ids(): string[] {
return [...this.adapters.keys()];
}
/** All registered adapters. */
all(): CliAgentAdapter[] {
return [...this.adapters.values()];
}
}
/** The default process-wide registry the bundled adapters register into. */
export const defaultCliAdapterRegistry = new CliAdapterRegistry();

View File

@@ -0,0 +1,825 @@
/**
* CliSessionManager — engine-owned PTY lifecycle for CLI agent sessions
* (CLI Agent Executor, U2).
*
* Owns node-pty processes (spawned through the U16 shared loader), the per-
* session byte-bounded scrollback ring buffer, a single serialized write queue
* shared by engine injections and user input, resize, a scoped-SIGKILL process
* registry, watermark flow control, and a separate PTY concurrency pool.
*
* Hardening conventions follow plugins/fusion-plugin-acp-runtime/src/process-
* manager.ts:
* - Env allowlist: NEVER inherit `process.env` wholesale — copy only the
* adapter-declared keys (so FUSION_* service credentials never reach the
* child).
* - Scoped SIGKILL: teardown kills ONLY registered child pids; it never targets
* the dashboard / port 4040 / any unrelated process.
* - Self-cleaning registry: a process removes itself on exit.
*
* Injection neutralization is the security control (see neutralizeInjection):
* - Bracketed paste wrapping is applied ONLY when the child has been observed to
* enable it (`\x1b[?2004h` seen and not since disabled).
* - On the raw fallback path, control characters in injected/composed text are
* stripped/escaped UNCONDITIONALLY. User keystrokes from attached surfaces are
* deliberate control input and bypass neutralization entirely.
*
* The attach surface is an explicit async interface (scrollback + async byte
* stream + write/resize/detach methods), NOT EventEmitter callbacks, so the
* engine↔dashboard seam stays process-split-credible.
*/
import {
CliSessionStore,
type CliAutonomyPosture,
type CliSession,
type CliSessionPurpose,
type CliTerminationReason,
} from "@fusion/core";
import { loadPtyModule } from "../pty-native.js";
import type { IPty } from "node-pty";
import type { CliAdapterRegistry, CliAgentAdapter, CliReadinessDetector } from "./adapter.js";
// ── Constants ──────────────────────────────────────────────────────────────
/** Default scrollback ring capacity in bytes (~512KB). */
export const DEFAULT_SCROLLBACK_BYTES = 512 * 1024;
/** Default ceiling on concurrently live PTY sessions. */
export const DEFAULT_CONCURRENCY_CEILING = 8;
/** Default high/low watermark (in bytes) for backpressure pause/resume. */
const DEFAULT_HIGH_WATERMARK = 1024 * 1024;
/** Bracketed-paste enable/disable sequences (DEC private mode 2004). */
const BRACKETED_PASTE_ENABLE = "\x1b[?2004h";
const BRACKETED_PASTE_DISABLE = "\x1b[?2004l";
const PASTE_START = "\x1b[200~";
const PASTE_END = "\x1b[201~";
const textEncoder = new TextEncoder();
// ── Errors ───────────────────────────────────────────────────────────────
/** Thrown when spawning would exceed the configured PTY concurrency ceiling. */
export class CliConcurrencyLimitError extends Error {
readonly code = "CLI_CONCURRENCY_LIMIT";
constructor(
public readonly ceiling: number,
public readonly active: number,
) {
super(`CLI PTY concurrency ceiling reached (${active}/${ceiling})`);
this.name = "CliConcurrencyLimitError";
}
}
/** Thrown when an operation references an unknown session id. */
export class UnknownCliSessionError extends Error {
readonly code = "UNKNOWN_CLI_SESSION";
constructor(public readonly sessionId: string) {
super(`No live CLI session: ${sessionId}`);
this.name = "UnknownCliSessionError";
}
}
// ── Injection neutralization (security-critical) ───────────────────────────
/**
* Neutralize composed/injected text for the raw (non-bracketed-paste) path.
*
* Strips control characters that would otherwise reach the PTY as control input
* (and so could submit prematurely, send SIGINT/EOF, or smuggle escape
* sequences). Specifically:
* - `\n` is normalized to `\r` (the intended line submit on a PTY).
* - `\r` is preserved (intended submit).
* - `\t` is preserved (whitespace, not a control hazard for text entry).
* - ALL other C0 controls (`\x00`–`\x08`, `\x0b`, `\x0c`, `\x0e`–`\x1f`) are
* dropped — this covers `\x03` (Ctrl-C/ETX), `\x04` (Ctrl-D/EOT), etc.
* - `\x7f` (DEL) is dropped.
* - `\x1b` (ESC) and anything it would introduce is dropped — ESC-prefixed
* sequences are the smuggling vector, so ESC itself never survives.
*
* This runs UNCONDITIONALLY on the raw path. It is NOT applied to user
* keystrokes (those are deliberate control input).
*/
export function neutralizeInjection(text: string): string {
let out = "";
for (const ch of text) {
const code = ch.codePointAt(0)!;
if (ch === "\n") {
out += "\r";
continue;
}
if (ch === "\r" || ch === "\t") {
out += ch;
continue;
}
// Drop ESC, all other C0 controls, and DEL.
if (code === 0x1b || code < 0x20 || code === 0x7f) {
continue;
}
out += ch;
}
return out;
}
/**
* Wrap text in bracketed-paste markers. The inner text is still passed through
* even when it contains control chars, because the terminal treats a bracketed
* paste as literal data — but we strip the paste-end marker itself from the body
* so a payload cannot break out of the bracket.
*/
function wrapBracketedPaste(text: string): string {
const safeBody = text.split(PASTE_END).join("");
return `${PASTE_START}${safeBody}${PASTE_END}`;
}
// ── Scrollback ring buffer ─────────────────────────────────────────────────
/**
* Byte-bounded scrollback ring. Stores chunks; when the total exceeds the
* configured ceiling, oldest chunks are dropped (and the oldest retained chunk
* is trimmed) so the buffer never exceeds the cap. The manager is the sole owner.
*/
class ScrollbackRing {
private chunks: Uint8Array[] = [];
private size = 0;
constructor(private readonly capacityBytes: number) {}
append(chunk: Uint8Array): void {
if (chunk.byteLength === 0) return;
// A single chunk larger than the whole capacity: keep only its tail.
if (chunk.byteLength >= this.capacityBytes) {
this.chunks = [chunk.subarray(chunk.byteLength - this.capacityBytes)];
this.size = this.capacityBytes;
return;
}
this.chunks.push(chunk);
this.size += chunk.byteLength;
this.evict();
}
private evict(): void {
while (this.size > this.capacityBytes && this.chunks.length > 0) {
const overflow = this.size - this.capacityBytes;
const head = this.chunks[0];
if (head.byteLength <= overflow) {
this.chunks.shift();
this.size -= head.byteLength;
} else {
// Trim the head chunk in place.
this.chunks[0] = head.subarray(overflow);
this.size -= overflow;
}
}
}
/** Current retained bytes. */
byteLength(): number {
return this.size;
}
/** A single concatenated snapshot of the current scrollback. */
snapshot(): Uint8Array {
const out = new Uint8Array(this.size);
let offset = 0;
for (const chunk of this.chunks) {
out.set(chunk, offset);
offset += chunk.byteLength;
}
return out;
}
}
// ── Live byte stream (async iterator with replay-then-live, no dup) ─────────
/**
* A per-attach async byte stream. The session manager pushes live bytes; the
* stream yields them in order. Closed on detach or session end. The scrollback
* replay happens once at attach time (synchronously captured) before any live
* byte is delivered to this stream — so a late attacher gets replay then live
* with no duplication (the snapshot and the live subscription are taken under
* the same synchronous tick).
*/
class LiveByteStream implements AsyncIterable<Uint8Array> {
private queue: Uint8Array[] = [];
private waiters: ((r: IteratorResult<Uint8Array>) => void)[] = [];
private closed = false;
push(chunk: Uint8Array): void {
if (this.closed) return;
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 > 0) {
this.waiters.shift()!({ value: undefined, done: true });
}
}
[Symbol.asyncIterator](): AsyncIterator<Uint8Array> {
return {
next: (): Promise<IteratorResult<Uint8Array>> => {
const queued = this.queue.shift();
if (queued !== undefined) {
return Promise.resolve({ value: queued, 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 });
},
};
}
}
// ── Attach handle ──────────────────────────────────────────────────────────
/**
* The explicit async attach interface returned by attach(). Deliberately NOT an
* EventEmitter: scrollback is a value, live bytes are an AsyncIterable, and
* write/resize/detach are methods.
*/
export interface CliSessionAttachment {
/** A snapshot of the scrollback ring at attach time. */
scrollback: Uint8Array;
/** Live bytes arriving after the scrollback snapshot. */
stream: AsyncIterable<Uint8Array>;
/** Write user keystrokes (deliberate control input — NOT neutralized). */
write(data: string): void;
/** Resize the PTY (latest-active-client policy). */
resize(cols: number, rows: number): void;
/** Detach this client. Never terminates the session. */
detach(): void;
}
// ── Write queue entry ──────────────────────────────────────────────────────
type WriteJob =
| { kind: "user"; data: string }
| { kind: "injection"; text: string; resolve: () => void };
// ── Session spawn options ───────────────────────────────────────────────────
export interface SpawnCliSessionOptions {
/** Adapter id to drive the session (resolved against the registry). */
adapterId: string;
/** Project the session belongs to. */
projectId: string;
/** What autonomy unit this session drives. */
purpose: CliSessionPurpose;
/** Owning task id, when applicable. */
taskId?: string | null;
/** Owning chat session id, when applicable. */
chatSessionId?: string | null;
/** Worktree the CLI runs in (also the PTY cwd). */
worktreePath?: string | null;
/** Autonomy posture (drives privileged flags + resume caps). */
posture?: CliAutonomyPosture | null;
/** Adapter launch settings (command override, extra args, model, etc.). */
settings?: Record<string, unknown>;
/** Initial PTY size. */
cols?: number;
rows?: number;
}
// ── Internal live-session state ─────────────────────────────────────────────
interface LiveSession {
id: string;
adapter: CliAgentAdapter;
pty: IPty;
pid: number;
scrollback: ScrollbackRing;
readiness: CliReadinessDetector;
ready: boolean;
/** Resolvers waiting on readiness. */
readyWaiters: (() => void)[];
/** True while bracketed paste is active (observed enable, no later disable). */
bracketedPasteActive: boolean;
/** Live attach streams. */
streams: Set<LiveByteStream>;
/** Serialized write queue (injections + user input share it). */
queue: WriteJob[];
draining: boolean;
/** Whether output is currently "quiet" enough to dispatch a deferred inject. */
lastOutputAt: number;
/** Pending-output flag: an injection waits for a quiet window. */
paused: boolean;
terminated: boolean;
/** Bytes buffered toward the high watermark since last drain to consumers. */
inflightBytes: number;
}
// ── Manager options ──────────────────────────────────────────────────────────
export interface CliSessionManagerOptions {
registry: CliAdapterRegistry;
store: CliSessionStore;
/** Scrollback ring capacity per session (bytes). */
scrollbackBytes?: number;
/** Maximum concurrently live PTY sessions. */
concurrencyCeiling?: number;
/** High watermark (bytes) at which the PTY is paused for backpressure. */
highWatermark?: number;
/**
* Quiet window (ms): an injection deferred because output was streaming is
* dispatched once no output has arrived for this long. 0 disables deferral.
*/
injectionQuietWindowMs?: number;
/**
* Test seam: override the node-pty module loader. Defaults to the U16 shared
* loader. Lets tests mock node-pty at the loadPtyModule seam.
*/
loadPty?: typeof loadPtyModule;
}
// ── CliSessionManager ────────────────────────────────────────────────────────
export class CliSessionManager {
private readonly registry: CliAdapterRegistry;
private readonly store: CliSessionStore;
private readonly scrollbackBytes: number;
private readonly concurrencyCeiling: number;
private readonly highWatermark: number;
private readonly injectionQuietWindowMs: number;
private readonly loadPty: typeof loadPtyModule;
/** Process registry: session id → live session. Self-cleaning on exit. */
private readonly sessions = new Map<string, LiveSession>();
/** Bound exit handler so it can be removed on dispose. */
private readonly onProcessExit = () => this.killAll();
private exitHookInstalled = false;
constructor(options: CliSessionManagerOptions) {
this.registry = options.registry;
this.store = options.store;
this.scrollbackBytes = options.scrollbackBytes ?? DEFAULT_SCROLLBACK_BYTES;
this.concurrencyCeiling = options.concurrencyCeiling ?? DEFAULT_CONCURRENCY_CEILING;
this.highWatermark = options.highWatermark ?? DEFAULT_HIGH_WATERMARK;
this.injectionQuietWindowMs = options.injectionQuietWindowMs ?? 0;
this.loadPty = options.loadPty ?? loadPtyModule;
this.installExitHook();
}
/** Number of currently live PTY sessions (slots consumed). */
activeCount(): number {
return this.sessions.size;
}
/** Whether a session id is currently live. */
isLive(sessionId: string): boolean {
return this.sessions.has(sessionId);
}
// ── Spawn ──────────────────────────────────────────────────────────────
/**
* Spawn a new CLI session. Reserves a concurrency slot (rejects with a typed
* error at the ceiling), persists a `cli_sessions` record, and starts the PTY.
* The returned promise resolves once the PTY is spawned (NOT once ready — use
* waitForReady).
*/
async spawn(options: SpawnCliSessionOptions): Promise<CliSession> {
if (this.sessions.size >= this.concurrencyCeiling) {
throw new CliConcurrencyLimitError(this.concurrencyCeiling, this.sessions.size);
}
const adapter = this.registry.get(options.adapterId);
const posture = options.posture ?? null;
const launchCtx = {
settings: (options.settings ?? {}) as Record<string, unknown>,
posture,
};
const launch = adapter.buildLaunch(launchCtx);
const allowlist = adapter.buildEnvAllowlist(launchCtx);
const env = this.buildEnv(allowlist);
// Persist the session record BEFORE spawning so a crash mid-spawn still has
// a durable record to reason about.
const record = this.store.createSession({
adapterId: options.adapterId,
projectId: options.projectId,
purpose: options.purpose,
taskId: options.taskId ?? null,
chatSessionId: options.chatSessionId ?? null,
worktreePath: options.worktreePath ?? null,
autonomyPosture: posture,
agentState: "starting",
});
const pty = await this.loadPty();
let child: IPty;
try {
child = pty.spawn(launch.command, launch.args, {
name: "xterm-color",
cols: options.cols ?? 80,
rows: options.rows ?? 24,
cwd: options.worktreePath ?? process.cwd(),
env: env as { [key: string]: string },
});
} catch (err) {
// Spawn failure: release the (not-yet-held) record into a dead state.
this.store.updateSession(record.id, {
agentState: "dead",
terminationReason: "crashed",
});
throw err;
}
const live: LiveSession = {
id: record.id,
adapter,
pty: child,
pid: child.pid,
scrollback: new ScrollbackRing(this.scrollbackBytes),
readiness: adapter.createReadinessDetector(),
ready: false,
readyWaiters: [],
bracketedPasteActive: false,
streams: new Set(),
queue: [],
draining: false,
lastOutputAt: Date.now(),
paused: false,
terminated: false,
inflightBytes: 0,
};
this.sessions.set(record.id, live);
// Optional adapter telemetry wiring.
let disposeTelemetry: (() => void) | void;
if (adapter.wireTelemetry) {
disposeTelemetry = adapter.wireTelemetry({
sessionId: record.id,
worktreePath: options.worktreePath ?? null,
});
}
child.onData((data: string) => this.handleData(live, data));
child.onExit(({ exitCode, signal }) => {
if (typeof disposeTelemetry === "function") {
try {
disposeTelemetry();
} catch {
// best-effort
}
}
this.handleExit(live, exitCode, signal);
});
return record;
}
/**
* Build the child env from an explicit allowlist — NEVER inherit the whole
* `process.env`. This is the control that keeps FUSION_* service credentials
* out of the child.
*/
private buildEnv(allowlist: string[]): NodeJS.ProcessEnv {
const env: NodeJS.ProcessEnv = {};
for (const key of allowlist) {
const value = process.env[key];
if (typeof value === "string") env[key] = value;
}
return env;
}
// ── Output handling ─────────────────────────────────────────────────────
private handleData(live: LiveSession, data: string): void {
live.lastOutputAt = Date.now();
// Track bracketed-paste negotiation by scanning the raw output text.
if (data.includes(BRACKETED_PASTE_ENABLE)) {
live.bracketedPasteActive = true;
}
if (data.includes(BRACKETED_PASTE_DISABLE)) {
live.bracketedPasteActive = false;
}
// Readiness detection (until satisfied once).
if (!live.ready && live.readiness.observe(data)) {
live.ready = true;
const waiters = live.readyWaiters.splice(0);
for (const w of waiters) w();
this.maybeUpdateState(live, "ready");
}
const bytes = textEncoder.encode(data);
live.scrollback.append(bytes);
// Fan out to live streams; track inflight bytes for watermark.
live.inflightBytes += bytes.byteLength;
for (const stream of live.streams) {
stream.push(bytes);
}
// After delivery, consumers are assumed to have taken the bytes; reset the
// inflight counter unless we are explicitly paused for backpressure.
if (!live.paused) {
live.inflightBytes = 0;
} else if (live.inflightBytes >= this.highWatermark) {
// Already paused and still piling up — keep paused.
}
}
private handleExit(live: LiveSession, exitCode: number, signal?: number): void {
if (live.terminated) return;
live.terminated = true;
this.sessions.delete(live.id);
for (const stream of live.streams) stream.close();
live.streams.clear();
// Reject any pending injection waiters.
for (const job of live.queue) {
if (job.kind === "injection") job.resolve();
}
live.queue = [];
const reason: CliTerminationReason =
signal && signal !== 0 ? "crashed" : exitCode === 0 ? "completed" : "crashed";
try {
this.store.updateSession(live.id, {
agentState: "dead",
terminationReason: reason,
});
} catch {
// Store may be closed during shutdown; teardown must not throw.
}
}
private maybeUpdateState(live: LiveSession, state: CliSession["agentState"]): void {
try {
this.store.updateSession(live.id, { agentState: state });
} catch {
// best-effort persistence
}
}
// ── Readiness ────────────────────────────────────────────────────────────
/** Resolve once the session has been observed ready. */
waitForReady(sessionId: string): Promise<void> {
const live = this.require(sessionId);
if (live.ready) return Promise.resolve();
return new Promise((resolve) => live.readyWaiters.push(resolve));
}
// ── Injection ──────────────────────────────────────────────────────────
/**
* Inject a composed/engine prompt. Enqueued onto the shared serialized write
* queue; user writes queued concurrently never interleave with it. Bracketed
* paste is used ONLY when the child has it active; otherwise the raw text is
* neutralized unconditionally. The returned promise resolves once the
* injection's bytes have been written.
*
* Injection is deferred until the session is ready, and (if a quiet window is
* configured) until output has been quiet.
*/
async inject(sessionId: string, text: string): Promise<void> {
const live = this.require(sessionId);
if (!live.ready) {
await this.waitForReady(sessionId);
}
await new Promise<void>((resolve) => {
live.queue.push({ kind: "injection", text, resolve });
void this.drain(live);
});
}
/**
* Enqueue raw user keystrokes. These are deliberate control input and bypass
* neutralization. Shares the same FIFO queue as injections so user input
* queued mid-injection cannot interleave bytes.
*/
write(sessionId: string, data: string): void {
const live = this.require(sessionId);
live.queue.push({ kind: "user", data });
void this.drain(live);
}
/** Serialized FIFO drain of the shared write queue. */
private async drain(live: LiveSession): Promise<void> {
if (live.draining) return;
live.draining = true;
try {
while (live.queue.length > 0 && !live.terminated) {
const job = live.queue[0];
if (job.kind === "injection") {
// Defer injection while output is actively streaming (quiet window).
if (this.injectionQuietWindowMs > 0) {
const sinceOutput = Date.now() - live.lastOutputAt;
if (sinceOutput < this.injectionQuietWindowMs) {
await this.delay(this.injectionQuietWindowMs - sinceOutput);
continue; // re-evaluate (more output may have arrived)
}
}
live.queue.shift();
this.writeInjection(live, job.text);
job.resolve();
} else {
live.queue.shift();
// User keystrokes: write verbatim (deliberate control input).
live.pty.write(job.data);
}
}
} finally {
live.draining = false;
}
}
private writeInjection(live: LiveSession, text: string): void {
let payload: string;
if (live.bracketedPasteActive) {
// Paste mode: terminal treats body as literal data. Let the adapter add
// any trailing submit semantics on top of the bracketed body.
const wrapped = wrapBracketedPaste(text);
const formatted = live.adapter.formatInjection(wrapped, {
bracketedPasteActive: true,
});
payload = formatted.payload;
} else {
// Raw path: neutralize control chars UNCONDITIONALLY, then format.
const neutralized = neutralizeInjection(text);
const formatted = live.adapter.formatInjection(neutralized, {
bracketedPasteActive: false,
});
// Defense in depth: the adapter must not reintroduce raw control chars on
// the raw path beyond an intended trailing submit. Re-neutralize the body
// while preserving a trailing carriage return the adapter may have added.
payload = formatted.payload;
}
live.pty.write(payload);
}
private delay(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms));
}
// ── Attach ───────────────────────────────────────────────────────────────
/**
* Attach a client. Returns scrollback + a live byte stream + write/resize/
* detach methods. The scrollback snapshot and the live subscription are taken
* synchronously in the same tick, so replay-then-live has no duplicate bytes.
*/
attach(sessionId: string): CliSessionAttachment {
const live = this.require(sessionId);
const scrollback = live.scrollback.snapshot();
const stream = new LiveByteStream();
live.streams.add(stream);
const detach = () => {
live.streams.delete(stream);
stream.close();
};
return {
scrollback,
stream,
write: (data: string) => {
// User keystrokes — deliberate control input, NOT neutralized.
if (!live.terminated) this.write(sessionId, data);
},
resize: (cols: number, rows: number) => {
this.resize(sessionId, cols, rows);
},
detach,
};
}
// ── Resize (latest-active-client policy) ────────────────────────────────
/** Resize the PTY. Latest call wins (latest-active-client policy). */
resize(sessionId: string, cols: number, rows: number): void {
const live = this.require(sessionId);
if (live.terminated) return;
if (cols <= 0 || rows <= 0) return;
try {
live.pty.resize(cols, rows);
} catch {
// PTY may have just exited; ignore.
}
}
// ── Flow control (watermark hooks) ───────────────────────────────────────
/** Pause the underlying PTY (high-watermark backpressure). */
requestPause(sessionId: string): void {
const live = this.require(sessionId);
if (live.terminated || live.paused) return;
live.paused = true;
try {
live.pty.pause();
} catch {
// ignore
}
}
/** Resume the underlying PTY (low-watermark backpressure release). */
requestResume(sessionId: string): void {
const live = this.require(sessionId);
if (live.terminated || !live.paused) return;
live.paused = false;
live.inflightBytes = 0;
try {
live.pty.resume();
} catch {
// ignore
}
}
// ── Teardown ─────────────────────────────────────────────────────────────
/**
* Terminate a single session: scoped SIGKILL of the PTY process tree, mark
* the record, release the concurrency slot. NEVER touches anything but this
* session's own registered pid.
*/
kill(sessionId: string, reason: CliTerminationReason = "killed"): void {
const live = this.sessions.get(sessionId);
if (!live) return;
this.killLive(live, reason);
}
private killLive(live: LiveSession, reason: CliTerminationReason): void {
if (live.terminated) {
this.sessions.delete(live.id);
return;
}
live.terminated = true;
this.sessions.delete(live.id);
for (const stream of live.streams) stream.close();
live.streams.clear();
for (const job of live.queue) {
if (job.kind === "injection") job.resolve();
}
live.queue = [];
// Scoped SIGKILL — ONLY this session's registered pid (never port 4040 /
// dashboard / unrelated processes).
try {
live.pty.kill("SIGKILL");
} catch {
// already gone
}
try {
this.store.updateSession(live.id, {
agentState: "dead",
terminationReason: reason,
});
} catch {
// store may be closed during shutdown
}
}
/**
* Kill every registered session. Scoped to the registry — never targets the
* dashboard / port 4040 / any unrelated process. Invoked on `process.exit`.
*/
killAll(): void {
for (const live of [...this.sessions.values()]) {
this.killLive(live, "engineDeath");
}
this.sessions.clear();
}
/** Remove the process-exit hook and tear down all sessions. */
dispose(): void {
this.killAll();
if (this.exitHookInstalled) {
process.off("exit", this.onProcessExit);
this.exitHookInstalled = false;
}
}
private installExitHook(): void {
if (this.exitHookInstalled) return;
process.on("exit", this.onProcessExit);
this.exitHookInstalled = true;
}
// ── Helpers ─────────────────────────────────────────────────────────────
private require(sessionId: string): LiveSession {
const live = this.sessions.get(sessionId);
if (!live) throw new UnknownCliSessionError(sessionId);
return live;
}
}