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:
103
packages/engine/src/cli-agent/__tests__/adapter-registry.test.ts
Normal file
103
packages/engine/src/cli-agent/__tests__/adapter-registry.test.ts
Normal 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");
|
||||
});
|
||||
});
|
||||
626
packages/engine/src/cli-agent/__tests__/session-manager.test.ts
Normal file
626
packages/engine/src/cli-agent/__tests__/session-manager.test.ts
Normal 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;
|
||||
241
packages/engine/src/cli-agent/adapter.ts
Normal file
241
packages/engine/src/cli-agent/adapter.ts
Normal 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();
|
||||
825
packages/engine/src/cli-agent/session-manager.ts
Normal file
825
packages/engine/src/cli-agent/session-manager.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user