Merge U5: Codex, Droid, and Pi cli-agent adapters (binaries probed; exec -r footgun guarded)
This commit is contained in:
13
.changeset/cli-agent-codex-droid-pi-adapters.md
Normal file
13
.changeset/cli-agent-codex-droid-pi-adapters.md
Normal file
@@ -0,0 +1,13 @@
|
||||
---
|
||||
"@runfusion/fusion": patch
|
||||
---
|
||||
|
||||
Add the Codex, Droid, and Pi CLI agent adapters (U5).
|
||||
|
||||
Three new launch adapters join the engine's CLI agent executor, each declaring honest, verified capability flags so surfaces can render tier differences:
|
||||
|
||||
- **Codex** (hybrid tier): native turn-complete via the session-scoped `notify` config program (`-c notify=[…]`), capturing `thread-id` as the native session id; waiting-on-input is inferred from ANSI-stripped PTY prompt-pattern heuristics (approval menus, idle composer markers, with a spinner/working override) because Codex has no native waiting signal; resume via `codex resume <thread-id>`; rollout JSONL transcript tailed by probing (not hardcoding) the sessions directory for the file matching the thread-id.
|
||||
- **Droid** (native tier): Claude-style hooks (`SessionStart`, `Stop`, `Notification`, tool-activity) delivering `session_id`/`transcript_path`/`permission_mode`; a message classifier splits the conflated `Notification` event into permission-request vs idle sub-reasons (both treated as waiting-on-input); resume via interactive `droid --resume <id>` or headless `droid exec -s <id>` — never the bare `-r` that means `--reasoning-effort` in exec mode.
|
||||
- **Pi** (native tier): telemetry and transcript from session-JSONL tailing under a session-scoped `--session-dir`; lifecycle events (turn/agent start→busy, end→done, input-request→waiting) plus message rows→transcript; resume via `pi --session <path|partial-uuid>`.
|
||||
|
||||
A new `session-jsonl` transcript source is added to the adapter capability union for Pi.
|
||||
@@ -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). */
|
||||
|
||||
354
packages/engine/src/cli-agent/adapters/__tests__/codex.test.ts
Normal file
354
packages/engine/src/cli-agent/adapters/__tests__/codex.test.ts
Normal file
@@ -0,0 +1,354 @@
|
||||
import { describe, it, expect, beforeEach, afterEach } from "vitest";
|
||||
import { mkdtempSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { tmpdir } from "node:os";
|
||||
import { rm } from "node:fs/promises";
|
||||
import { Database, CliSessionStore } from "@fusion/core";
|
||||
import { TelemetryHub, type TelemetryEvent } from "../../telemetry-hub.js";
|
||||
import {
|
||||
codexAdapter,
|
||||
CODEX_CAPABILITIES,
|
||||
buildNotifyOverrideArg,
|
||||
codexSessionHomeLayout,
|
||||
mapNotifyPayload,
|
||||
parseNotifyPayload,
|
||||
CodexWaitingAnalyzer,
|
||||
CodexRolloutTailer,
|
||||
CodexReadinessDetector,
|
||||
findRolloutPath,
|
||||
type DirentLike,
|
||||
} from "../codex.js";
|
||||
|
||||
// ── fake fs for findRolloutPath ────────────────────────────────────────────────
|
||||
|
||||
function dir(name: string): DirentLike {
|
||||
return { name, isDirectory: () => true };
|
||||
}
|
||||
function file(name: string): DirentLike {
|
||||
return { name, isDirectory: () => false };
|
||||
}
|
||||
|
||||
describe("codexAdapter — capabilities + identity", () => {
|
||||
it("declares the HYBRID tier capability flags (nativeWaiting OFF)", () => {
|
||||
expect(codexAdapter.id).toBe("codex");
|
||||
expect(codexAdapter.capabilities).toEqual({
|
||||
nativeDone: true,
|
||||
nativeWaiting: false,
|
||||
transcriptSource: "jsonl",
|
||||
supportsResume: true,
|
||||
});
|
||||
expect(CODEX_CAPABILITIES).toEqual(codexAdapter.capabilities);
|
||||
});
|
||||
});
|
||||
|
||||
describe("codexAdapter — buildLaunch + notify override", () => {
|
||||
it("launches bare `codex` with no notify program", () => {
|
||||
const spec = codexAdapter.buildLaunch({ settings: {}, posture: null });
|
||||
expect(spec.command).toBe("codex");
|
||||
expect(spec.args).toEqual([]);
|
||||
});
|
||||
|
||||
it("appends `-c notify=[...]` when a session-scoped notify program is set", () => {
|
||||
const spec = codexAdapter.buildLaunch({
|
||||
settings: { notifyProgram: "/tmp/sess/notify.sh" },
|
||||
posture: null,
|
||||
});
|
||||
const idx = spec.args.indexOf("-c");
|
||||
expect(idx).toBeGreaterThanOrEqual(0);
|
||||
expect(spec.args[idx + 1]).toBe('notify=["/tmp/sess/notify.sh"]');
|
||||
});
|
||||
|
||||
it("buildNotifyOverrideArg returns empty for a missing program", () => {
|
||||
expect(buildNotifyOverrideArg(undefined)).toEqual([]);
|
||||
expect(buildNotifyOverrideArg("")).toEqual([]);
|
||||
});
|
||||
|
||||
it("sets the model via `-c model=` so it composes with notify", () => {
|
||||
const spec = codexAdapter.buildLaunch({
|
||||
settings: { model: "gpt-5.4", notifyProgram: "/n.sh" },
|
||||
posture: null,
|
||||
});
|
||||
expect(spec.args).toContain("model=\"gpt-5.4\"");
|
||||
expect(spec.args).toContain('notify=["/n.sh"]');
|
||||
});
|
||||
|
||||
it("emits the privileged bypass ONLY when posture.autoApprove is true", () => {
|
||||
const off = codexAdapter.buildLaunch({ settings: {}, posture: { autoApprove: false } });
|
||||
expect(off.args).not.toContain("--dangerously-bypass-approvals-and-sandbox");
|
||||
const on = codexAdapter.buildLaunch({ settings: {}, posture: { autoApprove: true } });
|
||||
expect(on.args).toContain("--dangerously-bypass-approvals-and-sandbox");
|
||||
});
|
||||
|
||||
it("env allowlist includes CODEX_HOME, excludes FUSION_* / service creds", () => {
|
||||
const allow = codexAdapter.buildEnvAllowlist({ settings: {}, posture: null });
|
||||
expect(allow).toContain("PATH");
|
||||
expect(allow).toContain("CODEX_HOME");
|
||||
expect(allow.some((k) => k.startsWith("FUSION_"))).toBe(false);
|
||||
});
|
||||
|
||||
it("codexSessionHomeLayout describes the layered scratch CODEX_HOME", () => {
|
||||
const layout = codexSessionHomeLayout("/tmp/sess/codex-home");
|
||||
expect(layout).toEqual({
|
||||
home: "/tmp/sess/codex-home",
|
||||
configPath: "/tmp/sess/codex-home/config.toml",
|
||||
authPath: "/tmp/sess/codex-home/auth.json",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("codexAdapter — buildResume", () => {
|
||||
it("produces `codex resume <thread-id>` (AE3)", () => {
|
||||
const spec = codexAdapter.buildResume!({
|
||||
settings: {},
|
||||
posture: null,
|
||||
nativeSessionId: "thread-abc",
|
||||
});
|
||||
expect(spec.command).toBe("codex");
|
||||
expect(spec.args.slice(0, 2)).toEqual(["resume", "thread-abc"]);
|
||||
});
|
||||
|
||||
it("re-applies notify + model on resume", () => {
|
||||
const spec = codexAdapter.buildResume!({
|
||||
settings: { notifyProgram: "/n.sh", model: "gpt-5.4" },
|
||||
posture: { autoApprove: true },
|
||||
nativeSessionId: "t9",
|
||||
});
|
||||
expect(spec.args.slice(0, 2)).toEqual(["resume", "t9"]);
|
||||
expect(spec.args).toContain('notify=["/n.sh"]');
|
||||
expect(spec.args).toContain("--dangerously-bypass-approvals-and-sandbox");
|
||||
});
|
||||
});
|
||||
|
||||
describe("codexAdapter — formatInjection", () => {
|
||||
it("appends a trailing \\r submit", () => {
|
||||
expect(codexAdapter.formatInjection("hello", { bracketedPasteActive: false })).toEqual({
|
||||
payload: "hello\r",
|
||||
});
|
||||
});
|
||||
it("does not double the trailing \\r", () => {
|
||||
expect(codexAdapter.formatInjection("hi\r", { bracketedPasteActive: true })).toEqual({
|
||||
payload: "hi\r",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("mapNotifyPayload — native done via notify", () => {
|
||||
it("agent-turn-complete → done, capturing thread-id as nativeSessionId", () => {
|
||||
const ev = mapNotifyPayload({
|
||||
type: "agent-turn-complete",
|
||||
"thread-id": "T1",
|
||||
"turn-id": "U1",
|
||||
cwd: "/repo",
|
||||
"last-assistant-message": "all done",
|
||||
});
|
||||
expect(ev?.kind).toBe("done");
|
||||
expect(ev?.payload?.nativeSessionId).toBe("T1");
|
||||
expect(ev?.payload?.turnId).toBe("U1");
|
||||
expect(ev?.payload?.lastAssistantMessage).toBe("all done");
|
||||
expect(ev?.payload?.cwd).toBe("/repo");
|
||||
});
|
||||
|
||||
it("tolerates snake_case / camelCase key spellings", () => {
|
||||
expect(mapNotifyPayload({ type: "agent-turn-complete", thread_id: "T2" })?.payload?.nativeSessionId).toBe(
|
||||
"T2",
|
||||
);
|
||||
expect(mapNotifyPayload({ type: "agent-turn-complete", threadId: "T3" })?.payload?.nativeSessionId).toBe(
|
||||
"T3",
|
||||
);
|
||||
});
|
||||
|
||||
it("ignores non-turn-complete payloads", () => {
|
||||
expect(mapNotifyPayload({ type: "something-else", "thread-id": "X" })).toBeNull();
|
||||
expect(mapNotifyPayload({})).toBeNull();
|
||||
});
|
||||
|
||||
it("parseNotifyPayload parses the raw JSON arg and never throws", () => {
|
||||
expect(parseNotifyPayload('{"type":"agent-turn-complete","thread-id":"Z"}')?.kind).toBe("done");
|
||||
expect(parseNotifyPayload("not json")).toBeNull();
|
||||
expect(parseNotifyPayload("[]")).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("CodexWaitingAnalyzer — heuristic waiting detection (hybrid fallback)", () => {
|
||||
function collect(): { events: TelemetryEvent[]; analyzer: CodexWaitingAnalyzer } {
|
||||
const events: TelemetryEvent[] = [];
|
||||
const analyzer = new CodexWaitingAnalyzer({ emit: (e) => events.push(e) });
|
||||
return { events, analyzer };
|
||||
}
|
||||
|
||||
it("detects an approval prompt buried in ANSI noise → waitingOnInput", () => {
|
||||
const { events, analyzer } = collect();
|
||||
// Approval menu wrapped in ANSI color codes (the "noise included" scenario).
|
||||
const ansi =
|
||||
"\x1b[1m\x1b[33mApply this patch?\x1b[0m\n" +
|
||||
"\x1b[2m1. Yes\x1b[0m\n\x1b[2m2. No\x1b[0m\n";
|
||||
analyzer.observe(ansi);
|
||||
expect(events).toHaveLength(1);
|
||||
expect(events[0].kind).toBe("waitingOnInput");
|
||||
expect((events[0].payload?.notification as Record<string, unknown>).kind).toBe(
|
||||
"approval_prompt",
|
||||
);
|
||||
expect((events[0].payload?.notification as Record<string, unknown>).source).toBe("heuristic");
|
||||
});
|
||||
|
||||
it("detects a bare y/n prompt at the trailing edge", () => {
|
||||
const { events, analyzer } = collect();
|
||||
analyzer.observe("Run command `rm -rf build`? (y/n) ");
|
||||
expect(events.map((e) => e.kind)).toEqual(["waitingOnInput"]);
|
||||
});
|
||||
|
||||
it("detects an idle 'enter to send' composer marker → idle_prompt", () => {
|
||||
const { events, analyzer } = collect();
|
||||
analyzer.observe("\x1b[90m enter to send \x1b[0m");
|
||||
expect(events).toHaveLength(1);
|
||||
expect((events[0].payload?.notification as Record<string, unknown>).kind).toBe("idle_prompt");
|
||||
});
|
||||
|
||||
it("a working/spinner marker OVERRIDES a prompt (still busy)", () => {
|
||||
const { events, analyzer } = collect();
|
||||
analyzer.observe("Working… esc to interrupt y/n");
|
||||
expect(events).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("de-dupes a repeated prompt and re-arms on fresh non-prompt output", () => {
|
||||
const { events, analyzer } = collect();
|
||||
analyzer.observe("Approve? (y/n) ");
|
||||
analyzer.observe("Approve? (y/n) "); // still waiting → no second emit
|
||||
expect(events).toHaveLength(1);
|
||||
analyzer.observe("\nreading files...\n"); // fresh output re-arms
|
||||
analyzer.observe("Approve? (y/n) ");
|
||||
expect(events).toHaveLength(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe("findRolloutPath — probe, don't hardcode the dated layout", () => {
|
||||
it("finds rollout-<...>-<thread-id>.jsonl under a dated subtree", () => {
|
||||
const fs = {
|
||||
readdirSync(p: string): DirentLike[] {
|
||||
if (p === "/sessions") return [dir("2026")];
|
||||
if (p === "/sessions/2026") return [dir("05")];
|
||||
if (p === "/sessions/2026/05") return [dir("03")];
|
||||
if (p === "/sessions/2026/05/03") {
|
||||
return [
|
||||
file("rollout-2026-05-03T17-03-33-other.jsonl"),
|
||||
file("rollout-2026-05-03T20-08-32-THREAD42.jsonl"),
|
||||
];
|
||||
}
|
||||
return [];
|
||||
},
|
||||
};
|
||||
expect(findRolloutPath("/sessions", "THREAD42", fs)).toBe(
|
||||
"/sessions/2026/05/03/rollout-2026-05-03T20-08-32-THREAD42.jsonl",
|
||||
);
|
||||
});
|
||||
|
||||
it("returns null when no file matches / dir missing (tolerant)", () => {
|
||||
const fs = {
|
||||
readdirSync(p: string): DirentLike[] {
|
||||
if (p === "/sessions") return [file("rollout-x-AAA.jsonl")];
|
||||
throw new Error("ENOENT");
|
||||
},
|
||||
};
|
||||
expect(findRolloutPath("/sessions", "ZZZ", fs)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("CodexRolloutTailer — incremental rollout JSONL tail", () => {
|
||||
it("yields only response_item message rows, incrementally, with offset", () => {
|
||||
const tailer = new CodexRolloutTailer();
|
||||
const meta =
|
||||
JSON.stringify({ type: "session_meta", payload: { id: "T1", cwd: "/r" } }) + "\n";
|
||||
const started =
|
||||
JSON.stringify({ type: "event_msg", payload: { type: "task_started" } }) + "\n";
|
||||
const msg =
|
||||
JSON.stringify({
|
||||
type: "response_item",
|
||||
payload: { type: "message", role: "user", content: [{ type: "input_text", text: "hi" }] },
|
||||
}) + "\n";
|
||||
|
||||
const first = tailer.push(meta + started); // no chat rows
|
||||
expect(first).toEqual([]);
|
||||
const second = tailer.push(msg);
|
||||
expect(second).toEqual([{ role: "user", text: "hi" }]);
|
||||
expect(tailer.bytesRead).toBe(Buffer.byteLength(meta + started + msg, "utf8"));
|
||||
});
|
||||
|
||||
it("holds a partial trailing line until its newline arrives", () => {
|
||||
const tailer = new CodexRolloutTailer();
|
||||
const line = JSON.stringify({
|
||||
type: "response_item",
|
||||
payload: { type: "message", role: "assistant", content: [{ type: "output_text", text: "ok" }] },
|
||||
});
|
||||
expect(tailer.push(line.slice(0, 20))).toEqual([]);
|
||||
expect(tailer.push(line.slice(20) + "\n")).toEqual([{ role: "assistant", text: "ok" }]);
|
||||
});
|
||||
|
||||
it("skips unparseable lines without throwing", () => {
|
||||
const tailer = new CodexRolloutTailer();
|
||||
expect(tailer.push("{bad}\n\n")).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("CodexReadinessDetector", () => {
|
||||
it("becomes ready on bracketed-paste enable", () => {
|
||||
const d = new CodexReadinessDetector();
|
||||
expect(d.observe("loading\n")).toBe(false);
|
||||
expect(d.observe("\x1b[?2004h")).toBe(true);
|
||||
expect(d.observe("x")).toBe(true); // latches
|
||||
});
|
||||
it("falls back to a composer prompt glyph", () => {
|
||||
const d = new CodexReadinessDetector();
|
||||
expect(d.observe("welcome\n")).toBe(false);
|
||||
expect(d.observe("\n❯")).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("end-to-end via TelemetryHub: notify done + heuristic waiting", () => {
|
||||
let tmpDir: string;
|
||||
let db: Database;
|
||||
let store: CliSessionStore;
|
||||
let hub: TelemetryHub;
|
||||
let sessionId: string;
|
||||
|
||||
beforeEach(() => {
|
||||
tmpDir = mkdtempSync(join(tmpdir(), "kb-codex-e2e-"));
|
||||
const fusionDir = join(tmpDir, ".fusion");
|
||||
db = new Database(fusionDir, { inMemory: true });
|
||||
db.init();
|
||||
store = new CliSessionStore(fusionDir, db);
|
||||
const rec = store.createSession({
|
||||
purpose: "execute",
|
||||
projectId: "p1",
|
||||
adapterId: "codex",
|
||||
agentState: "starting",
|
||||
});
|
||||
sessionId = rec.id;
|
||||
hub = new TelemetryHub({ store });
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
db.close();
|
||||
await rm(tmpDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it("notify agent-turn-complete drives busy → done and captures thread-id", () => {
|
||||
const machine = hub.getStateMachine(sessionId)!;
|
||||
machine.markReady();
|
||||
machine.injectPrompt(); // ready → busy
|
||||
|
||||
const waiting: TelemetryEvent[] = [];
|
||||
const analyzer = new CodexWaitingAnalyzer({ emit: (e) => waiting.push(e) });
|
||||
analyzer.observe("Approve patch? (y/n) ");
|
||||
for (const e of waiting) hub.ingest(sessionId, e);
|
||||
expect(machine.getState()).toBe("waitingOnInput");
|
||||
|
||||
// user answers → busy again (hub `busy` route)
|
||||
hub.ingest(sessionId, { kind: "busy" });
|
||||
expect(machine.getState()).toBe("busy");
|
||||
|
||||
const done = parseNotifyPayload('{"type":"agent-turn-complete","thread-id":"native-T"}');
|
||||
hub.ingest(sessionId, done!);
|
||||
expect(machine.getState()).toBe("done");
|
||||
expect(store.getSession(sessionId)?.nativeSessionId).toBe("native-T");
|
||||
});
|
||||
});
|
||||
297
packages/engine/src/cli-agent/adapters/__tests__/droid.test.ts
Normal file
297
packages/engine/src/cli-agent/adapters/__tests__/droid.test.ts
Normal file
@@ -0,0 +1,297 @@
|
||||
import { describe, it, expect, beforeEach, afterEach } from "vitest";
|
||||
import { mkdtempSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { tmpdir } from "node:os";
|
||||
import { rm } from "node:fs/promises";
|
||||
import { Database, CliSessionStore } from "@fusion/core";
|
||||
import { TelemetryHub } from "../../telemetry-hub.js";
|
||||
import {
|
||||
droidAdapter,
|
||||
DROID_CAPABILITIES,
|
||||
buildDroidSettings,
|
||||
classifyNotification,
|
||||
mapHookPayload,
|
||||
parseHookPayload,
|
||||
classifyStop,
|
||||
DroidTranscriptTailer,
|
||||
DroidReadinessDetector,
|
||||
type DroidHookScriptRefs,
|
||||
} from "../droid.js";
|
||||
|
||||
const SCRIPTS: DroidHookScriptRefs = {
|
||||
stopScript: "/tmp/sess/hooks/stop.sh",
|
||||
notificationScript: "/tmp/sess/hooks/notify.sh",
|
||||
sessionStartScript: "/tmp/sess/hooks/start.sh",
|
||||
};
|
||||
|
||||
describe("droidAdapter — capabilities + identity", () => {
|
||||
it("declares the native tier capability flags", () => {
|
||||
expect(droidAdapter.id).toBe("droid");
|
||||
expect(droidAdapter.capabilities).toEqual({
|
||||
nativeDone: true,
|
||||
nativeWaiting: true,
|
||||
transcriptSource: "jsonl",
|
||||
supportsResume: true,
|
||||
});
|
||||
expect(DROID_CAPABILITIES).toEqual(droidAdapter.capabilities);
|
||||
});
|
||||
});
|
||||
|
||||
describe("droidAdapter — buildLaunch + settings", () => {
|
||||
it("launches bare `droid` with no hook scripts", () => {
|
||||
const spec = droidAdapter.buildLaunch({ settings: {}, posture: null });
|
||||
expect(spec.command).toBe("droid");
|
||||
expect(spec.args).toEqual([]);
|
||||
});
|
||||
|
||||
it("builds the Claude-style hooks settings for the core events", () => {
|
||||
const doc = buildDroidSettings(SCRIPTS);
|
||||
expect(Object.keys(doc.hooks).sort()).toEqual(["Notification", "SessionStart", "Stop"]);
|
||||
expect(doc.hooks.Stop[0].hooks[0].command).toBe(SCRIPTS.stopScript);
|
||||
expect(doc.hooks.Notification[0].hooks[0].command).toBe(SCRIPTS.notificationScript);
|
||||
});
|
||||
|
||||
it("registers tool-activity hooks only when a toolActivityScript is provided", () => {
|
||||
const doc = buildDroidSettings({ ...SCRIPTS, toolActivityScript: "/tmp/act.sh" });
|
||||
expect(doc.hooks.PreToolUse[0].hooks[0].command).toBe("/tmp/act.sh");
|
||||
expect(doc.hooks.PostToolUse).toBeDefined();
|
||||
});
|
||||
|
||||
it("inlines settings via --settings when no settingsPath given", () => {
|
||||
const spec = droidAdapter.buildLaunch({ settings: { hookScripts: SCRIPTS }, posture: null });
|
||||
const idx = spec.args.indexOf("--settings");
|
||||
expect(idx).toBeGreaterThanOrEqual(0);
|
||||
expect(JSON.parse(spec.args[idx + 1]).hooks.Stop[0].hooks[0].command).toBe(SCRIPTS.stopScript);
|
||||
});
|
||||
|
||||
it("emits `--auto high` ONLY when posture.autoApprove is true", () => {
|
||||
const off = droidAdapter.buildLaunch({ settings: {}, posture: { autoApprove: false } });
|
||||
expect(off.args).not.toContain("--auto");
|
||||
const on = droidAdapter.buildLaunch({ settings: {}, posture: { autoApprove: true } });
|
||||
expect(on.args).toEqual(expect.arrayContaining(["--auto", "high"]));
|
||||
});
|
||||
|
||||
it("env allowlist excludes FUSION_* / service credentials", () => {
|
||||
const allow = droidAdapter.buildEnvAllowlist({ settings: {}, posture: null });
|
||||
expect(allow).toContain("PATH");
|
||||
expect(allow).toContain("FACTORY_API_KEY");
|
||||
expect(allow.some((k) => k.startsWith("FUSION_"))).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("droidAdapter — buildResume (the `-r` footgun)", () => {
|
||||
it("interactive: `droid --resume <id>`", () => {
|
||||
const spec = droidAdapter.buildResume!({
|
||||
settings: {},
|
||||
posture: null,
|
||||
nativeSessionId: "sess-1",
|
||||
});
|
||||
expect(spec.command).toBe("droid");
|
||||
expect(spec.args.slice(0, 2)).toEqual(["--resume", "sess-1"]);
|
||||
});
|
||||
|
||||
it("headless exec: `droid exec -s <id>` and NEVER a bare `-r`", () => {
|
||||
const spec = droidAdapter.buildResume!({
|
||||
settings: { execMode: true } as never,
|
||||
posture: null,
|
||||
nativeSessionId: "sess-2",
|
||||
});
|
||||
expect(spec.args.slice(0, 3)).toEqual(["exec", "-s", "sess-2"]);
|
||||
// THE FOOTGUN: in exec mode `-r` means --reasoning-effort, not resume.
|
||||
expect(spec.args).not.toContain("-r");
|
||||
});
|
||||
|
||||
it("headless exec NEVER emits `-r` even with model + autoApprove", () => {
|
||||
const spec = droidAdapter.buildResume!({
|
||||
settings: { execMode: true, model: "claude-opus-4-7" } as never,
|
||||
posture: { autoApprove: true },
|
||||
nativeSessionId: "sess-3",
|
||||
});
|
||||
expect(spec.args).not.toContain("-r");
|
||||
expect(spec.args).toContain("-s");
|
||||
expect(spec.args).toEqual(expect.arrayContaining(["--model", "claude-opus-4-7"]));
|
||||
});
|
||||
});
|
||||
|
||||
describe("droidAdapter — formatInjection", () => {
|
||||
it("appends a trailing \\r submit, no doubling", () => {
|
||||
expect(droidAdapter.formatInjection("hello", { bracketedPasteActive: false })).toEqual({
|
||||
payload: "hello\r",
|
||||
});
|
||||
expect(droidAdapter.formatInjection("hi\r", { bracketedPasteActive: true })).toEqual({
|
||||
payload: "hi\r",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("classifyNotification — the conflated Notification discriminator", () => {
|
||||
it("classifies permission wording as permission_request", () => {
|
||||
expect(classifyNotification("Droid wants to run `npm test` — approve?")).toBe(
|
||||
"permission_request",
|
||||
);
|
||||
expect(classifyNotification("Permission needed to edit file")).toBe("permission_request");
|
||||
});
|
||||
|
||||
it("classifies idle wording as idle_prompt", () => {
|
||||
expect(classifyNotification("Still waiting for your input")).toBe("idle_prompt");
|
||||
expect(classifyNotification("Session has been idle for 60s")).toBe("idle_prompt");
|
||||
});
|
||||
|
||||
it("defaults an ambiguous/bare ping to idle_prompt", () => {
|
||||
expect(classifyNotification("Notification")).toBe("idle_prompt");
|
||||
expect(classifyNotification(undefined)).toBe("idle_prompt");
|
||||
});
|
||||
|
||||
it("permission wording wins when both are present", () => {
|
||||
expect(classifyNotification("Idle — but Droid wants to approve a command")).toBe(
|
||||
"permission_request",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("mapHookPayload — telemetry mapping", () => {
|
||||
it("SessionStart → sessionStart capturing session_id + transcript_path + permission_mode", () => {
|
||||
const ev = mapHookPayload({
|
||||
hook_event_name: "SessionStart",
|
||||
session_id: "S1",
|
||||
transcript_path: "/t.jsonl",
|
||||
permission_mode: "auto",
|
||||
source: "startup",
|
||||
});
|
||||
expect(ev?.kind).toBe("sessionStart");
|
||||
expect(ev?.payload?.nativeSessionId).toBe("S1");
|
||||
expect(ev?.payload?.transcriptPath).toBe("/t.jsonl");
|
||||
expect(ev?.payload?.permissionMode).toBe("auto");
|
||||
});
|
||||
|
||||
it("Notification{permission} → waitingOnInput tagged permission_request", () => {
|
||||
const ev = mapHookPayload({
|
||||
hook_event_name: "Notification",
|
||||
session_id: "S1",
|
||||
message: "Droid wants to run a command — approve?",
|
||||
});
|
||||
expect(ev?.kind).toBe("waitingOnInput");
|
||||
expect((ev?.payload?.notification as Record<string, unknown>).kind).toBe("permission_request");
|
||||
});
|
||||
|
||||
it("Notification{idle} → waitingOnInput tagged idle_prompt", () => {
|
||||
const ev = mapHookPayload({
|
||||
hook_event_name: "Notification",
|
||||
message: "Waiting for your input (idle 60s)",
|
||||
});
|
||||
expect(ev?.kind).toBe("waitingOnInput");
|
||||
expect((ev?.payload?.notification as Record<string, unknown>).kind).toBe("idle_prompt");
|
||||
});
|
||||
|
||||
it("PreToolUse/PostToolUse → toolActivity; Stop → done", () => {
|
||||
expect(mapHookPayload({ hook_event_name: "PreToolUse", tool_name: "Bash" })?.kind).toBe(
|
||||
"toolActivity",
|
||||
);
|
||||
expect(mapHookPayload({ hook_event_name: "Stop", session_id: "S1" })?.kind).toBe("done");
|
||||
});
|
||||
|
||||
it("tolerates missing fields; unknown event → null unless a session id", () => {
|
||||
expect(mapHookPayload({ hook_event_name: "Stop" })?.kind).toBe("done");
|
||||
expect(mapHookPayload({})).toBeNull();
|
||||
expect(mapHookPayload({ hook_event_name: "Weird", session_id: "S" })?.kind).toBe(
|
||||
"outputProgress",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("classifyStop — failure downgrade", () => {
|
||||
it("clean Stop → done; error-ish stop_reason → toolActivity", () => {
|
||||
expect(classifyStop({ hook_event_name: "Stop" }).kind).toBe("done");
|
||||
const ev = classifyStop({ hook_event_name: "Stop", stop_reason: "error_aborted" });
|
||||
expect(ev.kind).toBe("toolActivity");
|
||||
expect(ev.payload?.stopReason).toBe("error_aborted");
|
||||
});
|
||||
});
|
||||
|
||||
describe("parseHookPayload — raw stdin parsing", () => {
|
||||
it("parses JSON and never throws", () => {
|
||||
expect(parseHookPayload('{"hook_event_name":"Stop","session_id":"S1"}')?.kind).toBe("done");
|
||||
expect(parseHookPayload("not json")).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("DroidTranscriptTailer — incremental JSONL tail", () => {
|
||||
it("yields entries incrementally with offset tracking", () => {
|
||||
const tailer = new DroidTranscriptTailer();
|
||||
const l1 = JSON.stringify({ message: { role: "user", content: "hi" } }) + "\n";
|
||||
expect(tailer.push(l1)).toEqual([{ role: "user", text: "hi" }]);
|
||||
const l2 = JSON.stringify({ message: { role: "assistant", content: [{ type: "text", text: "yo" }] } }) + "\n";
|
||||
expect(tailer.push(l2)).toEqual([{ role: "assistant", text: "yo" }]);
|
||||
expect(tailer.bytesRead).toBe(Buffer.byteLength(l1 + l2, "utf8"));
|
||||
});
|
||||
|
||||
it("holds a partial line and flushes an unterminated final line", () => {
|
||||
const tailer = new DroidTranscriptTailer();
|
||||
const full = JSON.stringify({ role: "tool", content: "result" });
|
||||
expect(tailer.push(full.slice(0, 8))).toEqual([]);
|
||||
expect(tailer.push(full.slice(8))).toEqual([]);
|
||||
expect(tailer.flush()).toEqual([{ role: "tool", text: "result" }]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("DroidReadinessDetector", () => {
|
||||
it("ready on bracketed-paste enable or prompt glyph", () => {
|
||||
const a = new DroidReadinessDetector();
|
||||
expect(a.observe("loading\n")).toBe(false);
|
||||
expect(a.observe("\x1b[?2004h")).toBe(true);
|
||||
const b = new DroidReadinessDetector();
|
||||
expect(b.observe("hi\n")).toBe(false);
|
||||
expect(b.observe("\n❯ ")).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("end-to-end via TelemetryHub: SessionStart → Notification → Stop", () => {
|
||||
let tmpDir: string;
|
||||
let db: Database;
|
||||
let store: CliSessionStore;
|
||||
let hub: TelemetryHub;
|
||||
let sessionId: string;
|
||||
|
||||
beforeEach(() => {
|
||||
tmpDir = mkdtempSync(join(tmpdir(), "kb-droid-e2e-"));
|
||||
const fusionDir = join(tmpDir, ".fusion");
|
||||
db = new Database(fusionDir, { inMemory: true });
|
||||
db.init();
|
||||
store = new CliSessionStore(fusionDir, db);
|
||||
const rec = store.createSession({
|
||||
purpose: "execute",
|
||||
projectId: "p1",
|
||||
adapterId: "droid",
|
||||
agentState: "starting",
|
||||
});
|
||||
sessionId = rec.id;
|
||||
hub = new TelemetryHub({ store });
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
db.close();
|
||||
await rm(tmpDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
function feed(p: Parameters<typeof mapHookPayload>[0]) {
|
||||
const ev = mapHookPayload(p);
|
||||
if (ev) hub.ingest(sessionId, ev);
|
||||
}
|
||||
|
||||
it("drives ready → busy → waitingOnInput → busy → done; captures session_id", () => {
|
||||
feed({ hook_event_name: "SessionStart", session_id: "native-d", transcript_path: "/t.jsonl" });
|
||||
expect(hub.getStateMachine(sessionId)?.getState()).toBe("ready");
|
||||
expect(store.getSession(sessionId)?.nativeSessionId).toBe("native-d");
|
||||
|
||||
hub.getStateMachine(sessionId)!.injectPrompt(); // ready → busy
|
||||
feed({ hook_event_name: "Notification", session_id: "native-d", message: "approve command?" });
|
||||
expect(hub.getStateMachine(sessionId)?.getState()).toBe("waitingOnInput");
|
||||
|
||||
feed({ hook_event_name: "PreToolUse" }); // tolerated activity; no advance
|
||||
hub.ingest(sessionId, { kind: "busy" }); // user answered
|
||||
expect(hub.getStateMachine(sessionId)?.getState()).toBe("busy");
|
||||
|
||||
feed({ hook_event_name: "Stop", session_id: "native-d" });
|
||||
expect(hub.getStateMachine(sessionId)?.getState()).toBe("done");
|
||||
});
|
||||
});
|
||||
279
packages/engine/src/cli-agent/adapters/__tests__/pi.test.ts
Normal file
279
packages/engine/src/cli-agent/adapters/__tests__/pi.test.ts
Normal file
@@ -0,0 +1,279 @@
|
||||
import { describe, it, expect, beforeEach, afterEach } from "vitest";
|
||||
import { mkdtempSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { tmpdir } from "node:os";
|
||||
import { rm } from "node:fs/promises";
|
||||
import { Database, CliSessionStore } from "@fusion/core";
|
||||
import { TelemetryHub } from "../../telemetry-hub.js";
|
||||
import {
|
||||
piAdapter,
|
||||
PI_CAPABILITIES,
|
||||
mapSessionLine,
|
||||
toTelemetryEvent,
|
||||
PiSessionTailer,
|
||||
PiReadinessDetector,
|
||||
findSessionFile,
|
||||
type DirentLike,
|
||||
} from "../pi.js";
|
||||
|
||||
function dir(name: string): DirentLike {
|
||||
return { name, isDirectory: () => true };
|
||||
}
|
||||
function file(name: string): DirentLike {
|
||||
return { name, isDirectory: () => false };
|
||||
}
|
||||
|
||||
describe("piAdapter — capabilities + identity", () => {
|
||||
it("declares the native tier capability flags (session-jsonl)", () => {
|
||||
expect(piAdapter.id).toBe("pi");
|
||||
expect(piAdapter.capabilities).toEqual({
|
||||
nativeDone: true,
|
||||
nativeWaiting: true,
|
||||
transcriptSource: "session-jsonl",
|
||||
supportsResume: true,
|
||||
});
|
||||
expect(PI_CAPABILITIES).toEqual(piAdapter.capabilities);
|
||||
});
|
||||
});
|
||||
|
||||
describe("piAdapter — buildLaunch", () => {
|
||||
it("launches bare `pi` with no settings", () => {
|
||||
const spec = piAdapter.buildLaunch({ settings: {}, posture: null });
|
||||
expect(spec.command).toBe("pi");
|
||||
expect(spec.args).toEqual([]);
|
||||
});
|
||||
|
||||
it("passes --provider, --model, and a session-scoped --session-dir", () => {
|
||||
const spec = piAdapter.buildLaunch({
|
||||
settings: { provider: "anthropic", model: "*sonnet*", sessionDir: "/tmp/sess/pi" },
|
||||
posture: null,
|
||||
});
|
||||
expect(spec.args).toEqual([
|
||||
"--provider",
|
||||
"anthropic",
|
||||
"--model",
|
||||
"*sonnet*",
|
||||
"--session-dir",
|
||||
"/tmp/sess/pi",
|
||||
]);
|
||||
});
|
||||
|
||||
it("widens tool access ONLY when posture.autoApprove is true", () => {
|
||||
const off = piAdapter.buildLaunch({ settings: {}, posture: { autoApprove: false } });
|
||||
expect(off.args).not.toContain("--tools");
|
||||
const on = piAdapter.buildLaunch({ settings: {}, posture: { autoApprove: true } });
|
||||
expect(on.args).toEqual(expect.arrayContaining(["--tools", "read,bash,edit,write"]));
|
||||
});
|
||||
|
||||
it("env allowlist excludes FUSION_* / service credentials", () => {
|
||||
const allow = piAdapter.buildEnvAllowlist({ settings: {}, posture: null });
|
||||
expect(allow).toContain("PATH");
|
||||
expect(allow).toContain("PI_CODING_AGENT_SESSION_DIR");
|
||||
expect(allow.some((k) => k.startsWith("FUSION_"))).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("piAdapter — buildResume", () => {
|
||||
it("produces `pi --session <id>` (partial-uuid or path)", () => {
|
||||
const spec = piAdapter.buildResume!({
|
||||
settings: { sessionDir: "/tmp/sess/pi" },
|
||||
posture: null,
|
||||
nativeSessionId: "0e64b2d0",
|
||||
});
|
||||
expect(spec.command).toBe("pi");
|
||||
expect(spec.args).toEqual(expect.arrayContaining(["--session", "0e64b2d0"]));
|
||||
// session-dir re-applied for lookup.
|
||||
expect(spec.args).toEqual(expect.arrayContaining(["--session-dir", "/tmp/sess/pi"]));
|
||||
});
|
||||
});
|
||||
|
||||
describe("piAdapter — formatInjection", () => {
|
||||
it("appends a trailing \\r submit, no doubling", () => {
|
||||
expect(piAdapter.formatInjection("hello", { bracketedPasteActive: false })).toEqual({
|
||||
payload: "hello\r",
|
||||
});
|
||||
expect(piAdapter.formatInjection("hi\r", { bracketedPasteActive: true })).toEqual({
|
||||
payload: "hi\r",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("mapSessionLine — session JSONL event mapping", () => {
|
||||
it("session header → sessionStart capturing the uuid as nativeSessionId", () => {
|
||||
const ev = mapSessionLine({ type: "session", version: 3, id: "uuid-1", cwd: "/r" });
|
||||
expect(ev).toEqual({ kind: "sessionStart", nativeSessionId: "uuid-1" });
|
||||
});
|
||||
|
||||
it("turn_start/agent_start → busy; turn_end/agent_end → done", () => {
|
||||
expect(mapSessionLine({ type: "turn_start" })).toEqual({ kind: "busy" });
|
||||
expect(mapSessionLine({ type: "agent_start" })).toEqual({ kind: "busy" });
|
||||
expect(mapSessionLine({ type: "turn_end" })).toEqual({ kind: "done" });
|
||||
expect(mapSessionLine({ type: "agent_end" })).toEqual({ kind: "done" });
|
||||
});
|
||||
|
||||
it("input-request events → waitingOnInput", () => {
|
||||
const ev = mapSessionLine({ type: "input_request" });
|
||||
expect(ev?.kind).toBe("waitingOnInput");
|
||||
const ev2 = mapSessionLine({ type: "ask_user" });
|
||||
expect(ev2?.kind).toBe("waitingOnInput");
|
||||
});
|
||||
|
||||
it("message rows → transcript (flattening text + thinking blocks)", () => {
|
||||
const ev = mapSessionLine({
|
||||
type: "message",
|
||||
message: { role: "assistant", content: [{ type: "thinking", thinking: "hmm" }, { type: "text", text: "answer" }] },
|
||||
});
|
||||
expect(ev).toEqual({ kind: "transcript", role: "assistant", text: "hmmanswer" });
|
||||
});
|
||||
|
||||
it("normalizes the toolResult role to tool", () => {
|
||||
const ev = mapSessionLine({
|
||||
type: "message",
|
||||
message: { role: "toolResult", content: [{ type: "text", text: "out" }] },
|
||||
});
|
||||
expect(ev).toEqual({ kind: "transcript", role: "tool", text: "out" });
|
||||
});
|
||||
|
||||
it("returns null for noise rows (model_change, thinking_level_change, empty)", () => {
|
||||
expect(mapSessionLine({ type: "model_change", provider: "x" })).toBeNull();
|
||||
expect(mapSessionLine({ type: "thinking_level_change" })).toBeNull();
|
||||
expect(mapSessionLine({ type: "message", message: { role: "user", content: [] } })).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("toTelemetryEvent — PiSessionEvent → hub TelemetryEvent", () => {
|
||||
it("maps lifecycle + transcript onto the hub contract", () => {
|
||||
expect(toTelemetryEvent({ kind: "sessionStart", nativeSessionId: "u" })).toEqual({
|
||||
kind: "sessionStart",
|
||||
payload: { nativeSessionId: "u" },
|
||||
});
|
||||
expect(toTelemetryEvent({ kind: "busy" })).toEqual({ kind: "busy", payload: {} });
|
||||
expect(toTelemetryEvent({ kind: "done" })).toEqual({ kind: "done", payload: {} });
|
||||
expect(toTelemetryEvent({ kind: "waitingOnInput", notification: { kind: "input_request" } })).toEqual({
|
||||
kind: "waitingOnInput",
|
||||
payload: { notification: { kind: "input_request" } },
|
||||
});
|
||||
expect(toTelemetryEvent({ kind: "transcript", role: "user", text: "hi" })).toEqual({
|
||||
kind: "transcript",
|
||||
payload: { text: "hi", role: "user" },
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("PiSessionTailer — incremental session JSONL tail", () => {
|
||||
it("yields events incrementally with offset; skips noise", () => {
|
||||
const tailer = new PiSessionTailer();
|
||||
const header = JSON.stringify({ type: "session", id: "u1", cwd: "/r" }) + "\n";
|
||||
const noise = JSON.stringify({ type: "model_change", provider: "x" }) + "\n";
|
||||
const msg =
|
||||
JSON.stringify({ type: "message", message: { role: "user", content: [{ type: "text", text: "hi" }] } }) + "\n";
|
||||
|
||||
expect(tailer.push(header + noise)).toEqual([{ kind: "sessionStart", nativeSessionId: "u1" }]);
|
||||
expect(tailer.push(msg)).toEqual([{ kind: "transcript", role: "user", text: "hi" }]);
|
||||
expect(tailer.bytesRead).toBe(Buffer.byteLength(header + noise + msg, "utf8"));
|
||||
});
|
||||
|
||||
it("holds a partial line until its newline arrives", () => {
|
||||
const tailer = new PiSessionTailer();
|
||||
const line = JSON.stringify({ type: "session", id: "u2" });
|
||||
expect(tailer.push(line.slice(0, 10))).toEqual([]);
|
||||
expect(tailer.push(line.slice(10) + "\n")).toEqual([{ kind: "sessionStart", nativeSessionId: "u2" }]);
|
||||
});
|
||||
|
||||
it("skips unparseable lines without throwing", () => {
|
||||
const tailer = new PiSessionTailer();
|
||||
expect(tailer.push("{bad}\n\n")).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("findSessionFile — newest *.jsonl, one level of cwd-nesting", () => {
|
||||
it("finds the lexically-greatest session file across nested dirs", () => {
|
||||
const fs = {
|
||||
readdirSync(p: string): DirentLike[] {
|
||||
if (p === "/sess") return [dir("--Users-x--"), file("2026-04-09T10_uuidA.jsonl")];
|
||||
if (p === "/sess/--Users-x--")
|
||||
return [file("2026-04-09T21_uuidB.jsonl"), file("2026-04-09T08_uuidC.jsonl")];
|
||||
return [];
|
||||
},
|
||||
};
|
||||
expect(findSessionFile("/sess", fs)).toBe("/sess/--Users-x--/2026-04-09T21_uuidB.jsonl");
|
||||
});
|
||||
|
||||
it("returns null when the dir is missing / empty (tolerant)", () => {
|
||||
const fs = {
|
||||
readdirSync(): DirentLike[] {
|
||||
throw new Error("ENOENT");
|
||||
},
|
||||
};
|
||||
expect(findSessionFile("/missing", fs)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("PiReadinessDetector", () => {
|
||||
it("ready on bracketed-paste enable or prompt glyph", () => {
|
||||
const a = new PiReadinessDetector();
|
||||
expect(a.observe("starting\n")).toBe(false);
|
||||
expect(a.observe("\x1b[?2004h")).toBe(true);
|
||||
const b = new PiReadinessDetector();
|
||||
expect(b.observe("hi\n")).toBe(false);
|
||||
expect(b.observe("\n❯")).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("end-to-end via TelemetryHub: session header → busy → input-request → done", () => {
|
||||
let tmpDir: string;
|
||||
let db: Database;
|
||||
let store: CliSessionStore;
|
||||
let hub: TelemetryHub;
|
||||
let sessionId: string;
|
||||
|
||||
beforeEach(() => {
|
||||
tmpDir = mkdtempSync(join(tmpdir(), "kb-pi-e2e-"));
|
||||
const fusionDir = join(tmpDir, ".fusion");
|
||||
db = new Database(fusionDir, { inMemory: true });
|
||||
db.init();
|
||||
store = new CliSessionStore(fusionDir, db);
|
||||
const rec = store.createSession({
|
||||
purpose: "execute",
|
||||
projectId: "p1",
|
||||
adapterId: "pi",
|
||||
agentState: "starting",
|
||||
});
|
||||
sessionId = rec.id;
|
||||
hub = new TelemetryHub({ store });
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
db.close();
|
||||
await rm(tmpDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
function feed(obj: Record<string, unknown>) {
|
||||
const ev = mapSessionLine(obj);
|
||||
if (ev) hub.ingest(sessionId, toTelemetryEvent(ev));
|
||||
}
|
||||
|
||||
it("drives ready → busy → waitingOnInput → busy → done and captures session uuid", () => {
|
||||
feed({ type: "session", id: "pi-uuid" });
|
||||
expect(hub.getStateMachine(sessionId)?.getState()).toBe("ready");
|
||||
expect(store.getSession(sessionId)?.nativeSessionId).toBe("pi-uuid");
|
||||
|
||||
feed({ type: "turn_start" }); // ready → busy via the busy route... but markReady leaves us at ready
|
||||
// markReady put us at ready; a busy event from ready is invalid, so the hub
|
||||
// swallows it. Drive the injection transition explicitly (the session manager
|
||||
// does this when it injects the prompt), then continue.
|
||||
if (hub.getStateMachine(sessionId)?.getState() === "ready") {
|
||||
hub.getStateMachine(sessionId)!.injectPrompt();
|
||||
}
|
||||
expect(hub.getStateMachine(sessionId)?.getState()).toBe("busy");
|
||||
|
||||
feed({ type: "input_request" });
|
||||
expect(hub.getStateMachine(sessionId)?.getState()).toBe("waitingOnInput");
|
||||
|
||||
feed({ type: "turn_start" }); // user answered → busy
|
||||
expect(hub.getStateMachine(sessionId)?.getState()).toBe("busy");
|
||||
|
||||
feed({ type: "turn_end" });
|
||||
expect(hub.getStateMachine(sessionId)?.getState()).toBe("done");
|
||||
});
|
||||
});
|
||||
646
packages/engine/src/cli-agent/adapters/codex.ts
Normal file
646
packages/engine/src/cli-agent/adapters/codex.ts
Normal file
@@ -0,0 +1,646 @@
|
||||
/**
|
||||
* Codex adapter — HYBRID-tier CliAgentAdapter (U5).
|
||||
*
|
||||
* Codex is the hybrid tier: it has a NATIVE turn-complete signal (via its
|
||||
* `notify` config program) and a structured rollout transcript on disk, but NO
|
||||
* native waiting-on-input signal. Waiting-on-input is therefore inferred from
|
||||
* the PTY byte stream with Codex-specific prompt-pattern heuristics (composed on
|
||||
* top of the same ANSI-stripping the generic adapter uses). The capability flags
|
||||
* advertise this honestly: `nativeDone: true`, `nativeWaiting: false`.
|
||||
*
|
||||
* This adapter teaches the engine to:
|
||||
* - launch `codex` with a SESSION-SCOPED notify program so a turn-complete
|
||||
* event reaches the engine without touching the user's `~/.codex/config.toml`
|
||||
* (mechanism below);
|
||||
* - normalize the notify JSON payload → a `done` `TelemetryEvent`, capturing
|
||||
* `thread-id` as the native session id;
|
||||
* - detect waiting-on-input via {@link CodexWaitingAnalyzer} (heuristic — see
|
||||
* the per-method docs; this is the hybrid-tier fallback, NOT a native signal);
|
||||
* - tail the rollout JSONL transcript incrementally (probing the sessions dir,
|
||||
* never hardcoding the layout — it is version-sensitive);
|
||||
* - resume via `codex resume <thread-id>`.
|
||||
*
|
||||
* ── Verified against the installed binary (Codex 0.128.0, arm64) ──
|
||||
* - `codex` is on PATH; `~/.codex/` is the default `CODEX_HOME`.
|
||||
* - Rollout JSONL layout CONFIRMED by inspecting real files:
|
||||
* `~/.codex/sessions/YYYY/MM/DD/rollout-<ts>-<thread-id>.jsonl`
|
||||
* with a first line `{type:"session_meta", payload:{ id:<thread-id>, cwd,
|
||||
* originator, cli_version, ... }}`, then `{type:"event_msg", payload:{
|
||||
* type:"task_started", turn_id, ... }}`, then `{type:"response_item",
|
||||
* payload:{ type:"message", role, content:[{type,text}] }}` lines. The
|
||||
* `thread-id` IS the `session_meta.payload.id` and is embedded in the
|
||||
* filename. We PROBE for the file by thread-id (see {@link findRolloutPath}),
|
||||
* never assuming the date path.
|
||||
* - `codex resume` and `codex exec` subcommands exist (`codex --help`); the
|
||||
* interactive `--help` for subcommands could not be captured in this sandbox
|
||||
* (the binary opens a TUI), so the exact resume arg shape below is per the
|
||||
* documented public interface: `codex resume <thread-id>` and the `-c
|
||||
* key=value` config-override flag.
|
||||
*
|
||||
* ── Assumed / mechanism choice (marked so wiring composes; revisit on drift) ──
|
||||
* - NOTIFY MECHANISM. Codex's documented native turn-complete is the `notify`
|
||||
* config key: `notify = ["<program>", ...args]`. Codex invokes that program
|
||||
* with a single JSON-string argument `{ type:"agent-turn-complete",
|
||||
* "thread-id":…, "turn-id":…, cwd:…, "last-assistant-message":… }`. Two
|
||||
* session-scoped delivery options exist; we choose **`-c notify=[...]`
|
||||
* config-override on the launch argv** as the PRIMARY mechanism (it is
|
||||
* session-scoped by construction and never mutates the user's config), and
|
||||
* expose a **layered `CODEX_HOME`** fallback for callers that prefer a
|
||||
* scratch config dir. See {@link buildNotifyOverrideArg} and
|
||||
* {@link codexSessionHomeLayout}. The notify PROGRAM itself (the shim that
|
||||
* forwards the payload to the engine telemetry hub) is produced by the U17
|
||||
* hook-scripts module; this adapter only references its path.
|
||||
* - The notify payload uses hyphenated keys (`thread-id`, `turn-id`,
|
||||
* `last-assistant-message`) per the documented schema; {@link mapNotifyPayload}
|
||||
* also tolerates the snake_case / camelCase variants in case a version drifts.
|
||||
*/
|
||||
|
||||
import type {
|
||||
CliAdapterCapabilities,
|
||||
CliAdapterLaunchContext,
|
||||
CliAdapterResumeContext,
|
||||
CliAgentAdapter,
|
||||
CliInjectionFormat,
|
||||
CliLaunchSpec,
|
||||
CliReadinessDetector,
|
||||
} from "../adapter.js";
|
||||
import { stripAnsiControl, type TelemetryEvent } from "../telemetry-hub.js";
|
||||
|
||||
// ── Capabilities ────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Codex hybrid tier: native done (notify) + rollout transcript + resume, but NO
|
||||
* native waiting-on-input (heuristic PTY detection instead).
|
||||
*/
|
||||
export const CODEX_CAPABILITIES: CliAdapterCapabilities = {
|
||||
nativeDone: true,
|
||||
nativeWaiting: false,
|
||||
transcriptSource: "jsonl",
|
||||
supportsResume: true,
|
||||
};
|
||||
|
||||
// ── Launch settings ───────────────────────────────────────────────────────────
|
||||
|
||||
const DEFAULT_COMMAND = "codex";
|
||||
|
||||
/**
|
||||
* Adapter-specific launch settings recognized by the Codex adapter. All optional
|
||||
* so a bare `codex` still launches without telemetry wiring.
|
||||
*/
|
||||
export interface CodexLaunchSettings {
|
||||
/** Override the `codex` binary. */
|
||||
command?: string;
|
||||
/** Extra args appended after the computed base args. */
|
||||
extraArgs?: readonly string[];
|
||||
/** Model override (`-c model=<id>`). */
|
||||
model?: string;
|
||||
/**
|
||||
* Absolute path to the session-scoped notify program (from U17). When present
|
||||
* the adapter appends `-c notify=["<path>"]` so a turn-complete event is
|
||||
* delivered session-scoped without touching the user's config.
|
||||
*/
|
||||
notifyProgram?: string;
|
||||
/**
|
||||
* Optional layered `CODEX_HOME` directory for callers that prefer a scratch
|
||||
* config dir over the `-c` override (see {@link codexSessionHomeLayout}). When
|
||||
* set, it is surfaced via the env allowlist and the caller is responsible for
|
||||
* materializing the dir; the adapter does not write it.
|
||||
*/
|
||||
codexHome?: string;
|
||||
/**
|
||||
* Override the sessions root the rollout tailer probes. Defaults to
|
||||
* `<CODEX_HOME>/sessions`. The exact dated sub-layout is probed, never assumed.
|
||||
*/
|
||||
sessionsDir?: string;
|
||||
}
|
||||
|
||||
function readSettings(ctx: CliAdapterLaunchContext): CodexLaunchSettings {
|
||||
return (ctx.settings ?? {}) as CodexLaunchSettings;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the `-c notify=[...]` config-override token vector for a session-scoped
|
||||
* notify program. Codex's `-c key=value` flag takes a TOML-ish value; an array
|
||||
* of one program path is `["<path>"]`. Returns the two argv tokens (`-c` and the
|
||||
* `notify=[...]` assignment) or an empty array when no program is configured.
|
||||
*/
|
||||
export function buildNotifyOverrideArg(notifyProgram: string | undefined): string[] {
|
||||
if (!notifyProgram || notifyProgram.trim().length === 0) return [];
|
||||
// JSON array literal is valid TOML array syntax for a single string element.
|
||||
const value = JSON.stringify([notifyProgram]);
|
||||
return ["-c", `notify=${value}`];
|
||||
}
|
||||
|
||||
/**
|
||||
* Describe the layered session-scoped `CODEX_HOME` mechanism (the alternative to
|
||||
* `-c notify`). The caller materializes `dir` (copying/symlinking the user's
|
||||
* `auth.json` so the child stays authenticated) and writes a `config.toml` that
|
||||
* sets `notify`. This adapter only computes the intended layout for the caller;
|
||||
* it performs NO filesystem writes (containment + lifecycle is the session
|
||||
* manager's job, mirroring the Claude adapter's settings-file contract).
|
||||
*/
|
||||
export function codexSessionHomeLayout(dir: string): {
|
||||
home: string;
|
||||
configPath: string;
|
||||
authPath: string;
|
||||
} {
|
||||
return {
|
||||
home: dir,
|
||||
configPath: `${dir}/config.toml`,
|
||||
authPath: `${dir}/auth.json`,
|
||||
};
|
||||
}
|
||||
|
||||
function buildBaseArgs(ctx: CliAdapterLaunchContext): { command: string; args: string[] } {
|
||||
const settings = readSettings(ctx);
|
||||
const command = settings.command ?? DEFAULT_COMMAND;
|
||||
const args: string[] = [];
|
||||
if (typeof settings.model === "string" && settings.model.length > 0) {
|
||||
// Model is set via a config override so it composes with `-c notify`.
|
||||
args.push("-c", `model=${JSON.stringify(settings.model)}`);
|
||||
}
|
||||
args.push(...buildNotifyOverrideArg(settings.notifyProgram));
|
||||
return { command, args };
|
||||
}
|
||||
|
||||
/** Append the autonomy posture's privileged flags, only when permitted. */
|
||||
function appendPostureFlags(args: string[], ctx: CliAdapterLaunchContext): void {
|
||||
// Visible-posture contract (R21): only emit the dangerous bypass when the
|
||||
// posture explicitly opts in. Codex's full-access sandbox bypass.
|
||||
if (ctx.posture?.autoApprove === true) {
|
||||
args.push("--dangerously-bypass-approvals-and-sandbox");
|
||||
}
|
||||
}
|
||||
|
||||
// ── Notify payload → telemetry ─────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* The raw Codex notify payload (delivered as a JSON string argument to the
|
||||
* notify program). Documented keys are hyphenated; we tolerate snake/camel too.
|
||||
*/
|
||||
export interface CodexNotifyPayload {
|
||||
type?: string;
|
||||
"thread-id"?: string;
|
||||
thread_id?: string;
|
||||
threadId?: string;
|
||||
"turn-id"?: string;
|
||||
turn_id?: string;
|
||||
turnId?: string;
|
||||
cwd?: string;
|
||||
"last-assistant-message"?: string;
|
||||
last_assistant_message?: string;
|
||||
lastAssistantMessage?: string;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
/** Read the first present of several key spellings off a payload. */
|
||||
function pick(payload: Record<string, unknown>, keys: string[]): string | undefined {
|
||||
for (const k of keys) {
|
||||
const v = payload[k];
|
||||
if (typeof v === "string" && v.length > 0) return v;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Map a Codex notify payload onto a normalized `TelemetryEvent`. The only Codex
|
||||
* notify event we act on is `agent-turn-complete` → `done`, capturing the
|
||||
* `thread-id` as the native session id. Returns null for any other / malformed
|
||||
* payload (telemetry is best-effort).
|
||||
*
|
||||
* Mapping (KTD telemetry tiering — hybrid tier):
|
||||
* notify{agent-turn-complete} → done (+ nativeSessionId = thread-id)
|
||||
*/
|
||||
export function mapNotifyPayload(payload: CodexNotifyPayload): TelemetryEvent | null {
|
||||
const type = typeof payload.type === "string" ? payload.type : "";
|
||||
if (type !== "agent-turn-complete") return null;
|
||||
const threadId = pick(payload, ["thread-id", "thread_id", "threadId"]);
|
||||
const turnId = pick(payload, ["turn-id", "turn_id", "turnId"]);
|
||||
const lastMessage = pick(payload, [
|
||||
"last-assistant-message",
|
||||
"last_assistant_message",
|
||||
"lastAssistantMessage",
|
||||
]);
|
||||
const out: TelemetryEvent = { kind: "done", payload: {} };
|
||||
if (threadId) out.payload!.nativeSessionId = threadId;
|
||||
if (turnId) out.payload!.turnId = turnId;
|
||||
if (typeof lastMessage === "string") out.payload!.lastAssistantMessage = lastMessage;
|
||||
if (typeof payload.cwd === "string") out.payload!.cwd = payload.cwd;
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse the raw notify argument (the JSON string Codex passes to the notify
|
||||
* program) into a normalized event. Returns null on unparseable input — never
|
||||
* throws at the ingestion boundary.
|
||||
*/
|
||||
export function parseNotifyPayload(raw: string): TelemetryEvent | null {
|
||||
let parsed: unknown;
|
||||
try {
|
||||
parsed = JSON.parse(raw);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
if (!parsed || typeof parsed !== "object") return null;
|
||||
return mapNotifyPayload(parsed as CodexNotifyPayload);
|
||||
}
|
||||
|
||||
// ── Waiting-on-input heuristics (hybrid-tier fallback — NOT native) ───────────
|
||||
|
||||
/**
|
||||
* Codex-specific prompt patterns that indicate the agent is blocked waiting on a
|
||||
* human. HEURISTIC (hybrid tier): Codex has no native waiting-on-input signal,
|
||||
* so we infer it from ANSI-stripped PTY output. These patterns are intentionally
|
||||
* conservative — a false positive only adds a needs-input affordance, never
|
||||
* advances the pipeline.
|
||||
*
|
||||
* - Approval prompt menus Codex draws when a command/patch needs approval
|
||||
* ("Allow", "Approve", "y/n", numbered "1. Yes / 2. No" menus).
|
||||
* - The idle "ready for input" marker Codex shows at the bottom of the composer
|
||||
* ("enter to send", "Ctrl+J newline", etc.).
|
||||
*/
|
||||
// Single-line approval patterns are matched against the LAST non-empty line
|
||||
// only (which IS the trailing edge), so a stale prompt earlier in scrollback
|
||||
// stops matching once fresh output renders below it — this is what makes the
|
||||
// heuristic re-arm correctly.
|
||||
const SINGLE_LINE_APPROVAL_PATTERNS: RegExp[] = [
|
||||
// Approval prompt verbs + a yes/no affordance on one line.
|
||||
/\b(allow|approve|apply (?:this )?(?:patch|change|command)|run (?:this )?command)\b.{0,80}\b(y\s*\/\s*n|yes\s*\/\s*no)\b/i,
|
||||
// Bare yes/no prompt.
|
||||
/\b(y\s*\/\s*n|yes\s*\/\s*no)\s*[?:]?\s*$/i,
|
||||
// Explicit "waiting for approval" wording.
|
||||
/\b(waiting for (?:your )?approval|requires (?:your )?approval|needs (?:your )?approval)\b/i,
|
||||
];
|
||||
|
||||
// The numbered approval menu (1. Yes … 2. No …) legitimately spans lines; it is
|
||||
// checked against the last few lines and anchored to the trailing edge.
|
||||
const CODEX_NUMBERED_MENU_PATTERN =
|
||||
/(^|\n)\s*1[.)]\s*(yes|approve|allow)[\s\S]{0,80}\n\s*2[.)]\s*(no|reject|deny)[\s\S]{0,40}$/i;
|
||||
|
||||
/** Idle "ready for input" composer markers (matched against the last line). */
|
||||
const CODEX_IDLE_MARKERS: RegExp[] = [
|
||||
/\benter to send\b/i,
|
||||
/\bpress enter\b/i,
|
||||
/\bsend a message\b/i,
|
||||
];
|
||||
|
||||
/**
|
||||
* Spinner / working markers Codex shows while busy. When one of these is present
|
||||
* at the trailing edge the waiting heuristic is OVERRIDDEN (the agent is working,
|
||||
* not waiting), mirroring the generic analyzer's spinner-override rule.
|
||||
*/
|
||||
const CODEX_WORKING_PATTERN = /\b(working|thinking|executing|running|esc to interrupt)\b/i;
|
||||
|
||||
/** Max trailing chars of the stripped window inspected for prompt patterns. */
|
||||
const CODEX_WINDOW_CHARS = 4_096;
|
||||
|
||||
/**
|
||||
* Stateful waiting-on-input analyzer for Codex (hybrid-tier heuristic). Fed
|
||||
* ANSI-bearing PTY output via {@link observe}; emits a `waitingOnInput`
|
||||
* `TelemetryEvent` once when an approval/idle prompt is detected at the trailing
|
||||
* edge and no working marker overrides it. De-dupes (re-arms when fresh non-
|
||||
* prompt output arrives).
|
||||
*
|
||||
* This is the explicitly-marked HEURISTIC fallback for the hybrid tier — Codex
|
||||
* exposes no native waiting signal (`nativeWaiting: false`).
|
||||
*/
|
||||
export class CodexWaitingAnalyzer {
|
||||
private readonly emit: (event: TelemetryEvent) => void;
|
||||
private window = "";
|
||||
private waitingEmitted = false;
|
||||
|
||||
constructor(opts: { emit: (event: TelemetryEvent) => void }) {
|
||||
this.emit = opts.emit;
|
||||
}
|
||||
|
||||
/** Observe a raw (ANSI-bearing) output chunk. */
|
||||
observe(rawChunk: string): void {
|
||||
const stripped = stripAnsiControl(rawChunk);
|
||||
if (stripped.length === 0) return;
|
||||
this.window = (this.window + stripped).slice(-CODEX_WINDOW_CHARS);
|
||||
|
||||
// Working marker overrides any prompt detection: the agent is busy.
|
||||
if (CODEX_WORKING_PATTERN.test(this.trailingChunk())) {
|
||||
this.waitingEmitted = false;
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.isWaiting()) {
|
||||
if (!this.waitingEmitted) {
|
||||
this.waitingEmitted = true;
|
||||
this.emit({
|
||||
kind: "waitingOnInput",
|
||||
payload: {
|
||||
notification: { kind: this.classify(), source: "heuristic" },
|
||||
},
|
||||
});
|
||||
}
|
||||
return;
|
||||
}
|
||||
// Fresh non-prompt output → re-arm so a later prompt re-emits.
|
||||
this.waitingEmitted = false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the trailing window currently looks like a waiting prompt. Single-
|
||||
* line prompts (verb+yn, bare yn, idle markers, "waiting for approval") are
|
||||
* checked against the LAST non-empty line ONLY — so a stale prompt earlier in
|
||||
* scrollback stops matching once fresh output renders below it (this is what
|
||||
* makes the de-dupe re-arm correct). The multi-line numbered menu is checked
|
||||
* against the last few lines (it legitimately spans lines) and is anchored to
|
||||
* the trailing edge.
|
||||
*/
|
||||
isWaiting(): boolean {
|
||||
return this.matchedSubReason() !== null;
|
||||
}
|
||||
|
||||
/** Sub-reason tag for the notification (approval vs idle prompt). */
|
||||
private classify(): string {
|
||||
return this.matchedSubReason() ?? "idle_prompt";
|
||||
}
|
||||
|
||||
/** The matched sub-reason at the trailing edge, or null when not waiting. */
|
||||
private matchedSubReason(): "approval_prompt" | "idle_prompt" | null {
|
||||
const lastLine = this.lastNonEmptyLine();
|
||||
const menuTail = this.trailingLines(4);
|
||||
if (
|
||||
SINGLE_LINE_APPROVAL_PATTERNS.some((re) => re.test(lastLine)) ||
|
||||
CODEX_NUMBERED_MENU_PATTERN.test(menuTail)
|
||||
) {
|
||||
return "approval_prompt";
|
||||
}
|
||||
if (CODEX_IDLE_MARKERS.some((re) => re.test(lastLine))) return "idle_prompt";
|
||||
return null;
|
||||
}
|
||||
|
||||
/** The last non-empty line of the window (whitespace-trimmed at the end). */
|
||||
private lastNonEmptyLine(): string {
|
||||
const lines = this.window.split(/\r?\n/);
|
||||
for (let i = lines.length - 1; i >= 0; i--) {
|
||||
if (lines[i].trim().length > 0) return lines[i].trimEnd();
|
||||
}
|
||||
return "";
|
||||
}
|
||||
|
||||
/** The last `n` non-empty lines joined (for the multi-line menu check). */
|
||||
private trailingLines(n: number): string {
|
||||
const lines = this.window.split(/\r?\n/).filter((l) => l.trim().length > 0);
|
||||
return lines.slice(-n).join("\n");
|
||||
}
|
||||
|
||||
/** Working-marker override is checked against the trailing few lines. */
|
||||
private trailingChunk(): string {
|
||||
return this.trailingLines(4);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Rollout transcript tailing ─────────────────────────────────────────────────
|
||||
|
||||
/** A normalized transcript entry surfaced to chat. */
|
||||
export interface CodexTranscriptEntry {
|
||||
role: "user" | "assistant" | "tool" | "system";
|
||||
text: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Probe the Codex sessions directory for the rollout file matching a thread-id.
|
||||
* The layout (`<sessionsDir>/YYYY/MM/DD/rollout-<ts>-<thread-id>.jsonl`) is
|
||||
* version-sensitive (community-sourced), so we DO NOT hardcode the dated path —
|
||||
* we recursively search for a `rollout-*<thread-id>*.jsonl` file. Returns the
|
||||
* first match or null. Tolerant of a missing dir.
|
||||
*/
|
||||
export function findRolloutPath(
|
||||
sessionsDir: string,
|
||||
threadId: string,
|
||||
fs: { readdirSync: (p: string, o: { withFileTypes: true }) => DirentLike[] },
|
||||
): string | null {
|
||||
const stack: string[] = [sessionsDir];
|
||||
while (stack.length > 0) {
|
||||
const dir = stack.pop()!;
|
||||
let entries: DirentLike[];
|
||||
try {
|
||||
entries = fs.readdirSync(dir, { withFileTypes: true });
|
||||
} catch {
|
||||
continue; // missing / unreadable dir → skip
|
||||
}
|
||||
for (const entry of entries) {
|
||||
const full = `${dir}/${entry.name}`;
|
||||
if (entry.isDirectory()) {
|
||||
stack.push(full);
|
||||
} else if (
|
||||
entry.name.startsWith("rollout-") &&
|
||||
entry.name.endsWith(".jsonl") &&
|
||||
entry.name.includes(threadId)
|
||||
) {
|
||||
return full;
|
||||
}
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/** Minimal Dirent shape (so callers can inject a fake fs in tests). */
|
||||
export interface DirentLike {
|
||||
name: string;
|
||||
isDirectory(): boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Incremental rollout JSONL tailer. Codex appends one JSON object per line; this
|
||||
* remembers the byte offset so each {@link push} yields only entries appended
|
||||
* since the last call. Mirrors {@link ClaudeTranscriptTailer}'s partial-line and
|
||||
* unparseable-line tolerance. Only `response_item` message rows become chat
|
||||
* entries; meta / event rows are skipped.
|
||||
*/
|
||||
export class CodexRolloutTailer {
|
||||
private offset = 0;
|
||||
private partial = "";
|
||||
|
||||
get bytesRead(): number {
|
||||
return this.offset;
|
||||
}
|
||||
|
||||
push(chunk: string): CodexTranscriptEntry[] {
|
||||
this.offset += Buffer.byteLength(chunk, "utf8");
|
||||
const text = this.partial + chunk;
|
||||
const lines = text.split("\n");
|
||||
this.partial = lines.pop() ?? "";
|
||||
const entries: CodexTranscriptEntry[] = [];
|
||||
for (const line of lines) {
|
||||
const entry = parseRolloutLine(line);
|
||||
if (entry) entries.push(entry);
|
||||
}
|
||||
return entries;
|
||||
}
|
||||
|
||||
flush(): CodexTranscriptEntry[] {
|
||||
if (this.partial.trim().length === 0) {
|
||||
this.partial = "";
|
||||
return [];
|
||||
}
|
||||
const entry = parseRolloutLine(this.partial);
|
||||
this.partial = "";
|
||||
return entry ? [entry] : [];
|
||||
}
|
||||
}
|
||||
|
||||
/** Parse a single rollout JSONL line into a normalized entry, or null. */
|
||||
function parseRolloutLine(line: string): CodexTranscriptEntry | null {
|
||||
const trimmed = line.trim();
|
||||
if (trimmed.length === 0) return null;
|
||||
let obj: Record<string, unknown>;
|
||||
try {
|
||||
const parsed = JSON.parse(trimmed);
|
||||
if (!parsed || typeof parsed !== "object") return null;
|
||||
obj = parsed as Record<string, unknown>;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
// Only `response_item` rows carrying a `message` payload are chat content.
|
||||
if (obj.type !== "response_item") return null;
|
||||
const payload = obj.payload as Record<string, unknown> | undefined;
|
||||
if (!payload || payload.type !== "message") return null;
|
||||
const role = normalizeRole(typeof payload.role === "string" ? payload.role : "");
|
||||
const text = flattenContent(payload.content);
|
||||
if (text.length === 0) return null;
|
||||
return { role, text };
|
||||
}
|
||||
|
||||
function normalizeRole(raw: string): CodexTranscriptEntry["role"] {
|
||||
switch (raw) {
|
||||
case "user":
|
||||
return "user";
|
||||
case "assistant":
|
||||
return "assistant";
|
||||
case "tool":
|
||||
return "tool";
|
||||
// `developer` / `system` instructions render as system.
|
||||
default:
|
||||
return "system";
|
||||
}
|
||||
}
|
||||
|
||||
/** Flatten Codex content blocks (`[{type:"input_text"|"output_text", text}]`). */
|
||||
function flattenContent(content: unknown): string {
|
||||
if (typeof content === "string") return content.trim();
|
||||
if (Array.isArray(content)) {
|
||||
return content
|
||||
.map((block) => {
|
||||
if (typeof block === "string") return block;
|
||||
if (block && typeof block === "object") {
|
||||
const b = block as Record<string, unknown>;
|
||||
if (typeof b.text === "string") return b.text;
|
||||
}
|
||||
return "";
|
||||
})
|
||||
.filter((s) => s.length > 0)
|
||||
.join("")
|
||||
.trim();
|
||||
}
|
||||
return "";
|
||||
}
|
||||
|
||||
// ── Readiness detector ─────────────────────────────────────────────────────────
|
||||
|
||||
/** Prompt-like trailing glyphs that suggest Codex's composer is ready. */
|
||||
const READY_GLYPHS = [">", "❯", "▌", "│"];
|
||||
|
||||
/**
|
||||
* Readiness detector for Codex's interactive TUI. Native readiness has no hook
|
||||
* (Codex's hybrid tier only natively signals done), so this output-based
|
||||
* detector is the primary readiness signal: ready on the bracketed-paste enable
|
||||
* sequence (the editor mounted) or a composer prompt glyph at a line's trailing
|
||||
* edge. Tolerant of partial chunks (keeps a bounded tail).
|
||||
*/
|
||||
export class CodexReadinessDetector implements CliReadinessDetector {
|
||||
private ready = false;
|
||||
private buffer = "";
|
||||
|
||||
observe(chunk: string): boolean {
|
||||
if (this.ready) return true;
|
||||
this.buffer = (this.buffer + chunk).slice(-4096);
|
||||
if (this.buffer.includes("\x1b[?2004h")) {
|
||||
this.ready = true;
|
||||
return true;
|
||||
}
|
||||
const stripped = stripAnsiControl(this.buffer);
|
||||
const tail = stripped.replace(/[ \t\r\n]+$/g, "").slice(-8);
|
||||
if (READY_GLYPHS.some((g) => tail.endsWith(g))) {
|
||||
this.ready = true;
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// ── The adapter ─────────────────────────────────────────────────────────────
|
||||
|
||||
export const codexAdapter: CliAgentAdapter = {
|
||||
id: "codex",
|
||||
name: "Codex",
|
||||
capabilities: CODEX_CAPABILITIES,
|
||||
|
||||
buildLaunch(ctx: CliAdapterLaunchContext): CliLaunchSpec {
|
||||
const settings = readSettings(ctx);
|
||||
const { command, args } = buildBaseArgs(ctx);
|
||||
appendPostureFlags(args, ctx);
|
||||
if (settings.extraArgs) args.push(...settings.extraArgs);
|
||||
return { command, args };
|
||||
},
|
||||
|
||||
buildEnvAllowlist(ctx: CliAdapterLaunchContext): string[] {
|
||||
const settings = readSettings(ctx);
|
||||
// Only what Codex needs to authenticate, find its config, and render a
|
||||
// terminal. NEVER inherit-everything — FUSION_* creds stay out of the child.
|
||||
const base = [
|
||||
"HOME",
|
||||
"PATH",
|
||||
"SHELL",
|
||||
"USER",
|
||||
"LOGNAME",
|
||||
"LANG",
|
||||
"LC_ALL",
|
||||
"LC_CTYPE",
|
||||
"TERM",
|
||||
"TERMINFO",
|
||||
"TMPDIR",
|
||||
"COLORTERM",
|
||||
"XDG_CONFIG_HOME",
|
||||
"XDG_CACHE_HOME",
|
||||
"XDG_DATA_HOME",
|
||||
// Codex config home + auth.
|
||||
"CODEX_HOME",
|
||||
"OPENAI_API_KEY",
|
||||
"OPENAI_BASE_URL",
|
||||
];
|
||||
// When a layered CODEX_HOME scratch dir is configured the env carries it.
|
||||
return settings.codexHome ? [...new Set([...base, "CODEX_HOME"])] : base;
|
||||
},
|
||||
|
||||
createReadinessDetector(): CliReadinessDetector {
|
||||
return new CodexReadinessDetector();
|
||||
},
|
||||
|
||||
formatInjection(text: string, _opts: { bracketedPasteActive: boolean }): CliInjectionFormat {
|
||||
// The session manager owns bracketed-paste wrapping and control-char
|
||||
// neutralization. This hook only adds the trailing submit CR.
|
||||
const payload = text.endsWith("\r") ? text : `${text}\r`;
|
||||
return { payload };
|
||||
},
|
||||
|
||||
buildResume(ctx: CliAdapterResumeContext): CliLaunchSpec {
|
||||
// `codex resume <thread-id>` re-attaches the prior conversation. The notify
|
||||
// override + model are re-applied so the resumed session keeps telemetry
|
||||
// wiring. (The `resume` subcommand precedes the thread-id and config flags.)
|
||||
const settings = readSettings(ctx);
|
||||
const command = settings.command ?? DEFAULT_COMMAND;
|
||||
const args: string[] = ["resume", ctx.nativeSessionId];
|
||||
if (typeof settings.model === "string" && settings.model.length > 0) {
|
||||
args.push("-c", `model=${JSON.stringify(settings.model)}`);
|
||||
}
|
||||
args.push(...buildNotifyOverrideArg(settings.notifyProgram));
|
||||
appendPostureFlags(args, ctx);
|
||||
if (settings.extraArgs) args.push(...settings.extraArgs);
|
||||
return { command, args };
|
||||
},
|
||||
};
|
||||
546
packages/engine/src/cli-agent/adapters/droid.ts
Normal file
546
packages/engine/src/cli-agent/adapters/droid.ts
Normal file
@@ -0,0 +1,546 @@
|
||||
/**
|
||||
* Droid adapter — NATIVE-tier CliAgentAdapter (U5).
|
||||
*
|
||||
* Droid (Factory's CLI) exposes Claude-style hooks (`Stop`, `Notification`,
|
||||
* `SessionStart`, `PreToolUse`/`PostToolUse`) that deliver a JSON payload
|
||||
* carrying `session_id`, `transcript_path`, and `permission_mode`. Like Claude
|
||||
* Code it is native tier — but with ONE caveat the KTD calls out: its
|
||||
* `Notification` hook CONFLATES a permission request and a 60s-idle prompt into a
|
||||
* single event whose only discriminator is a free-form `message` text field.
|
||||
* This adapter implements a {@link classifyNotification} message classifier that
|
||||
* tags the sub-reason while defaulting BOTH to `waitingOnInput` (both mean
|
||||
* blocked-on-human).
|
||||
*
|
||||
* Capability flags advertise the native tier honestly: `nativeDone: true`,
|
||||
* `nativeWaiting: true` (via parsing), `transcriptSource: "jsonl"`,
|
||||
* `supportsResume: true`.
|
||||
*
|
||||
* Resume modes (the `-r` footgun):
|
||||
* - INTERACTIVE: `droid --resume <sessionId>` (`-r`/`--resume`).
|
||||
* - HEADLESS: `droid exec -s <sessionId>` — `-s`/`--session-id`. In `exec`
|
||||
* mode `-r` means `--reasoning-effort`, NOT resume. {@link buildResume}
|
||||
* therefore NEVER emits a bare `-r` for resume in exec mode (asserted in the
|
||||
* tests). VERIFIED against the installed binary's `droid exec --help`.
|
||||
*
|
||||
* ── Verified against the installed binary (Factory Droid CLI) ──
|
||||
* - `droid` is on PATH (`~/.local/bin/droid`).
|
||||
* - `droid --help`: `-r, --resume [sessionId]`, `--settings <path>` ("Path to
|
||||
* runtime settings file merged for this process only" — the session-scoped
|
||||
* hook-config seam), `--cwd <path>`, `--fork <sessionId>`.
|
||||
* - `droid exec --help`: `-s, --session-id <id>` ("Existing session to
|
||||
* continue (requires a prompt)"), `-r, --reasoning-effort <level>`,
|
||||
* `-o, --output-format <format>`, `--auto <level>`,
|
||||
* `--skip-permissions-unsafe`. CONFIRMS the `-r` footgun.
|
||||
*
|
||||
* ── Assumed (marked so wiring composes; revisit on drift) ──
|
||||
* - HOOK CONFIG MECHANISM. Droid's `--settings <path>` merges a runtime
|
||||
* settings file for this process only — the session-scoped equivalent of
|
||||
* Claude's `--settings`. We assume it accepts a Claude-style `hooks` block
|
||||
* (event → [{ hooks:[{ type:"command", command }] }]); the binary's hooks
|
||||
* reference is documented as Claude-style. The hook SCRIPTS themselves come
|
||||
* from U17; this adapter only references their paths and emits the settings
|
||||
* JSON. If a Droid version diverges from the Claude hook schema this is the
|
||||
* one place to adjust ({@link buildDroidSettings}).
|
||||
* - The `Notification` payload's idle vs permission discriminator is the
|
||||
* `message` text; {@link classifyNotification} is the documented-gap
|
||||
* classifier.
|
||||
*/
|
||||
|
||||
import { writeFileSync } from "node:fs";
|
||||
import type {
|
||||
CliAdapterCapabilities,
|
||||
CliAdapterLaunchContext,
|
||||
CliAdapterResumeContext,
|
||||
CliAgentAdapter,
|
||||
CliInjectionFormat,
|
||||
CliLaunchSpec,
|
||||
CliReadinessDetector,
|
||||
} from "../adapter.js";
|
||||
import { stripAnsiControl, type TelemetryEvent } from "../telemetry-hub.js";
|
||||
|
||||
// ── Capabilities ────────────────────────────────────────────────────────────
|
||||
|
||||
/** Droid native tier: hooks (Stop/Notification/SessionStart) + JSONL + resume. */
|
||||
export const DROID_CAPABILITIES: CliAdapterCapabilities = {
|
||||
nativeDone: true,
|
||||
nativeWaiting: true,
|
||||
transcriptSource: "jsonl",
|
||||
supportsResume: true,
|
||||
};
|
||||
|
||||
// ── Hook script references (U17 seam) ─────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Paths to the session-scoped hook scripts the U17 module writes. The adapter
|
||||
* does NOT create these — it only references them from the generated settings.
|
||||
*/
|
||||
export interface DroidHookScriptRefs {
|
||||
/** Script for the `Stop` hook (positive completion). */
|
||||
stopScript: string;
|
||||
/** Script for the `Notification` hook (permission / idle — see classifier). */
|
||||
notificationScript: string;
|
||||
/** Script for the `SessionStart` hook (captures session_id / transcript_path). */
|
||||
sessionStartScript: string;
|
||||
/** Optional script for tool-activity hooks (PreToolUse/PostToolUse). */
|
||||
toolActivityScript?: string;
|
||||
}
|
||||
|
||||
/** Adapter-specific launch settings recognized by the Droid adapter. */
|
||||
export interface DroidLaunchSettings {
|
||||
/** Override the `droid` binary. */
|
||||
command?: string;
|
||||
/** Extra args appended after the computed base args. */
|
||||
extraArgs?: readonly string[];
|
||||
/** Model override (`--model <id>`). */
|
||||
model?: string;
|
||||
/** Session-scoped hook script paths (from U17). */
|
||||
hookScripts?: DroidHookScriptRefs;
|
||||
/**
|
||||
* Absolute path to WRITE the session-scoped settings JSON to (merged via
|
||||
* `--settings`). MUST live under the session scratch dir — never the user's
|
||||
* global Droid config. When absent the adapter passes the JSON inline if the
|
||||
* binary accepts it; Droid's `--settings` is documented as a PATH, so a path
|
||||
* is strongly preferred (the caller owns containment + lifecycle).
|
||||
*/
|
||||
settingsPath?: string;
|
||||
}
|
||||
|
||||
function readSettings(ctx: CliAdapterLaunchContext): DroidLaunchSettings {
|
||||
return (ctx.settings ?? {}) as DroidLaunchSettings;
|
||||
}
|
||||
|
||||
// ── Settings JSON generation (Claude-style hooks, assumed schema) ─────────────
|
||||
|
||||
interface HookCommandEntry {
|
||||
matcher?: string;
|
||||
hooks: { type: "command"; command: string }[];
|
||||
}
|
||||
|
||||
export interface DroidHooksConfig {
|
||||
[eventName: string]: HookCommandEntry[];
|
||||
}
|
||||
|
||||
export interface DroidSettings {
|
||||
hooks: DroidHooksConfig;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the session-scoped Droid settings document registering our hooks. Schema
|
||||
* is assumed Claude-style (see file header): `{ hooks: { EventName: [{ hooks:
|
||||
* [{ type:"command", command }] }] } }`. Tool-activity events are registered only
|
||||
* when a `toolActivityScript` is supplied.
|
||||
*/
|
||||
export function buildDroidSettings(scripts: DroidHookScriptRefs): DroidSettings {
|
||||
const cmd = (command: string): HookCommandEntry => ({
|
||||
hooks: [{ type: "command", command }],
|
||||
});
|
||||
const hooks: DroidHooksConfig = {
|
||||
SessionStart: [cmd(scripts.sessionStartScript)],
|
||||
Stop: [cmd(scripts.stopScript)],
|
||||
Notification: [cmd(scripts.notificationScript)],
|
||||
};
|
||||
if (scripts.toolActivityScript) {
|
||||
const activity = [cmd(scripts.toolActivityScript)];
|
||||
hooks.PreToolUse = activity;
|
||||
hooks.PostToolUse = activity;
|
||||
}
|
||||
return { hooks };
|
||||
}
|
||||
|
||||
const DEFAULT_COMMAND = "droid";
|
||||
|
||||
/** Append the `--settings` flag for the session-scoped hook config. */
|
||||
function appendSettingsFlag(args: string[], settings: DroidLaunchSettings): void {
|
||||
if (!settings.hookScripts) return;
|
||||
const doc = buildDroidSettings(settings.hookScripts);
|
||||
const json = JSON.stringify(doc);
|
||||
if (settings.settingsPath) {
|
||||
// Caller guarantees this path is under the session scratch dir.
|
||||
writeFileSync(settings.settingsPath, json, "utf8");
|
||||
args.push("--settings", settings.settingsPath);
|
||||
} else {
|
||||
// `--settings` is documented as a path; inline JSON is a best-effort fallback.
|
||||
args.push("--settings", json);
|
||||
}
|
||||
}
|
||||
|
||||
/** Append the autonomy posture's privileged flags, only when permitted. */
|
||||
function appendPostureFlags(args: string[], ctx: CliAdapterLaunchContext): void {
|
||||
// Visible-posture contract (R21): only bypass approvals when the posture opts
|
||||
// in. In interactive mode Droid uses `--auto high` for full autonomy.
|
||||
if (ctx.posture?.autoApprove === true) {
|
||||
args.push("--auto", "high");
|
||||
}
|
||||
}
|
||||
|
||||
function buildBaseArgs(ctx: CliAdapterLaunchContext): { command: string; args: string[] } {
|
||||
const settings = readSettings(ctx);
|
||||
const command = settings.command ?? DEFAULT_COMMAND;
|
||||
const args: string[] = [];
|
||||
if (typeof settings.model === "string" && settings.model.length > 0) {
|
||||
args.push("--model", settings.model);
|
||||
}
|
||||
return { command, args };
|
||||
}
|
||||
|
||||
// ── Notification classifier (the documented-gap discriminator) ────────────────
|
||||
|
||||
/** Sub-reason a Droid `Notification` resolves to after message classification. */
|
||||
export type DroidNotificationSubReason = "permission_request" | "idle_prompt";
|
||||
|
||||
/**
|
||||
* Wording that signals a PERMISSION request (vs a passive idle ping). Droid's
|
||||
* `Notification` hook fires for both with only a `message` string to tell them
|
||||
* apart, so this is a best-effort word classifier (the documented gap).
|
||||
*/
|
||||
const PERMISSION_WORDING =
|
||||
/\b(permission|approve|approval|allow|grant|confirm|authorize|wants to (?:run|edit|use)|requesting|needs your|asking to|blocked by)\b/i;
|
||||
|
||||
/**
|
||||
* Classify a Droid `Notification` message into its sub-reason. BOTH outcomes are
|
||||
* treated as `waitingOnInput` upstream (both mean blocked-on-human); this only
|
||||
* tags WHY for the surface/notification. Default when ambiguous is
|
||||
* `permission_request` ONLY when permission wording is present; otherwise
|
||||
* `idle_prompt` (the safer default for a generic "waiting" ping).
|
||||
*/
|
||||
export function classifyNotification(message: string | undefined): DroidNotificationSubReason {
|
||||
const text = typeof message === "string" ? message : "";
|
||||
// Permission wording wins whenever present (even alongside idle wording): a
|
||||
// permission request is the more actionable, blocking sub-reason. A bare ping
|
||||
// with no permission wording defaults to idle.
|
||||
if (PERMISSION_WORDING.test(text)) return "permission_request";
|
||||
return "idle_prompt";
|
||||
}
|
||||
|
||||
// ── Hook payload → telemetry ──────────────────────────────────────────────────
|
||||
|
||||
/** The raw shape of a Droid hook payload (tolerant of missing fields). */
|
||||
export interface DroidHookPayload {
|
||||
hook_event_name?: string;
|
||||
session_id?: string;
|
||||
transcript_path?: string;
|
||||
permission_mode?: string;
|
||||
source?: string;
|
||||
tool_name?: string;
|
||||
message?: string;
|
||||
stop_reason?: string;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
/**
|
||||
* Map a raw Droid hook payload onto a normalized engine `TelemetryEvent`.
|
||||
* Returns null for events with no state-relevant signal. Tolerant of missing
|
||||
* optional fields.
|
||||
*
|
||||
* Mapping (KTD telemetry tiering — native tier):
|
||||
* SessionStart → sessionStart (capture session_id +
|
||||
* transcript_path + permission_mode)
|
||||
* PreToolUse / PostToolUse → toolActivity (re-arm watchdog)
|
||||
* Notification → waitingOnInput (sub-reason via classifier)
|
||||
* Stop → done (positive completion); see classifyStop
|
||||
*/
|
||||
export function mapHookPayload(payload: DroidHookPayload): TelemetryEvent | null {
|
||||
const event = typeof payload.hook_event_name === "string" ? payload.hook_event_name : "";
|
||||
const nativeSessionId =
|
||||
typeof payload.session_id === "string" && payload.session_id.length > 0
|
||||
? payload.session_id
|
||||
: undefined;
|
||||
|
||||
const withSession = (
|
||||
base: TelemetryEvent,
|
||||
extra?: Record<string, unknown>,
|
||||
): TelemetryEvent => {
|
||||
const payloadOut: Record<string, unknown> = { ...(base.payload ?? {}), ...(extra ?? {}) };
|
||||
if (nativeSessionId) payloadOut.nativeSessionId = nativeSessionId;
|
||||
return { kind: base.kind, payload: payloadOut };
|
||||
};
|
||||
|
||||
switch (event) {
|
||||
case "SessionStart": {
|
||||
const extra: Record<string, unknown> = {};
|
||||
if (typeof payload.transcript_path === "string") {
|
||||
extra.transcriptPath = payload.transcript_path;
|
||||
}
|
||||
if (typeof payload.permission_mode === "string") {
|
||||
extra.permissionMode = payload.permission_mode;
|
||||
}
|
||||
if (typeof payload.source === "string") extra.source = payload.source;
|
||||
return withSession({ kind: "sessionStart" }, extra);
|
||||
}
|
||||
case "PreToolUse":
|
||||
case "PostToolUse": {
|
||||
const extra =
|
||||
typeof payload.tool_name === "string" ? { toolName: payload.tool_name } : undefined;
|
||||
return withSession({ kind: "toolActivity" }, extra);
|
||||
}
|
||||
case "Notification": {
|
||||
// The conflated event: classify the message to tag the sub-reason. Both
|
||||
// outcomes mean blocked-on-human → waitingOnInput.
|
||||
const subReason = classifyNotification(payload.message);
|
||||
return withSession(
|
||||
{ kind: "waitingOnInput" },
|
||||
{
|
||||
notification: {
|
||||
kind: subReason,
|
||||
...(typeof payload.message === "string" ? { message: payload.message } : {}),
|
||||
},
|
||||
},
|
||||
);
|
||||
}
|
||||
case "Stop":
|
||||
return classifyStop(payload);
|
||||
default:
|
||||
return nativeSessionId ? withSession({ kind: "outputProgress" }) : null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Map a `Stop` payload onto its telemetry event. Happy path is `done`; an
|
||||
* explicit error-ish `stop_reason` downgrades to `toolActivity` (refusing to
|
||||
* gate pipeline advancement on a failed stop), mirroring the Claude adapter.
|
||||
*/
|
||||
export function classifyStop(payload: DroidHookPayload): TelemetryEvent {
|
||||
const nativeSessionId =
|
||||
typeof payload.session_id === "string" && payload.session_id.length > 0
|
||||
? payload.session_id
|
||||
: undefined;
|
||||
const reason = typeof payload.stop_reason === "string" ? payload.stop_reason.toLowerCase() : "";
|
||||
const failed = reason.length > 0 && /error|fail|abort|cancel|interrupt/.test(reason);
|
||||
const kind: TelemetryEvent["kind"] = failed ? "toolActivity" : "done";
|
||||
const out: TelemetryEvent = { kind, payload: {} };
|
||||
if (nativeSessionId) out.payload!.nativeSessionId = nativeSessionId;
|
||||
if (failed) out.payload!.stopReason = payload.stop_reason;
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse a raw hook payload string (delivered on the hook command's stdin) into a
|
||||
* normalized event. Returns null on unparseable input — never throws.
|
||||
*/
|
||||
export function parseHookPayload(raw: string): TelemetryEvent | null {
|
||||
let parsed: unknown;
|
||||
try {
|
||||
parsed = JSON.parse(raw);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
if (!parsed || typeof parsed !== "object") return null;
|
||||
return mapHookPayload(parsed as DroidHookPayload);
|
||||
}
|
||||
|
||||
// ── Transcript tailing (JSONL, Claude-style nesting) ──────────────────────────
|
||||
|
||||
export interface DroidTranscriptEntry {
|
||||
role: "user" | "assistant" | "tool" | "system";
|
||||
text: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Incremental JSONL transcript tailer. Droid appends one JSON object per line to
|
||||
* the file at `transcript_path`. Mirrors the Claude tailer's offset tracking,
|
||||
* partial-line handling, and unparseable-line tolerance.
|
||||
*/
|
||||
export class DroidTranscriptTailer {
|
||||
private offset = 0;
|
||||
private partial = "";
|
||||
|
||||
get bytesRead(): number {
|
||||
return this.offset;
|
||||
}
|
||||
|
||||
push(chunk: string): DroidTranscriptEntry[] {
|
||||
this.offset += Buffer.byteLength(chunk, "utf8");
|
||||
const text = this.partial + chunk;
|
||||
const lines = text.split("\n");
|
||||
this.partial = lines.pop() ?? "";
|
||||
const entries: DroidTranscriptEntry[] = [];
|
||||
for (const line of lines) {
|
||||
const entry = parseTranscriptLine(line);
|
||||
if (entry) entries.push(entry);
|
||||
}
|
||||
return entries;
|
||||
}
|
||||
|
||||
flush(): DroidTranscriptEntry[] {
|
||||
if (this.partial.trim().length === 0) {
|
||||
this.partial = "";
|
||||
return [];
|
||||
}
|
||||
const entry = parseTranscriptLine(this.partial);
|
||||
this.partial = "";
|
||||
return entry ? [entry] : [];
|
||||
}
|
||||
}
|
||||
|
||||
function parseTranscriptLine(line: string): DroidTranscriptEntry | null {
|
||||
const trimmed = line.trim();
|
||||
if (trimmed.length === 0) return null;
|
||||
let obj: Record<string, unknown>;
|
||||
try {
|
||||
const parsed = JSON.parse(trimmed);
|
||||
if (!parsed || typeof parsed !== "object") return null;
|
||||
obj = parsed as Record<string, unknown>;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
const role = normalizeRole(obj);
|
||||
const text = extractText(obj);
|
||||
if (text.length === 0) return null;
|
||||
return { role, text };
|
||||
}
|
||||
|
||||
function normalizeRole(obj: Record<string, unknown>): DroidTranscriptEntry["role"] {
|
||||
const message = obj.message as Record<string, unknown> | undefined;
|
||||
const raw =
|
||||
(typeof message?.role === "string" && message.role) ||
|
||||
(typeof obj.role === "string" && obj.role) ||
|
||||
(typeof obj.type === "string" && obj.type) ||
|
||||
"";
|
||||
switch (raw) {
|
||||
case "user":
|
||||
case "human":
|
||||
return "user";
|
||||
case "assistant":
|
||||
case "model":
|
||||
return "assistant";
|
||||
case "tool":
|
||||
case "tool_result":
|
||||
case "tool_use":
|
||||
return "tool";
|
||||
default:
|
||||
return "system";
|
||||
}
|
||||
}
|
||||
|
||||
function extractText(obj: Record<string, unknown>): string {
|
||||
const message = obj.message as Record<string, unknown> | undefined;
|
||||
const content = message?.content ?? obj.content ?? obj.text;
|
||||
return flattenContent(content);
|
||||
}
|
||||
|
||||
function flattenContent(content: unknown): string {
|
||||
if (typeof content === "string") return content.trim();
|
||||
if (Array.isArray(content)) {
|
||||
return content
|
||||
.map((block) => {
|
||||
if (typeof block === "string") return block;
|
||||
if (block && typeof block === "object") {
|
||||
const b = block as Record<string, unknown>;
|
||||
if (typeof b.text === "string") return b.text;
|
||||
}
|
||||
return "";
|
||||
})
|
||||
.filter((s) => s.length > 0)
|
||||
.join("")
|
||||
.trim();
|
||||
}
|
||||
return "";
|
||||
}
|
||||
|
||||
// ── Readiness detector ─────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Readiness detector for Droid's interactive TUI. Native readiness arrives as
|
||||
* the first `SessionStart` hook (telemetry-driven); this output-based detector is
|
||||
* the FALLBACK for callers gating on PTY output. Ready on the bracketed-paste
|
||||
* enable sequence or a prompt glyph at a line's trailing edge.
|
||||
*/
|
||||
export class DroidReadinessDetector implements CliReadinessDetector {
|
||||
private ready = false;
|
||||
private buffer = "";
|
||||
|
||||
observe(chunk: string): boolean {
|
||||
if (this.ready) return true;
|
||||
this.buffer = (this.buffer + chunk).slice(-4096);
|
||||
if (this.buffer.includes("\x1b[?2004h")) {
|
||||
this.ready = true;
|
||||
return true;
|
||||
}
|
||||
const stripped = stripAnsiControl(this.buffer);
|
||||
if (/(^|\n)\s*[╭│]?\s*[>❯]\s/.test(stripped)) {
|
||||
this.ready = true;
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// ── The adapter ─────────────────────────────────────────────────────────────
|
||||
|
||||
export const droidAdapter: CliAgentAdapter = {
|
||||
id: "droid",
|
||||
name: "Droid",
|
||||
capabilities: DROID_CAPABILITIES,
|
||||
|
||||
buildLaunch(ctx: CliAdapterLaunchContext): CliLaunchSpec {
|
||||
const settings = readSettings(ctx);
|
||||
const { command, args } = buildBaseArgs(ctx);
|
||||
appendSettingsFlag(args, settings);
|
||||
appendPostureFlags(args, ctx);
|
||||
if (settings.extraArgs) args.push(...settings.extraArgs);
|
||||
return { command, args };
|
||||
},
|
||||
|
||||
buildEnvAllowlist(): string[] {
|
||||
// Only what Droid needs to authenticate, find its config, and render a
|
||||
// terminal. NEVER inherit-everything — FUSION_* creds stay out of the child.
|
||||
return [
|
||||
"HOME",
|
||||
"PATH",
|
||||
"SHELL",
|
||||
"USER",
|
||||
"LOGNAME",
|
||||
"LANG",
|
||||
"LC_ALL",
|
||||
"LC_CTYPE",
|
||||
"TERM",
|
||||
"TERMINFO",
|
||||
"TMPDIR",
|
||||
"COLORTERM",
|
||||
"XDG_CONFIG_HOME",
|
||||
"XDG_CACHE_HOME",
|
||||
"XDG_DATA_HOME",
|
||||
// Factory Droid auth.
|
||||
"FACTORY_API_KEY",
|
||||
];
|
||||
},
|
||||
|
||||
createReadinessDetector(): CliReadinessDetector {
|
||||
return new DroidReadinessDetector();
|
||||
},
|
||||
|
||||
formatInjection(text: string, _opts: { bracketedPasteActive: boolean }): CliInjectionFormat {
|
||||
// Session manager owns paste-wrapping + neutralization; we add the submit CR.
|
||||
const payload = text.endsWith("\r") ? text : `${text}\r`;
|
||||
return { payload };
|
||||
},
|
||||
|
||||
buildResume(ctx: CliAdapterResumeContext): CliLaunchSpec {
|
||||
// Resume mode is selected via the settings: headless `exec -s <id>` vs
|
||||
// interactive `--resume <id>`. The exec path NEVER uses `-r` (that is
|
||||
// `--reasoning-effort` in exec mode). The presence of a headless/exec request
|
||||
// is signalled by `extraArgs` containing `exec`, or by an explicit
|
||||
// `execMode` flag on settings; default is interactive.
|
||||
const settings = readSettings(ctx) as DroidLaunchSettings & { execMode?: boolean };
|
||||
const command = settings.command ?? DEFAULT_COMMAND;
|
||||
|
||||
if (settings.execMode === true) {
|
||||
// Headless: `droid exec -s <sessionId>` — NEVER `-r`.
|
||||
const args: string[] = ["exec", "-s", ctx.nativeSessionId];
|
||||
if (typeof settings.model === "string" && settings.model.length > 0) {
|
||||
args.push("--model", settings.model);
|
||||
}
|
||||
if (ctx.posture?.autoApprove === true) args.push("--auto", "high");
|
||||
if (settings.extraArgs) args.push(...settings.extraArgs);
|
||||
return { command, args };
|
||||
}
|
||||
|
||||
// Interactive: `droid --resume <sessionId>`.
|
||||
const { args } = buildBaseArgs(ctx);
|
||||
args.push("--resume", ctx.nativeSessionId);
|
||||
appendSettingsFlag(args, settings);
|
||||
appendPostureFlags(args, ctx);
|
||||
if (settings.extraArgs) args.push(...settings.extraArgs);
|
||||
return { command, args };
|
||||
},
|
||||
};
|
||||
461
packages/engine/src/cli-agent/adapters/pi.ts
Normal file
461
packages/engine/src/cli-agent/adapters/pi.ts
Normal file
@@ -0,0 +1,461 @@
|
||||
/**
|
||||
* Pi adapter — NATIVE-tier CliAgentAdapter (U5).
|
||||
*
|
||||
* Pi (github.com/earendil-works/pi) is native tier: it writes a structured
|
||||
* session JSONL to disk that we tail for telemetry AND transcript. Capability
|
||||
* flags advertise this honestly: `nativeDone: true`, `nativeWaiting: true`,
|
||||
* `transcriptSource: "session-jsonl"` (a JSONL transcript on disk), and
|
||||
* `supportsResume: true`.
|
||||
*
|
||||
* This adapter teaches the engine to:
|
||||
* - launch `pi` with a SESSION-SCOPED `--session-dir <dir>` so the session file
|
||||
* is written somewhere discoverable (and never collides with the user's other
|
||||
* sessions);
|
||||
* - tail the session JSONL incrementally, mapping its event/message entries
|
||||
* onto normalized `TelemetryEvent`s and chat transcript entries;
|
||||
* - capture the native session id from the file's `session` header;
|
||||
* - resume via `pi --session <path|partial-uuid>`.
|
||||
*
|
||||
* ── Verified against the installed binary (Pi) ──
|
||||
* - `pi` is on PATH (`/opt/homebrew/bin/pi`).
|
||||
* - `pi --help`: `--session <path|id>` ("Use specific session file or partial
|
||||
* UUID"), `--session-dir <dir>` ("Directory for session storage and lookup"),
|
||||
* `--mode <mode>` ("Output mode: text (default), json, or rpc"),
|
||||
* `--resume, -r` (interactive picker), `--continue, -c`, `--no-session`,
|
||||
* `--print, -p` (non-interactive), `--fork <path|id>`.
|
||||
* - Session JSONL layout CONFIRMED by inspecting real files under
|
||||
* `~/.pi/agent/sessions/<cwd-encoded>/<ts>_<uuid>.jsonl`: a first line
|
||||
* `{type:"session", version, id:<uuid>, timestamp, cwd}`, then
|
||||
* `{type:"model_change"|"thinking_level_change", ...}` and
|
||||
* `{type:"message", id, parentId, timestamp, message:{ role, content:[
|
||||
* {type:"text"|"thinking", text|thinking} ] }}` rows. `role` is one of
|
||||
* `user` / `assistant` / `toolResult`.
|
||||
*
|
||||
* ── Assumed (marked so wiring composes; revisit on drift) ──
|
||||
* - TELEMETRY EVENT MAPPING. The KTD specifies turn_start/agent_start→busy,
|
||||
* turn_end/agent_end→done, input-request→waitingOnInput, message→transcript.
|
||||
* The recorded v3 sessions I inspected contained only `message` rows (no
|
||||
* explicit turn_* / agent_* / input-request rows — those arrive via the
|
||||
* event bus / `--mode json` in newer builds). {@link mapSessionLine}
|
||||
* therefore handles BOTH: explicit lifecycle events when present, AND a
|
||||
* message-shape fallback (assistant message → busy/transcript). The explicit
|
||||
* lifecycle event names are best-effort per the documented event bus and are
|
||||
* matched case-insensitively with several spellings.
|
||||
* - `--mode json` is an ALTERNATIVE interactive event stream; we implement the
|
||||
* deterministic JSONL tail instead (the file is the source of truth and
|
||||
* survives restarts). The session-dir mechanism makes the file discoverable.
|
||||
*/
|
||||
|
||||
import type {
|
||||
CliAdapterCapabilities,
|
||||
CliAdapterLaunchContext,
|
||||
CliAdapterResumeContext,
|
||||
CliAgentAdapter,
|
||||
CliInjectionFormat,
|
||||
CliLaunchSpec,
|
||||
CliReadinessDetector,
|
||||
} from "../adapter.js";
|
||||
import { stripAnsiControl, type TelemetryEvent } from "../telemetry-hub.js";
|
||||
|
||||
// ── Capabilities ────────────────────────────────────────────────────────────
|
||||
|
||||
/** Pi native tier: session-JSONL telemetry + transcript + resume. */
|
||||
export const PI_CAPABILITIES: CliAdapterCapabilities = {
|
||||
nativeDone: true,
|
||||
nativeWaiting: true,
|
||||
transcriptSource: "session-jsonl",
|
||||
supportsResume: true,
|
||||
};
|
||||
|
||||
// ── Launch settings ───────────────────────────────────────────────────────────
|
||||
|
||||
const DEFAULT_COMMAND = "pi";
|
||||
|
||||
/** Adapter-specific launch settings recognized by the Pi adapter. */
|
||||
export interface PiLaunchSettings {
|
||||
/** Override the `pi` binary. */
|
||||
command?: string;
|
||||
/** Extra args appended after the computed base args. */
|
||||
extraArgs?: readonly string[];
|
||||
/** Model override (`--model <pattern>`). */
|
||||
model?: string;
|
||||
/** Provider override (`--provider <name>`). */
|
||||
provider?: string;
|
||||
/**
|
||||
* Session-scoped directory for session storage + lookup (`--session-dir`). The
|
||||
* caller (session manager) owns + cleans this dir; setting it makes the session
|
||||
* file discoverable by {@link findSessionFile}. Strongly recommended so the
|
||||
* session JSONL never lands in the user's global sessions tree.
|
||||
*/
|
||||
sessionDir?: string;
|
||||
}
|
||||
|
||||
function readSettings(ctx: CliAdapterLaunchContext): PiLaunchSettings {
|
||||
return (ctx.settings ?? {}) as PiLaunchSettings;
|
||||
}
|
||||
|
||||
function buildBaseArgs(ctx: CliAdapterLaunchContext): { command: string; args: string[] } {
|
||||
const settings = readSettings(ctx);
|
||||
const command = settings.command ?? DEFAULT_COMMAND;
|
||||
const args: string[] = [];
|
||||
if (typeof settings.provider === "string" && settings.provider.length > 0) {
|
||||
args.push("--provider", settings.provider);
|
||||
}
|
||||
if (typeof settings.model === "string" && settings.model.length > 0) {
|
||||
args.push("--model", settings.model);
|
||||
}
|
||||
if (typeof settings.sessionDir === "string" && settings.sessionDir.length > 0) {
|
||||
args.push("--session-dir", settings.sessionDir);
|
||||
}
|
||||
return { command, args };
|
||||
}
|
||||
|
||||
/** Append the autonomy posture's privileged flags, only when permitted. */
|
||||
function appendPostureFlags(args: string[], ctx: CliAdapterLaunchContext): void {
|
||||
// Visible-posture contract (R21). Pi enables all tools without confirmation via
|
||||
// its tool allowlist; full autonomy maps to enabling tools (`-t` with no
|
||||
// confirmation). We only widen tool access when the posture explicitly opts in.
|
||||
if (ctx.posture?.autoApprove === true) {
|
||||
// Pi prompts per-tool by default; auto-approve enables the full built-in set.
|
||||
args.push("--tools", "read,bash,edit,write");
|
||||
}
|
||||
}
|
||||
|
||||
// ── Session file discovery ─────────────────────────────────────────────────────
|
||||
|
||||
/** Minimal Dirent shape so callers can inject a fake fs in tests. */
|
||||
export interface DirentLike {
|
||||
name: string;
|
||||
isDirectory(): boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Probe a Pi session directory for the newest `*.jsonl` session file. Pi writes
|
||||
* `<ts>_<uuid>.jsonl`; when a session-scoped `--session-dir` was used the file
|
||||
* lands directly in that dir, but the user's global tree nests under a
|
||||
* cwd-encoded subdir — so we search one level deep too. Returns the lexically
|
||||
* greatest matching filename's full path (timestamps sort lexically), or null.
|
||||
*/
|
||||
export function findSessionFile(
|
||||
sessionDir: string,
|
||||
fs: { readdirSync: (p: string, o: { withFileTypes: true }) => DirentLike[] },
|
||||
): string | null {
|
||||
const best: { path: string; name: string } = { path: "", name: "" };
|
||||
let found = false;
|
||||
const consider = (dir: string) => {
|
||||
let entries: DirentLike[];
|
||||
try {
|
||||
entries = fs.readdirSync(dir, { withFileTypes: true });
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
for (const entry of entries) {
|
||||
const full = `${dir}/${entry.name}`;
|
||||
if (entry.isDirectory()) {
|
||||
consider(full); // one level of cwd-encoded nesting
|
||||
} else if (entry.name.endsWith(".jsonl")) {
|
||||
// Compare by FILENAME (timestamp-prefixed) so the dir prefix doesn't skew
|
||||
// the lexical ordering across nested vs flat layouts.
|
||||
if (!found || entry.name > best.name) {
|
||||
best.path = full;
|
||||
best.name = entry.name;
|
||||
found = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
consider(sessionDir);
|
||||
return found ? best.path : null;
|
||||
}
|
||||
|
||||
// ── Session JSONL → telemetry ──────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Telemetry events the Pi tailer can synthesize from a session line. `transcript`
|
||||
* carries chat content; the others drive the state machine.
|
||||
*/
|
||||
export type PiSessionEvent =
|
||||
| { kind: "busy" }
|
||||
| { kind: "done" }
|
||||
| { kind: "waitingOnInput"; notification?: Record<string, unknown> }
|
||||
| { kind: "sessionStart"; nativeSessionId?: string }
|
||||
| { kind: "transcript"; role: PiTranscriptEntry["role"]; text: string };
|
||||
|
||||
export interface PiTranscriptEntry {
|
||||
role: "user" | "assistant" | "tool" | "system";
|
||||
text: string;
|
||||
}
|
||||
|
||||
/** Lifecycle event-type spellings the tailer recognizes (case-insensitive set). */
|
||||
const TURN_START_TYPES = new Set(["turn_start", "agent_start", "turnstart", "agentstart"]);
|
||||
const TURN_END_TYPES = new Set(["turn_end", "agent_end", "turnend", "agentend"]);
|
||||
const INPUT_REQUEST_TYPES = new Set([
|
||||
"input_request",
|
||||
"input-request",
|
||||
"inputrequest",
|
||||
"request_input",
|
||||
"ask_user",
|
||||
"elicit",
|
||||
]);
|
||||
|
||||
/**
|
||||
* Map a parsed Pi session JSONL object onto a normalized {@link PiSessionEvent},
|
||||
* or null when the line carries no signal.
|
||||
*
|
||||
* Mapping (KTD telemetry tiering — native tier):
|
||||
* session → sessionStart (+ nativeSessionId)
|
||||
* turn_start / agent_start → busy
|
||||
* turn_end / agent_end → done
|
||||
* input_request / ask_user / elicit → waitingOnInput
|
||||
* message{role:user|assistant|toolResult} → transcript (assistant also implies
|
||||
* a busy turn is underway → handled
|
||||
* by the tailer)
|
||||
*/
|
||||
export function mapSessionLine(obj: Record<string, unknown>): PiSessionEvent | null {
|
||||
const type = typeof obj.type === "string" ? obj.type.toLowerCase() : "";
|
||||
|
||||
if (type === "session") {
|
||||
const id = typeof obj.id === "string" && obj.id.length > 0 ? obj.id : undefined;
|
||||
return { kind: "sessionStart", nativeSessionId: id };
|
||||
}
|
||||
if (TURN_START_TYPES.has(type)) return { kind: "busy" };
|
||||
if (TURN_END_TYPES.has(type)) return { kind: "done" };
|
||||
if (INPUT_REQUEST_TYPES.has(type)) {
|
||||
return {
|
||||
kind: "waitingOnInput",
|
||||
notification: { kind: "input_request", source: "session-jsonl" },
|
||||
};
|
||||
}
|
||||
if (type === "message") {
|
||||
const message = obj.message as Record<string, unknown> | undefined;
|
||||
if (!message) return null;
|
||||
const role = normalizeRole(typeof message.role === "string" ? message.role : "");
|
||||
const text = flattenContent(message.content);
|
||||
if (text.length === 0) return null;
|
||||
return { kind: "transcript", role, text };
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function normalizeRole(raw: string): PiTranscriptEntry["role"] {
|
||||
switch (raw.toLowerCase()) {
|
||||
case "user":
|
||||
case "human":
|
||||
return "user";
|
||||
case "assistant":
|
||||
case "model":
|
||||
return "assistant";
|
||||
case "toolresult":
|
||||
case "tool_result":
|
||||
case "tool":
|
||||
case "tooluse":
|
||||
case "tool_use":
|
||||
return "tool";
|
||||
default:
|
||||
return "system";
|
||||
}
|
||||
}
|
||||
|
||||
/** Flatten Pi content blocks (`[{type:"text",text}|{type:"thinking",thinking}]`). */
|
||||
function flattenContent(content: unknown): string {
|
||||
if (typeof content === "string") return content.trim();
|
||||
if (Array.isArray(content)) {
|
||||
return content
|
||||
.map((block) => {
|
||||
if (typeof block === "string") return block;
|
||||
if (block && typeof block === "object") {
|
||||
const b = block as Record<string, unknown>;
|
||||
if (typeof b.text === "string") return b.text;
|
||||
if (typeof b.thinking === "string") return b.thinking;
|
||||
}
|
||||
return "";
|
||||
})
|
||||
.filter((s) => s.length > 0)
|
||||
.join("")
|
||||
.trim();
|
||||
}
|
||||
return "";
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert a {@link PiSessionEvent} into the engine's normalized
|
||||
* {@link TelemetryEvent} shape (the hub's ingest contract). A `transcript` event
|
||||
* becomes a `transcript` kind carrying its flattened text; lifecycle events map
|
||||
* one-to-one. An assistant `transcript` ALSO implies the turn is busy, but to
|
||||
* keep mapping pure the tailer emits the `busy` separately (see
|
||||
* {@link PiSessionTailer.push}).
|
||||
*/
|
||||
export function toTelemetryEvent(event: PiSessionEvent): TelemetryEvent {
|
||||
switch (event.kind) {
|
||||
case "sessionStart":
|
||||
return {
|
||||
kind: "sessionStart",
|
||||
payload: event.nativeSessionId ? { nativeSessionId: event.nativeSessionId } : {},
|
||||
};
|
||||
case "busy":
|
||||
return { kind: "busy", payload: {} };
|
||||
case "done":
|
||||
return { kind: "done", payload: {} };
|
||||
case "waitingOnInput":
|
||||
return {
|
||||
kind: "waitingOnInput",
|
||||
payload: event.notification ? { notification: event.notification } : {},
|
||||
};
|
||||
case "transcript":
|
||||
return { kind: "transcript", payload: { text: event.text, role: event.role } };
|
||||
}
|
||||
}
|
||||
|
||||
// ── Session JSONL tailer ───────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Incremental Pi session-JSONL tailer. Pi appends one JSON object per line; this
|
||||
* remembers the byte offset so each {@link push} yields only entries appended
|
||||
* since the last call. Mirrors the Claude/Codex tailers' offset tracking,
|
||||
* partial-line handling, and unparseable-line tolerance. Returns the
|
||||
* {@link PiSessionEvent}s synthesized from the appended lines (lifecycle +
|
||||
* transcript), in order.
|
||||
*/
|
||||
export class PiSessionTailer {
|
||||
private offset = 0;
|
||||
private partial = "";
|
||||
|
||||
get bytesRead(): number {
|
||||
return this.offset;
|
||||
}
|
||||
|
||||
push(chunk: string): PiSessionEvent[] {
|
||||
this.offset += Buffer.byteLength(chunk, "utf8");
|
||||
const text = this.partial + chunk;
|
||||
const lines = text.split("\n");
|
||||
this.partial = lines.pop() ?? "";
|
||||
const events: PiSessionEvent[] = [];
|
||||
for (const line of lines) {
|
||||
const ev = parseSessionLine(line);
|
||||
if (ev) events.push(ev);
|
||||
}
|
||||
return events;
|
||||
}
|
||||
|
||||
flush(): PiSessionEvent[] {
|
||||
if (this.partial.trim().length === 0) {
|
||||
this.partial = "";
|
||||
return [];
|
||||
}
|
||||
const ev = parseSessionLine(this.partial);
|
||||
this.partial = "";
|
||||
return ev ? [ev] : [];
|
||||
}
|
||||
}
|
||||
|
||||
function parseSessionLine(line: string): PiSessionEvent | null {
|
||||
const trimmed = line.trim();
|
||||
if (trimmed.length === 0) return null;
|
||||
let obj: Record<string, unknown>;
|
||||
try {
|
||||
const parsed = JSON.parse(trimmed);
|
||||
if (!parsed || typeof parsed !== "object") return null;
|
||||
obj = parsed as Record<string, unknown>;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
return mapSessionLine(obj);
|
||||
}
|
||||
|
||||
// ── Readiness detector ─────────────────────────────────────────────────────────
|
||||
|
||||
const READY_GLYPHS = [">", "❯", "▌", "»"];
|
||||
|
||||
/**
|
||||
* Readiness detector for Pi's interactive TUI. Pi's session file is the
|
||||
* authoritative telemetry source but readiness gates the FIRST injection before
|
||||
* the file is populated, so this output-based detector is primary: ready on the
|
||||
* bracketed-paste enable sequence or a composer prompt glyph at a line's
|
||||
* trailing edge.
|
||||
*/
|
||||
export class PiReadinessDetector implements CliReadinessDetector {
|
||||
private ready = false;
|
||||
private buffer = "";
|
||||
|
||||
observe(chunk: string): boolean {
|
||||
if (this.ready) return true;
|
||||
this.buffer = (this.buffer + chunk).slice(-4096);
|
||||
if (this.buffer.includes("\x1b[?2004h")) {
|
||||
this.ready = true;
|
||||
return true;
|
||||
}
|
||||
const stripped = stripAnsiControl(this.buffer);
|
||||
const tail = stripped.replace(/[ \t\r\n]+$/g, "").slice(-8);
|
||||
if (READY_GLYPHS.some((g) => tail.endsWith(g))) {
|
||||
this.ready = true;
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// ── The adapter ─────────────────────────────────────────────────────────────
|
||||
|
||||
export const piAdapter: CliAgentAdapter = {
|
||||
id: "pi",
|
||||
name: "Pi",
|
||||
capabilities: PI_CAPABILITIES,
|
||||
|
||||
buildLaunch(ctx: CliAdapterLaunchContext): CliLaunchSpec {
|
||||
const settings = readSettings(ctx);
|
||||
const { command, args } = buildBaseArgs(ctx);
|
||||
appendPostureFlags(args, ctx);
|
||||
if (settings.extraArgs) args.push(...settings.extraArgs);
|
||||
return { command, args };
|
||||
},
|
||||
|
||||
buildEnvAllowlist(): string[] {
|
||||
// Only what Pi needs to authenticate, find its config, and render a terminal.
|
||||
// NEVER inherit-everything — FUSION_* creds stay out of the child.
|
||||
return [
|
||||
"HOME",
|
||||
"PATH",
|
||||
"SHELL",
|
||||
"USER",
|
||||
"LOGNAME",
|
||||
"LANG",
|
||||
"LC_ALL",
|
||||
"LC_CTYPE",
|
||||
"TERM",
|
||||
"TERMINFO",
|
||||
"TMPDIR",
|
||||
"COLORTERM",
|
||||
"XDG_CONFIG_HOME",
|
||||
"XDG_CACHE_HOME",
|
||||
"XDG_DATA_HOME",
|
||||
// Pi session-dir override + common provider auth keys.
|
||||
"PI_CODING_AGENT_SESSION_DIR",
|
||||
"GEMINI_API_KEY",
|
||||
"GOOGLE_API_KEY",
|
||||
"ANTHROPIC_API_KEY",
|
||||
"OPENAI_API_KEY",
|
||||
];
|
||||
},
|
||||
|
||||
createReadinessDetector(): CliReadinessDetector {
|
||||
return new PiReadinessDetector();
|
||||
},
|
||||
|
||||
formatInjection(text: string, _opts: { bracketedPasteActive: boolean }): CliInjectionFormat {
|
||||
// Session manager owns paste-wrapping + neutralization; we add the submit CR.
|
||||
const payload = text.endsWith("\r") ? text : `${text}\r`;
|
||||
return { payload };
|
||||
},
|
||||
|
||||
buildResume(ctx: CliAdapterResumeContext): CliLaunchSpec {
|
||||
// `pi --session <path|partial-uuid>` re-attaches the prior conversation. The
|
||||
// recorded native id is the session uuid (or its file path); both are accepted
|
||||
// by `--session`. Provider/model/session-dir are re-applied.
|
||||
const settings = readSettings(ctx);
|
||||
const { command, args } = buildBaseArgs(ctx);
|
||||
args.push("--session", ctx.nativeSessionId);
|
||||
appendPostureFlags(args, ctx);
|
||||
if (settings.extraArgs) args.push(...settings.extraArgs);
|
||||
return { command, args };
|
||||
},
|
||||
};
|
||||
Reference in New Issue
Block a user