feat(engine): add Codex, Droid, and Pi cli-agent adapters (U5)

Codex (hybrid tier): native done via session-scoped notify config,
heuristic PTY waiting detection, codex resume <thread-id>, probed rollout
JSONL tailer. Droid (native tier): Claude-style hooks with a Notification
permission-vs-idle classifier, --resume / exec -s resume (never -r in exec
mode). Pi (native tier): session-JSONL telemetry + transcript tailing,
pi --session resume. Adds the session-jsonl transcript source.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
gsxdsm
2026-06-05 00:00:05 -07:00
parent 3eca224e83
commit 07dcb1695e
8 changed files with 2598 additions and 0 deletions

View File

@@ -27,6 +27,8 @@ export type TranscriptSource =
| "hooks"
/** A JSONL transcript / rollout file tailed from disk. */
| "jsonl"
/** A per-session JSONL file tailed from disk for both telemetry + transcript (Pi). */
| "session-jsonl"
/** A native machine-readable event stream (e.g. `--mode json`). */
| "event-stream"
/** No structured transcript — raw terminal only (generic tier). */

View File

@@ -0,0 +1,354 @@
import { describe, it, expect, beforeEach, afterEach } from "vitest";
import { mkdtempSync } from "node:fs";
import { join } from "node:path";
import { tmpdir } from "node:os";
import { rm } from "node:fs/promises";
import { Database, CliSessionStore } from "@fusion/core";
import { TelemetryHub, type TelemetryEvent } from "../../telemetry-hub.js";
import {
codexAdapter,
CODEX_CAPABILITIES,
buildNotifyOverrideArg,
codexSessionHomeLayout,
mapNotifyPayload,
parseNotifyPayload,
CodexWaitingAnalyzer,
CodexRolloutTailer,
CodexReadinessDetector,
findRolloutPath,
type DirentLike,
} from "../codex.js";
// ── fake fs for findRolloutPath ────────────────────────────────────────────────
function dir(name: string): DirentLike {
return { name, isDirectory: () => true };
}
function file(name: string): DirentLike {
return { name, isDirectory: () => false };
}
describe("codexAdapter — capabilities + identity", () => {
it("declares the HYBRID tier capability flags (nativeWaiting OFF)", () => {
expect(codexAdapter.id).toBe("codex");
expect(codexAdapter.capabilities).toEqual({
nativeDone: true,
nativeWaiting: false,
transcriptSource: "jsonl",
supportsResume: true,
});
expect(CODEX_CAPABILITIES).toEqual(codexAdapter.capabilities);
});
});
describe("codexAdapter — buildLaunch + notify override", () => {
it("launches bare `codex` with no notify program", () => {
const spec = codexAdapter.buildLaunch({ settings: {}, posture: null });
expect(spec.command).toBe("codex");
expect(spec.args).toEqual([]);
});
it("appends `-c notify=[...]` when a session-scoped notify program is set", () => {
const spec = codexAdapter.buildLaunch({
settings: { notifyProgram: "/tmp/sess/notify.sh" },
posture: null,
});
const idx = spec.args.indexOf("-c");
expect(idx).toBeGreaterThanOrEqual(0);
expect(spec.args[idx + 1]).toBe('notify=["/tmp/sess/notify.sh"]');
});
it("buildNotifyOverrideArg returns empty for a missing program", () => {
expect(buildNotifyOverrideArg(undefined)).toEqual([]);
expect(buildNotifyOverrideArg("")).toEqual([]);
});
it("sets the model via `-c model=` so it composes with notify", () => {
const spec = codexAdapter.buildLaunch({
settings: { model: "gpt-5.4", notifyProgram: "/n.sh" },
posture: null,
});
expect(spec.args).toContain("model=\"gpt-5.4\"");
expect(spec.args).toContain('notify=["/n.sh"]');
});
it("emits the privileged bypass ONLY when posture.autoApprove is true", () => {
const off = codexAdapter.buildLaunch({ settings: {}, posture: { autoApprove: false } });
expect(off.args).not.toContain("--dangerously-bypass-approvals-and-sandbox");
const on = codexAdapter.buildLaunch({ settings: {}, posture: { autoApprove: true } });
expect(on.args).toContain("--dangerously-bypass-approvals-and-sandbox");
});
it("env allowlist includes CODEX_HOME, excludes FUSION_* / service creds", () => {
const allow = codexAdapter.buildEnvAllowlist({ settings: {}, posture: null });
expect(allow).toContain("PATH");
expect(allow).toContain("CODEX_HOME");
expect(allow.some((k) => k.startsWith("FUSION_"))).toBe(false);
});
it("codexSessionHomeLayout describes the layered scratch CODEX_HOME", () => {
const layout = codexSessionHomeLayout("/tmp/sess/codex-home");
expect(layout).toEqual({
home: "/tmp/sess/codex-home",
configPath: "/tmp/sess/codex-home/config.toml",
authPath: "/tmp/sess/codex-home/auth.json",
});
});
});
describe("codexAdapter — buildResume", () => {
it("produces `codex resume <thread-id>` (AE3)", () => {
const spec = codexAdapter.buildResume!({
settings: {},
posture: null,
nativeSessionId: "thread-abc",
});
expect(spec.command).toBe("codex");
expect(spec.args.slice(0, 2)).toEqual(["resume", "thread-abc"]);
});
it("re-applies notify + model on resume", () => {
const spec = codexAdapter.buildResume!({
settings: { notifyProgram: "/n.sh", model: "gpt-5.4" },
posture: { autoApprove: true },
nativeSessionId: "t9",
});
expect(spec.args.slice(0, 2)).toEqual(["resume", "t9"]);
expect(spec.args).toContain('notify=["/n.sh"]');
expect(spec.args).toContain("--dangerously-bypass-approvals-and-sandbox");
});
});
describe("codexAdapter — formatInjection", () => {
it("appends a trailing \\r submit", () => {
expect(codexAdapter.formatInjection("hello", { bracketedPasteActive: false })).toEqual({
payload: "hello\r",
});
});
it("does not double the trailing \\r", () => {
expect(codexAdapter.formatInjection("hi\r", { bracketedPasteActive: true })).toEqual({
payload: "hi\r",
});
});
});
describe("mapNotifyPayload — native done via notify", () => {
it("agent-turn-complete → done, capturing thread-id as nativeSessionId", () => {
const ev = mapNotifyPayload({
type: "agent-turn-complete",
"thread-id": "T1",
"turn-id": "U1",
cwd: "/repo",
"last-assistant-message": "all done",
});
expect(ev?.kind).toBe("done");
expect(ev?.payload?.nativeSessionId).toBe("T1");
expect(ev?.payload?.turnId).toBe("U1");
expect(ev?.payload?.lastAssistantMessage).toBe("all done");
expect(ev?.payload?.cwd).toBe("/repo");
});
it("tolerates snake_case / camelCase key spellings", () => {
expect(mapNotifyPayload({ type: "agent-turn-complete", thread_id: "T2" })?.payload?.nativeSessionId).toBe(
"T2",
);
expect(mapNotifyPayload({ type: "agent-turn-complete", threadId: "T3" })?.payload?.nativeSessionId).toBe(
"T3",
);
});
it("ignores non-turn-complete payloads", () => {
expect(mapNotifyPayload({ type: "something-else", "thread-id": "X" })).toBeNull();
expect(mapNotifyPayload({})).toBeNull();
});
it("parseNotifyPayload parses the raw JSON arg and never throws", () => {
expect(parseNotifyPayload('{"type":"agent-turn-complete","thread-id":"Z"}')?.kind).toBe("done");
expect(parseNotifyPayload("not json")).toBeNull();
expect(parseNotifyPayload("[]")).toBeNull();
});
});
describe("CodexWaitingAnalyzer — heuristic waiting detection (hybrid fallback)", () => {
function collect(): { events: TelemetryEvent[]; analyzer: CodexWaitingAnalyzer } {
const events: TelemetryEvent[] = [];
const analyzer = new CodexWaitingAnalyzer({ emit: (e) => events.push(e) });
return { events, analyzer };
}
it("detects an approval prompt buried in ANSI noise → waitingOnInput", () => {
const { events, analyzer } = collect();
// Approval menu wrapped in ANSI color codes (the "noise included" scenario).
const ansi =
"\x1b[1m\x1b[33mApply this patch?\x1b[0m\n" +
"\x1b[2m1. Yes\x1b[0m\n\x1b[2m2. No\x1b[0m\n";
analyzer.observe(ansi);
expect(events).toHaveLength(1);
expect(events[0].kind).toBe("waitingOnInput");
expect((events[0].payload?.notification as Record<string, unknown>).kind).toBe(
"approval_prompt",
);
expect((events[0].payload?.notification as Record<string, unknown>).source).toBe("heuristic");
});
it("detects a bare y/n prompt at the trailing edge", () => {
const { events, analyzer } = collect();
analyzer.observe("Run command `rm -rf build`? (y/n) ");
expect(events.map((e) => e.kind)).toEqual(["waitingOnInput"]);
});
it("detects an idle 'enter to send' composer marker → idle_prompt", () => {
const { events, analyzer } = collect();
analyzer.observe("\x1b[90m enter to send \x1b[0m");
expect(events).toHaveLength(1);
expect((events[0].payload?.notification as Record<string, unknown>).kind).toBe("idle_prompt");
});
it("a working/spinner marker OVERRIDES a prompt (still busy)", () => {
const { events, analyzer } = collect();
analyzer.observe("Working… esc to interrupt y/n");
expect(events).toHaveLength(0);
});
it("de-dupes a repeated prompt and re-arms on fresh non-prompt output", () => {
const { events, analyzer } = collect();
analyzer.observe("Approve? (y/n) ");
analyzer.observe("Approve? (y/n) "); // still waiting → no second emit
expect(events).toHaveLength(1);
analyzer.observe("\nreading files...\n"); // fresh output re-arms
analyzer.observe("Approve? (y/n) ");
expect(events).toHaveLength(2);
});
});
describe("findRolloutPath — probe, don't hardcode the dated layout", () => {
it("finds rollout-<...>-<thread-id>.jsonl under a dated subtree", () => {
const fs = {
readdirSync(p: string): DirentLike[] {
if (p === "/sessions") return [dir("2026")];
if (p === "/sessions/2026") return [dir("05")];
if (p === "/sessions/2026/05") return [dir("03")];
if (p === "/sessions/2026/05/03") {
return [
file("rollout-2026-05-03T17-03-33-other.jsonl"),
file("rollout-2026-05-03T20-08-32-THREAD42.jsonl"),
];
}
return [];
},
};
expect(findRolloutPath("/sessions", "THREAD42", fs)).toBe(
"/sessions/2026/05/03/rollout-2026-05-03T20-08-32-THREAD42.jsonl",
);
});
it("returns null when no file matches / dir missing (tolerant)", () => {
const fs = {
readdirSync(p: string): DirentLike[] {
if (p === "/sessions") return [file("rollout-x-AAA.jsonl")];
throw new Error("ENOENT");
},
};
expect(findRolloutPath("/sessions", "ZZZ", fs)).toBeNull();
});
});
describe("CodexRolloutTailer — incremental rollout JSONL tail", () => {
it("yields only response_item message rows, incrementally, with offset", () => {
const tailer = new CodexRolloutTailer();
const meta =
JSON.stringify({ type: "session_meta", payload: { id: "T1", cwd: "/r" } }) + "\n";
const started =
JSON.stringify({ type: "event_msg", payload: { type: "task_started" } }) + "\n";
const msg =
JSON.stringify({
type: "response_item",
payload: { type: "message", role: "user", content: [{ type: "input_text", text: "hi" }] },
}) + "\n";
const first = tailer.push(meta + started); // no chat rows
expect(first).toEqual([]);
const second = tailer.push(msg);
expect(second).toEqual([{ role: "user", text: "hi" }]);
expect(tailer.bytesRead).toBe(Buffer.byteLength(meta + started + msg, "utf8"));
});
it("holds a partial trailing line until its newline arrives", () => {
const tailer = new CodexRolloutTailer();
const line = JSON.stringify({
type: "response_item",
payload: { type: "message", role: "assistant", content: [{ type: "output_text", text: "ok" }] },
});
expect(tailer.push(line.slice(0, 20))).toEqual([]);
expect(tailer.push(line.slice(20) + "\n")).toEqual([{ role: "assistant", text: "ok" }]);
});
it("skips unparseable lines without throwing", () => {
const tailer = new CodexRolloutTailer();
expect(tailer.push("{bad}\n\n")).toEqual([]);
});
});
describe("CodexReadinessDetector", () => {
it("becomes ready on bracketed-paste enable", () => {
const d = new CodexReadinessDetector();
expect(d.observe("loading\n")).toBe(false);
expect(d.observe("\x1b[?2004h")).toBe(true);
expect(d.observe("x")).toBe(true); // latches
});
it("falls back to a composer prompt glyph", () => {
const d = new CodexReadinessDetector();
expect(d.observe("welcome\n")).toBe(false);
expect(d.observe("\n❯")).toBe(true);
});
});
describe("end-to-end via TelemetryHub: notify done + heuristic waiting", () => {
let tmpDir: string;
let db: Database;
let store: CliSessionStore;
let hub: TelemetryHub;
let sessionId: string;
beforeEach(() => {
tmpDir = mkdtempSync(join(tmpdir(), "kb-codex-e2e-"));
const fusionDir = join(tmpDir, ".fusion");
db = new Database(fusionDir, { inMemory: true });
db.init();
store = new CliSessionStore(fusionDir, db);
const rec = store.createSession({
purpose: "execute",
projectId: "p1",
adapterId: "codex",
agentState: "starting",
});
sessionId = rec.id;
hub = new TelemetryHub({ store });
});
afterEach(async () => {
db.close();
await rm(tmpDir, { recursive: true, force: true });
});
it("notify agent-turn-complete drives busy → done and captures thread-id", () => {
const machine = hub.getStateMachine(sessionId)!;
machine.markReady();
machine.injectPrompt(); // ready → busy
const waiting: TelemetryEvent[] = [];
const analyzer = new CodexWaitingAnalyzer({ emit: (e) => waiting.push(e) });
analyzer.observe("Approve patch? (y/n) ");
for (const e of waiting) hub.ingest(sessionId, e);
expect(machine.getState()).toBe("waitingOnInput");
// user answers → busy again (hub `busy` route)
hub.ingest(sessionId, { kind: "busy" });
expect(machine.getState()).toBe("busy");
const done = parseNotifyPayload('{"type":"agent-turn-complete","thread-id":"native-T"}');
hub.ingest(sessionId, done!);
expect(machine.getState()).toBe("done");
expect(store.getSession(sessionId)?.nativeSessionId).toBe("native-T");
});
});

