From 07dcb1695e6854e8e1a1b62616f0ec80c053746f Mon Sep 17 00:00:00 2001 From: gsxdsm Date: Fri, 5 Jun 2026 00:00:05 -0700 Subject: [PATCH] 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 , 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) --- .../cli-agent-codex-droid-pi-adapters.md | 13 + packages/engine/src/cli-agent/adapter.ts | 2 + .../adapters/__tests__/codex.test.ts | 354 ++++++++++ .../adapters/__tests__/droid.test.ts | 297 ++++++++ .../cli-agent/adapters/__tests__/pi.test.ts | 279 ++++++++ .../engine/src/cli-agent/adapters/codex.ts | 646 ++++++++++++++++++ .../engine/src/cli-agent/adapters/droid.ts | 546 +++++++++++++++ packages/engine/src/cli-agent/adapters/pi.ts | 461 +++++++++++++ 8 files changed, 2598 insertions(+) create mode 100644 .changeset/cli-agent-codex-droid-pi-adapters.md create mode 100644 packages/engine/src/cli-agent/adapters/__tests__/codex.test.ts create mode 100644 packages/engine/src/cli-agent/adapters/__tests__/droid.test.ts create mode 100644 packages/engine/src/cli-agent/adapters/__tests__/pi.test.ts create mode 100644 packages/engine/src/cli-agent/adapters/codex.ts create mode 100644 packages/engine/src/cli-agent/adapters/droid.ts create mode 100644 packages/engine/src/cli-agent/adapters/pi.ts diff --git a/.changeset/cli-agent-codex-droid-pi-adapters.md b/.changeset/cli-agent-codex-droid-pi-adapters.md new file mode 100644 index 0000000000..61a0f04bb2 --- /dev/null +++ b/.changeset/cli-agent-codex-droid-pi-adapters.md @@ -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 `; 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 ` or headless `droid exec -s ` — 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 `. + +A new `session-jsonl` transcript source is added to the adapter capability union for Pi. diff --git a/packages/engine/src/cli-agent/adapter.ts b/packages/engine/src/cli-agent/adapter.ts index 54d9365169..c6761aa6ac 100644 --- a/packages/engine/src/cli-agent/adapter.ts +++ b/packages/engine/src/cli-agent/adapter.ts @@ -27,6 +27,8 @@ export type TranscriptSource = | "hooks" /** A JSONL transcript / rollout file tailed from disk. */ | "jsonl" + /** A per-session JSONL file tailed from disk for both telemetry + transcript (Pi). */ + | "session-jsonl" /** A native machine-readable event stream (e.g. `--mode json`). */ | "event-stream" /** No structured transcript — raw terminal only (generic tier). */ diff --git a/packages/engine/src/cli-agent/adapters/__tests__/codex.test.ts b/packages/engine/src/cli-agent/adapters/__tests__/codex.test.ts new file mode 100644 index 0000000000..0e9147e58a --- /dev/null +++ b/packages/engine/src/cli-agent/adapters/__tests__/codex.test.ts @@ -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 ` (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).kind).toBe( + "approval_prompt", + ); + expect((events[0].payload?.notification as Record).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).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-<...>-.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"); + }); +}); diff --git a/packages/engine/src/cli-agent/adapters/__tests__/droid.test.ts b/packages/engine/src/cli-agent/adapters/__tests__/droid.test.ts new file mode 100644 index 0000000000..316520fe31 --- /dev/null +++ b/packages/engine/src/cli-agent/adapters/__tests__/droid.test.ts @@ -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 `", () => { + 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 ` 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).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).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[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"); + }); +}); diff --git a/packages/engine/src/cli-agent/adapters/__tests__/pi.test.ts b/packages/engine/src/cli-agent/adapters/__tests__/pi.test.ts new file mode 100644 index 0000000000..16e43e4502 --- /dev/null +++ b/packages/engine/src/cli-agent/adapters/__tests__/pi.test.ts @@ -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 ` (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) { + 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"); + }); +}); diff --git a/packages/engine/src/cli-agent/adapters/codex.ts b/packages/engine/src/cli-agent/adapters/codex.ts new file mode 100644 index 0000000000..974539ae2c --- /dev/null +++ b/packages/engine/src/cli-agent/adapters/codex.ts @@ -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 `. + * + * ── 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--.jsonl` + * with a first line `{type:"session_meta", payload:{ 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 ` 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 = ["", ...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=`). */ + model?: string; + /** + * Absolute path to the session-scoped notify program (from U17). When present + * the adapter appends `-c notify=[""]` 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 + * `/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 `[""]`. 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, 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 (`/YYYY/MM/DD/rollout--.jsonl`) is + * version-sensitive (community-sourced), so we DO NOT hardcode the dated path — + * we recursively search for a `rollout-**.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; + try { + const parsed = JSON.parse(trimmed); + if (!parsed || typeof parsed !== "object") return null; + obj = parsed as Record; + } 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 | 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; + 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 ` 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 }; + }, +}; diff --git a/packages/engine/src/cli-agent/adapters/droid.ts b/packages/engine/src/cli-agent/adapters/droid.ts new file mode 100644 index 0000000000..00c5350062 --- /dev/null +++ b/packages/engine/src/cli-agent/adapters/droid.ts @@ -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 ` (`-r`/`--resume`). + * - HEADLESS: `droid exec -s ` — `-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 to + * runtime settings file merged for this process only" — the session-scoped + * hook-config seam), `--cwd `, `--fork `. + * - `droid exec --help`: `-s, --session-id ` ("Existing session to + * continue (requires a prompt)"), `-r, --reasoning-effort `, + * `-o, --output-format `, `--auto `, + * `--skip-permissions-unsafe`. CONFIRMS the `-r` footgun. + * + * ── Assumed (marked so wiring composes; revisit on drift) ── + * - HOOK CONFIG MECHANISM. Droid's `--settings ` 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 `). */ + 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, + ): TelemetryEvent => { + const payloadOut: Record = { ...(base.payload ?? {}), ...(extra ?? {}) }; + if (nativeSessionId) payloadOut.nativeSessionId = nativeSessionId; + return { kind: base.kind, payload: payloadOut }; + }; + + switch (event) { + case "SessionStart": { + const extra: Record = {}; + 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; + try { + const parsed = JSON.parse(trimmed); + if (!parsed || typeof parsed !== "object") return null; + obj = parsed as Record; + } catch { + return null; + } + const role = normalizeRole(obj); + const text = extractText(obj); + if (text.length === 0) return null; + return { role, text }; +} + +function normalizeRole(obj: Record): DroidTranscriptEntry["role"] { + const message = obj.message as Record | 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 { + const message = obj.message as Record | 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; + 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 ` vs + // interactive `--resume `. 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 ` — 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 `. + 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 }; + }, +}; diff --git a/packages/engine/src/cli-agent/adapters/pi.ts b/packages/engine/src/cli-agent/adapters/pi.ts new file mode 100644 index 0000000000..8b8dff8d10 --- /dev/null +++ b/packages/engine/src/cli-agent/adapters/pi.ts @@ -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 ` 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 `. + * + * ── Verified against the installed binary (Pi) ── + * - `pi` is on PATH (`/opt/homebrew/bin/pi`). + * - `pi --help`: `--session ` ("Use specific session file or partial + * UUID"), `--session-dir ` ("Directory for session storage and lookup"), + * `--mode ` ("Output mode: text (default), json, or rpc"), + * `--resume, -r` (interactive picker), `--continue, -c`, `--no-session`, + * `--print, -p` (non-interactive), `--fork `. + * - Session JSONL layout CONFIRMED by inspecting real files under + * `~/.pi/agent/sessions//_.jsonl`: a first line + * `{type:"session", version, id:, 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 `). */ + model?: string; + /** Provider override (`--provider `). */ + 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 + * `_.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 } + | { 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): 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 | 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; + 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; + try { + const parsed = JSON.parse(trimmed); + if (!parsed || typeof parsed !== "object") return null; + obj = parsed as Record; + } 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 ` 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 }; + }, +};