feat(engine): add Claude Code native-tier cli-agent adapter (U4)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
gsxdsm
2026-06-04 23:32:58 -07:00
parent 773ba76209
commit ec4377e10a
2 changed files with 996 additions and 0 deletions

View File

@@ -0,0 +1,388 @@
import { describe, it, expect, beforeEach, afterEach } from "vitest";
import { mkdtempSync, readFileSync, existsSync, mkdirSync } from "node:fs";
import { join, dirname, sep } 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 {
claudeCodeAdapter,
buildClaudeCodeSettings,
mapHookPayload,
parseHookPayload,
classifyStop,
isResumeReattach,
ClaudeTranscriptTailer,
ClaudeCodeReadinessDetector,
CLAUDE_CODE_CAPABILITIES,
type HookScriptRefs,
} from "../claude-code.js";
const SCRIPTS: HookScriptRefs = {
stopScript: "/tmp/sess/hooks/stop.sh",
notificationScript: "/tmp/sess/hooks/notify.sh",
permissionScript: "/tmp/sess/hooks/perm.sh",
sessionStartScript: "/tmp/sess/hooks/start.sh",
};
describe("claudeCodeAdapter — capabilities + identity", () => {
it("declares the native tier capability flags", () => {
expect(claudeCodeAdapter.id).toBe("claude-code");
expect(claudeCodeAdapter.capabilities).toEqual({
nativeDone: true,
nativeWaiting: true,
transcriptSource: "jsonl",
supportsResume: true,
});
expect(CLAUDE_CODE_CAPABILITIES).toEqual(claudeCodeAdapter.capabilities);
});
});
describe("claudeCodeAdapter — buildLaunch + settings", () => {
it("launches bare `claude` with no hook scripts", () => {
const spec = claudeCodeAdapter.buildLaunch({ settings: {}, posture: null });
expect(spec.command).toBe("claude");
expect(spec.args).toEqual([]);
});
it("builds the verified hooks settings schema for the four core events", () => {
const doc = buildClaudeCodeSettings(SCRIPTS);
expect(Object.keys(doc.hooks).sort()).toEqual([
"Notification",
"PermissionRequest",
"SessionStart",
"Stop",
]);
expect(doc.hooks.Stop).toEqual([
{ hooks: [{ type: "command", command: SCRIPTS.stopScript }] },
]);
expect(doc.hooks.SessionStart[0].hooks[0].command).toBe(SCRIPTS.sessionStartScript);
});
it("registers tool-activity hooks only when a toolActivityScript is provided", () => {
const doc = buildClaudeCodeSettings({ ...SCRIPTS, toolActivityScript: "/tmp/sess/hooks/act.sh" });
expect(doc.hooks.PreToolUse).toBeDefined();
expect(doc.hooks.PostToolUse).toBeDefined();
expect(doc.hooks.UserPromptSubmit[0].hooks[0].command).toBe("/tmp/sess/hooks/act.sh");
});
it("inlines the settings JSON via --settings when no settingsPath is given", () => {
const spec = claudeCodeAdapter.buildLaunch({
settings: { hookScripts: SCRIPTS },
posture: null,
});
const idx = spec.args.indexOf("--settings");
expect(idx).toBeGreaterThanOrEqual(0);
const json = spec.args[idx + 1];
const parsed = JSON.parse(json);
expect(parsed.hooks.Stop[0].hooks[0].command).toBe(SCRIPTS.stopScript);
});
describe("session-scoped settings file containment", () => {
let sessionDir: string;
afterEach(async () => {
if (sessionDir) await rm(dirname(sessionDir), { recursive: true, force: true });
});
it("writes the settings file ONLY to the session-scoped path it was given", () => {
const root = mkdtempSync(join(tmpdir(), "kb-cc-settings-"));
sessionDir = join(root, "session-abc");
const settingsPath = join(sessionDir, "settings.json");
// create the session dir
mkdirSync(sessionDir, { recursive: true });
const spec = claudeCodeAdapter.buildLaunch({
settings: { hookScripts: SCRIPTS, settingsPath },
posture: null,
});
// The flag points at the session-scoped file, and the file is contained
// within the session dir — never the user's global ~/.claude.
const idx = spec.args.indexOf("--settings");
expect(spec.args[idx + 1]).toBe(settingsPath);
expect(settingsPath.startsWith(sessionDir)).toBe(true);
expect(settingsPath.includes(`${sep}.claude${sep}`)).toBe(false);
expect(existsSync(settingsPath)).toBe(true);
const written = JSON.parse(readFileSync(settingsPath, "utf8"));
expect(written.hooks.Notification[0].hooks[0].command).toBe(SCRIPTS.notificationScript);
});
});
it("appends model + extraArgs", () => {
const spec = claudeCodeAdapter.buildLaunch({
settings: { model: "claude-opus", extraArgs: ["--add-dir", "/x"] },
posture: null,
});
expect(spec.args).toEqual(["--model", "claude-opus", "--add-dir", "/x"]);
});
it("emits the privileged flag ONLY when posture.autoApprove is true", () => {
const off = claudeCodeAdapter.buildLaunch({ settings: {}, posture: { autoApprove: false } });
expect(off.args).not.toContain("--dangerously-skip-permissions");
const on = claudeCodeAdapter.buildLaunch({ settings: {}, posture: { autoApprove: true } });
expect(on.args).toContain("--dangerously-skip-permissions");
});
it("env allowlist excludes FUSION_* / service credentials", () => {
const allow = claudeCodeAdapter.buildEnvAllowlist({ settings: {}, posture: null });
expect(allow).toContain("PATH");
expect(allow).toContain("ANTHROPIC_API_KEY");
expect(allow.some((k) => k.startsWith("FUSION_"))).toBe(false);
});
});
describe("claudeCodeAdapter — buildResume", () => {
it("produces `claude --resume <id>` (AE3)", () => {
const spec = claudeCodeAdapter.buildResume!({
settings: {},
posture: null,
nativeSessionId: "sess-123",
});
expect(spec.command).toBe("claude");
expect(spec.args).toEqual(["--resume", "sess-123"]);
});
it("re-applies hook settings + posture on resume", () => {
const spec = claudeCodeAdapter.buildResume!({
settings: { hookScripts: SCRIPTS },
posture: { autoApprove: true },
nativeSessionId: "sess-9",
});
expect(spec.args.slice(0, 2)).toEqual(["--resume", "sess-9"]);
expect(spec.args).toContain("--settings");
expect(spec.args).toContain("--dangerously-skip-permissions");
});
it("recognizes SessionStart{source:resume} as a re-attach (AE3)", () => {
expect(isResumeReattach({ hook_event_name: "SessionStart", source: "resume" })).toBe(true);
expect(isResumeReattach({ hook_event_name: "SessionStart", source: "startup" })).toBe(false);
expect(isResumeReattach({ hook_event_name: "Stop", source: "resume" })).toBe(false);
});
});
describe("claudeCodeAdapter — formatInjection", () => {
it("appends a trailing \\r submit", () => {
expect(claudeCodeAdapter.formatInjection("hello", { bracketedPasteActive: false })).toEqual({
payload: "hello\r",
});
});
it("does not double the trailing \\r", () => {
expect(claudeCodeAdapter.formatInjection("hi\r", { bracketedPasteActive: true })).toEqual({
payload: "hi\r",
});
});
});
describe("claudeCodeAdapter — readiness detector", () => {
it("becomes ready on the bracketed-paste enable sequence", () => {
const d = new ClaudeCodeReadinessDetector();
expect(d.observe("loading...\n")).toBe(false);
expect(d.observe("\x1b[?2004h")).toBe(true);
expect(d.observe("more")).toBe(true); // latches
});
it("falls back to a prompt-glyph at line start", () => {
const d = new ClaudeCodeReadinessDetector();
expect(d.observe("welcome\n")).toBe(false);
expect(d.observe("\n> ")).toBe(true);
});
});
describe("mapHookPayload — telemetry mapping", () => {
it("SessionStart → sessionStart capturing session_id + transcript_path", () => {
const ev = mapHookPayload({
hook_event_name: "SessionStart",
session_id: "S1",
transcript_path: "/t/x.jsonl",
source: "startup",
});
expect(ev?.kind).toBe("sessionStart");
expect(ev?.payload?.nativeSessionId).toBe("S1");
expect(ev?.payload?.transcriptPath).toBe("/t/x.jsonl");
expect(ev?.payload?.source).toBe("startup");
});
it("UserPromptSubmit → busy; PreToolUse/PostToolUse → toolActivity", () => {
expect(mapHookPayload({ hook_event_name: "UserPromptSubmit", session_id: "S1" })?.kind).toBe(
"busy",
);
expect(mapHookPayload({ hook_event_name: "PreToolUse", tool_name: "Bash" })?.kind).toBe(
"toolActivity",
);
expect(mapHookPayload({ hook_event_name: "PostToolUse" })?.kind).toBe("toolActivity");
});
it("PermissionRequest → waitingOnInput", () => {
const ev = mapHookPayload({ hook_event_name: "PermissionRequest", session_id: "S1" });
expect(ev?.kind).toBe("waitingOnInput");
expect((ev?.payload?.notification as Record<string, unknown>).kind).toBe("permission_request");
});
it("Notification{permission_prompt|idle_prompt} → waitingOnInput", () => {
const perm = mapHookPayload({
hook_event_name: "Notification",
notification_type: "permission_prompt",
});
expect(perm?.kind).toBe("waitingOnInput");
const idle = mapHookPayload({
hook_event_name: "Notification",
notification_type: "idle_prompt",
});
expect(idle?.kind).toBe("waitingOnInput");
expect((idle?.payload?.notification as Record<string, unknown>).kind).toBe("idle_prompt");
});
it("Notification{other} → toolActivity (non-blocking)", () => {
expect(
mapHookPayload({ hook_event_name: "Notification", notification_type: "info" })?.kind,
).toBe("toolActivity");
});
it("Stop → done (positive completion)", () => {
const ev = mapHookPayload({ hook_event_name: "Stop", session_id: "S1" });
expect(ev?.kind).toBe("done");
expect(ev?.payload?.nativeSessionId).toBe("S1");
});
it("tolerates missing optional fields (no session_id, no source, etc.)", () => {
expect(mapHookPayload({ hook_event_name: "SessionStart" })?.kind).toBe("sessionStart");
expect(mapHookPayload({ hook_event_name: "Stop" })?.kind).toBe("done");
expect(mapHookPayload({})).toBeNull();
});
it("unknown hook with a session id → outputProgress, otherwise null", () => {
expect(mapHookPayload({ hook_event_name: "Weird", session_id: "S" })?.kind).toBe(
"outputProgress",
);
expect(mapHookPayload({ hook_event_name: "Weird" })).toBeNull();
});
});
describe("classifyStop — failure downgrade", () => {
it("maps a clean Stop to done", () => {
expect(classifyStop({ hook_event_name: "Stop" }).kind).toBe("done");
});
it("maps an error-ish stop_reason to toolActivity, not done", () => {
const ev = classifyStop({ hook_event_name: "Stop", stop_reason: "error_max_tokens" });
expect(ev.kind).toBe("toolActivity");
expect(ev.payload?.stopReason).toBe("error_max_tokens");
});
});
describe("parseHookPayload — raw stdin parsing", () => {
it("parses a JSON string into a normalized event", () => {
const ev = parseHookPayload('{"hook_event_name":"Stop","session_id":"S1"}');
expect(ev?.kind).toBe("done");
});
it("returns null on unparseable input (never throws)", () => {
expect(parseHookPayload("not json")).toBeNull();
expect(parseHookPayload("[]")).toBeNull();
});
});
describe("ClaudeTranscriptTailer — incremental JSONL tail", () => {
it("yields entries incrementally across appended writes and remembers offset", () => {
const tailer = new ClaudeTranscriptTailer();
const l1 = JSON.stringify({ message: { role: "user", content: "hi" } }) + "\n";
const first = tailer.push(l1);
expect(first).toEqual([{ role: "user", text: "hi" }]);
expect(tailer.bytesRead).toBe(Buffer.byteLength(l1, "utf8"));
const l2 = JSON.stringify({ message: { role: "assistant", content: "hello" } }) + "\n";
const second = tailer.push(l2);
expect(second).toEqual([{ role: "assistant", text: "hello" }]);
expect(tailer.bytesRead).toBe(Buffer.byteLength(l1 + l2, "utf8"));
});
it("holds a partial trailing line until its newline arrives", () => {
const tailer = new ClaudeTranscriptTailer();
const full = JSON.stringify({ message: { role: "user", content: "split" } });
expect(tailer.push(full.slice(0, 10))).toEqual([]); // partial
expect(tailer.push(full.slice(10) + "\n")).toEqual([{ role: "user", text: "split" }]);
});
it("flattens content-block arrays and normalizes roles", () => {
const tailer = new ClaudeTranscriptTailer();
const line =
JSON.stringify({
message: { role: "assistant", content: [{ type: "text", text: "A" }, { type: "text", text: "B" }] },
}) + "\n";
expect(tailer.push(line)).toEqual([{ role: "assistant", text: "AB" }]);
});
it("skips unparseable / empty lines without throwing", () => {
const tailer = new ClaudeTranscriptTailer();
expect(tailer.push("\n{bad}\n\n")).toEqual([]);
});
it("flush() emits a final unterminated line", () => {
const tailer = new ClaudeTranscriptTailer();
expect(tailer.push(JSON.stringify({ role: "tool", content: "result" }))).toEqual([]);
expect(tailer.flush()).toEqual([{ role: "tool", text: "result" }]);
});
});
describe("end-to-end via TelemetryHub: SessionStart → PreToolUse → Stop", () => {
let tmpDir: string;
let db: Database;
let store: CliSessionStore;
let hub: TelemetryHub;
let sessionId: string;
beforeEach(() => {
tmpDir = mkdtempSync(join(tmpdir(), "kb-cc-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: "claude-code",
agentState: "starting",
});
sessionId = rec.id;
hub = new TelemetryHub({ store });
});
afterEach(async () => {
db.close();
await rm(tmpDir, { recursive: true, force: true });
});
function feed(payload: Parameters<typeof mapHookPayload>[0]) {
const ev = mapHookPayload(payload);
if (ev) hub.ingest(sessionId, ev);
}
it("drives ready → busy → done and persists session_id from the first payload", () => {
feed({ hook_event_name: "SessionStart", session_id: "native-abc", transcript_path: "/t.jsonl" });
expect(hub.getStateMachine(sessionId)?.getState()).toBe("ready");
// session_id captured from the FIRST payload.
expect(store.getSession(sessionId)?.nativeSessionId).toBe("native-abc");
// ready → busy is the injection-driven transition the session manager makes
// when the engine injects the prompt; telemetry then tracks the busy turn.
hub.getStateMachine(sessionId)!.injectPrompt();
expect(hub.getStateMachine(sessionId)?.getState()).toBe("busy");
feed({ hook_event_name: "PreToolUse", session_id: "native-abc", tool_name: "Bash" });
expect(hub.getStateMachine(sessionId)?.getState()).toBe("busy"); // activity, no advance
feed({ hook_event_name: "Stop", session_id: "native-abc" });
expect(hub.getStateMachine(sessionId)?.getState()).toBe("done");
});
it("PermissionRequest → waitingOnInput; idle_prompt notification → waitingOnInput", () => {
feed({ hook_event_name: "SessionStart", session_id: "n2" });
hub.getStateMachine(sessionId)!.injectPrompt(); // ready → busy
feed({ hook_event_name: "PermissionRequest", session_id: "n2" });
expect(hub.getStateMachine(sessionId)?.getState()).toBe("waitingOnInput");
// user answers (waitingOnInput → busy via the hub's `busy` route), then an
// idle_prompt notification re-enters waiting.
feed({ hook_event_name: "UserPromptSubmit", session_id: "n2" }); // busy
expect(hub.getStateMachine(sessionId)?.getState()).toBe("busy");
feed({ hook_event_name: "Notification", session_id: "n2", notification_type: "idle_prompt" });
expect(hub.getStateMachine(sessionId)?.getState()).toBe("waitingOnInput");
});
});