View File

@@ -0,0 +1,297 @@
import { describe, it, expect, beforeEach, afterEach } from "vitest";
import { mkdtempSync } from "node:fs";
import { join } from "node:path";
import { tmpdir } from "node:os";
import { rm } from "node:fs/promises";
import { Database, CliSessionStore } from "@fusion/core";
import { TelemetryHub } from "../../telemetry-hub.js";
import {
droidAdapter,
DROID_CAPABILITIES,
buildDroidSettings,
classifyNotification,
mapHookPayload,
parseHookPayload,
classifyStop,
DroidTranscriptTailer,
DroidReadinessDetector,
type DroidHookScriptRefs,
} from "../droid.js";
const SCRIPTS: DroidHookScriptRefs = {
stopScript: "/tmp/sess/hooks/stop.sh",
notificationScript: "/tmp/sess/hooks/notify.sh",
sessionStartScript: "/tmp/sess/hooks/start.sh",
};
describe("droidAdapter — capabilities + identity", () => {
it("declares the native tier capability flags", () => {
expect(droidAdapter.id).toBe("droid");
expect(droidAdapter.capabilities).toEqual({
nativeDone: true,
nativeWaiting: true,
transcriptSource: "jsonl",
supportsResume: true,
});
expect(DROID_CAPABILITIES).toEqual(droidAdapter.capabilities);
});
});
describe("droidAdapter — buildLaunch + settings", () => {
it("launches bare `droid` with no hook scripts", () => {
const spec = droidAdapter.buildLaunch({ settings: {}, posture: null });
expect(spec.command).toBe("droid");
expect(spec.args).toEqual([]);
});
it("builds the Claude-style hooks settings for the core events", () => {
const doc = buildDroidSettings(SCRIPTS);
expect(Object.keys(doc.hooks).sort()).toEqual(["Notification", "SessionStart", "Stop"]);
expect(doc.hooks.Stop[0].hooks[0].command).toBe(SCRIPTS.stopScript);
expect(doc.hooks.Notification[0].hooks[0].command).toBe(SCRIPTS.notificationScript);
});
it("registers tool-activity hooks only when a toolActivityScript is provided", () => {
const doc = buildDroidSettings({ ...SCRIPTS, toolActivityScript: "/tmp/act.sh" });
expect(doc.hooks.PreToolUse[0].hooks[0].command).toBe("/tmp/act.sh");
expect(doc.hooks.PostToolUse).toBeDefined();
});
it("inlines settings via --settings when no settingsPath given", () => {
const spec = droidAdapter.buildLaunch({ settings: { hookScripts: SCRIPTS }, posture: null });
const idx = spec.args.indexOf("--settings");
expect(idx).toBeGreaterThanOrEqual(0);
expect(JSON.parse(spec.args[idx + 1]).hooks.Stop[0].hooks[0].command).toBe(SCRIPTS.stopScript);
});
it("emits `--auto high` ONLY when posture.autoApprove is true", () => {
const off = droidAdapter.buildLaunch({ settings: {}, posture: { autoApprove: false } });
expect(off.args).not.toContain("--auto");
const on = droidAdapter.buildLaunch({ settings: {}, posture: { autoApprove: true } });
expect(on.args).toEqual(expect.arrayContaining(["--auto", "high"]));
});
it("env allowlist excludes FUSION_* / service credentials", () => {
const allow = droidAdapter.buildEnvAllowlist({ settings: {}, posture: null });
expect(allow).toContain("PATH");
expect(allow).toContain("FACTORY_API_KEY");
expect(allow.some((k) => k.startsWith("FUSION_"))).toBe(false);
});
});
describe("droidAdapter — buildResume (the `-r` footgun)", () => {
it("interactive: `droid --resume <id>`", () => {
const spec = droidAdapter.buildResume!({
settings: {},
posture: null,
nativeSessionId: "sess-1",
});
expect(spec.command).toBe("droid");
expect(spec.args.slice(0, 2)).toEqual(["--resume", "sess-1"]);
});
it("headless exec: `droid exec -s <id>` and NEVER a bare `-r`", () => {
const spec = droidAdapter.buildResume!({
settings: { execMode: true } as never,
posture: null,
nativeSessionId: "sess-2",
});
expect(spec.args.slice(0, 3)).toEqual(["exec", "-s", "sess-2"]);
// THE FOOTGUN: in exec mode `-r` means --reasoning-effort, not resume.
expect(spec.args).not.toContain("-r");
});
it("headless exec NEVER emits `-r` even with model + autoApprove", () => {
const spec = droidAdapter.buildResume!({
settings: { execMode: true, model: "claude-opus-4-7" } as never,
posture: { autoApprove: true },
nativeSessionId: "sess-3",
});
expect(spec.args).not.toContain("-r");
expect(spec.args).toContain("-s");
expect(spec.args).toEqual(expect.arrayContaining(["--model", "claude-opus-4-7"]));
});
});
describe("droidAdapter — formatInjection", () => {
it("appends a trailing \\r submit, no doubling", () => {
expect(droidAdapter.formatInjection("hello", { bracketedPasteActive: false })).toEqual({
payload: "hello\r",
});
expect(droidAdapter.formatInjection("hi\r", { bracketedPasteActive: true })).toEqual({
payload: "hi\r",
});
});
});
describe("classifyNotification — the conflated Notification discriminator", () => {
it("classifies permission wording as permission_request", () => {
expect(classifyNotification("Droid wants to run `npm test` — approve?")).toBe(
"permission_request",
);
expect(classifyNotification("Permission needed to edit file")).toBe("permission_request");
});
it("classifies idle wording as idle_prompt", () => {
expect(classifyNotification("Still waiting for your input")).toBe("idle_prompt");
expect(classifyNotification("Session has been idle for 60s")).toBe("idle_prompt");
});
it("defaults an ambiguous/bare ping to idle_prompt", () => {
expect(classifyNotification("Notification")).toBe("idle_prompt");
expect(classifyNotification(undefined)).toBe("idle_prompt");
});
it("permission wording wins when both are present", () => {
expect(classifyNotification("Idle — but Droid wants to approve a command")).toBe(
"permission_request",
);
});
});
describe("mapHookPayload — telemetry mapping", () => {
it("SessionStart → sessionStart capturing session_id + transcript_path + permission_mode", () => {
const ev = mapHookPayload({
hook_event_name: "SessionStart",
session_id: "S1",
transcript_path: "/t.jsonl",
permission_mode: "auto",
source: "startup",
});
expect(ev?.kind).toBe("sessionStart");
expect(ev?.payload?.nativeSessionId).toBe("S1");
expect(ev?.payload?.transcriptPath).toBe("/t.jsonl");
expect(ev?.payload?.permissionMode).toBe("auto");
});
it("Notification{permission} → waitingOnInput tagged permission_request", () => {
const ev = mapHookPayload({
hook_event_name: "Notification",
session_id: "S1",
message: "Droid wants to run a command — approve?",
});
expect(ev?.kind).toBe("waitingOnInput");
expect((ev?.payload?.notification as Record<string, unknown>).kind).toBe("permission_request");
});
it("Notification{idle} → waitingOnInput tagged idle_prompt", () => {
const ev = mapHookPayload({
hook_event_name: "Notification",
message: "Waiting for your input (idle 60s)",
});
expect(ev?.kind).toBe("waitingOnInput");
expect((ev?.payload?.notification as Record<string, unknown>).kind).toBe("idle_prompt");
});
it("PreToolUse/PostToolUse → toolActivity; Stop → done", () => {
expect(mapHookPayload({ hook_event_name: "PreToolUse", tool_name: "Bash" })?.kind).toBe(
"toolActivity",
);
expect(mapHookPayload({ hook_event_name: "Stop", session_id: "S1" })?.kind).toBe("done");
});
it("tolerates missing fields; unknown event → null unless a session id", () => {
expect(mapHookPayload({ hook_event_name: "Stop" })?.kind).toBe("done");
expect(mapHookPayload({})).toBeNull();
expect(mapHookPayload({ hook_event_name: "Weird", session_id: "S" })?.kind).toBe(
"outputProgress",
);
});
});
describe("classifyStop — failure downgrade", () => {
it("clean Stop → done; error-ish stop_reason → toolActivity", () => {
expect(classifyStop({ hook_event_name: "Stop" }).kind).toBe("done");
const ev = classifyStop({ hook_event_name: "Stop", stop_reason: "error_aborted" });
expect(ev.kind).toBe("toolActivity");
expect(ev.payload?.stopReason).toBe("error_aborted");
});
});
describe("parseHookPayload — raw stdin parsing", () => {
it("parses JSON and never throws", () => {
expect(parseHookPayload('{"hook_event_name":"Stop","session_id":"S1"}')?.kind).toBe("done");
expect(parseHookPayload("not json")).toBeNull();
});
});
describe("DroidTranscriptTailer — incremental JSONL tail", () => {
it("yields entries incrementally with offset tracking", () => {
const tailer = new DroidTranscriptTailer();
const l1 = JSON.stringify({ message: { role: "user", content: "hi" } }) + "\n";
expect(tailer.push(l1)).toEqual([{ role: "user", text: "hi" }]);
const l2 = JSON.stringify({ message: { role: "assistant", content: [{ type: "text", text: "yo" }] } }) + "\n";
expect(tailer.push(l2)).toEqual([{ role: "assistant", text: "yo" }]);
expect(tailer.bytesRead).toBe(Buffer.byteLength(l1 + l2, "utf8"));
});
it("holds a partial line and flushes an unterminated final line", () => {
const tailer = new DroidTranscriptTailer();
const full = JSON.stringify({ role: "tool", content: "result" });
expect(tailer.push(full.slice(0, 8))).toEqual([]);
expect(tailer.push(full.slice(8))).toEqual([]);
expect(tailer.flush()).toEqual([{ role: "tool", text: "result" }]);
});
});
describe("DroidReadinessDetector", () => {
it("ready on bracketed-paste enable or prompt glyph", () => {
const a = new DroidReadinessDetector();
expect(a.observe("loading\n")).toBe(false);
expect(a.observe("\x1b[?2004h")).toBe(true);
const b = new DroidReadinessDetector();
expect(b.observe("hi\n")).toBe(false);
expect(b.observe("\n❯ ")).toBe(true);
});
});
describe("end-to-end via TelemetryHub: SessionStart → Notification → Stop", () => {
let tmpDir: string;
let db: Database;
let store: CliSessionStore;
let hub: TelemetryHub;
let sessionId: string;
beforeEach(() => {
tmpDir = mkdtempSync(join(tmpdir(), "kb-droid-e2e-"));
const fusionDir = join(tmpDir, ".fusion");
db = new Database(fusionDir, { inMemory: true });
db.init();
store = new CliSessionStore(fusionDir, db);
const rec = store.createSession({
purpose: "execute",
projectId: "p1",
adapterId: "droid",
agentState: "starting",
});
sessionId = rec.id;
hub = new TelemetryHub({ store });
});
afterEach(async () => {
db.close();
await rm(tmpDir, { recursive: true, force: true });
});
function feed(p: Parameters<typeof mapHookPayload>[0]) {
const ev = mapHookPayload(p);
if (ev) hub.ingest(sessionId, ev);
}
it("drives ready → busy → waitingOnInput → busy → done; captures session_id", () => {
feed({ hook_event_name: "SessionStart", session_id: "native-d", transcript_path: "/t.jsonl" });
expect(hub.getStateMachine(sessionId)?.getState()).toBe("ready");
expect(store.getSession(sessionId)?.nativeSessionId).toBe("native-d");
hub.getStateMachine(sessionId)!.injectPrompt(); // ready → busy
feed({ hook_event_name: "Notification", session_id: "native-d", message: "approve command?" });
expect(hub.getStateMachine(sessionId)?.getState()).toBe("waitingOnInput");
feed({ hook_event_name: "PreToolUse" }); // tolerated activity; no advance
hub.ingest(sessionId, { kind: "busy" }); // user answered
expect(hub.getStateMachine(sessionId)?.getState()).toBe("busy");
feed({ hook_event_name: "Stop", session_id: "native-d" });
expect(hub.getStateMachine(sessionId)?.getState()).toBe("done");
});
});

