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

@@ -0,0 +1,13 @@
---
"@runfusion/fusion": patch
---
Add the Codex, Droid, and Pi CLI agent adapters (U5).
Three new launch adapters join the engine's CLI agent executor, each declaring honest, verified capability flags so surfaces can render tier differences:
- **Codex** (hybrid tier): native turn-complete via the session-scoped `notify` config program (`-c notify=[…]`), capturing `thread-id` as the native session id; waiting-on-input is inferred from ANSI-stripped PTY prompt-pattern heuristics (approval menus, idle composer markers, with a spinner/working override) because Codex has no native waiting signal; resume via `codex resume <thread-id>`; rollout JSONL transcript tailed by probing (not hardcoding) the sessions directory for the file matching the thread-id.
- **Droid** (native tier): Claude-style hooks (`SessionStart`, `Stop`, `Notification`, tool-activity) delivering `session_id`/`transcript_path`/`permission_mode`; a message classifier splits the conflated `Notification` event into permission-request vs idle sub-reasons (both treated as waiting-on-input); resume via interactive `droid --resume <id>` or headless `droid exec -s <id>` — never the bare `-r` that means `--reasoning-effort` in exec mode.
- **Pi** (native tier): telemetry and transcript from session-JSONL tailing under a session-scoped `--session-dir`; lifecycle events (turn/agent start→busy, end→done, input-request→waiting) plus message rows→transcript; resume via `pi --session <path|partial-uuid>`.
A new `session-jsonl` transcript source is added to the adapter capability union for Pi.

View File

@@ -27,6 +27,8 @@ export type TranscriptSource =
| "hooks" | "hooks"
/** A JSONL transcript / rollout file tailed from disk. */ /** A JSONL transcript / rollout file tailed from disk. */
| "jsonl" | "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`). */ /** A native machine-readable event stream (e.g. `--mode json`). */
| "event-stream" | "event-stream"
/** No structured transcript — raw terminal only (generic tier). */ /** 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 };
},
};