View File

@@ -0,0 +1,608 @@
/**
* Claude Code adapter — the reference native-tier CliAgentAdapter (U4).
*
* Claude Code exposes the richest native telemetry of the four launch agents:
* a full hook roster (`SessionStart`, `PreToolUse`/`PostToolUse`,
* `UserPromptSubmit`, `Notification`, `PermissionRequest`, `Stop`) that each
* deliver a JSON payload (`hook_event_name`, `session_id`, `transcript_path`,
* `source`, `notification_type`, …) on the hook command's stdin, plus a JSONL
* transcript on disk. This adapter teaches the engine to:
* - launch `claude` with a SESSION-SCOPED additional settings file that
* registers those hooks pointing at this session's hook script (so we never
* touch the user's global `~/.claude` or the repo's tracked `.claude/`);
* - normalize raw hook payloads → engine `TelemetryEvent`s;
* - tail the JSONL transcript incrementally for chat;
* - resume via `claude --resume <session-id>`.
*
* ── Verified against the installed binary (Claude Code 2.1.165, arm64) ──
* The following were confirmed by probing the shipped binary, not assumed:
* - CLI flags: `--settings <file-or-json>` (additional settings), `--resume`
* / `-r [value]` (resume by session id), `--session-id <uuid>`,
* `--setting-sources`, `-p/--print` (non-interactive). [`claude --help`]
* - Hook event names present in the binary: `SessionStart`, `PreToolUse`,
* `PostToolUse`, `UserPromptSubmit`, `Notification`, `PermissionRequest`,
* `Stop`, `SubagentStop`, `PreCompact`, `SessionEnd`.
* - Payload field names present: `hook_event_name`, `session_id`,
* `transcript_path`, `notification_type`, plus notification kinds
* `permission_prompt` / `idle_prompt`, and SessionStart `source` enum
* values `startup` / `resume` / `clear` / `compact`.
* - Settings hook schema shape (from the binary's embedded docs):
* { "hooks": { "<EventName>": [ { "matcher": "...",
* "hooks": [ { "type": "command", "command": "<script>" } ] } ] } }
* The hook command receives the event payload as JSON on stdin.
*
* ── Assumed (marked so wiring composes; revisit if a version drifts) ──
* - The session-scoped hook SCRIPT itself is produced by the U17 hook-scripts
* module. That module does not exist in this worktree yet, so this adapter
* accepts the already-written script paths via `HookScriptRefs` on the
* launch settings (see `claudeCodeSettings`). The adapter only emits the
* settings JSON that references them.
* - `Stop` carries no machine-readable success/failure flag in the payload we
* can rely on across versions; we map `Stop` → `done` (positive completion)
* and expose `classifyStop` so a caller can downgrade to an error-ish event
* when it has out-of-band failure evidence (e.g. `stop_reason`).
*/
import { writeFileSync } from "node:fs";
import type {
CliAdapterCapabilities,
CliAdapterLaunchContext,
CliAdapterResumeContext,
CliAgentAdapter,
CliInjectionFormat,
CliLaunchSpec,
CliReadinessDetector,
} from "../adapter.js";
import type { TelemetryEvent } from "../telemetry-hub.js";
// ── Capabilities ────────────────────────────────────────────────────────────
/** Claude Code is the fully native tier: hooks + JSONL transcript + resume. */
export const CLAUDE_CODE_CAPABILITIES: CliAdapterCapabilities = {
nativeDone: true,
nativeWaiting: true,
transcriptSource: "jsonl",
supportsResume: true,
};
// ── Hook script references (U17 seam) ─────────────────────────────────────────
/**
* Paths to the session-scoped hook scripts the U17 hook-scripts module writes.
* The adapter does NOT create these scripts — it only references them from the
* generated settings JSON. Until U17 lands, a caller passes the paths directly
* (one script may back several events, or each event its own script).
*/
export interface HookScriptRefs {
/** Script for the `Stop` hook (positive completion). */
stopScript: string;
/** Script for the `Notification` hook (permission_prompt / idle_prompt). */
notificationScript: string;
/** Script for the `PermissionRequest` hook (waiting-on-input). */
permissionScript: string;
/** Script for the `SessionStart` hook (captures session_id / source). */
sessionStartScript: string;
/** Optional script for tool-activity hooks (PreToolUse/PostToolUse/UserPromptSubmit). */
toolActivityScript?: string;
}
/**
* Adapter-specific launch settings recognized by this adapter. These ride on the
* open `CliAdapterLaunchSettings` (`ctx.settings`). All optional so a generic
* spawn without hook wiring still launches a bare `claude`.
*/
export interface ClaudeCodeLaunchSettings {
/** Override the `claude` 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?: HookScriptRefs;
/**
* Absolute path the adapter should WRITE the session-scoped settings JSON to.
* MUST live under the session's scratch dir — never the user's global
* `~/.claude` nor the repo's tracked `.claude/`. The caller (session manager /
* task-session) owns the dir and deletes it on session end (U17 lifecycle).
* When absent the adapter emits the settings inline as a JSON string via
* `--settings <json>` (still session-scoped — no file written).
*/
settingsPath?: string;
}
// ── Settings JSON generation ──────────────────────────────────────────────────
/** One hook entry in Claude Code's settings schema. */
interface HookCommandEntry {
matcher?: string;
hooks: { type: "command"; command: string }[];
}
/** The `hooks` block of a Claude Code settings file. */
export interface ClaudeCodeHooksConfig {
[eventName: string]: HookCommandEntry[];
}
/** A minimal session-scoped Claude Code settings document. */
export interface ClaudeCodeSettings {
hooks: ClaudeCodeHooksConfig;
}
/**
* Build the session-scoped Claude Code settings document that registers our
* hooks. Shape verified against the binary's embedded settings docs:
* { hooks: { EventName: [{ matcher?, hooks: [{ type: "command", command }] }] } }
*
* Tool-activity events (PreToolUse/PostToolUse/UserPromptSubmit) are registered
* only when a `toolActivityScript` is supplied — they re-arm the inactivity
* watchdog but are not required for the core ready→busy→done flow.
*/
export function buildClaudeCodeSettings(scripts: HookScriptRefs): ClaudeCodeSettings {
const cmd = (command: string): HookCommandEntry => ({
hooks: [{ type: "command", command }],
});
const hooks: ClaudeCodeHooksConfig = {
SessionStart: [cmd(scripts.sessionStartScript)],
Stop: [cmd(scripts.stopScript)],
Notification: [cmd(scripts.notificationScript)],
PermissionRequest: [cmd(scripts.permissionScript)],
};
if (scripts.toolActivityScript) {
const activity = [cmd(scripts.toolActivityScript)];
hooks.PreToolUse = activity;
hooks.PostToolUse = activity;
hooks.UserPromptSubmit = activity;
}
return { hooks };
}
function readSettings(ctx: CliAdapterLaunchContext): ClaudeCodeLaunchSettings {
return (ctx.settings ?? {}) as ClaudeCodeLaunchSettings;
}
// ── Launch / resume builders ──────────────────────────────────────────────────
const DEFAULT_COMMAND = "claude";
/**
* Append the `--settings` flag for the session-scoped hook config. When
* `settingsPath` is given the JSON is written there (a real session-scoped file)
* and the flag points at the file; otherwise the JSON is passed inline (`claude
* --settings <json>` is documented to accept a JSON string). Either way the
* config is session-scoped and never mutates the user's global config.
*/
function appendSettingsFlag(
args: string[],
settings: ClaudeCodeLaunchSettings,
): void {
if (!settings.hookScripts) return;
const doc = buildClaudeCodeSettings(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 {
args.push("--settings", json);
}
}
/** Append the autonomy posture's privileged flags, only when permitted. */
function appendPostureFlags(
args: string[],
ctx: CliAdapterLaunchContext,
): void {
// The visible-posture contract (R21): only emit the dangerous flag when the
// posture explicitly opts in via `autoApprove`.
if (ctx.posture?.autoApprove === true) {
args.push("--dangerously-skip-permissions");
}
}
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 };
}
// ── Telemetry mapping ─────────────────────────────────────────────────────────
/** The raw shape of a Claude Code hook payload (all fields optional/tolerant). */
export interface ClaudeHookPayload {
hook_event_name?: string;
session_id?: string;
transcript_path?: string;
/** SessionStart source: startup | resume | clear | compact. */
source?: string;
/** Notification kind: permission_prompt | idle_prompt | … */
notification_type?: string;
/** Some versions surface a tool name on tool hooks. */
tool_name?: string;
/** Stop payloads may carry a failure flag in some versions. */
stop_reason?: string;
/** A free-form message (Notification / Stop systemMessage etc.). */
message?: string;
[key: string]: unknown;
}
/** Notification kinds that mean the agent is blocked waiting on the user. */
const WAITING_NOTIFICATION_TYPES = new Set(["permission_prompt", "idle_prompt"]);
/**
* Map a raw Claude Code hook payload onto a normalized engine `TelemetryEvent`.
* Returns `null` for hook events that carry no state-relevant signal (so the
* caller can drop them). Tolerant of missing optional fields.
*
* Mapping (KTD telemetry tiering — native tier):
* SessionStart → sessionStart (capture session_id
* + transcript_path)
* PreToolUse / PostToolUse / UserPromptSubmit → toolActivity (re-arm watchdog;
* UserPromptSubmit also implies a
* fresh busy turn → busy)
* PermissionRequest → waitingOnInput
* Notification{permission_prompt|idle_prompt} → waitingOnInput
* Notification{other} → toolActivity (informational)
* Stop → done (positive completion);
* see `classifyStop` for failures
* SubagentStop / PreCompact / SessionEnd → outputProgress (activity only)
*/
export function mapHookPayload(payload: ClaudeHookPayload): 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.source === "string") extra.source = payload.source;
return withSession({ kind: "sessionStart" }, extra);
}
case "UserPromptSubmit":
// A new user prompt begins a fresh busy turn.
return withSession({ kind: "busy" });
case "PreToolUse":
case "PostToolUse": {
const extra =
typeof payload.tool_name === "string" ? { toolName: payload.tool_name } : undefined;
return withSession({ kind: "toolActivity" }, extra);
}
case "PermissionRequest":
return withSession(
{ kind: "waitingOnInput" },
{ notification: { kind: "permission_request", ...notificationFields(payload) } },
);
case "Notification": {
const kind = typeof payload.notification_type === "string" ? payload.notification_type : "";
if (WAITING_NOTIFICATION_TYPES.has(kind)) {
return withSession(
{ kind: "waitingOnInput" },
{ notification: { kind, ...notificationFields(payload) } },
);
}
// Non-blocking notification → informational activity only.
return withSession({ kind: "toolActivity" });
}
case "Stop":
return classifyStop(payload);
case "SubagentStop":
case "PreCompact":
case "SessionEnd":
return withSession({ kind: "outputProgress" });
default:
// Unknown/unmapped hook event → no state-relevant signal.
return nativeSessionId ? withSession({ kind: "outputProgress" }) : null;
}
}
/** Pull the human-facing notification fields onto the normalized notification. */
function notificationFields(payload: ClaudeHookPayload): Record<string, unknown> {
const out: Record<string, unknown> = {};
if (typeof payload.notification_type === "string") out.notificationType = payload.notification_type;
if (typeof payload.message === "string") out.message = payload.message;
return out;
}
/**
* Map a `Stop` payload onto its telemetry event. The happy path is positive
* completion (`done`). When the payload carries an explicit failure signal
* (`stop_reason` looks error-ish), map it to a `toolActivity` carrying the
* failure context instead of `done` — refusing to gate pipeline advancement on
* a failed stop (the state machine only advances on a genuine `done`).
*/
export function classifyStop(payload: ClaudeHookPayload): 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;
}
/** Whether a SessionStart payload confirms a resume re-attach (`source: "resume"`). */
export function isResumeReattach(payload: ClaudeHookPayload): boolean {
return payload.hook_event_name === "SessionStart" && payload.source === "resume";
}
/**
* Parse a raw hook payload string (the JSON delivered on the hook command's
* stdin) into a normalized event. Returns null on unparseable input — telemetry
* is best-effort and must never throw at the ingestion boundary.
*/
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 ClaudeHookPayload);
}
// ── Transcript tailing ─────────────────────────────────────────────────────────
/** A normalized transcript entry surfaced to chat. */
export interface ClaudeTranscriptEntry {
/** Coarse role for chat rendering. */
role: "user" | "assistant" | "tool" | "system";
/** Flattened text content. */
text: string;
}
/**
* Incremental JSONL transcript tailer. Claude Code appends one JSON object per
* line to the file at `transcript_path`; this remembers the byte offset so each
* `read()` yields only entries appended since the last call. Tolerant of a
* partial trailing line (held until its newline arrives) and of unparseable
* lines (skipped).
*/
export class ClaudeTranscriptTailer {
/** Bytes already consumed from the file. */
private offset = 0;
/** A partial trailing line carried across reads until its newline arrives. */
private partial = "";
/** Current byte offset (for persistence / tests). */
get bytesRead(): number {
return this.offset;
}
/**
* Parse a buffer of newly-appended bytes and return the normalized entries.
* `chunk` MUST be exactly the bytes appended since the previous call (the
* caller reads from `bytesRead` to EOF). The tailer advances its offset by the
* chunk's byte length.
*/
push(chunk: string): ClaudeTranscriptEntry[] {
this.offset += Buffer.byteLength(chunk, "utf8");
const text = this.partial + chunk;
const lines = text.split("\n");
// The last element is an incomplete line unless the chunk ended on "\n"
// (in which case it is "").
this.partial = lines.pop() ?? "";
const entries: ClaudeTranscriptEntry[] = [];
for (const line of lines) {
const entry = parseTranscriptLine(line);
if (entry) entries.push(entry);
}
return entries;
}
/**
* Flush any buffered complete-but-unterminated final line (e.g. a transcript
* whose last line lacks a trailing newline). Call on session end.
*/
flush(): ClaudeTranscriptEntry[] {
if (this.partial.trim().length === 0) {
this.partial = "";
return [];
}
const entry = parseTranscriptLine(this.partial);
this.partial = "";
return entry ? [entry] : [];
}
}
/** Parse a single JSONL transcript line into a normalized entry, or null. */
function parseTranscriptLine(line: string): ClaudeTranscriptEntry | 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 };
}
/**
* Derive a coarse chat role from a transcript entry. Claude Code transcript rows
* nest the message under `message.role`/`message.content`; older/flat shapes use
* a top-level `role`/`type`. We tolerate both.
*/
function normalizeRole(obj: Record<string, unknown>): ClaudeTranscriptEntry["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";
}
}
/** Flatten the text content of a transcript entry (string or content-block array). */
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 Claude Code's interactive TUI. Native readiness is
* hook-driven (the first `SessionStart` hook event marks ready via the telemetry
* hub), so this output-based detector is the FALLBACK for callers that gate on
* PTY output before the hook lands. It looks for the input prompt box Claude
* draws once interactive: a line beginning with the prompt glyph `>` or the
* bracketed-paste enable sequence the TUI emits when its editor mounts.
*/
export class ClaudeCodeReadinessDetector implements CliReadinessDetector {
private ready = false;
private buffer = "";
observe(chunk: string): boolean {
if (this.ready) return true;
// Keep a bounded tail to catch a prompt split across chunks.
this.buffer = (this.buffer + chunk).slice(-4096);
// Bracketed-paste enable means the interactive editor mounted.
if (this.buffer.includes("\x1b[?2004h")) {
this.ready = true;
return true;
}
// The TUI draws a prompt box; a `>` prompt glyph at a line start is the
// output-based readiness signal.
if (/(^|\n)\s*[╭│]?\s*>\s/.test(this.buffer)) {
this.ready = true;
return true;
}
return false;
}
}
// ── The adapter ─────────────────────────────────────────────────────────────
export const claudeCodeAdapter: CliAgentAdapter = {
id: "claude-code",
name: "Claude Code",
capabilities: CLAUDE_CODE_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 Claude Code needs to authenticate, find its config, and render a
// terminal. NEVER an inherit-everything posture — FUSION_* service creds and
// unrelated secrets stay out of the child (the session manager copies ONLY
// these keys).
return [
"HOME",
"PATH",
"SHELL",
"USER",
"LOGNAME",
"LANG",
"LC_ALL",
"LC_CTYPE",
"TERM",
"TERMINFO",
"TMPDIR",
"XDG_CONFIG_HOME",
"XDG_CACHE_HOME",
"XDG_DATA_HOME",
"COLORTERM",
// Claude Code auth (API-key path; OAuth/keychain handled by the binary).
"ANTHROPIC_API_KEY",
"ANTHROPIC_AUTH_TOKEN",
"ANTHROPIC_BASE_URL",
"CLAUDE_CODE_USE_BEDROCK",
"CLAUDE_CODE_USE_VERTEX",
];
},
createReadinessDetector(): CliReadinessDetector {
return new ClaudeCodeReadinessDetector();
},
formatInjection(text: string, _opts: { bracketedPasteActive: boolean }): CliInjectionFormat {
// The session manager owns bracketed-paste wrapping and the security-critical
// control-char neutralization. This hook only adds the trailing submit: a
// carriage return submits the prompt in Claude Code's TUI. We do NOT add a
// second `\r` when the text already ends in one.
const payload = text.endsWith("\r") ? text : `${text}\r`;
return { payload };
},
buildResume(ctx: CliAdapterResumeContext): CliLaunchSpec {
// `claude --resume <session-id>` re-attaches the prior conversation. Resume
// confirmation arrives as a SessionStart hook with source === "resume" (see
// `isResumeReattach`). Posture/model flags carry over so a resumed session
// keeps its launch shape; hook settings are re-applied so telemetry rewires.
const settings = readSettings(ctx);
const { command, args } = buildBaseArgs(ctx);
args.push("--resume", ctx.nativeSessionId);
appendSettingsFlag(args, settings);
appendPostureFlags(args, ctx);
if (settings.extraArgs) args.push(...settings.extraArgs);
return { command, args };
},
};