View File

@@ -0,0 +1,279 @@
import { describe, it, expect, beforeEach, afterEach } from "vitest";
import { mkdtempSync } from "node:fs";
import { join } from "node:path";
import { tmpdir } from "node:os";
import { rm } from "node:fs/promises";
import { Database, CliSessionStore } from "@fusion/core";
import { TelemetryHub } from "../../telemetry-hub.js";
import {
piAdapter,
PI_CAPABILITIES,
mapSessionLine,
toTelemetryEvent,
PiSessionTailer,
PiReadinessDetector,
findSessionFile,
type DirentLike,
} from "../pi.js";
function dir(name: string): DirentLike {
return { name, isDirectory: () => true };
}
function file(name: string): DirentLike {
return { name, isDirectory: () => false };
}
describe("piAdapter — capabilities + identity", () => {
it("declares the native tier capability flags (session-jsonl)", () => {
expect(piAdapter.id).toBe("pi");
expect(piAdapter.capabilities).toEqual({
nativeDone: true,
nativeWaiting: true,
transcriptSource: "session-jsonl",
supportsResume: true,
});
expect(PI_CAPABILITIES).toEqual(piAdapter.capabilities);
});
});
describe("piAdapter — buildLaunch", () => {
it("launches bare `pi` with no settings", () => {
const spec = piAdapter.buildLaunch({ settings: {}, posture: null });
expect(spec.command).toBe("pi");
expect(spec.args).toEqual([]);
});
it("passes --provider, --model, and a session-scoped --session-dir", () => {
const spec = piAdapter.buildLaunch({
settings: { provider: "anthropic", model: "*sonnet*", sessionDir: "/tmp/sess/pi" },
posture: null,
});
expect(spec.args).toEqual([
"--provider",
"anthropic",
"--model",
"*sonnet*",
"--session-dir",
"/tmp/sess/pi",
]);
});
it("widens tool access ONLY when posture.autoApprove is true", () => {
const off = piAdapter.buildLaunch({ settings: {}, posture: { autoApprove: false } });
expect(off.args).not.toContain("--tools");
const on = piAdapter.buildLaunch({ settings: {}, posture: { autoApprove: true } });
expect(on.args).toEqual(expect.arrayContaining(["--tools", "read,bash,edit,write"]));
});
it("env allowlist excludes FUSION_* / service credentials", () => {
const allow = piAdapter.buildEnvAllowlist({ settings: {}, posture: null });
expect(allow).toContain("PATH");
expect(allow).toContain("PI_CODING_AGENT_SESSION_DIR");
expect(allow.some((k) => k.startsWith("FUSION_"))).toBe(false);
});
});
describe("piAdapter — buildResume", () => {
it("produces `pi --session <id>` (partial-uuid or path)", () => {
const spec = piAdapter.buildResume!({
settings: { sessionDir: "/tmp/sess/pi" },
posture: null,
nativeSessionId: "0e64b2d0",
});
expect(spec.command).toBe("pi");
expect(spec.args).toEqual(expect.arrayContaining(["--session", "0e64b2d0"]));
// session-dir re-applied for lookup.
expect(spec.args).toEqual(expect.arrayContaining(["--session-dir", "/tmp/sess/pi"]));
});
});
describe("piAdapter — formatInjection", () => {
it("appends a trailing \\r submit, no doubling", () => {
expect(piAdapter.formatInjection("hello", { bracketedPasteActive: false })).toEqual({
payload: "hello\r",
});
expect(piAdapter.formatInjection("hi\r", { bracketedPasteActive: true })).toEqual({
payload: "hi\r",
});
});
});
describe("mapSessionLine — session JSONL event mapping", () => {
it("session header → sessionStart capturing the uuid as nativeSessionId", () => {
const ev = mapSessionLine({ type: "session", version: 3, id: "uuid-1", cwd: "/r" });
expect(ev).toEqual({ kind: "sessionStart", nativeSessionId: "uuid-1" });
});
it("turn_start/agent_start → busy; turn_end/agent_end → done", () => {
expect(mapSessionLine({ type: "turn_start" })).toEqual({ kind: "busy" });
expect(mapSessionLine({ type: "agent_start" })).toEqual({ kind: "busy" });
expect(mapSessionLine({ type: "turn_end" })).toEqual({ kind: "done" });
expect(mapSessionLine({ type: "agent_end" })).toEqual({ kind: "done" });
});
it("input-request events → waitingOnInput", () => {
const ev = mapSessionLine({ type: "input_request" });
expect(ev?.kind).toBe("waitingOnInput");
const ev2 = mapSessionLine({ type: "ask_user" });
expect(ev2?.kind).toBe("waitingOnInput");
});
it("message rows → transcript (flattening text + thinking blocks)", () => {
const ev = mapSessionLine({
type: "message",
message: { role: "assistant", content: [{ type: "thinking", thinking: "hmm" }, { type: "text", text: "answer" }] },
});
expect(ev).toEqual({ kind: "transcript", role: "assistant", text: "hmmanswer" });
});
it("normalizes the toolResult role to tool", () => {
const ev = mapSessionLine({
type: "message",
message: { role: "toolResult", content: [{ type: "text", text: "out" }] },
});
expect(ev).toEqual({ kind: "transcript", role: "tool", text: "out" });
});
it("returns null for noise rows (model_change, thinking_level_change, empty)", () => {
expect(mapSessionLine({ type: "model_change", provider: "x" })).toBeNull();
expect(mapSessionLine({ type: "thinking_level_change" })).toBeNull();
expect(mapSessionLine({ type: "message", message: { role: "user", content: [] } })).toBeNull();
});
});
describe("toTelemetryEvent — PiSessionEvent → hub TelemetryEvent", () => {
it("maps lifecycle + transcript onto the hub contract", () => {
expect(toTelemetryEvent({ kind: "sessionStart", nativeSessionId: "u" })).toEqual({
kind: "sessionStart",
payload: { nativeSessionId: "u" },
});
expect(toTelemetryEvent({ kind: "busy" })).toEqual({ kind: "busy", payload: {} });
expect(toTelemetryEvent({ kind: "done" })).toEqual({ kind: "done", payload: {} });
expect(toTelemetryEvent({ kind: "waitingOnInput", notification: { kind: "input_request" } })).toEqual({
kind: "waitingOnInput",
payload: { notification: { kind: "input_request" } },
});
expect(toTelemetryEvent({ kind: "transcript", role: "user", text: "hi" })).toEqual({
kind: "transcript",
payload: { text: "hi", role: "user" },
});
});
});
describe("PiSessionTailer — incremental session JSONL tail", () => {
it("yields events incrementally with offset; skips noise", () => {
const tailer = new PiSessionTailer();
const header = JSON.stringify({ type: "session", id: "u1", cwd: "/r" }) + "\n";
const noise = JSON.stringify({ type: "model_change", provider: "x" }) + "\n";
const msg =
JSON.stringify({ type: "message", message: { role: "user", content: [{ type: "text", text: "hi" }] } }) + "\n";
expect(tailer.push(header + noise)).toEqual([{ kind: "sessionStart", nativeSessionId: "u1" }]);
expect(tailer.push(msg)).toEqual([{ kind: "transcript", role: "user", text: "hi" }]);
expect(tailer.bytesRead).toBe(Buffer.byteLength(header + noise + msg, "utf8"));
});
it("holds a partial line until its newline arrives", () => {
const tailer = new PiSessionTailer();
const line = JSON.stringify({ type: "session", id: "u2" });
expect(tailer.push(line.slice(0, 10))).toEqual([]);
expect(tailer.push(line.slice(10) + "\n")).toEqual([{ kind: "sessionStart", nativeSessionId: "u2" }]);
});
it("skips unparseable lines without throwing", () => {
const tailer = new PiSessionTailer();
expect(tailer.push("{bad}\n\n")).toEqual([]);
});
});
describe("findSessionFile — newest *.jsonl, one level of cwd-nesting", () => {
it("finds the lexically-greatest session file across nested dirs", () => {
const fs = {
readdirSync(p: string): DirentLike[] {
if (p === "/sess") return [dir("--Users-x--"), file("2026-04-09T10_uuidA.jsonl")];
if (p === "/sess/--Users-x--")
return [file("2026-04-09T21_uuidB.jsonl"), file("2026-04-09T08_uuidC.jsonl")];
return [];
},
};
expect(findSessionFile("/sess", fs)).toBe("/sess/--Users-x--/2026-04-09T21_uuidB.jsonl");
});
it("returns null when the dir is missing / empty (tolerant)", () => {
const fs = {
readdirSync(): DirentLike[] {
throw new Error("ENOENT");
},
};
expect(findSessionFile("/missing", fs)).toBeNull();
});
});
describe("PiReadinessDetector", () => {
it("ready on bracketed-paste enable or prompt glyph", () => {
const a = new PiReadinessDetector();
expect(a.observe("starting\n")).toBe(false);
expect(a.observe("\x1b[?2004h")).toBe(true);
const b = new PiReadinessDetector();
expect(b.observe("hi\n")).toBe(false);
expect(b.observe("\n❯")).toBe(true);
});
});
describe("end-to-end via TelemetryHub: session header → busy → input-request → done", () => {
let tmpDir: string;
let db: Database;
let store: CliSessionStore;
let hub: TelemetryHub;
let sessionId: string;
beforeEach(() => {
tmpDir = mkdtempSync(join(tmpdir(), "kb-pi-e2e-"));
const fusionDir = join(tmpDir, ".fusion");
db = new Database(fusionDir, { inMemory: true });
db.init();
store = new CliSessionStore(fusionDir, db);
const rec = store.createSession({
purpose: "execute",
projectId: "p1",
adapterId: "pi",
agentState: "starting",
});
sessionId = rec.id;
hub = new TelemetryHub({ store });
});
afterEach(async () => {
db.close();
await rm(tmpDir, { recursive: true, force: true });
});
function feed(obj: Record<string, unknown>) {
const ev = mapSessionLine(obj);
if (ev) hub.ingest(sessionId, toTelemetryEvent(ev));
}
it("drives ready → busy → waitingOnInput → busy → done and captures session uuid", () => {
feed({ type: "session", id: "pi-uuid" });
expect(hub.getStateMachine(sessionId)?.getState()).toBe("ready");
expect(store.getSession(sessionId)?.nativeSessionId).toBe("pi-uuid");
feed({ type: "turn_start" }); // ready → busy via the busy route... but markReady leaves us at ready
// markReady put us at ready; a busy event from ready is invalid, so the hub
// swallows it. Drive the injection transition explicitly (the session manager
// does this when it injects the prompt), then continue.
if (hub.getStateMachine(sessionId)?.getState() === "ready") {
hub.getStateMachine(sessionId)!.injectPrompt();
}
expect(hub.getStateMachine(sessionId)?.getState()).toBe("busy");
feed({ type: "input_request" });
expect(hub.getStateMachine(sessionId)?.getState()).toBe("waitingOnInput");
feed({ type: "turn_start" }); // user answered → busy
expect(hub.getStateMachine(sessionId)?.getState()).toBe("busy");
feed({ type: "turn_end" });
expect(hub.getStateMachine(sessionId)?.getState()).toBe("done");
});
});

View File

@@ -0,0 +1,646 @@
/**
* Codex adapter — HYBRID-tier CliAgentAdapter (U5).
*
* Codex is the hybrid tier: it has a NATIVE turn-complete signal (via its
* `notify` config program) and a structured rollout transcript on disk, but NO
* native waiting-on-input signal. Waiting-on-input is therefore inferred from
* the PTY byte stream with Codex-specific prompt-pattern heuristics (composed on
* top of the same ANSI-stripping the generic adapter uses). The capability flags
* advertise this honestly: `nativeDone: true`, `nativeWaiting: false`.
*
* This adapter teaches the engine to:
* - launch `codex` with a SESSION-SCOPED notify program so a turn-complete
* event reaches the engine without touching the user's `~/.codex/config.toml`
* (mechanism below);
* - normalize the notify JSON payload → a `done` `TelemetryEvent`, capturing
* `thread-id` as the native session id;
* - detect waiting-on-input via {@link CodexWaitingAnalyzer} (heuristic — see
* the per-method docs; this is the hybrid-tier fallback, NOT a native signal);
* - tail the rollout JSONL transcript incrementally (probing the sessions dir,
* never hardcoding the layout — it is version-sensitive);
* - resume via `codex resume <thread-id>`.
*
* ── Verified against the installed binary (Codex 0.128.0, arm64) ──
* - `codex` is on PATH; `~/.codex/` is the default `CODEX_HOME`.
* - Rollout JSONL layout CONFIRMED by inspecting real files:
* `~/.codex/sessions/YYYY/MM/DD/rollout-<ts>-<thread-id>.jsonl`
* with a first line `{type:"session_meta", payload:{ id:<thread-id>, cwd,
* originator, cli_version, ... }}`, then `{type:"event_msg", payload:{
* type:"task_started", turn_id, ... }}`, then `{type:"response_item",
* payload:{ type:"message", role, content:[{type,text}] }}` lines. The
* `thread-id` IS the `session_meta.payload.id` and is embedded in the
* filename. We PROBE for the file by thread-id (see {@link findRolloutPath}),
* never assuming the date path.
* - `codex resume` and `codex exec` subcommands exist (`codex --help`); the
* interactive `--help` for subcommands could not be captured in this sandbox
* (the binary opens a TUI), so the exact resume arg shape below is per the
* documented public interface: `codex resume <thread-id>` and the `-c
* key=value` config-override flag.
*
* ── Assumed / mechanism choice (marked so wiring composes; revisit on drift) ──
* - NOTIFY MECHANISM. Codex's documented native turn-complete is the `notify`
* config key: `notify = ["<program>", ...args]`. Codex invokes that program
* with a single JSON-string argument `{ type:"agent-turn-complete",
* "thread-id":…, "turn-id":…, cwd:…, "last-assistant-message":… }`. Two
* session-scoped delivery options exist; we choose **`-c notify=[...]`
* config-override on the launch argv** as the PRIMARY mechanism (it is
* session-scoped by construction and never mutates the user's config), and
* expose a **layered `CODEX_HOME`** fallback for callers that prefer a
* scratch config dir. See {@link buildNotifyOverrideArg} and
* {@link codexSessionHomeLayout}. The notify PROGRAM itself (the shim that
* forwards the payload to the engine telemetry hub) is produced by the U17
* hook-scripts module; this adapter only references its path.
* - The notify payload uses hyphenated keys (`thread-id`, `turn-id`,
* `last-assistant-message`) per the documented schema; {@link mapNotifyPayload}
* also tolerates the snake_case / camelCase variants in case a version drifts.
*/
import type {
CliAdapterCapabilities,
CliAdapterLaunchContext,
CliAdapterResumeContext,
CliAgentAdapter,
CliInjectionFormat,
CliLaunchSpec,
CliReadinessDetector,
} from "../adapter.js";
import { stripAnsiControl, type TelemetryEvent } from "../telemetry-hub.js";
// ── Capabilities ────────────────────────────────────────────────────────────
/**
* Codex hybrid tier: native done (notify) + rollout transcript + resume, but NO
* native waiting-on-input (heuristic PTY detection instead).
*/
export const CODEX_CAPABILITIES: CliAdapterCapabilities = {
nativeDone: true,
nativeWaiting: false,
transcriptSource: "jsonl",
supportsResume: true,
};
// ── Launch settings ───────────────────────────────────────────────────────────
const DEFAULT_COMMAND = "codex";
/**
* Adapter-specific launch settings recognized by the Codex adapter. All optional
* so a bare `codex` still launches without telemetry wiring.
*/
export interface CodexLaunchSettings {
/** Override the `codex` binary. */
command?: string;
/** Extra args appended after the computed base args. */
extraArgs?: readonly string[];
/** Model override (`-c model=<id>`). */
model?: string;
/**
* Absolute path to the session-scoped notify program (from U17). When present
* the adapter appends `-c notify=["<path>"]` so a turn-complete event is
* delivered session-scoped without touching the user's config.
*/
notifyProgram?: string;
/**
* Optional layered `CODEX_HOME` directory for callers that prefer a scratch
* config dir over the `-c` override (see {@link codexSessionHomeLayout}). When
* set, it is surfaced via the env allowlist and the caller is responsible for
* materializing the dir; the adapter does not write it.
*/
codexHome?: string;
/**
* Override the sessions root the rollout tailer probes. Defaults to
* `<CODEX_HOME>/sessions`. The exact dated sub-layout is probed, never assumed.
*/
sessionsDir?: string;
}
function readSettings(ctx: CliAdapterLaunchContext): CodexLaunchSettings {
return (ctx.settings ?? {}) as CodexLaunchSettings;
}
/**
* Build the `-c notify=[...]` config-override token vector for a session-scoped
* notify program. Codex's `-c key=value` flag takes a TOML-ish value; an array
* of one program path is `["<path>"]`. Returns the two argv tokens (`-c` and the
* `notify=[...]` assignment) or an empty array when no program is configured.
*/
export function buildNotifyOverrideArg(notifyProgram: string | undefined): string[] {
if (!notifyProgram || notifyProgram.trim().length === 0) return [];
// JSON array literal is valid TOML array syntax for a single string element.
const value = JSON.stringify([notifyProgram]);
return ["-c", `notify=${value}`];
}
/**
* Describe the layered session-scoped `CODEX_HOME` mechanism (the alternative to
* `-c notify`). The caller materializes `dir` (copying/symlinking the user's
* `auth.json` so the child stays authenticated) and writes a `config.toml` that
* sets `notify`. This adapter only computes the intended layout for the caller;
* it performs NO filesystem writes (containment + lifecycle is the session
* manager's job, mirroring the Claude adapter's settings-file contract).
*/
export function codexSessionHomeLayout(dir: string): {
home: string;
configPath: string;
authPath: string;
} {
return {
home: dir,
configPath: `${dir}/config.toml`,
authPath: `${dir}/auth.json`,
};
}
function buildBaseArgs(ctx: CliAdapterLaunchContext): { command: string; args: string[] } {
const settings = readSettings(ctx);
const command = settings.command ?? DEFAULT_COMMAND;
const args: string[] = [];
if (typeof settings.model === "string" && settings.model.length > 0) {
// Model is set via a config override so it composes with `-c notify`.
args.push("-c", `model=${JSON.stringify(settings.model)}`);
}
args.push(...buildNotifyOverrideArg(settings.notifyProgram));
return { command, args };
}
/** Append the autonomy posture's privileged flags, only when permitted. */
function appendPostureFlags(args: string[], ctx: CliAdapterLaunchContext): void {
// Visible-posture contract (R21): only emit the dangerous bypass when the
// posture explicitly opts in. Codex's full-access sandbox bypass.
if (ctx.posture?.autoApprove === true) {
args.push("--dangerously-bypass-approvals-and-sandbox");
}
}
// ── Notify payload → telemetry ─────────────────────────────────────────────────
/**
* The raw Codex notify payload (delivered as a JSON string argument to the
* notify program). Documented keys are hyphenated; we tolerate snake/camel too.
*/
export interface CodexNotifyPayload {
type?: string;
"thread-id"?: string;
thread_id?: string;
threadId?: string;
"turn-id"?: string;
turn_id?: string;
turnId?: string;
cwd?: string;
"last-assistant-message"?: string;
last_assistant_message?: string;
lastAssistantMessage?: string;
[key: string]: unknown;
}
/** Read the first present of several key spellings off a payload. */
function pick(payload: Record<string, unknown>, keys: string[]): string | undefined {
for (const k of keys) {
const v = payload[k];
if (typeof v === "string" && v.length > 0) return v;
}
return undefined;
}
/**
* Map a Codex notify payload onto a normalized `TelemetryEvent`. The only Codex
* notify event we act on is `agent-turn-complete` → `done`, capturing the
* `thread-id` as the native session id. Returns null for any other / malformed
* payload (telemetry is best-effort).
*
* Mapping (KTD telemetry tiering — hybrid tier):
* notify{agent-turn-complete} → done (+ nativeSessionId = thread-id)
*/
export function mapNotifyPayload(payload: CodexNotifyPayload): TelemetryEvent | null {
const type = typeof payload.type === "string" ? payload.type : "";
if (type !== "agent-turn-complete") return null;
const threadId = pick(payload, ["thread-id", "thread_id", "threadId"]);
const turnId = pick(payload, ["turn-id", "turn_id", "turnId"]);
const lastMessage = pick(payload, [
"last-assistant-message",
"last_assistant_message",
"lastAssistantMessage",
]);
const out: TelemetryEvent = { kind: "done", payload: {} };
if (threadId) out.payload!.nativeSessionId = threadId;
if (turnId) out.payload!.turnId = turnId;
if (typeof lastMessage === "string") out.payload!.lastAssistantMessage = lastMessage;
if (typeof payload.cwd === "string") out.payload!.cwd = payload.cwd;
return out;
}
/**
* Parse the raw notify argument (the JSON string Codex passes to the notify
* program) into a normalized event. Returns null on unparseable input — never
* throws at the ingestion boundary.
*/
export function parseNotifyPayload(raw: string): TelemetryEvent | null {
let parsed: unknown;
try {
parsed = JSON.parse(raw);
} catch {
return null;
}
if (!parsed || typeof parsed !== "object") return null;
return mapNotifyPayload(parsed as CodexNotifyPayload);
}
// ── Waiting-on-input heuristics (hybrid-tier fallback — NOT native) ───────────
/**
* Codex-specific prompt patterns that indicate the agent is blocked waiting on a
* human. HEURISTIC (hybrid tier): Codex has no native waiting-on-input signal,
* so we infer it from ANSI-stripped PTY output. These patterns are intentionally
* conservative — a false positive only adds a needs-input affordance, never
* advances the pipeline.
*
* - Approval prompt menus Codex draws when a command/patch needs approval
* ("Allow", "Approve", "y/n", numbered "1. Yes / 2. No" menus).
* - The idle "ready for input" marker Codex shows at the bottom of the composer
* ("enter to send", "Ctrl+J newline", etc.).
*/
// Single-line approval patterns are matched against the LAST non-empty line
// only (which IS the trailing edge), so a stale prompt earlier in scrollback
// stops matching once fresh output renders below it — this is what makes the
// heuristic re-arm correctly.
const SINGLE_LINE_APPROVAL_PATTERNS: RegExp[] = [
// Approval prompt verbs + a yes/no affordance on one line.
/\b(allow|approve|apply (?:this )?(?:patch|change|command)|run (?:this )?command)\b.{0,80}\b(y\s*\/\s*n|yes\s*\/\s*no)\b/i,
// Bare yes/no prompt.
/\b(y\s*\/\s*n|yes\s*\/\s*no)\s*[?:]?\s*$/i,
// Explicit "waiting for approval" wording.
/\b(waiting for (?:your )?approval|requires (?:your )?approval|needs (?:your )?approval)\b/i,
];
// The numbered approval menu (1. Yes … 2. No …) legitimately spans lines; it is
// checked against the last few lines and anchored to the trailing edge.
const CODEX_NUMBERED_MENU_PATTERN =
/(^|\n)\s*1[.)]\s*(yes|approve|allow)[\s\S]{0,80}\n\s*2[.)]\s*(no|reject|deny)[\s\S]{0,40}$/i;
/** Idle "ready for input" composer markers (matched against the last line). */
const CODEX_IDLE_MARKERS: RegExp[] = [
/\benter to send\b/i,
/\bpress enter\b/i,
/\bsend a message\b/i,
];
/**
* Spinner / working markers Codex shows while busy. When one of these is present
* at the trailing edge the waiting heuristic is OVERRIDDEN (the agent is working,
* not waiting), mirroring the generic analyzer's spinner-override rule.
*/
const CODEX_WORKING_PATTERN = /\b(working|thinking|executing|running|esc to interrupt)\b/i;
/** Max trailing chars of the stripped window inspected for prompt patterns. */
const CODEX_WINDOW_CHARS = 4_096;
/**
* Stateful waiting-on-input analyzer for Codex (hybrid-tier heuristic). Fed
* ANSI-bearing PTY output via {@link observe}; emits a `waitingOnInput`
* `TelemetryEvent` once when an approval/idle prompt is detected at the trailing
* edge and no working marker overrides it. De-dupes (re-arms when fresh non-
* prompt output arrives).
*
* This is the explicitly-marked HEURISTIC fallback for the hybrid tier — Codex
* exposes no native waiting signal (`nativeWaiting: false`).
*/
export class CodexWaitingAnalyzer {
private readonly emit: (event: TelemetryEvent) => void;
private window = "";
private waitingEmitted = false;
constructor(opts: { emit: (event: TelemetryEvent) => void }) {
this.emit = opts.emit;
}
/** Observe a raw (ANSI-bearing) output chunk. */
observe(rawChunk: string): void {
const stripped = stripAnsiControl(rawChunk);
if (stripped.length === 0) return;
this.window = (this.window + stripped).slice(-CODEX_WINDOW_CHARS);
// Working marker overrides any prompt detection: the agent is busy.
if (CODEX_WORKING_PATTERN.test(this.trailingChunk())) {
this.waitingEmitted = false;
return;
}
if (this.isWaiting()) {
if (!this.waitingEmitted) {
this.waitingEmitted = true;
this.emit({
kind: "waitingOnInput",
payload: {
notification: { kind: this.classify(), source: "heuristic" },
},
});
}
return;
}
// Fresh non-prompt output → re-arm so a later prompt re-emits.
this.waitingEmitted = false;
}
/**
* Whether the trailing window currently looks like a waiting prompt. Single-
* line prompts (verb+yn, bare yn, idle markers, "waiting for approval") are
* checked against the LAST non-empty line ONLY — so a stale prompt earlier in
* scrollback stops matching once fresh output renders below it (this is what
* makes the de-dupe re-arm correct). The multi-line numbered menu is checked
* against the last few lines (it legitimately spans lines) and is anchored to
* the trailing edge.
*/
isWaiting(): boolean {
return this.matchedSubReason() !== null;
}
/** Sub-reason tag for the notification (approval vs idle prompt). */
private classify(): string {
return this.matchedSubReason() ?? "idle_prompt";
}
/** The matched sub-reason at the trailing edge, or null when not waiting. */
private matchedSubReason(): "approval_prompt" | "idle_prompt" | null {
const lastLine = this.lastNonEmptyLine();
const menuTail = this.trailingLines(4);
if (
SINGLE_LINE_APPROVAL_PATTERNS.some((re) => re.test(lastLine)) ||
CODEX_NUMBERED_MENU_PATTERN.test(menuTail)
) {
return "approval_prompt";
}
if (CODEX_IDLE_MARKERS.some((re) => re.test(lastLine))) return "idle_prompt";
return null;
}
/** The last non-empty line of the window (whitespace-trimmed at the end). */
private lastNonEmptyLine(): string {
const lines = this.window.split(/\r?\n/);
for (let i = lines.length - 1; i >= 0; i--) {
if (lines[i].trim().length > 0) return lines[i].trimEnd();
}
return "";
}
/** The last `n` non-empty lines joined (for the multi-line menu check). */
private trailingLines(n: number): string {
const lines = this.window.split(/\r?\n/).filter((l) => l.trim().length > 0);
return lines.slice(-n).join("\n");
}
/** Working-marker override is checked against the trailing few lines. */
private trailingChunk(): string {
return this.trailingLines(4);
}
}
// ── Rollout transcript tailing ─────────────────────────────────────────────────
/** A normalized transcript entry surfaced to chat. */
export interface CodexTranscriptEntry {
role: "user" | "assistant" | "tool" | "system";
text: string;
}
/**
* Probe the Codex sessions directory for the rollout file matching a thread-id.
* The layout (`<sessionsDir>/YYYY/MM/DD/rollout-<ts>-<thread-id>.jsonl`) is
* version-sensitive (community-sourced), so we DO NOT hardcode the dated path —
* we recursively search for a `rollout-*<thread-id>*.jsonl` file. Returns the
* first match or null. Tolerant of a missing dir.
*/
export function findRolloutPath(
sessionsDir: string,
threadId: string,
fs: { readdirSync: (p: string, o: { withFileTypes: true }) => DirentLike[] },
): string | null {
const stack: string[] = [sessionsDir];
while (stack.length > 0) {
const dir = stack.pop()!;
let entries: DirentLike[];
try {
entries = fs.readdirSync(dir, { withFileTypes: true });
} catch {
continue; // missing / unreadable dir → skip
}
for (const entry of entries) {
const full = `${dir}/${entry.name}`;
if (entry.isDirectory()) {
stack.push(full);
} else if (
entry.name.startsWith("rollout-") &&
entry.name.endsWith(".jsonl") &&
entry.name.includes(threadId)
) {
return full;
}
}
}
return null;
}
/** Minimal Dirent shape (so callers can inject a fake fs in tests). */
export interface DirentLike {
name: string;
isDirectory(): boolean;
}
/**
* Incremental rollout JSONL tailer. Codex appends one JSON object per line; this
* remembers the byte offset so each {@link push} yields only entries appended
* since the last call. Mirrors {@link ClaudeTranscriptTailer}'s partial-line and
* unparseable-line tolerance. Only `response_item` message rows become chat
* entries; meta / event rows are skipped.
*/
export class CodexRolloutTailer {
private offset = 0;
private partial = "";
get bytesRead(): number {
return this.offset;
}
push(chunk: string): CodexTranscriptEntry[] {
this.offset += Buffer.byteLength(chunk, "utf8");
const text = this.partial + chunk;
const lines = text.split("\n");
this.partial = lines.pop() ?? "";
const entries: CodexTranscriptEntry[] = [];
for (const line of lines) {
const entry = parseRolloutLine(line);
if (entry) entries.push(entry);
}
return entries;
}
flush(): CodexTranscriptEntry[] {
if (this.partial.trim().length === 0) {
this.partial = "";
return [];
}
const entry = parseRolloutLine(this.partial);
this.partial = "";
return entry ? [entry] : [];
}
}
/** Parse a single rollout JSONL line into a normalized entry, or null. */
function parseRolloutLine(line: string): CodexTranscriptEntry | null {
const trimmed = line.trim();
if (trimmed.length === 0) return null;
let obj: Record<string, unknown>;
try {
const parsed = JSON.parse(trimmed);
if (!parsed || typeof parsed !== "object") return null;
obj = parsed as Record<string, unknown>;
} catch {
return null;
}
// Only `response_item` rows carrying a `message` payload are chat content.
if (obj.type !== "response_item") return null;
const payload = obj.payload as Record<string, unknown> | undefined;
if (!payload || payload.type !== "message") return null;
const role = normalizeRole(typeof payload.role === "string" ? payload.role : "");
const text = flattenContent(payload.content);
if (text.length === 0) return null;
return { role, text };
}
function normalizeRole(raw: string): CodexTranscriptEntry["role"] {
switch (raw) {
case "user":
return "user";
case "assistant":
return "assistant";
case "tool":
return "tool";
// `developer` / `system` instructions render as system.
default:
return "system";
}
}
/** Flatten Codex content blocks (`[{type:"input_text"|"output_text", text}]`). */
function flattenContent(content: unknown): string {
if (typeof content === "string") return content.trim();
if (Array.isArray(content)) {
return content
.map((block) => {
if (typeof block === "string") return block;
if (block && typeof block === "object") {
const b = block as Record<string, unknown>;
if (typeof b.text === "string") return b.text;
}
return "";
})
.filter((s) => s.length > 0)
.join("")
.trim();
}
return "";
}
// ── Readiness detector ─────────────────────────────────────────────────────────
/** Prompt-like trailing glyphs that suggest Codex's composer is ready. */
const READY_GLYPHS = [">", "❯", "▌", "│"];
/**
* Readiness detector for Codex's interactive TUI. Native readiness has no hook
* (Codex's hybrid tier only natively signals done), so this output-based
* detector is the primary readiness signal: ready on the bracketed-paste enable
* sequence (the editor mounted) or a composer prompt glyph at a line's trailing
* edge. Tolerant of partial chunks (keeps a bounded tail).
*/
export class CodexReadinessDetector implements CliReadinessDetector {
private ready = false;
private buffer = "";
observe(chunk: string): boolean {
if (this.ready) return true;
this.buffer = (this.buffer + chunk).slice(-4096);
if (this.buffer.includes("\x1b[?2004h")) {
this.ready = true;
return true;
}
const stripped = stripAnsiControl(this.buffer);
const tail = stripped.replace(/[ \t\r\n]+$/g, "").slice(-8);
if (READY_GLYPHS.some((g) => tail.endsWith(g))) {
this.ready = true;
return true;
}
return false;
}
}
// ── The adapter ─────────────────────────────────────────────────────────────
export const codexAdapter: CliAgentAdapter = {
id: "codex",
name: "Codex",
capabilities: CODEX_CAPABILITIES,
buildLaunch(ctx: CliAdapterLaunchContext): CliLaunchSpec {
const settings = readSettings(ctx);
const { command, args } = buildBaseArgs(ctx);
appendPostureFlags(args, ctx);
if (settings.extraArgs) args.push(...settings.extraArgs);
return { command, args };
},
buildEnvAllowlist(ctx: CliAdapterLaunchContext): string[] {
const settings = readSettings(ctx);
// Only what Codex needs to authenticate, find its config, and render a
// terminal. NEVER inherit-everything — FUSION_* creds stay out of the child.
const base = [
"HOME",
"PATH",
"SHELL",
"USER",
"LOGNAME",
"LANG",
"LC_ALL",
"LC_CTYPE",
"TERM",
"TERMINFO",
"TMPDIR",
"COLORTERM",
"XDG_CONFIG_HOME",
"XDG_CACHE_HOME",
"XDG_DATA_HOME",
// Codex config home + auth.
"CODEX_HOME",
"OPENAI_API_KEY",
"OPENAI_BASE_URL",
];
// When a layered CODEX_HOME scratch dir is configured the env carries it.
return settings.codexHome ? [...new Set([...base, "CODEX_HOME"])] : base;
},
createReadinessDetector(): CliReadinessDetector {
return new CodexReadinessDetector();
},
formatInjection(text: string, _opts: { bracketedPasteActive: boolean }): CliInjectionFormat {
// The session manager owns bracketed-paste wrapping and control-char
// neutralization. This hook only adds the trailing submit CR.
const payload = text.endsWith("\r") ? text : `${text}\r`;
return { payload };
},
buildResume(ctx: CliAdapterResumeContext): CliLaunchSpec {
// `codex resume <thread-id>` re-attaches the prior conversation. The notify
// override + model are re-applied so the resumed session keeps telemetry
// wiring. (The `resume` subcommand precedes the thread-id and config flags.)
const settings = readSettings(ctx);
const command = settings.command ?? DEFAULT_COMMAND;
const args: string[] = ["resume", ctx.nativeSessionId];
if (typeof settings.model === "string" && settings.model.length > 0) {
args.push("-c", `model=${JSON.stringify(settings.model)}`);
}
args.push(...buildNotifyOverrideArg(settings.notifyProgram));
appendPostureFlags(args, ctx);
if (settings.extraArgs) args.push(...settings.extraArgs);
return { command, args };
},
};

View File

@@ -0,0 +1,546 @@
/**
* Droid adapter — NATIVE-tier CliAgentAdapter (U5).
*
* Droid (Factory's CLI) exposes Claude-style hooks (`Stop`, `Notification`,
* `SessionStart`, `PreToolUse`/`PostToolUse`) that deliver a JSON payload
* carrying `session_id`, `transcript_path`, and `permission_mode`. Like Claude
* Code it is native tier — but with ONE caveat the KTD calls out: its
* `Notification` hook CONFLATES a permission request and a 60s-idle prompt into a
* single event whose only discriminator is a free-form `message` text field.
* This adapter implements a {@link classifyNotification} message classifier that
* tags the sub-reason while defaulting BOTH to `waitingOnInput` (both mean
* blocked-on-human).
*
* Capability flags advertise the native tier honestly: `nativeDone: true`,
* `nativeWaiting: true` (via parsing), `transcriptSource: "jsonl"`,
* `supportsResume: true`.
*
* Resume modes (the `-r` footgun):
* - INTERACTIVE: `droid --resume <sessionId>` (`-r`/`--resume`).
* - HEADLESS: `droid exec -s <sessionId>` — `-s`/`--session-id`. In `exec`
* mode `-r` means `--reasoning-effort`, NOT resume. {@link buildResume}
* therefore NEVER emits a bare `-r` for resume in exec mode (asserted in the
* tests). VERIFIED against the installed binary's `droid exec --help`.
*
* ── Verified against the installed binary (Factory Droid CLI) ──
* - `droid` is on PATH (`~/.local/bin/droid`).
* - `droid --help`: `-r, --resume [sessionId]`, `--settings <path>` ("Path to
* runtime settings file merged for this process only" — the session-scoped
* hook-config seam), `--cwd <path>`, `--fork <sessionId>`.
* - `droid exec --help`: `-s, --session-id <id>` ("Existing session to
* continue (requires a prompt)"), `-r, --reasoning-effort <level>`,
* `-o, --output-format <format>`, `--auto <level>`,
* `--skip-permissions-unsafe`. CONFIRMS the `-r` footgun.
*
* ── Assumed (marked so wiring composes; revisit on drift) ──
* - HOOK CONFIG MECHANISM. Droid's `--settings <path>` merges a runtime
* settings file for this process only — the session-scoped equivalent of
* Claude's `--settings`. We assume it accepts a Claude-style `hooks` block
* (event → [{ hooks:[{ type:"command", command }] }]); the binary's hooks
* reference is documented as Claude-style. The hook SCRIPTS themselves come
* from U17; this adapter only references their paths and emits the settings
* JSON. If a Droid version diverges from the Claude hook schema this is the
* one place to adjust ({@link buildDroidSettings}).
* - The `Notification` payload's idle vs permission discriminator is the
* `message` text; {@link classifyNotification} is the documented-gap
* classifier.
*/
import { writeFileSync } from "node:fs";
import type {
CliAdapterCapabilities,
CliAdapterLaunchContext,
CliAdapterResumeContext,
CliAgentAdapter,
CliInjectionFormat,
CliLaunchSpec,
CliReadinessDetector,
} from "../adapter.js";
import { stripAnsiControl, type TelemetryEvent } from "../telemetry-hub.js";
// ── Capabilities ────────────────────────────────────────────────────────────
/** Droid native tier: hooks (Stop/Notification/SessionStart) + JSONL + resume. */
export const DROID_CAPABILITIES: CliAdapterCapabilities = {
nativeDone: true,
nativeWaiting: true,
transcriptSource: "jsonl",
supportsResume: true,
};
// ── Hook script references (U17 seam) ─────────────────────────────────────────
/**
* Paths to the session-scoped hook scripts the U17 module writes. The adapter
* does NOT create these — it only references them from the generated settings.
*/
export interface DroidHookScriptRefs {
/** Script for the `Stop` hook (positive completion). */
stopScript: string;
/** Script for the `Notification` hook (permission / idle — see classifier). */
notificationScript: string;
/** Script for the `SessionStart` hook (captures session_id / transcript_path). */
sessionStartScript: string;
/** Optional script for tool-activity hooks (PreToolUse/PostToolUse). */
toolActivityScript?: string;
}
/** Adapter-specific launch settings recognized by the Droid adapter. */
export interface DroidLaunchSettings {
/** Override the `droid` binary. */
command?: string;
/** Extra args appended after the computed base args. */
extraArgs?: readonly string[];
/** Model override (`--model <id>`). */
model?: string;
/** Session-scoped hook script paths (from U17). */
hookScripts?: DroidHookScriptRefs;
/**
* Absolute path to WRITE the session-scoped settings JSON to (merged via
* `--settings`). MUST live under the session scratch dir — never the user's
* global Droid config. When absent the adapter passes the JSON inline if the
* binary accepts it; Droid's `--settings` is documented as a PATH, so a path
* is strongly preferred (the caller owns containment + lifecycle).
*/
settingsPath?: string;
}
function readSettings(ctx: CliAdapterLaunchContext): DroidLaunchSettings {
return (ctx.settings ?? {}) as DroidLaunchSettings;
}
// ── Settings JSON generation (Claude-style hooks, assumed schema) ─────────────
interface HookCommandEntry {
matcher?: string;
hooks: { type: "command"; command: string }[];
}
export interface DroidHooksConfig {
[eventName: string]: HookCommandEntry[];
}
export interface DroidSettings {
hooks: DroidHooksConfig;
}
/**
* Build the session-scoped Droid settings document registering our hooks. Schema
* is assumed Claude-style (see file header): `{ hooks: { EventName: [{ hooks:
* [{ type:"command", command }] }] } }`. Tool-activity events are registered only
* when a `toolActivityScript` is supplied.
*/
export function buildDroidSettings(scripts: DroidHookScriptRefs): DroidSettings {
const cmd = (command: string): HookCommandEntry => ({
hooks: [{ type: "command", command }],
});
const hooks: DroidHooksConfig = {
SessionStart: [cmd(scripts.sessionStartScript)],
Stop: [cmd(scripts.stopScript)],
Notification: [cmd(scripts.notificationScript)],
};
if (scripts.toolActivityScript) {
const activity = [cmd(scripts.toolActivityScript)];
hooks.PreToolUse = activity;
hooks.PostToolUse = activity;
}
return { hooks };
}
const DEFAULT_COMMAND = "droid";
/** Append the `--settings` flag for the session-scoped hook config. */
function appendSettingsFlag(args: string[], settings: DroidLaunchSettings): void {
if (!settings.hookScripts) return;
const doc = buildDroidSettings(settings.hookScripts);
const json = JSON.stringify(doc);
if (settings.settingsPath) {
// Caller guarantees this path is under the session scratch dir.
writeFileSync(settings.settingsPath, json, "utf8");
args.push("--settings", settings.settingsPath);
} else {
// `--settings` is documented as a path; inline JSON is a best-effort fallback.
args.push("--settings", json);
}
}
/** Append the autonomy posture's privileged flags, only when permitted. */
function appendPostureFlags(args: string[], ctx: CliAdapterLaunchContext): void {
// Visible-posture contract (R21): only bypass approvals when the posture opts
// in. In interactive mode Droid uses `--auto high` for full autonomy.
if (ctx.posture?.autoApprove === true) {
args.push("--auto", "high");
}
}
function buildBaseArgs(ctx: CliAdapterLaunchContext): { command: string; args: string[] } {
const settings = readSettings(ctx);
const command = settings.command ?? DEFAULT_COMMAND;
const args: string[] = [];
if (typeof settings.model === "string" && settings.model.length > 0) {
args.push("--model", settings.model);
}
return { command, args };
}
// ── Notification classifier (the documented-gap discriminator) ────────────────
/** Sub-reason a Droid `Notification` resolves to after message classification. */
export type DroidNotificationSubReason = "permission_request" | "idle_prompt";
/**
* Wording that signals a PERMISSION request (vs a passive idle ping). Droid's
* `Notification` hook fires for both with only a `message` string to tell them
* apart, so this is a best-effort word classifier (the documented gap).
*/
const PERMISSION_WORDING =
/\b(permission|approve|approval|allow|grant|confirm|authorize|wants to (?:run|edit|use)|requesting|needs your|asking to|blocked by)\b/i;
/**
* Classify a Droid `Notification` message into its sub-reason. BOTH outcomes are
* treated as `waitingOnInput` upstream (both mean blocked-on-human); this only
* tags WHY for the surface/notification. Default when ambiguous is
* `permission_request` ONLY when permission wording is present; otherwise
* `idle_prompt` (the safer default for a generic "waiting" ping).
*/
export function classifyNotification(message: string | undefined): DroidNotificationSubReason {
const text = typeof message === "string" ? message : "";
// Permission wording wins whenever present (even alongside idle wording): a
// permission request is the more actionable, blocking sub-reason. A bare ping
// with no permission wording defaults to idle.
if (PERMISSION_WORDING.test(text)) return "permission_request";
return "idle_prompt";
}
// ── Hook payload → telemetry ──────────────────────────────────────────────────
/** The raw shape of a Droid hook payload (tolerant of missing fields). */
export interface DroidHookPayload {
hook_event_name?: string;
session_id?: string;
transcript_path?: string;
permission_mode?: string;
source?: string;
tool_name?: string;
message?: string;
stop_reason?: string;
[key: string]: unknown;
}
/**
* Map a raw Droid hook payload onto a normalized engine `TelemetryEvent`.
* Returns null for events with no state-relevant signal. Tolerant of missing
* optional fields.
*
* Mapping (KTD telemetry tiering — native tier):
* SessionStart → sessionStart (capture session_id +
* transcript_path + permission_mode)
* PreToolUse / PostToolUse → toolActivity (re-arm watchdog)
* Notification → waitingOnInput (sub-reason via classifier)
* Stop → done (positive completion); see classifyStop
*/
export function mapHookPayload(payload: DroidHookPayload): TelemetryEvent | null {
const event = typeof payload.hook_event_name === "string" ? payload.hook_event_name : "";
const nativeSessionId =
typeof payload.session_id === "string" && payload.session_id.length > 0
? payload.session_id
: undefined;
const withSession = (
base: TelemetryEvent,
extra?: Record<string, unknown>,
): TelemetryEvent => {
const payloadOut: Record<string, unknown> = { ...(base.payload ?? {}), ...(extra ?? {}) };
if (nativeSessionId) payloadOut.nativeSessionId = nativeSessionId;
return { kind: base.kind, payload: payloadOut };
};
switch (event) {
case "SessionStart": {
const extra: Record<string, unknown> = {};
if (typeof payload.transcript_path === "string") {
extra.transcriptPath = payload.transcript_path;
}
if (typeof payload.permission_mode === "string") {
extra.permissionMode = payload.permission_mode;
}
if (typeof payload.source === "string") extra.source = payload.source;
return withSession({ kind: "sessionStart" }, extra);
}
case "PreToolUse":
case "PostToolUse": {
const extra =
typeof payload.tool_name === "string" ? { toolName: payload.tool_name } : undefined;
return withSession({ kind: "toolActivity" }, extra);
}
case "Notification": {
// The conflated event: classify the message to tag the sub-reason. Both
// outcomes mean blocked-on-human → waitingOnInput.
const subReason = classifyNotification(payload.message);
return withSession(
{ kind: "waitingOnInput" },
{
notification: {
kind: subReason,
...(typeof payload.message === "string" ? { message: payload.message } : {}),
},
},
);
}
case "Stop":
return classifyStop(payload);
default:
return nativeSessionId ? withSession({ kind: "outputProgress" }) : null;
}
}
/**
* Map a `Stop` payload onto its telemetry event. Happy path is `done`; an
* explicit error-ish `stop_reason` downgrades to `toolActivity` (refusing to
* gate pipeline advancement on a failed stop), mirroring the Claude adapter.
*/
export function classifyStop(payload: DroidHookPayload): TelemetryEvent {
const nativeSessionId =
typeof payload.session_id === "string" && payload.session_id.length > 0
? payload.session_id
: undefined;
const reason = typeof payload.stop_reason === "string" ? payload.stop_reason.toLowerCase() : "";
const failed = reason.length > 0 && /error|fail|abort|cancel|interrupt/.test(reason);
const kind: TelemetryEvent["kind"] = failed ? "toolActivity" : "done";
const out: TelemetryEvent = { kind, payload: {} };
if (nativeSessionId) out.payload!.nativeSessionId = nativeSessionId;
if (failed) out.payload!.stopReason = payload.stop_reason;
return out;
}
/**
* Parse a raw hook payload string (delivered on the hook command's stdin) into a
* normalized event. Returns null on unparseable input — never throws.
*/
export function parseHookPayload(raw: string): TelemetryEvent | null {
let parsed: unknown;
try {
parsed = JSON.parse(raw);
} catch {
return null;
}
if (!parsed || typeof parsed !== "object") return null;
return mapHookPayload(parsed as DroidHookPayload);
}
// ── Transcript tailing (JSONL, Claude-style nesting) ──────────────────────────
export interface DroidTranscriptEntry {
role: "user" | "assistant" | "tool" | "system";
text: string;
}
/**
* Incremental JSONL transcript tailer. Droid appends one JSON object per line to
* the file at `transcript_path`. Mirrors the Claude tailer's offset tracking,
* partial-line handling, and unparseable-line tolerance.
*/
export class DroidTranscriptTailer {
private offset = 0;
private partial = "";
get bytesRead(): number {
return this.offset;
}
push(chunk: string): DroidTranscriptEntry[] {
this.offset += Buffer.byteLength(chunk, "utf8");
const text = this.partial + chunk;
const lines = text.split("\n");
this.partial = lines.pop() ?? "";
const entries: DroidTranscriptEntry[] = [];
for (const line of lines) {
const entry = parseTranscriptLine(line);
if (entry) entries.push(entry);
}
return entries;
}
flush(): DroidTranscriptEntry[] {
if (this.partial.trim().length === 0) {
this.partial = "";
return [];
}
const entry = parseTranscriptLine(this.partial);
this.partial = "";
return entry ? [entry] : [];
}
}
function parseTranscriptLine(line: string): DroidTranscriptEntry | null {
const trimmed = line.trim();
if (trimmed.length === 0) return null;
let obj: Record<string, unknown>;
try {
const parsed = JSON.parse(trimmed);
if (!parsed || typeof parsed !== "object") return null;
obj = parsed as Record<string, unknown>;
} catch {
return null;
}
const role = normalizeRole(obj);
const text = extractText(obj);
if (text.length === 0) return null;
return { role, text };
}
function normalizeRole(obj: Record<string, unknown>): DroidTranscriptEntry["role"] {
const message = obj.message as Record<string, unknown> | undefined;
const raw =
(typeof message?.role === "string" && message.role) ||
(typeof obj.role === "string" && obj.role) ||
(typeof obj.type === "string" && obj.type) ||
"";
switch (raw) {
case "user":
case "human":
return "user";
case "assistant":
case "model":
return "assistant";
case "tool":
case "tool_result":
case "tool_use":
return "tool";
default:
return "system";
}
}
function extractText(obj: Record<string, unknown>): string {
const message = obj.message as Record<string, unknown> | undefined;
const content = message?.content ?? obj.content ?? obj.text;
return flattenContent(content);
}
function flattenContent(content: unknown): string {
if (typeof content === "string") return content.trim();
if (Array.isArray(content)) {
return content
.map((block) => {
if (typeof block === "string") return block;
if (block && typeof block === "object") {
const b = block as Record<string, unknown>;
if (typeof b.text === "string") return b.text;
}
return "";
})
.filter((s) => s.length > 0)
.join("")
.trim();
}
return "";
}
// ── Readiness detector ─────────────────────────────────────────────────────────
/**
* Readiness detector for Droid's interactive TUI. Native readiness arrives as
* the first `SessionStart` hook (telemetry-driven); this output-based detector is
* the FALLBACK for callers gating on PTY output. Ready on the bracketed-paste
* enable sequence or a prompt glyph at a line's trailing edge.
*/
export class DroidReadinessDetector implements CliReadinessDetector {
private ready = false;
private buffer = "";
observe(chunk: string): boolean {
if (this.ready) return true;
this.buffer = (this.buffer + chunk).slice(-4096);
if (this.buffer.includes("\x1b[?2004h")) {
this.ready = true;
return true;
}
const stripped = stripAnsiControl(this.buffer);
if (/(^|\n)\s*[╭│]?\s*[>❯]\s/.test(stripped)) {
this.ready = true;
return true;
}
return false;
}
}
// ── The adapter ─────────────────────────────────────────────────────────────
export const droidAdapter: CliAgentAdapter = {
id: "droid",
name: "Droid",
capabilities: DROID_CAPABILITIES,
buildLaunch(ctx: CliAdapterLaunchContext): CliLaunchSpec {
const settings = readSettings(ctx);
const { command, args } = buildBaseArgs(ctx);
appendSettingsFlag(args, settings);
appendPostureFlags(args, ctx);
if (settings.extraArgs) args.push(...settings.extraArgs);
return { command, args };
},
buildEnvAllowlist(): string[] {
// Only what Droid needs to authenticate, find its config, and render a
// terminal. NEVER inherit-everything — FUSION_* creds stay out of the child.
return [
"HOME",
"PATH",
"SHELL",
"USER",
"LOGNAME",
"LANG",
"LC_ALL",
"LC_CTYPE",
"TERM",
"TERMINFO",
"TMPDIR",
"COLORTERM",
"XDG_CONFIG_HOME",
"XDG_CACHE_HOME",
"XDG_DATA_HOME",
// Factory Droid auth.
"FACTORY_API_KEY",
];
},
createReadinessDetector(): CliReadinessDetector {
return new DroidReadinessDetector();
},
formatInjection(text: string, _opts: { bracketedPasteActive: boolean }): CliInjectionFormat {
// Session manager owns paste-wrapping + neutralization; we add the submit CR.
const payload = text.endsWith("\r") ? text : `${text}\r`;
return { payload };
},
buildResume(ctx: CliAdapterResumeContext): CliLaunchSpec {
// Resume mode is selected via the settings: headless `exec -s <id>` vs
// interactive `--resume <id>`. The exec path NEVER uses `-r` (that is
// `--reasoning-effort` in exec mode). The presence of a headless/exec request
// is signalled by `extraArgs` containing `exec`, or by an explicit
// `execMode` flag on settings; default is interactive.
const settings = readSettings(ctx) as DroidLaunchSettings & { execMode?: boolean };
const command = settings.command ?? DEFAULT_COMMAND;
if (settings.execMode === true) {
// Headless: `droid exec -s <sessionId>` — NEVER `-r`.
const args: string[] = ["exec", "-s", ctx.nativeSessionId];
if (typeof settings.model === "string" && settings.model.length > 0) {
args.push("--model", settings.model);
}
if (ctx.posture?.autoApprove === true) args.push("--auto", "high");
if (settings.extraArgs) args.push(...settings.extraArgs);
return { command, args };
}
// Interactive: `droid --resume <sessionId>`.
const { args } = buildBaseArgs(ctx);
args.push("--resume", ctx.nativeSessionId);
appendSettingsFlag(args, settings);
appendPostureFlags(args, ctx);
if (settings.extraArgs) args.push(...settings.extraArgs);
return { command, args };
},
};

View File

@@ -0,0 +1,461 @@
/**
* Pi adapter — NATIVE-tier CliAgentAdapter (U5).
*
* Pi (github.com/earendil-works/pi) is native tier: it writes a structured
* session JSONL to disk that we tail for telemetry AND transcript. Capability
* flags advertise this honestly: `nativeDone: true`, `nativeWaiting: true`,
* `transcriptSource: "session-jsonl"` (a JSONL transcript on disk), and
* `supportsResume: true`.
*
* This adapter teaches the engine to:
* - launch `pi` with a SESSION-SCOPED `--session-dir <dir>` so the session file
* is written somewhere discoverable (and never collides with the user's other
* sessions);
* - tail the session JSONL incrementally, mapping its event/message entries
* onto normalized `TelemetryEvent`s and chat transcript entries;
* - capture the native session id from the file's `session` header;
* - resume via `pi --session <path|partial-uuid>`.
*
* ── Verified against the installed binary (Pi) ──
* - `pi` is on PATH (`/opt/homebrew/bin/pi`).
* - `pi --help`: `--session <path|id>` ("Use specific session file or partial
* UUID"), `--session-dir <dir>` ("Directory for session storage and lookup"),
* `--mode <mode>` ("Output mode: text (default), json, or rpc"),
* `--resume, -r` (interactive picker), `--continue, -c`, `--no-session`,
* `--print, -p` (non-interactive), `--fork <path|id>`.
* - Session JSONL layout CONFIRMED by inspecting real files under
* `~/.pi/agent/sessions/<cwd-encoded>/<ts>_<uuid>.jsonl`: a first line
* `{type:"session", version, id:<uuid>, timestamp, cwd}`, then
* `{type:"model_change"|"thinking_level_change", ...}` and
* `{type:"message", id, parentId, timestamp, message:{ role, content:[
* {type:"text"|"thinking", text|thinking} ] }}` rows. `role` is one of
* `user` / `assistant` / `toolResult`.
*
* ── Assumed (marked so wiring composes; revisit on drift) ──
* - TELEMETRY EVENT MAPPING. The KTD specifies turn_start/agent_start→busy,
* turn_end/agent_end→done, input-request→waitingOnInput, message→transcript.
* The recorded v3 sessions I inspected contained only `message` rows (no
* explicit turn_* / agent_* / input-request rows — those arrive via the
* event bus / `--mode json` in newer builds). {@link mapSessionLine}
* therefore handles BOTH: explicit lifecycle events when present, AND a
* message-shape fallback (assistant message → busy/transcript). The explicit
* lifecycle event names are best-effort per the documented event bus and are
* matched case-insensitively with several spellings.
* - `--mode json` is an ALTERNATIVE interactive event stream; we implement the
* deterministic JSONL tail instead (the file is the source of truth and
* survives restarts). The session-dir mechanism makes the file discoverable.
*/
import type {
CliAdapterCapabilities,
CliAdapterLaunchContext,
CliAdapterResumeContext,
CliAgentAdapter,
CliInjectionFormat,
CliLaunchSpec,
CliReadinessDetector,
} from "../adapter.js";
import { stripAnsiControl, type TelemetryEvent } from "../telemetry-hub.js";
// ── Capabilities ────────────────────────────────────────────────────────────
/** Pi native tier: session-JSONL telemetry + transcript + resume. */
export const PI_CAPABILITIES: CliAdapterCapabilities = {
nativeDone: true,
nativeWaiting: true,
transcriptSource: "session-jsonl",
supportsResume: true,
};
// ── Launch settings ───────────────────────────────────────────────────────────
const DEFAULT_COMMAND = "pi";
/** Adapter-specific launch settings recognized by the Pi adapter. */
export interface PiLaunchSettings {
/** Override the `pi` binary. */
command?: string;
/** Extra args appended after the computed base args. */
extraArgs?: readonly string[];
/** Model override (`--model <pattern>`). */
model?: string;
/** Provider override (`--provider <name>`). */
provider?: string;
/**
* Session-scoped directory for session storage + lookup (`--session-dir`). The
* caller (session manager) owns + cleans this dir; setting it makes the session
* file discoverable by {@link findSessionFile}. Strongly recommended so the
* session JSONL never lands in the user's global sessions tree.
*/
sessionDir?: string;
}
function readSettings(ctx: CliAdapterLaunchContext): PiLaunchSettings {
return (ctx.settings ?? {}) as PiLaunchSettings;
}
function buildBaseArgs(ctx: CliAdapterLaunchContext): { command: string; args: string[] } {
const settings = readSettings(ctx);
const command = settings.command ?? DEFAULT_COMMAND;
const args: string[] = [];
if (typeof settings.provider === "string" && settings.provider.length > 0) {
args.push("--provider", settings.provider);
}
if (typeof settings.model === "string" && settings.model.length > 0) {
args.push("--model", settings.model);
}
if (typeof settings.sessionDir === "string" && settings.sessionDir.length > 0) {
args.push("--session-dir", settings.sessionDir);
}
return { command, args };
}
/** Append the autonomy posture's privileged flags, only when permitted. */
function appendPostureFlags(args: string[], ctx: CliAdapterLaunchContext): void {
// Visible-posture contract (R21). Pi enables all tools without confirmation via
// its tool allowlist; full autonomy maps to enabling tools (`-t` with no
// confirmation). We only widen tool access when the posture explicitly opts in.
if (ctx.posture?.autoApprove === true) {
// Pi prompts per-tool by default; auto-approve enables the full built-in set.
args.push("--tools", "read,bash,edit,write");
}
}
// ── Session file discovery ─────────────────────────────────────────────────────
/** Minimal Dirent shape so callers can inject a fake fs in tests. */
export interface DirentLike {
name: string;
isDirectory(): boolean;
}
/**
* Probe a Pi session directory for the newest `*.jsonl` session file. Pi writes
* `<ts>_<uuid>.jsonl`; when a session-scoped `--session-dir` was used the file
* lands directly in that dir, but the user's global tree nests under a
* cwd-encoded subdir — so we search one level deep too. Returns the lexically
* greatest matching filename's full path (timestamps sort lexically), or null.
*/
export function findSessionFile(
sessionDir: string,
fs: { readdirSync: (p: string, o: { withFileTypes: true }) => DirentLike[] },
): string | null {
const best: { path: string; name: string } = { path: "", name: "" };
let found = false;
const consider = (dir: string) => {
let entries: DirentLike[];
try {
entries = fs.readdirSync(dir, { withFileTypes: true });
} catch {
return;
}
for (const entry of entries) {
const full = `${dir}/${entry.name}`;
if (entry.isDirectory()) {
consider(full); // one level of cwd-encoded nesting
} else if (entry.name.endsWith(".jsonl")) {
// Compare by FILENAME (timestamp-prefixed) so the dir prefix doesn't skew
// the lexical ordering across nested vs flat layouts.
if (!found || entry.name > best.name) {
best.path = full;
best.name = entry.name;
found = true;
}
}
}
};
consider(sessionDir);
return found ? best.path : null;
}
// ── Session JSONL → telemetry ──────────────────────────────────────────────────
/**
* Telemetry events the Pi tailer can synthesize from a session line. `transcript`
* carries chat content; the others drive the state machine.
*/
export type PiSessionEvent =
| { kind: "busy" }
| { kind: "done" }
| { kind: "waitingOnInput"; notification?: Record<string, unknown> }
| { kind: "sessionStart"; nativeSessionId?: string }
| { kind: "transcript"; role: PiTranscriptEntry["role"]; text: string };
export interface PiTranscriptEntry {
role: "user" | "assistant" | "tool" | "system";
text: string;
}
/** Lifecycle event-type spellings the tailer recognizes (case-insensitive set). */
const TURN_START_TYPES = new Set(["turn_start", "agent_start", "turnstart", "agentstart"]);
const TURN_END_TYPES = new Set(["turn_end", "agent_end", "turnend", "agentend"]);
const INPUT_REQUEST_TYPES = new Set([
"input_request",
"input-request",
"inputrequest",
"request_input",
"ask_user",
"elicit",
]);
/**
* Map a parsed Pi session JSONL object onto a normalized {@link PiSessionEvent},
* or null when the line carries no signal.
*
* Mapping (KTD telemetry tiering — native tier):
* session → sessionStart (+ nativeSessionId)
* turn_start / agent_start → busy
* turn_end / agent_end → done
* input_request / ask_user / elicit → waitingOnInput
* message{role:user|assistant|toolResult} → transcript (assistant also implies
* a busy turn is underway → handled
* by the tailer)
*/
export function mapSessionLine(obj: Record<string, unknown>): PiSessionEvent | null {
const type = typeof obj.type === "string" ? obj.type.toLowerCase() : "";
if (type === "session") {
const id = typeof obj.id === "string" && obj.id.length > 0 ? obj.id : undefined;
return { kind: "sessionStart", nativeSessionId: id };
}
if (TURN_START_TYPES.has(type)) return { kind: "busy" };
if (TURN_END_TYPES.has(type)) return { kind: "done" };
if (INPUT_REQUEST_TYPES.has(type)) {
return {
kind: "waitingOnInput",
notification: { kind: "input_request", source: "session-jsonl" },
};
}
if (type === "message") {
const message = obj.message as Record<string, unknown> | undefined;
if (!message) return null;
const role = normalizeRole(typeof message.role === "string" ? message.role : "");
const text = flattenContent(message.content);
if (text.length === 0) return null;
return { kind: "transcript", role, text };
}
return null;
}
function normalizeRole(raw: string): PiTranscriptEntry["role"] {
switch (raw.toLowerCase()) {
case "user":
case "human":
return "user";
case "assistant":
case "model":
return "assistant";
case "toolresult":
case "tool_result":
case "tool":
case "tooluse":
case "tool_use":
return "tool";
default:
return "system";
}
}
/** Flatten Pi content blocks (`[{type:"text",text}|{type:"thinking",thinking}]`). */
function flattenContent(content: unknown): string {
if (typeof content === "string") return content.trim();
if (Array.isArray(content)) {
return content
.map((block) => {
if (typeof block === "string") return block;
if (block && typeof block === "object") {
const b = block as Record<string, unknown>;
if (typeof b.text === "string") return b.text;
if (typeof b.thinking === "string") return b.thinking;
}
return "";
})
.filter((s) => s.length > 0)
.join("")
.trim();
}
return "";
}
/**
* Convert a {@link PiSessionEvent} into the engine's normalized
* {@link TelemetryEvent} shape (the hub's ingest contract). A `transcript` event
* becomes a `transcript` kind carrying its flattened text; lifecycle events map
* one-to-one. An assistant `transcript` ALSO implies the turn is busy, but to
* keep mapping pure the tailer emits the `busy` separately (see
* {@link PiSessionTailer.push}).
*/
export function toTelemetryEvent(event: PiSessionEvent): TelemetryEvent {
switch (event.kind) {
case "sessionStart":
return {
kind: "sessionStart",
payload: event.nativeSessionId ? { nativeSessionId: event.nativeSessionId } : {},
};
case "busy":
return { kind: "busy", payload: {} };
case "done":
return { kind: "done", payload: {} };
case "waitingOnInput":
return {
kind: "waitingOnInput",
payload: event.notification ? { notification: event.notification } : {},
};
case "transcript":
return { kind: "transcript", payload: { text: event.text, role: event.role } };
}
}
// ── Session JSONL tailer ───────────────────────────────────────────────────────
/**
* Incremental Pi session-JSONL tailer. Pi appends one JSON object per line; this
* remembers the byte offset so each {@link push} yields only entries appended
* since the last call. Mirrors the Claude/Codex tailers' offset tracking,
* partial-line handling, and unparseable-line tolerance. Returns the
* {@link PiSessionEvent}s synthesized from the appended lines (lifecycle +
* transcript), in order.
*/
export class PiSessionTailer {
private offset = 0;
private partial = "";
get bytesRead(): number {
return this.offset;
}
push(chunk: string): PiSessionEvent[] {
this.offset += Buffer.byteLength(chunk, "utf8");
const text = this.partial + chunk;
const lines = text.split("\n");
this.partial = lines.pop() ?? "";
const events: PiSessionEvent[] = [];
for (const line of lines) {
const ev = parseSessionLine(line);
if (ev) events.push(ev);
}
return events;
}
flush(): PiSessionEvent[] {
if (this.partial.trim().length === 0) {
this.partial = "";
return [];
}
const ev = parseSessionLine(this.partial);
this.partial = "";
return ev ? [ev] : [];
}
}
function parseSessionLine(line: string): PiSessionEvent | null {
const trimmed = line.trim();
if (trimmed.length === 0) return null;
let obj: Record<string, unknown>;
try {
const parsed = JSON.parse(trimmed);
if (!parsed || typeof parsed !== "object") return null;
obj = parsed as Record<string, unknown>;
} catch {
return null;
}
return mapSessionLine(obj);
}
// ── Readiness detector ─────────────────────────────────────────────────────────
const READY_GLYPHS = [">", "❯", "▌", "»"];
/**
* Readiness detector for Pi's interactive TUI. Pi's session file is the
* authoritative telemetry source but readiness gates the FIRST injection before
* the file is populated, so this output-based detector is primary: ready on the
* bracketed-paste enable sequence or a composer prompt glyph at a line's
* trailing edge.
*/
export class PiReadinessDetector implements CliReadinessDetector {
private ready = false;
private buffer = "";
observe(chunk: string): boolean {
if (this.ready) return true;
this.buffer = (this.buffer + chunk).slice(-4096);
if (this.buffer.includes("\x1b[?2004h")) {
this.ready = true;
return true;
}
const stripped = stripAnsiControl(this.buffer);
const tail = stripped.replace(/[ \t\r\n]+$/g, "").slice(-8);
if (READY_GLYPHS.some((g) => tail.endsWith(g))) {
this.ready = true;
return true;
}
return false;
}
}
// ── The adapter ─────────────────────────────────────────────────────────────
export const piAdapter: CliAgentAdapter = {
id: "pi",
name: "Pi",
capabilities: PI_CAPABILITIES,
buildLaunch(ctx: CliAdapterLaunchContext): CliLaunchSpec {
const settings = readSettings(ctx);
const { command, args } = buildBaseArgs(ctx);
appendPostureFlags(args, ctx);
if (settings.extraArgs) args.push(...settings.extraArgs);
return { command, args };
},
buildEnvAllowlist(): string[] {
// Only what Pi needs to authenticate, find its config, and render a terminal.
// NEVER inherit-everything — FUSION_* creds stay out of the child.
return [
"HOME",
"PATH",
"SHELL",
"USER",
"LOGNAME",
"LANG",
"LC_ALL",
"LC_CTYPE",
"TERM",
"TERMINFO",
"TMPDIR",
"COLORTERM",
"XDG_CONFIG_HOME",
"XDG_CACHE_HOME",
"XDG_DATA_HOME",
// Pi session-dir override + common provider auth keys.
"PI_CODING_AGENT_SESSION_DIR",
"GEMINI_API_KEY",
"GOOGLE_API_KEY",
"ANTHROPIC_API_KEY",
"OPENAI_API_KEY",
];
},
createReadinessDetector(): CliReadinessDetector {
return new PiReadinessDetector();
},
formatInjection(text: string, _opts: { bracketedPasteActive: boolean }): CliInjectionFormat {
// Session manager owns paste-wrapping + neutralization; we add the submit CR.
const payload = text.endsWith("\r") ? text : `${text}\r`;
return { payload };
},
buildResume(ctx: CliAdapterResumeContext): CliLaunchSpec {
// `pi --session <path|partial-uuid>` re-attaches the prior conversation. The
// recorded native id is the session uuid (or its file path); both are accepted
// by `--session`. Provider/model/session-dir are re-applied.
const settings = readSettings(ctx);
const { command, args } = buildBaseArgs(ctx);
args.push("--session", ctx.nativeSessionId);
appendPostureFlags(args, ctx);
if (settings.extraArgs) args.push(...settings.extraArgs);
return { command, args };
},
};