From ad5205f186cee19d18c232b0a548d0cd61b60266 Mon Sep 17 00:00:00 2001 From: gsxdsm Date: Thu, 4 Jun 2026 22:52:20 -0700 Subject: [PATCH 01/30] feat(core): add cli_sessions table and CliSessionStore (U1) Co-Authored-By: Claude Opus 4.8 (1M context) --- .../src/__tests__/cli-session-store.test.ts | 211 +++++++++++ .../core/src/__tests__/db-migrate.test.ts | 79 +++- packages/core/src/__tests__/db.test.ts | 42 +-- .../core/src/__tests__/goals-schema.test.ts | 2 +- .../core/src/__tests__/insight-store.test.ts | 10 +- .../__tests__/merge-request-record.test.ts | 2 +- .../core/src/__tests__/mission-store.test.ts | 2 +- packages/core/src/__tests__/run-audit.test.ts | 2 +- .../src/__tests__/store-merge-queue.test.ts | 2 +- .../core/src/__tests__/task-documents.test.ts | 2 +- packages/core/src/cli-session-store.ts | 336 ++++++++++++++++++ packages/core/src/cli-session-types.ts | 196 ++++++++++ packages/core/src/db.ts | 55 ++- packages/core/src/index.ts | 19 + 14 files changed, 919 insertions(+), 41 deletions(-) create mode 100644 packages/core/src/__tests__/cli-session-store.test.ts create mode 100644 packages/core/src/cli-session-store.ts create mode 100644 packages/core/src/cli-session-types.ts diff --git a/packages/core/src/__tests__/cli-session-store.test.ts b/packages/core/src/__tests__/cli-session-store.test.ts new file mode 100644 index 0000000000..41583cbbed --- /dev/null +++ b/packages/core/src/__tests__/cli-session-store.test.ts @@ -0,0 +1,211 @@ +import { describe, it, expect, beforeAll, beforeEach, afterAll } from "vitest"; +import { CliSessionStore } from "../cli-session-store.js"; +import { Database } from "../db.js"; +import { mkdtempSync } from "node:fs"; +import { join } from "node:path"; +import { tmpdir } from "node:os"; +import { rm } from "node:fs/promises"; + +function makeTmpDir(): string { + return mkdtempSync(join(tmpdir(), "kb-cli-session-store-test-")); +} + +describe("CliSessionStore", () => { + let tmpDir: string; + let fusionDir: string; + let db: Database; + let store: CliSessionStore; + + beforeAll(() => { + tmpDir = makeTmpDir(); + fusionDir = join(tmpDir, ".fusion"); + db = new Database(fusionDir, { inMemory: true }); + db.init(); + store = new CliSessionStore(fusionDir, db); + }); + + beforeEach(() => { + db.exec("DELETE FROM cli_sessions"); + store.removeAllListeners(); + }); + + afterAll(async () => { + db.close(); + await rm(tmpDir, { recursive: true, force: true }); + }); + + it("creates and reads a session record", () => { + const created = store.createSession({ + taskId: "FN-100", + purpose: "execute", + projectId: "proj-1", + adapterId: "claude-local", + worktreePath: "/tmp/wt/FN-100", + autonomyPosture: { autoApprove: true, maxResumeAttempts: 3 }, + }); + + expect(created.id).toMatch(/^cli-/); + expect(created.agentState).toBe("starting"); + expect(created.terminationReason).toBeNull(); + expect(created.resumeAttempts).toBe(0); + expect(created.chatSessionId).toBeNull(); + expect(created.autonomyPosture).toEqual({ autoApprove: true, maxResumeAttempts: 3 }); + + const fetched = store.getSession(created.id); + expect(fetched).toEqual(created); + }); + + it("persists state transitions", () => { + const s = store.createSession({ + taskId: "FN-101", + purpose: "planning", + projectId: "proj-1", + adapterId: "codex-local", + }); + + const states = ["ready", "busy", "waitingOnInput", "busy", "done"] as const; + for (const state of states) { + const updated = store.updateSession(s.id, { agentState: state }); + expect(updated?.agentState).toBe(state); + // Persisted, not just returned. + expect(store.getSession(s.id)?.agentState).toBe(state); + } + }); + + it("round-trips the native session id", () => { + const s = store.createSession({ + taskId: "FN-102", + purpose: "execute", + projectId: "proj-1", + adapterId: "claude-local", + }); + expect(s.nativeSessionId).toBeNull(); + + store.updateSession(s.id, { nativeSessionId: "native-abc-123" }); + expect(store.getSession(s.id)?.nativeSessionId).toBe("native-abc-123"); + + // Reopen via a fresh store instance on the same DB to prove durability. + const reopened = new CliSessionStore(fusionDir, db); + expect(reopened.getSession(s.id)?.nativeSessionId).toBe("native-abc-123"); + }); + + it("updates terminationReason and resumeAttempts atomically with state", () => { + const s = store.createSession({ + taskId: "FN-103", + purpose: "validator", + projectId: "proj-1", + adapterId: "claude-local", + }); + + const updated = store.updateSession(s.id, { + agentState: "dead", + terminationReason: "crashed", + resumeAttempts: 2, + }); + + expect(updated?.agentState).toBe("dead"); + expect(updated?.terminationReason).toBe("crashed"); + expect(updated?.resumeAttempts).toBe(2); + + const persisted = store.getSession(s.id)!; + expect(persisted.agentState).toBe("dead"); + expect(persisted.terminationReason).toBe("crashed"); + expect(persisted.resumeAttempts).toBe(2); + }); + + it("clears terminationReason when set back to null", () => { + const s = store.createSession({ + taskId: "FN-104", + purpose: "execute", + projectId: "proj-1", + adapterId: "claude-local", + agentState: "dead", + terminationReason: "killed", + }); + expect(s.terminationReason).toBe("killed"); + + store.updateSession(s.id, { agentState: "starting", terminationReason: null }); + const persisted = store.getSession(s.id)!; + expect(persisted.terminationReason).toBeNull(); + expect(persisted.agentState).toBe("starting"); + }); + + it("queries sessions by task and by chat entity", () => { + store.createSession({ taskId: "FN-200", purpose: "execute", projectId: "p", adapterId: "a" }); + store.createSession({ taskId: "FN-200", purpose: "validator", projectId: "p", adapterId: "a" }); + store.createSession({ taskId: "FN-201", purpose: "execute", projectId: "p", adapterId: "a" }); + store.createSession({ chatSessionId: "chat-xyz", purpose: "chat", projectId: "p", adapterId: "a" }); + + expect(store.listByTask("FN-200")).toHaveLength(2); + expect(store.listByTask("FN-201")).toHaveLength(1); + expect(store.listByTask("FN-999")).toHaveLength(0); + + const chatSessions = store.listByChatSession("chat-xyz"); + expect(chatSessions).toHaveLength(1); + expect(chatSessions[0].purpose).toBe("chat"); + }); + + it("filters by projectId and agentState", () => { + store.createSession({ taskId: "FN-300", purpose: "execute", projectId: "pA", adapterId: "a", agentState: "busy" }); + store.createSession({ taskId: "FN-301", purpose: "execute", projectId: "pA", adapterId: "a", agentState: "done" }); + store.createSession({ taskId: "FN-302", purpose: "execute", projectId: "pB", adapterId: "a", agentState: "busy" }); + + expect(store.listSessions({ projectId: "pA" })).toHaveLength(2); + expect(store.listSessions({ projectId: "pA", agentState: "busy" })).toHaveLength(1); + expect(store.listSessions({ agentState: "busy" })).toHaveLength(2); + }); + + it("rejects an invalid agent state at the store boundary", () => { + const s = store.createSession({ + taskId: "FN-400", + purpose: "execute", + projectId: "p", + adapterId: "a", + }); + + expect(() => + // @ts-expect-error invalid state value rejected at runtime + store.updateSession(s.id, { agentState: "bogus" }), + ).toThrow(/Invalid CLI agent state/); + + expect(() => + // @ts-expect-error invalid state value rejected at runtime + store.createSession({ purpose: "execute", projectId: "p", adapterId: "a", agentState: "nope" }), + ).toThrow(/Invalid CLI agent state/); + + // The original record was untouched by the failed update. + expect(store.getSession(s.id)?.agentState).toBe("starting"); + }); + + it("rejects an invalid purpose and termination reason at the store boundary", () => { + expect(() => + // @ts-expect-error invalid purpose rejected at runtime + store.createSession({ purpose: "wat", projectId: "p", adapterId: "a" }), + ).toThrow(/Invalid CLI session purpose/); + + const s = store.createSession({ taskId: "FN-401", purpose: "execute", projectId: "p", adapterId: "a" }); + expect(() => + // @ts-expect-error invalid termination reason rejected at runtime + store.updateSession(s.id, { terminationReason: "exploded" }), + ).toThrow(/Invalid CLI termination reason/); + }); + + it("emits create/update/delete events", () => { + const events: string[] = []; + store.on("cli-session:created", () => events.push("created")); + store.on("cli-session:updated", () => events.push("updated")); + store.on("cli-session:deleted", () => events.push("deleted")); + + const s = store.createSession({ taskId: "FN-500", purpose: "ce", projectId: "p", adapterId: "a" }); + store.updateSession(s.id, { agentState: "ready" }); + expect(store.deleteSession(s.id)).toBe(true); + expect(store.getSession(s.id)).toBeUndefined(); + + expect(events).toEqual(["created", "updated", "deleted"]); + }); + + it("returns undefined when updating a missing session and false when deleting one", () => { + expect(store.updateSession("cli-missing", { agentState: "ready" })).toBeUndefined(); + expect(store.deleteSession("cli-missing")).toBe(false); + }); +}); diff --git a/packages/core/src/__tests__/db-migrate.test.ts b/packages/core/src/__tests__/db-migrate.test.ts index b36c04e128..30407179a4 100644 --- a/packages/core/src/__tests__/db-migrate.test.ts +++ b/packages/core/src/__tests__/db-migrate.test.ts @@ -715,7 +715,7 @@ describe("schema migration", () => { const row = db.prepare("SELECT deletedAt FROM tasks WHERE id = 'FN-legacy'").get() as { deletedAt: string | null }; expect(row.deletedAt).toBeNull(); - expect(db.getSchemaVersion()).toBe(108); + expect(db.getSchemaVersion()).toBe(109); db.close(); }); @@ -748,7 +748,7 @@ describe("schema migration", () => { { id: "WS-001", mode: "prompt", gateMode: "advisory" }, { id: "WS-002", mode: "script", gateMode: "advisory" }, ]); - expect(db.getSchemaVersion()).toBe(108); + expect(db.getSchemaVersion()).toBe(109); db.close(); }); @@ -798,7 +798,7 @@ describe("schema migration", () => { reviewerContextRetryCount: 0, reviewerFallbackRetryCount: 0, }); - expect(db.getSchemaVersion()).toBe(108); + expect(db.getSchemaVersion()).toBe(109); db.close(); }); @@ -827,7 +827,7 @@ describe("schema migration", () => { const columns = db.prepare("PRAGMA table_info(milestones)").all() as Array<{ name: string }>; expect(columns.map((column) => column.name)).toContain("acceptanceCriteria"); - expect(db.getSchemaVersion()).toBe(108); + expect(db.getSchemaVersion()).toBe(109); db.close(); }); @@ -868,7 +868,7 @@ describe("schema migration", () => { const missionColumns = db.prepare("PRAGMA table_info(missions)").all() as Array<{ name: string }>; expect(missionColumns.map((column) => column.name)).toContain("autoMerge"); - expect(db.getSchemaVersion()).toBe(108); + expect(db.getSchemaVersion()).toBe(109); db.close(); }); @@ -902,7 +902,7 @@ describe("schema migration", () => { { id: "WS-002", mode: "script", enabled: 1, gateMode: "advisory" }, { id: "WS-003", mode: "prompt", enabled: 0, gateMode: "advisory" }, ]); - expect(db.getSchemaVersion()).toBe(108); + expect(db.getSchemaVersion()).toBe(109); db.close(); }); @@ -939,7 +939,7 @@ describe("schema migration", () => { const indexes = db.prepare("PRAGMA index_list(mission_goals)").all() as Array<{ name: string }>; expect(indexes.some((index) => index.name === "idxMissionGoalsGoalId")).toBe(true); - expect(db.getSchemaVersion()).toBe(108); + expect(db.getSchemaVersion()).toBe(109); db.close(); }); @@ -1000,7 +1000,70 @@ describe("schema migration", () => { expect(customFieldsColumn).toBeDefined(); expect(customFieldsColumn?.dflt_value).toBe("'{}'"); - expect(db.getSchemaVersion()).toBe(108); + expect(db.getSchemaVersion()).toBe(109); + db.close(); + }); + + it("adds cli_sessions table + indexes when migrating from schema version 108", () => { + const db = new Database(fusionDir); + db.exec("CREATE TABLE IF NOT EXISTS __meta (key TEXT PRIMARY KEY, value TEXT)"); + db.exec("INSERT INTO __meta (key, value) VALUES ('schemaVersion', '108')"); + db.exec("INSERT INTO __meta (key, value) VALUES ('lastModified', '1000')"); + db.exec(` + CREATE TABLE IF NOT EXISTS tasks ( + id TEXT PRIMARY KEY, + description TEXT NOT NULL, + "column" TEXT NOT NULL, + createdAt TEXT NOT NULL, + updatedAt TEXT NOT NULL + ) + `); + + db.init(); + + // The durable CLI-session record table exists. + const tables = db.prepare("SELECT name FROM sqlite_master WHERE type='table'").all() as Array<{ name: string }>; + expect(tables.map((row) => row.name)).toContain("cli_sessions"); + + const cliSessionColumns = db + .prepare("PRAGMA table_info(cli_sessions)") + .all() as Array<{ name: string }>; + expect(cliSessionColumns.map((column) => column.name)).toEqual([ + "id", + "taskId", + "chatSessionId", + "purpose", + "projectId", + "adapterId", + "agentState", + "terminationReason", + "nativeSessionId", + "resumeAttempts", + "autonomyPosture", + "worktreePath", + "createdAt", + "updatedAt", + ]); + + const cliSessionIndexes = db + .prepare("PRAGMA index_list(cli_sessions)") + .all() as Array<{ name: string }>; + const indexNames = cliSessionIndexes.map((index) => index.name); + expect(indexNames).toContain("idx_cli_sessions_taskId"); + expect(indexNames).toContain("idx_cli_sessions_chatSessionId"); + expect(indexNames).toContain("idx_cli_sessions_project_state"); + + expect(db.getSchemaVersion()).toBe(109); + db.close(); + }); + + it("creates cli_sessions on a fresh database (fresh-create path)", () => { + const db = new Database(fusionDir); + db.init(); + + const tables = db.prepare("SELECT name FROM sqlite_master WHERE type='table'").all() as Array<{ name: string }>; + expect(tables.map((row) => row.name)).toContain("cli_sessions"); + expect(db.getSchemaVersion()).toBe(109); db.close(); }); }); diff --git a/packages/core/src/__tests__/db.test.ts b/packages/core/src/__tests__/db.test.ts index 8a618bd583..191b67591a 100644 --- a/packages/core/src/__tests__/db.test.ts +++ b/packages/core/src/__tests__/db.test.ts @@ -334,7 +334,7 @@ describe("Database", () => { }); it("seeds schema version", () => { - expect(db.getSchemaVersion()).toBe(108); + expect(db.getSchemaVersion()).toBe(109); }); it("includes tokenUsageCacheWriteTokens on freshly initialized tasks table", () => { @@ -393,7 +393,7 @@ describe("Database", () => { it("is idempotent - calling init() twice does not fail", () => { expect(() => db.init()).not.toThrow(); - expect(db.getSchemaVersion()).toBe(108); + expect(db.getSchemaVersion()).toBe(109); }); it("does not overwrite existing config on re-init", () => { // Update the config @@ -1463,7 +1463,7 @@ describe("schema migrations", () => { db.init(); // Verify version bumped to 29 (includes v1→v2 through v26→v29) - expect(db.getSchemaVersion()).toBe(108); + expect(db.getSchemaVersion()).toBe(109); // Verify new columns exist and existing data is intact const cols = db.prepare("PRAGMA table_info(tasks)").all() as Array<{ name: string }>; @@ -1488,11 +1488,11 @@ describe("schema migrations", () => { const db = new Database(fusionDir); db.init(); - expect(db.getSchemaVersion()).toBe(108); + expect(db.getSchemaVersion()).toBe(109); // Re-init should not fail db.init(); - expect(db.getSchemaVersion()).toBe(108); + expect(db.getSchemaVersion()).toBe(109); db.close(); }); @@ -1527,7 +1527,7 @@ describe("schema migrations", () => { db.init(); - expect(db.getSchemaVersion()).toBe(108); + expect(db.getSchemaVersion()).toBe(109); const cols = db.prepare("PRAGMA table_info(tasks)").all() as Array<{ name: string }>; expect(cols.map((col) => col.name)).toContain("priority"); @@ -1568,7 +1568,7 @@ describe("schema migrations", () => { db.init(); - expect(db.getSchemaVersion()).toBe(108); + expect(db.getSchemaVersion()).toBe(109); const cols = db.prepare("PRAGMA table_info(tasks)").all() as Array<{ name: string }>; const colNames = cols.map((col) => col.name); @@ -1640,7 +1640,7 @@ describe("schema migrations", () => { db.init(); - expect(db.getSchemaVersion()).toBe(108); + expect(db.getSchemaVersion()).toBe(109); const cols = db.prepare("PRAGMA table_info(tasks)").all() as Array<{ name: string }>; const colNames = cols.map((col) => col.name); @@ -1880,7 +1880,7 @@ describe("schema migrations", () => { db.init(); - expect(db.getSchemaVersion()).toBe(108); + expect(db.getSchemaVersion()).toBe(109); const cols = db.prepare("PRAGMA table_info(chat_messages)").all() as Array<{ name: string }>; expect(cols.map((col) => col.name)).toContain("attachments"); @@ -1954,7 +1954,7 @@ describe("schema migrations", () => { db.init(); - expect(db.getSchemaVersion()).toBe(108); + expect(db.getSchemaVersion()).toBe(109); const tables = db.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name = 'agentRatings'").all() as Array<{ name: string }>; expect(tables).toEqual([{ name: "agentRatings" }]); @@ -1978,7 +1978,7 @@ describe("schema migrations", () => { db.init(); - expect(db.getSchemaVersion()).toBe(108); + expect(db.getSchemaVersion()).toBe(109); const tables = db.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name = 'mission_events'").all() as Array<{ name: string }>; expect(tables).toEqual([{ name: "mission_events" }]); @@ -2082,7 +2082,7 @@ describe("schema migrations", () => { db.init(); // Verify version bumped to 29 - expect(db.getSchemaVersion()).toBe(108); + expect(db.getSchemaVersion()).toBe(109); // Verify new columns exist and existing data is intact const cols = db.prepare("PRAGMA table_info(tasks)").all() as Array<{ name: string }>; @@ -2301,7 +2301,7 @@ describe("schema migrations", () => { localDb.init(); - expect(localDb.getSchemaVersion()).toBe(108); + expect(localDb.getSchemaVersion()).toBe(109); const columns = localDb.prepare("PRAGMA table_info(tasks)").all() as Array<{ name: string }>; expect(columns.map((column) => column.name)).toContain("tokenUsageCacheWriteTokens"); @@ -2612,7 +2612,7 @@ describe("createDatabase factory", () => { const db = createDatabase(fusionDir); db.init(); - expect(db.getSchemaVersion()).toBe(108); + expect(db.getSchemaVersion()).toBe(109); expect(db.getLastModified()).toBeGreaterThan(0); db.close(); @@ -2766,7 +2766,7 @@ describe("migration v77 task token budget columns", () => { migrated = new Database(fusion); migrated.init(); - expect(migrated.getSchemaVersion()).toBe(108); + expect(migrated.getSchemaVersion()).toBe(109); const rows = migrated.prepare("PRAGMA table_info(tasks)").all() as Array<{ name: string }>; const names = new Set(rows.map((row) => row.name)); expect(names.has("tokenBudgetSoftAlertedAt")).toBe(true); @@ -2797,7 +2797,7 @@ describe("migration v106 adds tasks.transitionPending (FN-1417)", () => { const fresh = new Database(fusion); try { fresh.init(); - expect(fresh.getSchemaVersion()).toBe(108); + expect(fresh.getSchemaVersion()).toBe(109); const names = new Set( (fresh.prepare("PRAGMA table_info(tasks)").all() as Array<{ name: string }>).map((r) => r.name), ); @@ -2825,7 +2825,7 @@ describe("migration v106 adds tasks.transitionPending (FN-1417)", () => { migrated = new Database(fusion); migrated.init(); - expect(migrated.getSchemaVersion()).toBe(108); + expect(migrated.getSchemaVersion()).toBe(109); const names = new Set( (migrated.prepare("PRAGMA table_info(tasks)").all() as Array<{ name: string }>).map((r) => r.name), ); @@ -2851,7 +2851,7 @@ describe("migration v107 adds workflow_run_branches + index (FN-1417)", () => { const fresh = new Database(fusion); try { fresh.init(); - expect(fresh.getSchemaVersion()).toBe(108); + expect(fresh.getSchemaVersion()).toBe(109); const table = fresh .prepare("SELECT name FROM sqlite_master WHERE type='table' AND name = 'workflow_run_branches'") .get() as { name: string } | undefined; @@ -2885,7 +2885,7 @@ describe("migration v107 adds workflow_run_branches + index (FN-1417)", () => { migrated = new Database(fusion); migrated.init(); - expect(migrated.getSchemaVersion()).toBe(108); + expect(migrated.getSchemaVersion()).toBe(109); const table = migrated .prepare("SELECT name FROM sqlite_master WHERE type='table' AND name = 'workflow_run_branches'") .get() as { name: string } | undefined; @@ -2926,7 +2926,7 @@ describe("migration v67 drops orphan project auth tables", () => { migrated = new Database(fusion); migrated.init(); - expect(migrated.getSchemaVersion()).toBe(108); + expect(migrated.getSchemaVersion()).toBe(109); const tables = migrated .prepare("SELECT name FROM sqlite_master WHERE type='table' AND name LIKE 'project_auth_%'") .all() as Array<{ name: string }>; @@ -2953,7 +2953,7 @@ describe("migration v67 drops orphan project auth tables", () => { try { fresh.init(); - expect(fresh.getSchemaVersion()).toBe(108); + expect(fresh.getSchemaVersion()).toBe(109); const tables = fresh .prepare("SELECT name FROM sqlite_master WHERE type='table' AND name LIKE 'project_auth_%'") .all() as Array<{ name: string }>; diff --git a/packages/core/src/__tests__/goals-schema.test.ts b/packages/core/src/__tests__/goals-schema.test.ts index 333a68e551..03398b9515 100644 --- a/packages/core/src/__tests__/goals-schema.test.ts +++ b/packages/core/src/__tests__/goals-schema.test.ts @@ -91,6 +91,6 @@ describe("goals schema", () => { }); it("reports schema version 101", () => { - expect(db.getSchemaVersion()).toBe(108); + expect(db.getSchemaVersion()).toBe(109); }); }); diff --git a/packages/core/src/__tests__/insight-store.test.ts b/packages/core/src/__tests__/insight-store.test.ts index 7aa0e611ad..5d8ac9b47a 100644 --- a/packages/core/src/__tests__/insight-store.test.ts +++ b/packages/core/src/__tests__/insight-store.test.ts @@ -1000,7 +1000,7 @@ describe("Migration: pre-33 DB upgrade", () => { // Step 1: Create a fresh database at v33 (runs all migrations up to 33) const db1 = createDatabase(legacyDir); db1.init(); - expect(db1.getSchemaVersion()).toBe(108); + expect(db1.getSchemaVersion()).toBe(109); db1.close(); // Step 2: Manually downgrade to version 32 and drop insight tables @@ -1035,7 +1035,7 @@ describe("Migration: pre-33 DB upgrade", () => { expect(tableNamesBefore).not.toContain("project_insight_runs"); // Now run init — this triggers the v32→v33 migration db3.init(); - expect(db3.getSchemaVersion()).toBe(108); + expect(db3.getSchemaVersion()).toBe(109); // Step 4: Verify insight tables exist after migration const tablesAfter = db3.prepare( @@ -1066,12 +1066,12 @@ describe("Migration: pre-33 DB upgrade", () => { try { const db1 = createDatabase(testDir); db1.init(); - expect(db1.getSchemaVersion()).toBe(108); + expect(db1.getSchemaVersion()).toBe(109); db1.close(); const db2 = createDatabase(testDir); expect(() => db2.init()).not.toThrow(); - expect(db2.getSchemaVersion()).toBe(108); + expect(db2.getSchemaVersion()).toBe(109); db2.close(); } finally { rmSync(testDir, { recursive: true, force: true }); @@ -1085,7 +1085,7 @@ describe("Migration: pre-33 DB upgrade", () => { // Step 1: Create a fresh DB and run migrations const db1 = createDatabase(compatDir); db1.init(); - expect(db1.getSchemaVersion()).toBe(108); + expect(db1.getSchemaVersion()).toBe(109); // Step 2: Strip lifecycle and cancelledAt columns by recreating the // table without them. This simulates a DB that was created before the diff --git a/packages/core/src/__tests__/merge-request-record.test.ts b/packages/core/src/__tests__/merge-request-record.test.ts index ef2a10d136..64dc82817b 100644 --- a/packages/core/src/__tests__/merge-request-record.test.ts +++ b/packages/core/src/__tests__/merge-request-record.test.ts @@ -38,7 +38,7 @@ describe("TaskStore merge request record + completion handoff marker", () => { .all() as Array<{ name: string }>; expect(tableRows).toEqual([{ name: "completion_handoff_markers" }, { name: "merge_requests" }]); - expect(db.getSchemaVersion()).toBe(108); + expect(db.getSchemaVersion()).toBe(109); }); it("upserts merge request records", async () => { diff --git a/packages/core/src/__tests__/mission-store.test.ts b/packages/core/src/__tests__/mission-store.test.ts index 7410346c67..2ded53b090 100644 --- a/packages/core/src/__tests__/mission-store.test.ts +++ b/packages/core/src/__tests__/mission-store.test.ts @@ -3746,7 +3746,7 @@ describe("MissionStore", () => { describe("Loop State & Validator Run Schema (v31)", () => { it("schema version is 101 after migration", () => { - expect(db.getSchemaVersion()).toBe(108); + expect(db.getSchemaVersion()).toBe(109); }); it("mission_features table has loop state columns", () => { diff --git a/packages/core/src/__tests__/run-audit.test.ts b/packages/core/src/__tests__/run-audit.test.ts index 3be684ea9a..ca428b6914 100644 --- a/packages/core/src/__tests__/run-audit.test.ts +++ b/packages/core/src/__tests__/run-audit.test.ts @@ -584,7 +584,7 @@ describe("Run Audit", () => { }); it("schema version is bumped to 40", () => { - expect(db.getSchemaVersion()).toBe(108); + expect(db.getSchemaVersion()).toBe(109); }); }); }); diff --git a/packages/core/src/__tests__/store-merge-queue.test.ts b/packages/core/src/__tests__/store-merge-queue.test.ts index 6c09641156..96a9ea905b 100644 --- a/packages/core/src/__tests__/store-merge-queue.test.ts +++ b/packages/core/src/__tests__/store-merge-queue.test.ts @@ -60,7 +60,7 @@ describe("TaskStore merge queue", () => { expect.arrayContaining(["idx_mergeQueue_lease_ready", "idx_mergeQueue_leaseExpiresAt"]), ); - expect(store.getDatabase().getSchemaVersion()).toBe(108); + expect(store.getDatabase().getSchemaVersion()).toBe(109); }); it("migrates a legacy v88 database and preserves task rows", async () => { diff --git a/packages/core/src/__tests__/task-documents.test.ts b/packages/core/src/__tests__/task-documents.test.ts index 7352ec08bf..63e5eebfad 100644 --- a/packages/core/src/__tests__/task-documents.test.ts +++ b/packages/core/src/__tests__/task-documents.test.ts @@ -51,7 +51,7 @@ describe("TaskStore task documents", () => { expect(tableNames.has("task_documents")).toBe(true); expect(tableNames.has("task_document_revisions")).toBe(true); - expect(db.getSchemaVersion()).toBe(108); + expect(db.getSchemaVersion()).toBe(109); const index = db .prepare( diff --git a/packages/core/src/cli-session-store.ts b/packages/core/src/cli-session-store.ts new file mode 100644 index 0000000000..05c624f9ae --- /dev/null +++ b/packages/core/src/cli-session-store.ts @@ -0,0 +1,336 @@ +/** + * CliSessionStore - Data layer for durable CLI agent session records + * (CLI Agent Executor, U1). + * + * Manages CRUD for the `cli_sessions` table: the long-lived record that + * survives executor restarts so a session can be reasoned about, resumed, + * or reaped from its persisted state. + * + * Follows the same patterns as ChatStore: + * - EventEmitter for change notifications. + * - SQLite for structured data storage. + * - JSON columns for nested data (autonomyPosture). + * - Validation at the store boundary: invalid enum values are rejected. + */ + +import { EventEmitter } from "node:events"; +import { randomUUID } from "node:crypto"; +import type { Database } from "./db.js"; +import { fromJson, toJsonNullable } from "./db.js"; +import { + isCliAgentState, + isCliSessionPurpose, + isCliTerminationReason, + type CliAgentState, + type CliAutonomyPosture, + type CliSession, + type CliSessionCreateInput, + type CliSessionPurpose, + type CliSessionUpdateInput, + type CliTerminationReason, +} from "./cli-session-types.js"; + +// ── Event Types ───────────────────────────────────────────────────────── + +export interface CliSessionStoreEvents { + /** Emitted when a CLI session record is created. */ + "cli-session:created": [session: CliSession]; + /** Emitted when a CLI session record is updated. */ + "cli-session:updated": [session: CliSession]; + /** Emitted when a CLI session record is deleted. */ + "cli-session:deleted": [sessionId: string]; +} + +// ── Row Interface ──────────────────────────────────────────────────────── + +/** Database row shape for cli_sessions. */ +interface CliSessionRow { + id: string; + taskId: string | null; + chatSessionId: string | null; + purpose: string; + projectId: string; + adapterId: string; + agentState: string; + terminationReason: string | null; + nativeSessionId: string | null; + resumeAttempts: number; + autonomyPosture: string | null; + worktreePath: string | null; + createdAt: string; + updatedAt: string; +} + +// ── CliSessionStore Class ──────────────────────────────────────────────── + +export class CliSessionStore extends EventEmitter { + constructor( + private fusionDir: string, + private db: Database, + ) { + super(); + this.setMaxListeners(100); + } + + // ── Row-to-Object Converter ────────────────────────────────────────── + + private rowToSession(row: CliSessionRow): CliSession { + return { + id: row.id, + taskId: row.taskId ?? null, + chatSessionId: row.chatSessionId ?? null, + purpose: row.purpose as CliSessionPurpose, + projectId: row.projectId, + adapterId: row.adapterId, + agentState: row.agentState as CliAgentState, + terminationReason: (row.terminationReason as CliTerminationReason | null) ?? null, + nativeSessionId: row.nativeSessionId ?? null, + resumeAttempts: row.resumeAttempts ?? 0, + autonomyPosture: fromJson(row.autonomyPosture) ?? null, + worktreePath: row.worktreePath ?? null, + createdAt: row.createdAt, + updatedAt: row.updatedAt, + }; + } + + // ── Boundary validation ────────────────────────────────────────────── + + private assertAgentState(value: unknown): asserts value is CliAgentState { + if (!isCliAgentState(value)) { + throw new Error(`Invalid CLI agent state: ${JSON.stringify(value)}`); + } + } + + private assertPurpose(value: unknown): asserts value is CliSessionPurpose { + if (!isCliSessionPurpose(value)) { + throw new Error(`Invalid CLI session purpose: ${JSON.stringify(value)}`); + } + } + + private assertTerminationReason( + value: unknown, + ): asserts value is CliTerminationReason | null { + if (value === null || value === undefined) return; + if (!isCliTerminationReason(value)) { + throw new Error(`Invalid CLI termination reason: ${JSON.stringify(value)}`); + } + } + + // ── CRUD Operations ────────────────────────────────────────────────── + + /** + * Create a new CLI session record. + * + * @throws Error if any enum value (purpose / agentState / terminationReason) + * is invalid, or required fields are missing. + */ + createSession(input: CliSessionCreateInput): CliSession { + this.assertPurpose(input.purpose); + const agentState: CliAgentState = input.agentState ?? "starting"; + this.assertAgentState(agentState); + this.assertTerminationReason(input.terminationReason ?? null); + + if (!input.projectId) { + throw new Error("CLI session requires a projectId"); + } + if (!input.adapterId) { + throw new Error("CLI session requires an adapterId"); + } + + const now = new Date().toISOString(); + const id = input.id ?? `cli-${randomUUID().slice(0, 8)}`; + const resumeAttempts = input.resumeAttempts ?? 0; + + const session: CliSession = { + id, + taskId: input.taskId ?? null, + chatSessionId: input.chatSessionId ?? null, + purpose: input.purpose, + projectId: input.projectId, + adapterId: input.adapterId, + agentState, + terminationReason: input.terminationReason ?? null, + nativeSessionId: input.nativeSessionId ?? null, + resumeAttempts, + autonomyPosture: input.autonomyPosture ?? null, + worktreePath: input.worktreePath ?? null, + createdAt: now, + updatedAt: now, + }; + + this.db + .prepare( + `INSERT INTO cli_sessions ( + id, taskId, chatSessionId, purpose, projectId, adapterId, + agentState, terminationReason, nativeSessionId, resumeAttempts, + autonomyPosture, worktreePath, createdAt, updatedAt + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + ) + .run( + session.id, + session.taskId, + session.chatSessionId, + session.purpose, + session.projectId, + session.adapterId, + session.agentState, + session.terminationReason, + session.nativeSessionId, + session.resumeAttempts, + toJsonNullable(session.autonomyPosture), + session.worktreePath, + session.createdAt, + session.updatedAt, + ); + + this.db.bumpLastModified(); + this.emit("cli-session:created", session); + return session; + } + + /** Get a CLI session record by ID. */ + getSession(id: string): CliSession | undefined { + const row = this.db + .prepare("SELECT * FROM cli_sessions WHERE id = ?") + .get(id) as unknown as CliSessionRow | undefined; + if (!row) return undefined; + return this.rowToSession(row); + } + + /** + * List CLI session records with optional filtering. + * + * @returns Array of sessions ordered by updatedAt DESC. + */ + listSessions(options?: { + taskId?: string; + chatSessionId?: string; + projectId?: string; + agentState?: CliAgentState; + purpose?: CliSessionPurpose; + }): CliSession[] { + const whereClauses: string[] = []; + const params: string[] = []; + + if (options?.taskId !== undefined) { + whereClauses.push("taskId = ?"); + params.push(options.taskId); + } + if (options?.chatSessionId !== undefined) { + whereClauses.push("chatSessionId = ?"); + params.push(options.chatSessionId); + } + if (options?.projectId !== undefined) { + whereClauses.push("projectId = ?"); + params.push(options.projectId); + } + if (options?.agentState !== undefined) { + this.assertAgentState(options.agentState); + whereClauses.push("agentState = ?"); + params.push(options.agentState); + } + if (options?.purpose !== undefined) { + this.assertPurpose(options.purpose); + whereClauses.push("purpose = ?"); + params.push(options.purpose); + } + + const whereSql = whereClauses.length > 0 ? `WHERE ${whereClauses.join(" AND ")}` : ""; + const rows = this.db + .prepare(`SELECT * FROM cli_sessions ${whereSql} ORDER BY updatedAt DESC`) + .all(...params); + + return (rows as unknown as CliSessionRow[]).map((row) => this.rowToSession(row)); + } + + /** List CLI session records owned by a task. */ + listByTask(taskId: string): CliSession[] { + return this.listSessions({ taskId }); + } + + /** List CLI session records owned by a chat session. */ + listByChatSession(chatSessionId: string): CliSession[] { + return this.listSessions({ chatSessionId }); + } + + /** + * Update a CLI session record. + * + * State, terminationReason, and resumeAttempts are written atomically in a + * single UPDATE statement, so a state transition that also records why the + * session ended and how many resumes were attempted cannot tear. + * + * @throws Error if any provided enum value is invalid. + * @returns The updated session, or undefined if not found. + */ + updateSession(id: string, input: CliSessionUpdateInput): CliSession | undefined { + const existing = this.getSession(id); + if (!existing) return undefined; + + if (input.agentState !== undefined) { + this.assertAgentState(input.agentState); + } + if (input.terminationReason !== undefined) { + this.assertTerminationReason(input.terminationReason); + } + + const now = new Date().toISOString(); + const setClauses: string[] = ["updatedAt = ?"]; + const params: (string | number | null)[] = [now]; + + if (input.taskId !== undefined) { + setClauses.push("taskId = ?"); + params.push(input.taskId); + } + if (input.chatSessionId !== undefined) { + setClauses.push("chatSessionId = ?"); + params.push(input.chatSessionId); + } + if (input.agentState !== undefined) { + setClauses.push("agentState = ?"); + params.push(input.agentState); + } + if (input.terminationReason !== undefined) { + setClauses.push("terminationReason = ?"); + params.push(input.terminationReason); + } + if (input.nativeSessionId !== undefined) { + setClauses.push("nativeSessionId = ?"); + params.push(input.nativeSessionId); + } + if (input.resumeAttempts !== undefined) { + setClauses.push("resumeAttempts = ?"); + params.push(input.resumeAttempts); + } + if (input.autonomyPosture !== undefined) { + setClauses.push("autonomyPosture = ?"); + params.push(toJsonNullable(input.autonomyPosture)); + } + if (input.worktreePath !== undefined) { + setClauses.push("worktreePath = ?"); + params.push(input.worktreePath); + } + + params.push(id); + + this.db + .prepare(`UPDATE cli_sessions SET ${setClauses.join(", ")} WHERE id = ?`) + .run(...params); + + const updated = this.getSession(id)!; + this.db.bumpLastModified(); + this.emit("cli-session:updated", updated); + return updated; + } + + /** Delete a CLI session record. */ + deleteSession(id: string): boolean { + const existing = this.getSession(id); + if (!existing) return false; + + this.db.prepare("DELETE FROM cli_sessions WHERE id = ?").run(id); + this.db.bumpLastModified(); + this.emit("cli-session:deleted", id); + return true; + } +} diff --git a/packages/core/src/cli-session-types.ts b/packages/core/src/cli-session-types.ts new file mode 100644 index 0000000000..06471d5c2a --- /dev/null +++ b/packages/core/src/cli-session-types.ts @@ -0,0 +1,196 @@ +/** + * CLI agent session type definitions (CLI Agent Executor, U1). + * + * Defines the durable record shape for a CLI agent session — the long-lived + * process that drives a single autonomy unit (a task execution, a planning + * pass, a validator run, a CE run, or an interactive chat). These records + * outlive the in-memory executor so a crashed/restarted Fusion instance can + * reason about, resume, or reap sessions from their persisted state. + * + * Follows the same conventions as chat-types.ts: + * - String-literal unions for enums. + * - Nullable owning-entity references (taskId / chatSessionId). + * - JSON-serialized structured columns (autonomyPosture). + */ + +// ── Enums / String Literals ───────────────────────────────────────────── + +/** + * Lifecycle state of a CLI agent session. + * + * Transitions (typical): starting → ready → busy ↔ waitingOnInput → done, + * with dead / needsAttention reachable from any active state on failure or + * a condition requiring operator intervention. + */ +export type CliAgentState = + | "starting" + | "ready" + | "busy" + | "waitingOnInput" + | "done" + | "dead" + | "needsAttention"; + +/** All valid agent states, for runtime validation at the store boundary. */ +export const CLI_AGENT_STATES: readonly CliAgentState[] = [ + "starting", + "ready", + "busy", + "waitingOnInput", + "done", + "dead", + "needsAttention", +] as const; + +/** + * Why a CLI agent session terminated. Null while the session is still live. + * + * Termination taxonomy (KTD): + * - completed — the agent finished its unit of work successfully. + * - userExited — the user/operator deliberately stopped the session. + * - killed — the session was force-terminated (e.g. supervisor reap). + * - crashed — the underlying process exited abnormally / unexpectedly. + * - authFailed — the session ended because credentials/auth were rejected. + * - engineDeath — the owning Fusion engine/process died, orphaning the session. + */ +export type CliTerminationReason = + | "completed" + | "userExited" + | "killed" + | "crashed" + | "authFailed" + | "engineDeath"; + +/** All valid termination reasons, for runtime validation at the store boundary. */ +export const CLI_TERMINATION_REASONS: readonly CliTerminationReason[] = [ + "completed", + "userExited", + "killed", + "crashed", + "authFailed", + "engineDeath", +] as const; + +/** + * The purpose a CLI agent session serves — which autonomy unit it drives. + * + * - execute — a task execution run. + * - planning — a planning / triage pass. + * - validator — a validator / acceptance run. + * - ce — a compound-engineering run. + * - chat — an interactive chat session. + */ +export type CliSessionPurpose = "execute" | "planning" | "validator" | "ce" | "chat"; + +/** All valid session purposes, for runtime validation at the store boundary. */ +export const CLI_SESSION_PURPOSES: readonly CliSessionPurpose[] = [ + "execute", + "planning", + "validator", + "ce", + "chat", +] as const; + +// ── Core Types ────────────────────────────────────────────────────────── + +/** + * Operator-configured autonomy posture for a session. Stored as JSON. + * + * Kept intentionally open-ended (structured but extensible) so posture + * controls can evolve without a schema migration. Persisted verbatim. + */ +export interface CliAutonomyPosture { + /** Whether the session may proceed without per-step approval. */ + autoApprove?: boolean; + /** Maximum number of resume attempts permitted before giving up. */ + maxResumeAttempts?: number; + /** Free-form, forward-compatible posture fields. */ + [key: string]: unknown; +} + +/** + * A durable CLI agent session record. + * + * Exactly one of `taskId` / `chatSessionId` is typically set, matching the + * owning entity for the session's `purpose` (chat → chatSessionId; the rest → + * taskId). Both may be null for sessions not yet attached to an entity. + */ +export interface CliSession { + /** Stable primary key. */ + id: string; + /** Owning task ID, when this session drives task work. Null otherwise. */ + taskId: string | null; + /** Owning chat session ID, when purpose is "chat". Null otherwise. */ + chatSessionId: string | null; + /** What autonomy unit this session drives. */ + purpose: CliSessionPurpose; + /** Project this session belongs to. */ + projectId: string; + /** Adapter (CLI agent integration) backing the session. */ + adapterId: string; + /** Current lifecycle state. */ + agentState: CliAgentState; + /** Why the session terminated, or null while live. */ + terminationReason: CliTerminationReason | null; + /** Native (adapter/process) session identifier, for resume. Null until known. */ + nativeSessionId: string | null; + /** Number of resume attempts made so far. */ + resumeAttempts: number; + /** Operator-configured autonomy posture. */ + autonomyPosture: CliAutonomyPosture | null; + /** Worktree path the session operates in. */ + worktreePath: string | null; + /** When the record was created (ISO 8601). */ + createdAt: string; + /** When the record was last updated (ISO 8601). */ + updatedAt: string; +} + +/** Input for creating a CLI session record. */ +export interface CliSessionCreateInput { + /** Optional explicit ID; generated when omitted. */ + id?: string; + taskId?: string | null; + chatSessionId?: string | null; + purpose: CliSessionPurpose; + projectId: string; + adapterId: string; + /** Initial state; defaults to "starting" when omitted. */ + agentState?: CliAgentState; + terminationReason?: CliTerminationReason | null; + nativeSessionId?: string | null; + resumeAttempts?: number; + autonomyPosture?: CliAutonomyPosture | null; + worktreePath?: string | null; +} + +/** Partial updates to a CLI session record. */ +export interface CliSessionUpdateInput { + taskId?: string | null; + chatSessionId?: string | null; + agentState?: CliAgentState; + terminationReason?: CliTerminationReason | null; + nativeSessionId?: string | null; + resumeAttempts?: number; + autonomyPosture?: CliAutonomyPosture | null; + worktreePath?: string | null; +} + +// ── Validation helpers ─────────────────────────────────────────────────── + +/** Narrow an unknown value to a valid CliAgentState. */ +export function isCliAgentState(value: unknown): value is CliAgentState { + return typeof value === "string" && (CLI_AGENT_STATES as readonly string[]).includes(value); +} + +/** Narrow an unknown value to a valid CliTerminationReason. */ +export function isCliTerminationReason(value: unknown): value is CliTerminationReason { + return ( + typeof value === "string" && (CLI_TERMINATION_REASONS as readonly string[]).includes(value) + ); +} + +/** Narrow an unknown value to a valid CliSessionPurpose. */ +export function isCliSessionPurpose(value: unknown): value is CliSessionPurpose { + return typeof value === "string" && (CLI_SESSION_PURPOSES as readonly string[]).includes(value); +} diff --git a/packages/core/src/db.ts b/packages/core/src/db.ts index 6403a186f1..266adee074 100644 --- a/packages/core/src/db.ts +++ b/packages/core/src/db.ts @@ -149,7 +149,7 @@ export function probeFts5(db: DatabaseSync): boolean { // ── Schema Definition ──────────────────────────────────────────────── -const SCHEMA_VERSION = 108; +const SCHEMA_VERSION = 109; export { SCHEMA_VERSION }; @@ -1233,6 +1233,22 @@ export const MIGRATION_ONLY_TABLE_SCHEMAS: Record cliSessionFile: "TEXT", inFlightGeneration: "TEXT", }, + cli_sessions: { + id: "TEXT PRIMARY KEY", + taskId: "TEXT", + chatSessionId: "TEXT", + purpose: "TEXT NOT NULL", + projectId: "TEXT NOT NULL", + adapterId: "TEXT NOT NULL", + agentState: "TEXT NOT NULL DEFAULT 'starting'", + terminationReason: "TEXT", + nativeSessionId: "TEXT", + resumeAttempts: "INTEGER NOT NULL DEFAULT 0", + autonomyPosture: "TEXT", + worktreePath: "TEXT", + createdAt: "TEXT NOT NULL", + updatedAt: "TEXT NOT NULL", + }, chat_messages: { id: "TEXT PRIMARY KEY", sessionId: "TEXT NOT NULL", @@ -4291,6 +4307,43 @@ export class Database { }); } + // Migration 109: Durable CLI agent session records (CLI Agent Executor U1). + // Adds cli_sessions — one row per long-lived CLI agent session (task + // execution, planning, validator, ce, or chat) — so a crashed/restarted + // Fusion instance can reason about, resume, or reap sessions from their + // persisted state (agentState + terminationReason + resumeAttempts + + // nativeSessionId). taskId/chatSessionId are the nullable owning-entity + // references; autonomyPosture is JSON. Additive-only, idempotent + // (table-exists guard); no backfill. + // agentState ∈ starting|ready|busy|waitingOnInput|done|dead|needsAttention. + // terminationReason ∈ completed|userExited|killed|crashed|authFailed|engineDeath. + // purpose ∈ execute|planning|validator|ce|chat. + if (version < 109) { + this.applyMigration(109, () => { + this.db.exec(` + CREATE TABLE IF NOT EXISTS cli_sessions ( + id TEXT PRIMARY KEY, + taskId TEXT, + chatSessionId TEXT, + purpose TEXT NOT NULL, + projectId TEXT NOT NULL, + adapterId TEXT NOT NULL, + agentState TEXT NOT NULL DEFAULT 'starting', + terminationReason TEXT, + nativeSessionId TEXT, + resumeAttempts INTEGER NOT NULL DEFAULT 0, + autonomyPosture TEXT, + worktreePath TEXT, + createdAt TEXT NOT NULL, + updatedAt TEXT NOT NULL + ); + CREATE INDEX IF NOT EXISTS idx_cli_sessions_taskId ON cli_sessions(taskId); + CREATE INDEX IF NOT EXISTS idx_cli_sessions_chatSessionId ON cli_sessions(chatSessionId); + CREATE INDEX IF NOT EXISTS idx_cli_sessions_project_state ON cli_sessions(projectId, agentState); + `); + }); + } + } /** diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 999eb211cb..1ff337ddf1 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -1527,6 +1527,25 @@ export type { } from "./chat-types.js"; export { ChatStore } from "./chat-store.js"; export type { ChatStoreEvents } from "./chat-store.js"; +export { + CLI_AGENT_STATES, + CLI_TERMINATION_REASONS, + CLI_SESSION_PURPOSES, + isCliAgentState, + isCliTerminationReason, + isCliSessionPurpose, +} from "./cli-session-types.js"; +export type { + CliAgentState, + CliTerminationReason, + CliSessionPurpose, + CliAutonomyPosture, + CliSession, + CliSessionCreateInput, + CliSessionUpdateInput, +} from "./cli-session-types.js"; +export { CliSessionStore } from "./cli-session-store.js"; +export type { CliSessionStoreEvents } from "./cli-session-store.js"; export { choosePreferredStoredCredential, extractClaudeCliStoredCredential, From ea18ef8d440a246f4a6728329d7717e5176359aa Mon Sep 17 00:00:00 2001 From: gsxdsm Date: Thu, 4 Jun 2026 22:53:02 -0700 Subject: [PATCH 02/30] feat(engine): extract shared PTY native-asset loader and redactSecrets (U16) Co-Authored-By: Claude Opus 4.8 (1M context) --- .../core/src/__tests__/redact-secrets.test.ts | 38 ++++ packages/core/src/index.ts | 1 + packages/core/src/redact-secrets.ts | 31 +++ packages/dashboard/src/terminal-service.ts | 174 +-------------- packages/engine/package.json | 1 + .../engine/src/__tests__/pty-native.test.ts | 92 ++++++++ packages/engine/src/index.ts | 9 + packages/engine/src/pty-native.ts | 211 ++++++++++++++++++ packages/engine/src/types/node-pty/index.d.ts | 80 +++++++ packages/engine/tsconfig.json | 3 +- .../fusion-plugin-acp-runtime/package.json | 1 + .../src/process-manager.ts | 29 +-- pnpm-lock.yaml | 134 ++++++++++- 13 files changed, 602 insertions(+), 202 deletions(-) create mode 100644 packages/core/src/__tests__/redact-secrets.test.ts create mode 100644 packages/core/src/redact-secrets.ts create mode 100644 packages/engine/src/__tests__/pty-native.test.ts create mode 100644 packages/engine/src/pty-native.ts create mode 100644 packages/engine/src/types/node-pty/index.d.ts diff --git a/packages/core/src/__tests__/redact-secrets.test.ts b/packages/core/src/__tests__/redact-secrets.test.ts new file mode 100644 index 0000000000..fa60c38e24 --- /dev/null +++ b/packages/core/src/__tests__/redact-secrets.test.ts @@ -0,0 +1,38 @@ +import { describe, it, expect } from "vitest"; +import { redactSecrets } from "../redact-secrets.js"; + +// Parity fixtures mirror the original ACP plugin's process-manager tests so the +// shared implementation produces identical behavior (Risk S8). +describe("redactSecrets (shared @fusion/core)", () => { + it("redacts bearer tokens", () => { + const out = redactSecrets("Authorization: Bearer sk-live-ABCDEFG1234567890abcdef"); + expect(out).not.toContain("sk-live-ABCDEFG1234567890abcdef"); + expect(out).toContain("[REDACTED]"); + }); + + it("redacts key=/token= assignments", () => { + const out = redactSecrets("api_key=abcdef0123456789 token=ZZZ987654321"); + expect(out).not.toContain("abcdef0123456789"); + expect(out).not.toContain("ZZZ987654321"); + }); + + it("redacts long opaque hex/base64 secrets", () => { + const out = redactSecrets("value 0123456789abcdef0123456789abcdef done"); + expect(out).not.toContain("0123456789abcdef0123456789abcdef"); + }); + + it("leaves benign text intact", () => { + expect(redactSecrets("hello world")).toBe("hello world"); + }); + + it("redacts standalone sk-/ghp_/AKIA opaque tokens", () => { + const out = redactSecrets("ghp_ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"); + expect(out).toBe("[REDACTED]"); + }); + + it("redacts quoted secret assignments", () => { + const out = redactSecrets('client_secret="topsecretvalue123"'); + expect(out).not.toContain("topsecretvalue123"); + expect(out).toContain("[REDACTED]"); + }); +}); diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 999eb211cb..735127bb72 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -16,6 +16,7 @@ export type { EntryPointBranchAssignment, } from "./branch-assignment.js"; export { customProviderRegistryKey } from "./custom-provider-key.js"; +export { redactSecrets } from "./redact-secrets.js"; export { MOCK_PROVIDER_ID } from "./mock-provider-constants.js"; export type { MockProviderId, MockSessionPurpose } from "./mock-provider-constants.js"; export { diff --git a/packages/core/src/redact-secrets.ts b/packages/core/src/redact-secrets.ts new file mode 100644 index 0000000000..ad998b31b8 --- /dev/null +++ b/packages/core/src/redact-secrets.ts @@ -0,0 +1,31 @@ +/** + * Shared secret-redaction helper. + * + * Pure string logic that strips token-like / auth patterns from text so auth + * errors and process output don't leak verbatim into logs or buffers. Best + * effort: covers bearer tokens, `Authorization:` header values, + * `key=`/`token=`/`secret=` assignments, and long base64/hex secrets. + */ + +/** + * Redact token-like / auth patterns from `text`. + */ +export function redactSecrets(text: string): string { + return ( + text + // Authorization: Bearer / Authorization: + .replace(/(authorization\s*[:=]\s*)(bearer\s+)?[^\s,;"']+/gi, "$1$2[REDACTED]") + // Bearer + .replace(/\b(bearer)\s+[A-Za-z0-9._\-+/=]+/gi, "$1 [REDACTED]") + // key=... token=... secret=... password=... apikey=... (quoted or bare) + .replace( + /\b((?:api[_-]?key|key|token|secret|password|passwd|pwd|access[_-]?token|refresh[_-]?token|client[_-]?secret)\s*[:=]\s*)("?)[^\s,;"']+\2/gi, + "$1$2[REDACTED]$2", + ) + // sk-/ghp_/github_pat_/xoxb-/AKIA-style long opaque tokens + .replace(/\b(sk-|ghp_|gho_|github_pat_|xox[abpr]-|AKIA)[A-Za-z0-9_\-]{8,}/g, "[REDACTED]") + // standalone long base64/hex secrets (>=32 chars) + .replace(/\b[A-Za-z0-9+/]{40,}={0,2}\b/g, "[REDACTED]") + .replace(/\b[0-9a-fA-F]{32,}\b/g, "[REDACTED]") + ); +} diff --git a/packages/dashboard/src/terminal-service.ts b/packages/dashboard/src/terminal-service.ts index 69210d19f6..cb674dc1f8 100644 --- a/packages/dashboard/src/terminal-service.ts +++ b/packages/dashboard/src/terminal-service.ts @@ -11,176 +11,10 @@ import { EventEmitter } from "events"; import * as os from "os"; import * as path from "path"; import * as fs from "node:fs"; -import { createRequire } from "node:module"; -import { join, dirname } from "node:path"; - -// Detect if we're running as a Bun-compiled binary -// @ts-expect-error - Bun global is only available in Bun runtime -const isBunBinary = typeof Bun !== "undefined" && !!Bun.embeddedFiles; - -// Lazy-loaded node-pty module (only loaded when terminal is actually used) -let ptyModule: typeof import("node-pty") | null = null; -let ptyLoadError: Error | null = null; - -const require = createRequire(import.meta.url); - -/** - * Find the staged native assets directory for Bun-compiled binaries. - * Looks for runtime// next to the binary. - * - * NOTE: The fs.existsSync() calls in this function run during service initialization - * (when terminal is first used). This is acceptable as it only executes once per - * service lifetime, not per-request. - */ -function getNativePrebuildName(): string { - const platform = process.platform === "darwin" ? "darwin" : - process.platform === "linux" ? "linux" : - process.platform === "win32" ? "win32" : "unknown"; - const arch = process.arch === "arm64" ? "arm64" : - process.arch === "x64" ? "x64" : "unknown"; - return `${platform}-${arch}`; -} - -function findInstalledNodePtyNativeDir(): string | null { - try { - const packageJsonPath = require.resolve("node-pty/package.json"); - const pkgRoot = dirname(packageJsonPath); - - // @homebridge/node-pty-prebuilt-multiarch (aliased as node-pty) places the binary - // in build/Release/pty.node after prebuild-install runs at install time. - // Prefer this location as it is the fork's standard output path. - const releaseDir = join(pkgRoot, "build", "Release"); - if (fs.existsSync(join(releaseDir, "pty.node"))) { - return releaseDir; - } - - // Fallback: check the old prebuilds// layout (upstream node-pty style). - const prebuildDir = join(pkgRoot, "prebuilds", getNativePrebuildName()); - if (fs.existsSync(join(prebuildDir, "pty.node"))) { - return prebuildDir; - } - - return null; - } catch { - return null; - } -} - -function ensureNodePtyNativePermissions(): void { - if (process.platform === "win32") { - return; - } - - const candidateDirs = new Set(); - const envNativeDir = process.env.NODE_PTY_SPAWN_HELPER_DIR || process.env.FUSION_NATIVE_ASSETS_PATH; - if (envNativeDir) { - candidateDirs.add(envNativeDir); - } - - const stagedNativeDir = findStagedNativeDir(); - if (stagedNativeDir) { - candidateDirs.add(stagedNativeDir); - } - - const installedNativeDir = findInstalledNodePtyNativeDir(); - if (installedNativeDir) { - candidateDirs.add(installedNativeDir); - } - - for (const nativeDir of candidateDirs) { - const helperPath = join(nativeDir, "spawn-helper"); - const nativeModulePath = join(nativeDir, "pty.node"); - - try { - fs.chmodSync(helperPath, 0o755); - } catch { - // Best-effort permission repair; helper may not exist in some layouts. - } - - try { - fs.chmodSync(nativeModulePath, 0o755); - } catch (err) { - // Keep diagnostics for the native module path since missing/invalid perms - // here are more likely to prevent PTY startup. - console.warn("[terminal] Failed to repair node-pty native permissions:", { - nativeDir, - error: err instanceof Error ? err.message : String(err), - }); - } - } -} - -function findStagedNativeDir(): string | null { - const prebuildName = getNativePrebuildName(); - - // Check FUSION_RUNTIME_DIR env var first - if (process.env.FUSION_RUNTIME_DIR) { - const envPath = join(process.env.FUSION_RUNTIME_DIR, prebuildName); - if (fs.existsSync(join(envPath, "pty.node"))) { - return envPath; - } - } - - // Look next to the executable - const execDir = dirname(process.execPath); - const nextToBinary = join(execDir, "runtime", prebuildName); - if (fs.existsSync(join(nextToBinary, "pty.node"))) { - return nextToBinary; - } - - return null; -} - -async function loadPtyModule(): Promise { - ensureNodePtyNativePermissions(); - - if (ptyModule) { - return ptyModule; - } - - if (ptyLoadError) { - throw ptyLoadError; - } - - // For Bun-compiled binary, set up native paths before loading - if (isBunBinary) { - const nativeDir = findStagedNativeDir(); - if (nativeDir) { - // Set spawn-helper directory - if (process.platform !== "win32") { - process.env.NODE_PTY_SPAWN_HELPER_DIR = nativeDir; - } - // Store reference for debugging - process.env.FUSION_NATIVE_ASSETS_PATH = nativeDir; - - // Try to pre-load the native module using process.dlopen - // This can help when the normal require() path fails - const nativePath = join(nativeDir, "pty.node"); - if (fs.existsSync(nativePath)) { - try { - const nativeModule: { exports?: unknown } = { exports: {} }; - // process.dlopen is a Node internal API - process.dlopen(nativeModule, nativePath); - console.log("[terminal] Pre-loaded native module via dlopen"); - } catch (dlopenErr) { - // dlopen failed - log but continue, normal import might still work - console.log("[terminal] dlopen pre-load failed (continuing):", dlopenErr); - } - } - } - } - - try { - // Standard import path - the native-patch setup should have created - // the necessary symlink structure for node-pty to find the module - const mod = await import("node-pty"); - ptyModule = mod; - return ptyModule as typeof import("node-pty"); - } catch (err) { - ptyLoadError = err instanceof Error ? err : new Error(String(err)); - throw ptyLoadError; - } -} +// The node-pty native-asset loader (lazy-load, prebuild resolution, dlopen +// fallback, and permission repair) lives in @fusion/engine so PTY owners share +// one implementation. See packages/engine/src/pty-native.ts. +import { loadPtyModule } from "@fusion/engine"; // Maximum scrollback buffer size (characters) const MAX_SCROLLBACK_SIZE = 50000; // ~50KB per terminal diff --git a/packages/engine/package.json b/packages/engine/package.json index 61df34611a..d4d403fffc 100644 --- a/packages/engine/package.json +++ b/packages/engine/package.json @@ -43,6 +43,7 @@ "@earendil-works/pi-coding-agent": "^0.78.0", "cron-parser": "^5.5.0", "esbuild": "^0.25.12", + "node-pty": "npm:@homebridge/node-pty-prebuilt-multiarch@^0.13.1", "proper-lockfile": "^4.1.2", "typebox": "^1.0.0" }, diff --git a/packages/engine/src/__tests__/pty-native.test.ts b/packages/engine/src/__tests__/pty-native.test.ts new file mode 100644 index 0000000000..4bfc1cc30c --- /dev/null +++ b/packages/engine/src/__tests__/pty-native.test.ts @@ -0,0 +1,92 @@ +import { describe, it, expect, beforeEach, afterEach } from "vitest"; +import * as fs from "node:fs"; +import * as os from "node:os"; +import { join } from "node:path"; +import { + getNativePrebuildName, + findStagedNativeDir, + ensureNodePtyNativePermissions, +} from "../pty-native.js"; + +const SAVED_ENV = { + FUSION_RUNTIME_DIR: process.env.FUSION_RUNTIME_DIR, + NODE_PTY_SPAWN_HELPER_DIR: process.env.NODE_PTY_SPAWN_HELPER_DIR, + FUSION_NATIVE_ASSETS_PATH: process.env.FUSION_NATIVE_ASSETS_PATH, +}; + +let tmpRoot: string; + +beforeEach(() => { + tmpRoot = fs.mkdtempSync(join(os.tmpdir(), "pty-native-")); + delete process.env.FUSION_RUNTIME_DIR; + delete process.env.NODE_PTY_SPAWN_HELPER_DIR; + delete process.env.FUSION_NATIVE_ASSETS_PATH; +}); + +afterEach(() => { + fs.rmSync(tmpRoot, { recursive: true, force: true }); + for (const [k, v] of Object.entries(SAVED_ENV)) { + if (v === undefined) delete process.env[k]; + else process.env[k] = v; + } +}); + +/** Create a fixture `//pty.node` (+ spawn-helper) directory. */ +function makeStagedDir(root: string, opts: { broken?: boolean } = {}): string { + const dir = join(root, getNativePrebuildName()); + fs.mkdirSync(dir, { recursive: true }); + const nativePath = join(dir, "pty.node"); + const helperPath = join(dir, "spawn-helper"); + fs.writeFileSync(nativePath, "fake-native"); + fs.writeFileSync(helperPath, "fake-helper"); + if (opts.broken) { + // Strip executable + write/read bits to simulate a broken-mode install. + fs.chmodSync(nativePath, 0o400); + fs.chmodSync(helperPath, 0o400); + } + return dir; +} + +describe("getNativePrebuildName", () => { + it("returns a - token", () => { + const name = getNativePrebuildName(); + expect(name).toMatch(/^(darwin|linux|win32|unknown)-(arm64|x64|unknown)$/); + }); +}); + +describe("findStagedNativeDir (packaged-binary mode)", () => { + it("resolves the staged dir via FUSION_RUNTIME_DIR fixture", () => { + const staged = makeStagedDir(tmpRoot); + process.env.FUSION_RUNTIME_DIR = tmpRoot; + expect(findStagedNativeDir()).toBe(staged); + }); + + it("returns null when no staged pty.node is present", () => { + process.env.FUSION_RUNTIME_DIR = tmpRoot; // empty, no pty.node + expect(findStagedNativeDir()).toBeNull(); + }); +}); + +describe("ensureNodePtyNativePermissions (permission repair)", () => { + // chmod semantics don't apply on win32; skip there. + const maybe = process.platform === "win32" ? it.skip : it; + + maybe("repairs broken modes on a fixture native dir to 0o755", () => { + const dir = makeStagedDir(tmpRoot, { broken: true }); + process.env.FUSION_RUNTIME_DIR = tmpRoot; + + const nativePath = join(dir, "pty.node"); + const helperPath = join(dir, "spawn-helper"); + // Precondition: not executable. + expect(fs.statSync(nativePath).mode & 0o111).toBe(0); + + ensureNodePtyNativePermissions(); + + expect(fs.statSync(nativePath).mode & 0o777).toBe(0o755); + expect(fs.statSync(helperPath).mode & 0o777).toBe(0o755); + }); + + maybe("is a no-op (does not throw) when no candidate dirs exist", () => { + expect(() => ensureNodePtyNativePermissions()).not.toThrow(); + }); +}); diff --git a/packages/engine/src/index.ts b/packages/engine/src/index.ts index dec5919a47..addb8e5e78 100644 --- a/packages/engine/src/index.ts +++ b/packages/engine/src/index.ts @@ -614,3 +614,12 @@ export { type RuntimeStatus, type RuntimeMetrics, } from "./project-runtime.js"; +// Shared node-pty native-asset loader +export { + loadPtyModule, + ensureNodePtyNativePermissions, + findStagedNativeDir, + findInstalledNodePtyNativeDir, + getNativePrebuildName, + resetPtyModuleCacheForTests, +} from "./pty-native.js"; diff --git a/packages/engine/src/pty-native.ts b/packages/engine/src/pty-native.ts new file mode 100644 index 0000000000..1a8564e72b --- /dev/null +++ b/packages/engine/src/pty-native.ts @@ -0,0 +1,211 @@ +/** + * Shared node-pty native-asset loader. + * + * Centralizes the lazy-load, prebuild path resolution, dlopen fallback, and + * native-permission repair machinery so PTY owners (the dashboard terminal + * service and the CLI agent executor) share one implementation. The runtime + * package is `@homebridge/node-pty-prebuilt-multiarch`, aliased as `node-pty` + * in package.json. + */ + +import * as fs from "node:fs"; +import { createRequire } from "node:module"; +import { join, dirname } from "node:path"; + +// Detect if we're running as a Bun-compiled binary +// @ts-expect-error - Bun global is only available in Bun runtime +const isBunBinary = typeof Bun !== "undefined" && !!Bun.embeddedFiles; + +// Lazy-loaded node-pty module (only loaded when a PTY is actually used) +let ptyModule: typeof import("node-pty") | null = null; +let ptyLoadError: Error | null = null; + +const require = createRequire(import.meta.url); + +/** + * Resolve the `-` directory name used for staged native + * prebuilds next to a Bun-compiled binary. + */ +export function getNativePrebuildName(): string { + const platform = + process.platform === "darwin" + ? "darwin" + : process.platform === "linux" + ? "linux" + : process.platform === "win32" + ? "win32" + : "unknown"; + const arch = process.arch === "arm64" ? "arm64" : process.arch === "x64" ? "x64" : "unknown"; + return `${platform}-${arch}`; +} + +/** + * Locate the installed node-pty native module directory in dev/workspace mode. + * + * NOTE: The fs.existsSync() calls in this function run during loader + * initialization (when a PTY is first used). This is acceptable as it only + * executes once per process lifetime, not per-request. + */ +export function findInstalledNodePtyNativeDir(): string | null { + try { + const packageJsonPath = require.resolve("node-pty/package.json"); + const pkgRoot = dirname(packageJsonPath); + + // @homebridge/node-pty-prebuilt-multiarch (aliased as node-pty) places the binary + // in build/Release/pty.node after prebuild-install runs at install time. + // Prefer this location as it is the fork's standard output path. + const releaseDir = join(pkgRoot, "build", "Release"); + if (fs.existsSync(join(releaseDir, "pty.node"))) { + return releaseDir; + } + + // Fallback: check the old prebuilds// layout (upstream node-pty style). + const prebuildDir = join(pkgRoot, "prebuilds", getNativePrebuildName()); + if (fs.existsSync(join(prebuildDir, "pty.node"))) { + return prebuildDir; + } + + return null; + } catch { + return null; + } +} + +/** + * Locate the native assets directory staged next to a Bun-compiled binary + * (packaged-binary mode). Looks for `runtime//pty.node`. + */ +export function findStagedNativeDir(): string | null { + const prebuildName = getNativePrebuildName(); + + // Check FUSION_RUNTIME_DIR env var first + if (process.env.FUSION_RUNTIME_DIR) { + const envPath = join(process.env.FUSION_RUNTIME_DIR, prebuildName); + if (fs.existsSync(join(envPath, "pty.node"))) { + return envPath; + } + } + + // Look next to the executable + const execDir = dirname(process.execPath); + const nextToBinary = join(execDir, "runtime", prebuildName); + if (fs.existsSync(join(nextToBinary, "pty.node"))) { + return nextToBinary; + } + + return null; +} + +/** + * Best-effort repair of native-asset permissions so node-pty's `pty.node` and + * `spawn-helper` are executable. No-op on Windows. + */ +export function ensureNodePtyNativePermissions(): void { + if (process.platform === "win32") { + return; + } + + const candidateDirs = new Set(); + const envNativeDir = + process.env.NODE_PTY_SPAWN_HELPER_DIR || process.env.FUSION_NATIVE_ASSETS_PATH; + if (envNativeDir) { + candidateDirs.add(envNativeDir); + } + + const stagedNativeDir = findStagedNativeDir(); + if (stagedNativeDir) { + candidateDirs.add(stagedNativeDir); + } + + const installedNativeDir = findInstalledNodePtyNativeDir(); + if (installedNativeDir) { + candidateDirs.add(installedNativeDir); + } + + for (const nativeDir of candidateDirs) { + const helperPath = join(nativeDir, "spawn-helper"); + const nativeModulePath = join(nativeDir, "pty.node"); + + try { + fs.chmodSync(helperPath, 0o755); + } catch { + // Best-effort permission repair; helper may not exist in some layouts. + } + + try { + fs.chmodSync(nativeModulePath, 0o755); + } catch (err) { + // Keep diagnostics for the native module path since missing/invalid perms + // here are more likely to prevent PTY startup. + console.warn("[terminal] Failed to repair node-pty native permissions:", { + nativeDir, + error: err instanceof Error ? err.message : String(err), + }); + } + } +} + +/** + * Lazily load the node-pty module, repairing native permissions and (for + * Bun-compiled binaries) pre-loading the native module via dlopen. The loaded + * module is cached; a load failure is cached and re-thrown on subsequent calls. + */ +export async function loadPtyModule(): Promise { + ensureNodePtyNativePermissions(); + + if (ptyModule) { + return ptyModule; + } + + if (ptyLoadError) { + throw ptyLoadError; + } + + // For Bun-compiled binary, set up native paths before loading + if (isBunBinary) { + const nativeDir = findStagedNativeDir(); + if (nativeDir) { + // Set spawn-helper directory + if (process.platform !== "win32") { + process.env.NODE_PTY_SPAWN_HELPER_DIR = nativeDir; + } + // Store reference for debugging + process.env.FUSION_NATIVE_ASSETS_PATH = nativeDir; + + // Try to pre-load the native module using process.dlopen + // This can help when the normal require() path fails + const nativePath = join(nativeDir, "pty.node"); + if (fs.existsSync(nativePath)) { + try { + const nativeModule: { exports?: unknown } = { exports: {} }; + // process.dlopen is a Node internal API + process.dlopen(nativeModule, nativePath); + console.log("[terminal] Pre-loaded native module via dlopen"); + } catch (dlopenErr) { + // dlopen failed - log but continue, normal import might still work + console.log("[terminal] dlopen pre-load failed (continuing):", dlopenErr); + } + } + } + } + + try { + // Standard import path - the native-patch setup should have created + // the necessary symlink structure for node-pty to find the module + const mod = await import("node-pty"); + ptyModule = mod; + return ptyModule as typeof import("node-pty"); + } catch (err) { + ptyLoadError = err instanceof Error ? err : new Error(String(err)); + throw ptyLoadError; + } +} + +/** + * Reset the cached module / error state. Intended for tests that exercise the + * loader across multiple scenarios. + */ +export function resetPtyModuleCacheForTests(): void { + ptyModule = null; + ptyLoadError = null; +} diff --git a/packages/engine/src/types/node-pty/index.d.ts b/packages/engine/src/types/node-pty/index.d.ts new file mode 100644 index 0000000000..34b55c56fb --- /dev/null +++ b/packages/engine/src/types/node-pty/index.d.ts @@ -0,0 +1,80 @@ +/** + * Type shim for the `node-pty` import specifier. + * + * The runtime package is @homebridge/node-pty-prebuilt-multiarch, aliased as + * "node-pty" in package.json. Its bundled typings use `declare module + * '@homebridge/node-pty-prebuilt-multiarch'` which TypeScript cannot resolve + * via the npm alias alone. This shim re-declares the module under the `node-pty` + * specifier so all source imports of `"node-pty"` resolve correctly. + * + * API surface matches node-pty 0.10.x / @homebridge/node-pty-prebuilt-multiarch 0.13.x. + */ +declare module "node-pty" { + /** + * An object that can be disposed via a dispose function. + */ + export interface IDisposable { + dispose(): void; + } + + /** + * An event that can be listened to. + * @returns an IDisposable to stop listening. + */ + export interface IEvent { + (listener: (e: T) => unknown): IDisposable; + } + + export interface IBasePtyForkOptions { + name?: string; + cols?: number; + rows?: number; + cwd?: string; + env?: { [key: string]: string | undefined }; + encoding?: string | null; + handleFlowControl?: boolean; + flowControlPause?: string; + flowControlResume?: string; + } + + export interface IPtyForkOptions extends IBasePtyForkOptions { + uid?: number; + gid?: number; + } + + export interface IWindowsPtyForkOptions extends IBasePtyForkOptions { + useConpty?: boolean; + useConptyDll?: boolean; + conptyInheritCursor?: boolean; + } + + /** + * An interface representing a pseudoterminal. + */ + export interface IPty { + readonly pid: number; + readonly cols: number; + readonly rows: number; + readonly process: string; + handleFlowControl: boolean; + readonly onData: IEvent; + readonly onExit: IEvent<{ exitCode: number; signal?: number }>; + resize(columns: number, rows: number): void; + on(event: "data", listener: (data: string) => void): void; + on(event: "exit", listener: (exitCode: number, signal?: number) => void): void; + clear(): void; + write(data: string): void; + kill(signal?: string): void; + pause(): void; + resume(): void; + } + + /** + * Forks a process as a pseudoterminal. + */ + export function spawn( + file: string, + args: string[] | string, + options: IPtyForkOptions | IWindowsPtyForkOptions, + ): IPty; +} diff --git a/packages/engine/tsconfig.json b/packages/engine/tsconfig.json index 0f23f665e4..6a3abdd9a5 100644 --- a/packages/engine/tsconfig.json +++ b/packages/engine/tsconfig.json @@ -5,7 +5,8 @@ "rootDir": "src", "types": ["node", "vitest/globals"], "paths": { - "@fusion/test-utils": ["../core/src/__test-utils__/workspace.ts"] + "@fusion/test-utils": ["../core/src/__test-utils__/workspace.ts"], + "node-pty": ["./src/types/node-pty/index.d.ts"] } }, "include": ["src/**/*"] diff --git a/plugins/fusion-plugin-acp-runtime/package.json b/plugins/fusion-plugin-acp-runtime/package.json index 73e53beb74..86e32bc70d 100644 --- a/plugins/fusion-plugin-acp-runtime/package.json +++ b/plugins/fusion-plugin-acp-runtime/package.json @@ -27,6 +27,7 @@ }, "dependencies": { "@agentclientprotocol/sdk": "0.24.0", + "@fusion/core": "workspace:*", "@fusion/plugin-sdk": "workspace:*" }, "peerDependencies": { diff --git a/plugins/fusion-plugin-acp-runtime/src/process-manager.ts b/plugins/fusion-plugin-acp-runtime/src/process-manager.ts index 2e52335f5b..d55fbc23cd 100644 --- a/plugins/fusion-plugin-acp-runtime/src/process-manager.ts +++ b/plugins/fusion-plugin-acp-runtime/src/process-manager.ts @@ -13,6 +13,7 @@ // to the agent. import { spawn, type ChildProcess } from "node:child_process"; +import { redactSecrets } from "@fusion/core"; function debugLog(message: string): void { if (process.env.PI_ACP_DEBUG !== "1") return; @@ -113,31 +114,9 @@ export function spawnAgent(options: SpawnAgentOptions): ChildProcess { /** Maximum stderr bytes retained; older output is dropped to bound memory. */ const STDERR_BUFFER_CEILING = 64 * 1024; -/** - * Redact token-like / auth patterns from text so auth errors don't leak - * verbatim into the stderr buffer or logs (Risk S8). Best-effort: covers - * bearer tokens, `Authorization:` header values, `key=`/`token=`/`secret=` - * assignments, and long base64/hex secrets. - */ -export function redactSecrets(text: string): string { - return ( - text - // Authorization: Bearer / Authorization: - .replace(/(authorization\s*[:=]\s*)(bearer\s+)?[^\s,;"']+/gi, "$1$2[REDACTED]") - // Bearer - .replace(/\b(bearer)\s+[A-Za-z0-9._\-+/=]+/gi, "$1 [REDACTED]") - // key=... token=... secret=... password=... apikey=... (quoted or bare) - .replace( - /\b((?:api[_-]?key|key|token|secret|password|passwd|pwd|access[_-]?token|refresh[_-]?token|client[_-]?secret)\s*[:=]\s*)("?)[^\s,;"']+\2/gi, - "$1$2[REDACTED]$2", - ) - // sk-/ghp_/github_pat_/xoxb-/AKIA-style long opaque tokens - .replace(/\b(sk-|ghp_|gho_|github_pat_|xox[abpr]-|AKIA)[A-Za-z0-9_\-]{8,}/g, "[REDACTED]") - // standalone long base64/hex secrets (>=32 chars) - .replace(/\b[A-Za-z0-9+/]{40,}={0,2}\b/g, "[REDACTED]") - .replace(/\b[0-9a-fA-F]{32,}\b/g, "[REDACTED]") - ); -} +// Secret redaction (Risk S8) lives in @fusion/core so PTY/process owners share +// one implementation; re-exported here to preserve this module's public surface. +export { redactSecrets }; /** * Accumulate stderr into a bounded, secret-redacted buffer. diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 375685e675..f080060e31 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -46,10 +46,10 @@ importers: dependencies: '@earendil-works/pi-ai': specifier: ^0.78.0 - version: 0.78.0(@modelcontextprotocol/sdk@1.28.0(zod@4.3.6))(ws@8.20.0)(zod@4.3.6) + version: 0.78.0(@modelcontextprotocol/sdk@1.28.0(zod@3.25.76))(ws@8.20.0)(zod@3.25.76) '@earendil-works/pi-coding-agent': specifier: ^0.78.0 - version: 0.78.0(@modelcontextprotocol/sdk@1.28.0(zod@4.3.6))(ws@8.20.0)(zod@4.3.6) + version: 0.78.0(@modelcontextprotocol/sdk@1.28.0(zod@3.25.76))(ws@8.20.0)(zod@3.25.76) dockerode: specifier: ^4.0.12 version: 4.0.12 @@ -482,6 +482,9 @@ importers: esbuild: specifier: ^0.25.12 version: 0.25.12 + node-pty: + specifier: npm:@homebridge/node-pty-prebuilt-multiarch@^0.13.1 + version: '@homebridge/node-pty-prebuilt-multiarch@0.13.1' proper-lockfile: specifier: ^4.1.2 version: 4.1.2 @@ -693,6 +696,9 @@ importers: '@earendil-works/pi-coding-agent': specifier: '*' version: 0.78.0(@modelcontextprotocol/sdk@1.28.0(zod@4.3.6))(ws@8.20.0)(zod@4.3.6) + '@fusion/core': + specifier: workspace:* + version: link:../../packages/core '@fusion/plugin-sdk': specifier: workspace:* version: link:../../packages/plugin-sdk @@ -7102,6 +7108,10 @@ snapshots: '@jridgewell/gen-mapping': 0.3.13 '@jridgewell/trace-mapping': 0.3.31 + '@anthropic-ai/sdk@0.91.1': + dependencies: + json-schema-to-ts: 3.1.1 + '@anthropic-ai/sdk@0.91.1(zod@3.25.76)': dependencies: json-schema-to-ts: 3.1.1 @@ -7836,6 +7846,20 @@ snapshots: - ws - zod + '@earendil-works/pi-agent-core@0.78.0(@modelcontextprotocol/sdk@1.28.0(zod@3.25.76))(ws@8.20.0)(zod@3.25.76)': + dependencies: + '@earendil-works/pi-ai': 0.78.0(@modelcontextprotocol/sdk@1.28.0(zod@3.25.76))(ws@8.20.0)(zod@3.25.76) + ignore: 7.0.5 + typebox: 1.1.38 + yaml: 2.9.0 + transitivePeerDependencies: + - '@modelcontextprotocol/sdk' + - bufferutil + - supports-color + - utf-8-validate + - ws + - zod + '@earendil-works/pi-agent-core@0.78.0(@modelcontextprotocol/sdk@1.28.0(zod@4.3.6))(ws@8.20.0)(zod@4.3.6)': dependencies: '@earendil-works/pi-ai': 0.78.0(@modelcontextprotocol/sdk@1.28.0(zod@4.3.6))(ws@8.20.0)(zod@4.3.6) @@ -7866,14 +7890,14 @@ snapshots: '@earendil-works/pi-ai@0.77.0': dependencies: - '@anthropic-ai/sdk': 0.91.1(zod@3.25.76) + '@anthropic-ai/sdk': 0.91.1 '@aws-sdk/client-bedrock-runtime': 3.1048.0 - '@google/genai': 1.52.0(@modelcontextprotocol/sdk@1.28.0(zod@4.3.6)) + '@google/genai': 1.52.0 '@mistralai/mistralai': 2.2.1 '@smithy/node-http-handler': 4.7.3 http-proxy-agent: 7.0.2 https-proxy-agent: 7.0.6 - openai: 6.26.0(ws@8.20.0)(zod@3.25.76) + openai: 6.26.0 partial-json: 0.1.7 typebox: 1.1.38 transitivePeerDependencies: @@ -7904,6 +7928,26 @@ snapshots: - ws - zod + '@earendil-works/pi-ai@0.78.0(@modelcontextprotocol/sdk@1.28.0(zod@3.25.76))(ws@8.20.0)(zod@3.25.76)': + dependencies: + '@anthropic-ai/sdk': 0.91.1(zod@3.25.76) + '@aws-sdk/client-bedrock-runtime': 3.1048.0 + '@google/genai': 1.52.0(@modelcontextprotocol/sdk@1.28.0(zod@3.25.76)) + '@mistralai/mistralai': 2.2.1 + '@smithy/node-http-handler': 4.7.3 + http-proxy-agent: 7.0.2 + https-proxy-agent: 7.0.6 + openai: 6.26.0(ws@8.20.0)(zod@3.25.76) + partial-json: 0.1.7 + typebox: 1.1.38 + transitivePeerDependencies: + - '@modelcontextprotocol/sdk' + - bufferutil + - supports-color + - utf-8-validate + - ws + - zod + '@earendil-works/pi-ai@0.78.0(@modelcontextprotocol/sdk@1.28.0(zod@4.3.6))(ws@8.20.0)(zod@4.3.6)': dependencies: '@anthropic-ai/sdk': 0.91.1(zod@4.3.6) @@ -7928,7 +7972,7 @@ snapshots: dependencies: '@anthropic-ai/sdk': 0.91.1(zod@3.25.76) '@aws-sdk/client-bedrock-runtime': 3.1048.0 - '@google/genai': 1.52.0(@modelcontextprotocol/sdk@1.28.0(zod@4.3.6)) + '@google/genai': 1.52.0 '@mistralai/mistralai': 2.2.1 '@smithy/node-http-handler': 4.7.3 http-proxy-agent: 7.0.2 @@ -8002,6 +8046,35 @@ snapshots: - ws - zod + '@earendil-works/pi-coding-agent@0.78.0(@modelcontextprotocol/sdk@1.28.0(zod@3.25.76))(ws@8.20.0)(zod@3.25.76)': + dependencies: + '@earendil-works/pi-agent-core': 0.78.0(@modelcontextprotocol/sdk@1.28.0(zod@3.25.76))(ws@8.20.0)(zod@3.25.76) + '@earendil-works/pi-ai': 0.78.0(@modelcontextprotocol/sdk@1.28.0(zod@3.25.76))(ws@8.20.0)(zod@3.25.76) + '@earendil-works/pi-tui': 0.78.0 + '@silvia-odwyer/photon-node': 0.3.4 + chalk: 5.6.2 + cross-spawn: 7.0.6 + diff: 8.0.4 + glob: 13.0.6 + highlight.js: 10.7.3 + hosted-git-info: 9.0.3 + ignore: 7.0.5 + jiti: 2.7.0 + minimatch: 10.2.5 + proper-lockfile: 4.1.2 + typebox: 1.1.38 + undici: 8.3.0 + yaml: 2.9.0 + optionalDependencies: + '@mariozechner/clipboard': 0.3.9 + transitivePeerDependencies: + - '@modelcontextprotocol/sdk' + - bufferutil + - supports-color + - utf-8-validate + - ws + - zod + '@earendil-works/pi-coding-agent@0.78.0(@modelcontextprotocol/sdk@1.28.0(zod@4.3.6))(ws@8.20.0)(zod@4.3.6)': dependencies: '@earendil-works/pi-agent-core': 0.78.0(@modelcontextprotocol/sdk@1.28.0(zod@4.3.6))(ws@8.20.0)(zod@4.3.6) @@ -8381,6 +8454,30 @@ snapshots: '@exodus/bytes@1.15.0': {} + '@google/genai@1.52.0': + dependencies: + google-auth-library: 10.6.2 + p-retry: 4.6.2 + protobufjs: 7.5.8 + ws: 8.20.0 + transitivePeerDependencies: + - bufferutil + - supports-color + - utf-8-validate + + '@google/genai@1.52.0(@modelcontextprotocol/sdk@1.28.0(zod@3.25.76))': + dependencies: + google-auth-library: 10.6.2 + p-retry: 4.6.2 + protobufjs: 7.5.8 + ws: 8.20.0 + optionalDependencies: + '@modelcontextprotocol/sdk': 1.28.0(zod@3.25.76) + transitivePeerDependencies: + - bufferutil + - supports-color + - utf-8-validate + '@google/genai@1.52.0(@modelcontextprotocol/sdk@1.28.0(zod@4.3.6))': dependencies: google-auth-library: 10.6.2 @@ -8887,6 +8984,29 @@ snapshots: - bufferutil - utf-8-validate + '@modelcontextprotocol/sdk@1.28.0(zod@3.25.76)': + dependencies: + '@hono/node-server': 1.19.12(hono@4.12.9) + ajv: 8.18.0 + ajv-formats: 3.0.1(ajv@8.18.0) + content-type: 1.0.5 + cors: 2.8.6 + cross-spawn: 7.0.6 + eventsource: 3.0.7 + eventsource-parser: 3.0.6 + express: 5.2.1 + express-rate-limit: 8.3.1(express@5.2.1) + hono: 4.12.9 + jose: 6.2.2 + json-schema-typed: 8.0.2 + pkce-challenge: 5.0.1 + raw-body: 3.0.2 + zod: 3.25.76 + zod-to-json-schema: 3.25.1(zod@3.25.76) + transitivePeerDependencies: + - supports-color + optional: true + '@modelcontextprotocol/sdk@1.28.0(zod@4.3.6)': dependencies: '@hono/node-server': 1.19.12(hono@4.12.9) @@ -12572,6 +12692,8 @@ snapshots: is-docker: 2.2.1 is-wsl: 2.2.0 + openai@6.26.0: {} + openai@6.26.0(ws@8.20.0)(zod@3.25.76): optionalDependencies: ws: 8.20.0 From 33f9dadfb39cdf525d18ee57b76cdf052bcd8d7f Mon Sep 17 00:00:00 2001 From: gsxdsm Date: Thu, 4 Jun 2026 22:53:19 -0700 Subject: [PATCH 03/30] docs: add cli-agent executor requirements and implementation plan --- CONCEPTS.md | 15 + .../2026-06-04-cli-executor-requirements.md | 211 ++++++++ ...-06-04-002-feat-cli-agent-executor-plan.md | 488 ++++++++++++++++++ 3 files changed, 714 insertions(+) create mode 100644 docs/brainstorms/2026-06-04-cli-executor-requirements.md create mode 100644 docs/plans/2026-06-04-002-feat-cli-agent-executor-plan.md diff --git a/CONCEPTS.md b/CONCEPTS.md index 4602719bf1..db5f5de95b 100644 --- a/CONCEPTS.md +++ b/CONCEPTS.md @@ -179,6 +179,21 @@ A workflow graph node that reads a declared Artifact and runs a registry parser ### Custom task field A workflow-declared, typed task field (`string | text | number | boolean | enum | multi-enum | date | url`, with enum options and render hints) whose values live in `tasks.customFields`, keyed by field id. The task model is thereby recast as core fields (title, description) + standard metadata + these workflow-defined fields. Writes pass through a single store authority (`updateTaskCustomFields`) that validates each value against the resolving workflow's schema and returns typed rejections (offending `fieldId` + `code`); agents write them via `fn_task_update`'s `custom_fields` patch. Editing a workflow's fields or switching a task's workflow orphans (never destroys) values for removed or type-incompatible ids — orphans are retained and surfaced under a detail disclosure, excluded from cards. Same id means the same field within a project; there is no cross-workflow shared field namespace. +## CLI executor + +### CLI Executor +The executor type `cli-agent`: a Fusion agent session (task execute step, planning, validator, CE plugin session, or chat) driven by an interactive CLI coding agent running in a Fusion-owned PTY. Distinct from the pre-existing non-interactive `cli` executor kind (the named-script/raw-command runner). Selected on the workflow node for task surfaces (per-task override) and per session for chat/CE. The board lifecycle is unchanged — the terminal is the execution surface, not a separate workflow. +*Avoid:* `cli` as the executor identifier — that name is taken by the script-runner kind. + +### CLI Adapter +The per-CLI integration that launches and understands one CLI agent. Native-telemetry adapters (Claude Code, Codex, Droid, Pi) tap the CLI's own hooks/session logs for precise agent state, structured transcript, and native session identity; the generic adapter runs any CLI command with heuristic idle detection and a raw-terminal-only view. Adapters carry their own launch configuration (command, args, permission mode) with shipped defaults. + +### CLI Session +A server-owned PTY bound to a task or chat entity. It survives client disconnects, supports concurrent attach from any surface, and carries an agent state (starting, ready, busy, waiting-on-input, done, dead). Its CLI-native session ID is persisted so a dead PTY or engine restart resumes via the CLI's own resume mechanism — needs-attention is the fallback when resume fails, never the first response. + +### Waiting-on-input +The CLI Session state where the agent is blocked on the human (permission prompt, clarifying question), as distinct from idle-because-done. Entering it fires the notification configured on the workflow node; the task neither advances nor fails while in it. + ## Flagged ambiguities - "Merging" a shared-branch-group Task had been used for both member integration and group promotion — these are distinct steps with independent gating and must not be conflated. diff --git a/docs/brainstorms/2026-06-04-cli-executor-requirements.md b/docs/brainstorms/2026-06-04-cli-executor-requirements.md new file mode 100644 index 0000000000..d51795bd50 --- /dev/null +++ b/docs/brainstorms/2026-06-04-cli-executor-requirements.md @@ -0,0 +1,211 @@ +--- +date: 2026-06-04 +topic: cli-executor +--- + +# CLI Executor — Requirements + +## Summary + +Add a new executor type — **cli-agent** — that runs Fusion agent sessions inside server-owned PTYs running interactive CLI coding agents (Claude Code, Codex, Droid, Pi). Fusion injects prompts, tracks agent state and session identity through each CLI's native telemetry, and drives the full task pipeline off it, while the user co-drives through a live interactive terminal on any surface. Chat gains a CLI-backed mode rendered as a structured transcript with a raw-terminal toggle. + +--- + +## Problem Frame + +Fusion's primary persona is the developer juggling many Claude Code and Codex terminals across machines. Today Fusion can only drive agents through API-backed runtimes (model executors and agent executors over headless adapters like ACP). But the CLI agents are where these developers actually live: their subscriptions, authentication, configuration, skills, hooks, and muscle memory are attached to the CLI tools, not to raw API keys. There is currently no way to make a board task's execution *be* a Claude Code or Codex session — visible, steerable, and co-drivable — nor any way to chat through one. + +Orca (onorca.dev) demonstrates the model this feature should match: server-managed terminal sessions with per-CLI awareness — readiness detection, prompt injection, idle/busy tracking via installed agent hooks, native session-id capture, and resume across restarts — while the terminal stays fully interactive for the human. + +--- + +## Key Decisions + +- **Native telemetry first, heuristics as fallback.** Each launch CLI gets an adapter that taps the CLI's own machinery — hooks and structured session logs — for precise state (busy / waiting-on-input / done), transcript content, and session identity. Any CLI without an adapter can still run through a generic PTY adapter using screen-quiet/idle heuristics, with a raw-terminal-only view. This mirrors Orca's hooks-based approach and keeps the adapter registry open, consistent with Fusion's neutrality thesis. Native-telemetry status for Codex, Droid, and Pi is contingent on a planning-time verification gate; only Claude Code is verified today. +- **The executor identifier is `cli-agent`, not `cli`.** The engine already wires an `executorKind` of `cli` as a non-interactive runner for named project scripts and approval-gated raw commands (`packages/engine/src/executor.ts`) — opposite semantics to this feature. The interactive-PTY executor ships under the distinct `cli-agent` identifier, joining the existing executor kinds (model, agent, skill, and the script-runner `cli`). +- **Full pipeline retained.** A CLI-executed task is a normal task: worktree and branch setup, completion detection driving the lifecycle, validator, in-review, and auto-merge all apply. The terminal is the execution surface; the board lifecycle is unchanged. +- **Per-CLI launch configuration.** Each adapter carries its own configurable launch settings (command, args, permission/autonomy flags) with sensible shipped defaults, rather than a single global autonomy posture. +- **Executor selection and attention behavior live on the workflow node for task surfaces; chat and CE sessions choose per session.** The workflow's execute node configures the CLI executor for task execution — the same node-level pattern covers planning and validator runs — including what happens when the agent blocks on input (notification), with per-task override. Chat and CE plugin sessions are not workflow nodes; they select their CLI executor in per-session settings. +- **Orca-style session identity and resume.** The CLI's native session ID is captured per session and persisted; a dead PTY or engine restart relaunches the CLI with its native resume mechanism so agent context survives. Needs-attention is the fallback when resume fails, not the first response. +- **Chat is a hybrid transcript.** A CLI-backed chat session renders structured messages parsed from the CLI's native telemetry, with a toggle to drop into the raw interactive terminal. No screen-scraping of TUI output into bubbles. +- **Full surface parity.** The interactive terminal works on the desktop dashboard, mobile, and the TUI surface, all attached to the same server-owned session. + +--- + +## Actors + +- A1. Developer — selects CLI executors, watches sessions, co-drives in the terminal, answers agent prompts. +- A2. CLI agent — an external interactive process (Claude Code, Codex, Droid, Pi) running in a Fusion-owned PTY. +- A3. Engine — spawns and owns CLI sessions, injects prompts, consumes telemetry, drives the task pipeline. +- A4. Surfaces — desktop dashboard, mobile, and TUI clients that attach to live sessions for viewing and input. + +--- + +## Key Flows + +- F1. CLI task execution + - **Trigger:** A task whose resolved executor is `cli-agent` starts its execute step. + - **Steps:** Engine prepares worktree/branch as usual; the adapter launches the configured CLI in a server-owned PTY in the worktree; waits for readiness; injects the task prompt; tracks busy state via telemetry; on done, the normal pipeline continues (validator, in-review, auto-merge). + - **Covers:** R1, R4, R5, R6, R10. +- F2. Waiting on input + - **Trigger:** The CLI agent blocks mid-task (permission prompt, clarifying question). + - **Steps:** Telemetry (or heuristic) detects waiting-on-input; the task surfaces the state and fires the notification configured on the workflow node; the user opens the terminal on any surface, answers, and the agent resumes; state returns to busy. + - **Covers:** R6, R9, R11, R14. +- F3. CLI-backed chat + - **Trigger:** User starts or switches a chat session to a CLI executor. + - **Steps:** Engine spawns (or resumes) the CLI session; chat messages are injected into the CLI; the conversation renders as a structured transcript from native telemetry; the user can toggle into the raw terminal at any time and type directly; transcript persists as chat history. + - **Covers:** R1, R15, R16. +- F4. Session resume + - **Trigger:** Engine restart, PTY death, or a task/chat reopening an existing session. + - **Steps:** Engine looks up the persisted native session ID; relaunches the CLI with its resume mechanism in the same worktree; telemetry re-attaches; if resume fails, the task or chat surfaces needs-attention instead. + - **Covers:** R7, R8. +- F5. Multi-surface attach + - **Trigger:** User opens a running session from another surface (e.g., phone). + - **Steps:** The surface attaches to the same server-owned PTY; output streams live; input from any attached surface reaches the session; detaching never kills the session. + - **Covers:** R4, R14. + +--- + +## Requirements + +**Executor model** + +- R1. A task's execute step, planning session, validator run, Compound Engineering plugin session, and chat session can each specify executor type `cli-agent` with a chosen CLI adapter, alongside the existing executor kinds (model, agent, skill, and the non-interactive `cli` script runner, from which `cli-agent` is distinct). +- R2. For task surfaces (execute, planning, validator), CLI executor selection and attention/notification behavior are configured on the workflow node, with per-task override. Chat and CE plugin sessions are not workflow nodes: they select their CLI executor in per-session settings. +- R3. CLI support is an adapter registry. Claude Code ships as a verified native-telemetry adapter; Codex, Droid, and Pi are native-telemetry targets contingent on a planning-time verification gate (telemetry + resume), and any that fail verification launch on the generic tier instead. Any other CLI command can run through the generic PTY adapter (heuristic state detection, raw-terminal-only view). + +**Session management** + +- R4. CLI sessions are server-owned PTYs bound to their task or chat entity: they survive client disconnects and browser refreshes, and any number of surfaces can attach concurrently. +- R5. The adapter detects CLI readiness before injecting a prompt, and defines the canonical safe injection format for its CLI; all injected text — task prompts and chat-composed messages alike — is escaped and delivered per that format (no premature or interleaved injection, no raw control-sequence passthrough). +- R6. The engine tracks each session's agent state — starting, ready, busy, waiting-on-input, done/idle, dead — via the adapter's native telemetry, falling back to idle heuristics for generic adapters. A positive completion signal is distinct from mere idleness; adapters report which of the two they observed. +- R7. The adapter captures the CLI's native session identity and persists it with the Fusion session record. +- R8. After engine restart or PTY death, the engine resumes the session via the CLI's native resume mechanism in the same worktree; if resume fails, the owning task or chat surfaces needs-attention. Resume restores conversation context, not in-flight work: behavior for an action interrupted mid-flight (mid-tool-call or mid-edit at death) is explicitly defined, and worktree state is reconciled at resume rather than assumed clean. +- R9. The user can type into the session at any time. Non-interference is a designed behavior, not an assumption: engine injection and human keystrokes are serialized on the shared PTY input stream (injection only occurs in detected ready/quiet windows, never mid-keystroke), and user input that changes the agent's state is reflected back into state tracking via telemetry. +- R17. Attaching to a session from any surface requires the same authentication that governs other dashboard access; a session ID alone is never sufficient authorization, and sessions are accessible only to their owning authenticated user or workspace member. +- R18. The engine enforces a configurable per-node limit on concurrent CLI sessions with a defined behavior at the ceiling (queue, or reject with a clear error) — never silent degradation. +- R19. Stall backstop: a session showing no output progress beyond a configurable threshold without a detected done or waiting-on-input signal surfaces needs-attention, bounding the cost of a missed detection. + +```mermaid +stateDiagram-v2 + [*] --> starting + starting --> ready: readiness detected + ready --> busy: prompt injected + busy --> waitingOnInput: approval / question detected + waitingOnInput --> busy: user answers + busy --> done: completion detected + done --> busy: follow-up prompt + busy --> dead: PTY/engine death + waitingOnInput --> dead: PTY/engine death + dead --> busy: native resume + dead --> needsAttention: resume failed + done --> [*] +``` + +**Pipeline integration** + +- R10. A CLI-executed task follows the full task lifecycle: worktree/branch setup, completion detection advancing the task to the next stage, validator, in-review, and auto-merge behave as they do for model-executed tasks. +- R11. When a session enters waiting-on-input, Fusion fires a notification according to the workflow node's configuration. +- R12. A validator run on a CLI executor produces the same verdict contract (pass / fail / blocked / error) as a model-executed validator run. +- R20. Advancement that leads toward merge (execute → validator → in-review/auto-merge) requires a positive completion signal from the adapter's native telemetry; idleness alone never advances a task. On the generic heuristic tier, idle-based completion requires explicit user confirmation before the task leaves the execute step; idle without a completion signal surfaces needs-attention instead of advancing. + +**Per-CLI configuration** + +- R13. Each adapter exposes launch configuration — command, arguments, permission/autonomy mode — with shipped defaults, editable in settings at the adapter level. +- R21. Autonomy/permission launch flags above an adapter's shipped baseline are privileged settings (workspace-administrator editable), and a session's active autonomy posture is visibly surfaced wherever its terminal renders. +- R22. Each adapter defines an explicit environment allowlist for its spawned CLI process; Fusion service credentials (API keys, tokens, database paths) are never forwarded into CLI-agent PTY environments. + +**Surfaces** + +- R14. The desktop dashboard task card, mobile, and the TUI surface each provide a live interactive terminal attached to the task's session. +- R15. A CLI-backed chat session renders as a structured transcript with the standard chat composer injecting into the session, plus a toggle to a raw interactive terminal view. +- R16. The structured transcript persists as the chat session's history, available after the session ends and across surfaces. Persistence reuses the existing chat-history storage layer (no parallel history store), and transcripts are sensitive data inheriting the originating session's access controls; retention specifics are a planning question. + +--- + +## Acceptance Examples + +- AE1. **Covers R6, R10.** Given a CLI task whose agent finishes its work and goes idle with a completed result, when the adapter reports done, then the task advances out of the execute step into the normal validator/in-review flow without user action. +- AE2. **Covers R6, R11.** Given a Claude Code task configured interactive, when the CLI shows a permission prompt, then the session state becomes waiting-on-input and the notification configured on the workflow node fires; the task does not advance and is not marked failed. +- AE3. **Covers R7, R8.** Given an in-progress CLI task whose engine restarts, when the engine comes back up, then the session relaunches with the CLI's native resume and the agent retains its prior conversation context; the task remains in-progress. +- AE4. **Covers R3.** Given a CLI with no native adapter launched via the generic adapter, when its session runs, then the user gets a raw interactive terminal and heuristic idle-based state, and no structured transcript is shown. +- AE5. **Covers R9.** Given a busy CLI task session, when the user types guidance directly into the terminal mid-run, then the agent receives it, state tracking continues, and subsequent completion detection still advances the task normally. +- AE6. **Covers R4, R14.** Given a CLI session started from the desktop, when the user opens the same task on mobile, then the same live terminal renders there and input from either surface reaches the one session. +- AE7. **Covers R15, R16.** Given a CLI-backed chat session, when the user toggles between transcript and terminal views, then both reflect the same underlying session, and the transcript persists as chat history after the session ends. + +--- + +## Scope Boundaries + +Deferred for later: + +- Agent-side completion protocol (instructing the agent to run a command when done) — a reliability layer on top of telemetry, not core. +- CLI executors for arbitrary workflow script/prompt nodes — v1 surfaces are the execute step, planning, validator, CE plugin sessions, and chat. +- Structured transcripts for generic-adapter CLIs (screen-output parsing) — generic tier is raw terminal only. +- Multi-user collaborative co-driving semantics (presence, input arbitration) — v1 assumes the single-developer persona; concurrent attach is supported but unmediated. + +--- + +## Dependencies / Assumptions + +- The chosen CLIs are installed and authenticated on the node where the engine runs; Fusion does not manage CLI installation or vendor auth in v1. +- Each launch CLI offers usable native telemetry (hooks and/or structured session logs) and a session-resume mechanism. Verified for Claude Code (hooks, JSONL transcripts, `--resume`). Codex, Droid, and Pi verification is an explicit planning gate (per R3): each must demonstrate telemetry and resume before shipping at the native tier, with the generic tier as the defined fallback. +- Existing engine PTY infrastructure (node-pty) and realtime transport (SSE/WebSocket) can carry interactive terminal streams to all three surfaces. +- Mobile interactive terminal is feasible within the existing mobile web constraints (virtual keyboard handling is a known hard area). + +--- + +## Outstanding Questions + +Deferred to planning: + +- Exact telemetry mechanism per CLI (hook events vs session-log tailing vs both) and what each CLI's resume supports. +- Idle-heuristic thresholds and prompt-pattern sets for the generic adapter. +- How planning and validator sessions map onto each CLI (interactive session vs the CLI's non-interactive/one-shot mode) while keeping the terminal visible. +- Transcript persistence format and its relationship to existing chat history storage. +- Concurrency/resource limits for simultaneous PTY sessions per node. + +--- + +## Sources / Research + +- Orca behavior (inspected locally): per-CLI agent hook scripts (`~/.orca/agent-hooks/*.sh`) POST native CLI telemetry payloads — including the CLI's session ID — to a local endpoint keyed by pane/tab/worktree; terminals carry stable runtime handles; workspace session state (tabs, agent association) persists and restores across restarts; `orca terminal wait --for tui-idle` exposes idle detection as a primitive. +- Existing executor/runtime seam: `packages/engine/src/runtime-resolution.ts`, `packages/engine/src/agent-runtime.ts`, `packages/engine/src/executor.ts`; headless CLI adapters already exist (`plugins/fusion-plugin-acp-runtime`, `packages/pi-claude-cli`, `packages/droid-cli`) — precedent for adapters, but none expose an interactive PTY. +- PTY and transport infrastructure: `packages/dashboard/src/terminal-service.ts` (node-pty session manager, backend-only today), SSE buffers (`packages/dashboard/src/sse-buffer.ts`) and WebSocket manager (`packages/dashboard/src/websocket.ts`). +- Workflow graph context: execute-node seam and node-level configuration per `docs/workflow-steps.md` (workflow IR, columns/traits, step instances) — the natural home for R2. + +--- + +## Deferred / Open Questions + +### From 2026-06-04 review + +- **Mobile interactive terminal fallback posture** — Surfaces / Dependencies (P1, design-lens, scope-guardian, confidence 100) + + R14 commits full mobile interactive terminal while the dependencies section acknowledges mobile virtual-keyboard handling as a known hard area. No fallback posture is defined if full interactivity slips — e.g., a read-only terminal stream with a simplified input affordance and desktop handoff. + + + +- **Terminal embed placement on task card/detail view** — Surfaces (P1, design-lens, confidence 100) + + The requirements give no product-level guidance on where the terminal lives in the existing task detail structure — a new tab, replacing the log viewer, or an overlay. Different implementers will independently invent the placement, producing inconsistent UX across surfaces. + + + +- **Composer behavior in raw-terminal chat mode** — Surfaces (P1, design-lens, confidence 100) + + R15 defines the transcript/terminal toggle but not what happens to the standard chat composer when raw-terminal mode is active. If the composer stays visible alongside a terminal that also accepts input, two competing input paths exist simultaneously. + + + +- **Waiting/needs-attention surfacing vs existing stall badges** — Session management (P1, design-lens, confidence 100) + + waiting-on-input and needs-attention are new task-card states with no defined visual relationship to the existing staleness, stuck, and stalled-review signals. Implementers will invent badges that may collide with the existing signal system. + + + +- **Generic-adapter empty-state where transcripts render** — Requirements (P2, design-lens, confidence 75) + + AE4 specifies generic-adapter sessions show no structured transcript, but not what users see where a transcript normally renders — hidden panel, explanatory message, or absent toggle. Each surface rendering transcripts must handle this fallback state consistently. + + diff --git a/docs/plans/2026-06-04-002-feat-cli-agent-executor-plan.md b/docs/plans/2026-06-04-002-feat-cli-agent-executor-plan.md new file mode 100644 index 0000000000..da0d418ce9 --- /dev/null +++ b/docs/plans/2026-06-04-002-feat-cli-agent-executor-plan.md @@ -0,0 +1,488 @@ +--- +title: "feat: Add cli-agent executor with interactive PTY sessions" +type: feat +status: active +date: 2026-06-04 +deepened: 2026-06-04 +origin: docs/brainstorms/2026-06-04-cli-executor-requirements.md +--- + +# feat: Add cli-agent executor with interactive PTY sessions + +## Summary + +Add a new executor kind, `cli-agent`, that runs Fusion agent sessions inside engine-owned PTYs running interactive CLI coding agents (Claude Code, Codex, Droid, Pi). The engine injects prompts, tracks agent state through per-CLI native telemetry adapters, captures native session IDs for resume, and drives the full task pipeline; users co-drive through live terminals on the dashboard, mobile, and TUI, and chat gains a hybrid transcript mode. + +--- + +## Problem Frame + +Fusion drives agents only through API-backed runtimes today, but the primary persona lives in CLI coding agents — their subscriptions, auth, config, and skills are attached to the CLI tools. There is no way to make a board task's execution *be* a Claude Code or Codex session: visible, steerable, co-drivable, and resumable. Orca demonstrates the target model (hooks-based telemetry, session-id capture, resume); the origin doc pins the product behavior. This plan defines how it lands in the Fusion codebase. + +--- + +## Requirements + +Carried from origin (see origin: docs/brainstorms/2026-06-04-cli-executor-requirements.md); the origin's R-IDs are authoritative and referenced by units below. + +**Executor model** — origin R1–R3: `cli-agent` selectable on the task execute step, planning, validator, CE plugin sessions, and chat; workflow-node configuration for task surfaces with per-task override, per-session for chat/CE; adapter registry with Claude Code verified native, Codex/Droid/Pi gated, generic PTY fallback. + +**Session management** — origin R4–R9, R17–R19: server-owned reattachable PTYs; safe readiness-gated injection; telemetry-driven state machine; native session-id capture and resume with worktree reconciliation; designed injection/keystroke serialization; attach auth; per-node concurrency limit; stall backstop. + +**Pipeline integration** — origin R10–R12, R20: full task lifecycle; waiting-on-input notifications per workflow-node config; validator verdict contract preserved; positive-completion-signal gating before merge-bearing advancement. + +**Per-CLI configuration** — origin R13, R21–R22: adapter-level launch config with shipped defaults; privileged autonomy flags with visible posture; per-adapter env allowlist. + +**Surfaces** — origin R14–R16: interactive terminal on dashboard, mobile, TUI; chat hybrid transcript with raw-terminal toggle; transcript persistence reusing chat history storage. + +The CLI verification gate origin R3 requires has been run (see Sources): all four CLIs pass for the native tier — Codex with a hybrid caveat (no native waiting-on-input signal), Droid with message-parsing on its `Notification` hook. + +--- + +## Key Technical Decisions + +- **Engine-owned `CliAgentAdapter` abstraction, not an `AgentRuntime` plugin.** The existing runtime contract (`packages/engine/src/agent-runtime.ts`: `createSession`/`promptWithFallback`/`describeModel`) is API-shaped and cannot model a PTY stream, two-way co-driving, or resume. A new adapter interface (spawn command/args, telemetry wiring, state classification, resume invocation, injection formatting) lives in the engine; the four launch adapters ship as engine code. A plugin contribution point for third-party adapters is deferred to follow-up — this avoids the 5-list bundled-plugin registration burden (see `docs/solutions/integration-issues/bundled-plugin-registration-drift.md`) while keeping the registry shape open. +- **Executor identifier is `cli-agent`.** `executorKind === "cli"` already exists in `packages/engine/src/executor.ts` (`runGraphCustomNode`) as the non-interactive script runner. The new kind branches alongside `model`/`agent`/`skill`/`cli` and routes to the PTY session path — never into `executeWorkflowStep`. +- **Resume-the-CLI architecture; SIGKILL registry is the authoritative teardown.** PTYs cannot survive engine process death (no detached broker in v1). Recovery is relaunching the CLI with its native resume mechanism (`claude --resume`, `codex resume`, `droid exec -s`, `pi --session`) in the same worktree. Teardown follows the ACP precedent: process registry with scoped SIGKILL on engine exit, graceful close opportunistic, never targeting port 4040. +- **Termination taxonomy and resume-eligibility predicate.** Every PTY end is classified — `completed` (positive done signal), `userExited` (clean child exit mid-task), `killed` (hard cancel / column exit), `crashed` (signal/nonzero exit), `authFailed` (credential pattern), `engineDeath` (found dead on restart). Only `engineDeath` and `crashed` are resume-eligible, capped at 2 attempts with backoff; `killed` and `userExited` never auto-resume; `authFailed` goes straight to needs-attention with a re-authenticate message. The classification persists on the session record so self-healing sweeps cannot resurrect a cancelled session. +- **Telemetry tiering with per-adapter capability flags.** Adapters declare which states they detect natively. Claude Code: full hooks (`Stop`, `Notification`, `PermissionRequest`, `session_id` in every payload). Codex: native turn-complete via `notify` config only — waiting-on-input falls back to PTY prompt-pattern detection (hybrid tier). Droid: Claude-style hooks, but `Notification` conflates permission/idle and requires message parsing. Pi: event stream / session JSONL. Generic adapter: output-quiet heuristics, raw-terminal-only. Hook scripts POST to a **dashboard-served localhost endpoint that forwards to the engine telemetry hub** (the engine has no HTTP server — only the dashboard serves HTTP; the Orca pattern, adapted). The engine mints a high-entropy per-session hook token at spawn; the dashboard route validates it against the engine-held registry and rejects Origin/Host headers from browser contexts — localhost is not a trust boundary, and a forged completion event would otherwise drive pipeline advancement (see Risks). +- **PTY ownership: node-pty becomes an engine dependency, with the native-asset machinery extracted to a shared utility living in the engine.** The PTY-fragility apparatus in `packages/dashboard/src/terminal-service.ts` (prebuild path resolution, dlopen fallback, permission repair for packaged binaries) is extracted into a shared module **in `@fusion/engine`** consumed by both the engine's `CliSessionManager` and the dashboard terminal service (the dashboard already depends statically on `@fusion/engine`; `@fusion/core` never takes node-pty — a native dep in core would transitively reach every core consumer). Binary-release validation of engine-side PTY spawn is an explicit acceptance item (U16), not deferred polish. Alternative rejected: dashboard-injected PTY service — it would invert the ownership the whole design rests on (engine owns sessions). +- **The engine `CliSessionManager` exposes an explicit async interface, not EventEmitter callbacks.** Attach returns scrollback + an async byte stream; write/resize/requestPause/requestResume are methods. The engine is the sole owner of the scrollback ring buffer and of watermark-driven PTY pause/resume; the dashboard WS layer forwards ACK credit and never buffers bytes itself. This keeps the engine↔dashboard seam process-split-credible (today's terminal-service EventEmitter shape would not survive a split). +- **Positive completion signal gates pipeline advancement (origin R20).** Idle never advances a task. Native adapters advance on their done event; the generic tier surfaces an idle-based "looks done — confirm to advance" affordance; idle without signal beyond the stall threshold → needs-attention. waiting-on-input suppresses the existing stuck-task detector (expected idleness). +- **Separate per-node PTY concurrency pool.** CLI sessions hold slots for human-paced durations; they get their own configurable ceiling (default modest, reject-with-error at ceiling) instead of consuming `AgentSemaphore` execute slots, so a watched terminal never starves model-executor throughput. Resume-on-restart respects the same ceiling (queue beyond it). +- **Transport: WebSocket for terminal bytes, SSE for state.** Terminal I/O extends the existing `/api/terminal/ws` upgrade path (JSON-framed scrollback/data messages, daemon-token authenticated at upgrade, project-scoped) with CLI-session attach; agent-state transitions (`cli:session:state`) ride the existing SSE event bus so cards/banners update without touching the byte stream. Attach auth rides the existing single daemon-token model + project scoping — a per-user/workspace-member model does not exist in the codebase and is explicitly deferred; origin R17's intent (session ID alone is never authorization) is satisfied by token-gated upgrade. +- **Privileged autonomy flags map to the approval-gate precedent, not a new role system.** Launch configs above an adapter's shipped baseline (e.g. `--dangerously-skip-permissions`, `codex --full-auto`) require an explicit stored approval per project — same shape as `isWorkflowCliCommandApproved` for raw workflow commands — and the active posture renders as a visible chip wherever the terminal renders (origin R21). A real admin role is out of scope. +- **Validator and planning run the CLI's non-interactive one-shot mode with a read-only terminal.** One-shot invocations (`claude -p`, `codex exec --json`, `droid exec`, pi headless) yield deterministic output for the pass/fail/blocked/error verdict contract (origin R12) while the PTY output still streams to a read-only terminal view for observability. Interactive co-driving is execute-step and chat only. +- **Chat transcripts reuse `chat_sessions`/`chat_messages`.** The structured transcript is parsed from native telemetry (transcript JSONL tail / event stream) into `chat_messages` rows; the native session id persists on the session record (the `chat_sessions.cliSessionFile` column is existing precedent). Injection from the composer and raw keystrokes share one FIFO per session; the composer shows a queued state while the agent is busy. +- **UI placements (resolves the origin's deferred design questions).** Task detail: a new `terminal` tab in `TaskDetailModal`'s `TabId` union (Logs tab unchanged). Chat: raw-terminal mode replaces the message list and hides the composer (the terminal owns input; a toggle returns to transcript view). waiting-on-input / needs-attention surface as a task-card badge plus the existing `SessionNotificationBanner` — distinct from staleness/stall badges, which are suppressed while waiting. Generic-adapter sessions render terminal-only (no transcript pane, no toggle). Mobile ships the interactive terminal with a visible input field + accessory key bar (Esc/Tab/Ctrl/arrows) — xterm's hidden-textarea input is unreliable on mobile; if interactivity slips during implementation, the defined fallback is read-only stream + input field. +- **Client terminal stack: `@xterm/xterm` 6.x** with fit, webgl (with context-loss fallback to DOM renderer), unicode11 addons; custom WS bridge (not `addon-attach` — no flow control); server-side byte ring buffer replay on attach; resize policy: latest-active-client wins (single-developer multi-surface), debounced. ACK-based backpressure (pause/resume PTY on watermarks). +- **No web-push in v1.** Origin R11's notification fires through the existing in-app surfaces (SSE-driven banner/badge + OS-level notification where the desktop shell supports it); workflow-node config selects banner-only vs. banner+notify. Push infra is a deferred follow-up. + +--- + +## High-Level Technical Design + +Component topology: + +```mermaid +flowchart TB + subgraph engine [Engine] + SEAM[Executor seam
execute / stepExecute / validator / planning] + CSM[CliSessionManager
PTY spawn, ring buffer, injection FIFO,
process registry, concurrency pool] + HUB[Telemetry hub
hook endpoint + log tailers
state machine, stall backstop] + AD[Adapter registry
claude-code / codex / droid / pi / generic] + DB[(cli_sessions table
+ chat_messages)] + end + subgraph surfaces [Surfaces] + WEB[Dashboard terminal tab + chat] + MOB[Mobile terminal] + TUI[TUI passthrough] + end + CLI[CLI process in task worktree
claude / codex / droid / pi] + SEAM --> CSM + CSM --> AD + CSM <-->|PTY| CLI + HOOKR[Dashboard hook route
per-session token + Origin check] + CLI -->|hook POSTs| HOOKR + HOOKR -->|forward| HUB + CLI -->|session logs tail| HUB + HUB --> DB + HUB -->|SSE cli:session:state| surfaces + CSM <-->|WS bytes + input| surfaces + HUB -->|done / waiting| SEAM +``` + +Session state machine with termination taxonomy (extends the origin's R6 diagram; prose in Key Technical Decisions is authoritative): + +```mermaid +stateDiagram-v2 + [*] --> starting + starting --> ready: readiness detected + ready --> busy: prompt injected + busy --> waitingOnInput: permission / question signal + waitingOnInput --> busy: user answers + busy --> done: positive completion signal + done --> busy: follow-up (resume first if PTY reaped) + busy --> dead: PTY end / engine death + waitingOnInput --> dead: PTY end / engine death + state dead_classify <> + dead --> dead_classify + dead_classify --> killed: hard cancel / column exit + dead_classify --> userExited: clean exit mid-task + dead_classify --> authFailed: credential failure + dead_classify --> resuming: crash / engine death + resuming --> busy: native resume ok + resuming --> needsAttention: 2 attempts exhausted + userExited --> needsAttention: advance / retry / cancel prompt + authFailed --> needsAttention: re-authenticate message + done --> [*] + killed --> [*] +``` + +Attach + injection sequence (execute-step happy path): + +```mermaid +sequenceDiagram + participant E as Engine seam + participant M as CliSessionManager + participant C as CLI (PTY) + participant H as Telemetry hub + participant S as Surface (xterm) + E->>M: start session (worktree, adapter, node config) + M->>C: spawn via adapter (env allowlist, hooks installed) + C-->>H: SessionStart hook (session_id) + H->>M: ready + M->>C: inject task prompt (bracketed paste if negotiated) + S->>M: WS attach (daemon token at upgrade) + M-->>S: scrollback replay, then live bytes + S->>M: user keystrokes (shared FIFO with engine injections) + C-->>H: Stop hook (positive completion) + H->>E: done → pipeline advances (validator, in-review) + E->>M: reap PTY at in-review handoff +``` + +--- + +## Implementation Units + +Phased: A (engine core) → B (pipeline) → C (transport & surfaces) → D (config & polish). Units are dependency-ordered within phases; Phase A order is U16 → U1 → U2 → U3 → U17 → U4 → U5 → U6. + +### U16. Shared PTY native-asset utility and redactSecrets extraction + +**Goal:** Extract the node-pty loading/permission-repair machinery into an engine-owned shared module consumed by engine and dashboard; extract `redactSecrets` into core; validate engine-side PTY spawn in packaged binaries. +**Requirements:** prerequisite for origin R4 (engine-owned PTYs) and the R16 transcript-redaction mitigation. +**Dependencies:** none. (U16 and U1 are independent and parallelizable; the stated Phase A order is a suggested sequence, not a dependency chain.) +**Files:** `packages/engine/src/pty-native.ts` (new — extracted from `packages/dashboard/src/terminal-service.ts` lines ~21–178), `packages/dashboard/src/terminal-service.ts` (consume the shared module via its existing static `@fusion/engine` dependency), `packages/engine/package.json` (node-pty dependency), `packages/core/src/redact-secrets.ts` (new — extracted from `plugins/fusion-plugin-acp-runtime/src/process-manager.ts`, with the plugin re-importing it), `packages/engine/src/__tests__/pty-native.test.ts`, `packages/core/src/__tests__/redact-secrets.test.ts`. +**Approach:** Move the lazy-load, prebuild path resolution, dlopen fallback, and native-permission repair into one engine utility; the dashboard terminal service keeps identical behavior; node-pty is declared in the engine (never in core — a native dep there would reach every core consumer). `redactSecrets` moves to `@fusion/core` (pure string logic, no native deps) so U12's transcript persistence can import it; the ACP plugin re-imports the shared function. Binary-release validation (Bun-compiled binary spawns a PTY from engine code) is part of this unit's acceptance, exercised via the release-branch `workflow_dispatch` path noted in the release-pipeline learnings. +**Patterns to follow:** existing terminal-service loader; release-pipeline gotchas (never cache node_modules on Windows, native asset staging). +**Test scenarios:** +- Loader resolves node-pty in dev (workspace) mode and packaged-binary mode (fixture paths). +- Dashboard terminal service behavior unchanged (existing terminal tests stay green). +- Permission-repair path exercised on a fixture with broken modes. +- `redactSecrets` parity: shared function produces identical output to the plugin's previous local copy on its existing fixtures; the ACP plugin's tests stay green against the re-import. +**Verification:** existing dashboard terminal tests green against the shared module; packaged-binary PTY smoke recorded as a release-validation checklist item. + +### U1. cli_sessions persistence and session records + +**Goal:** Durable session records carrying identity, state, termination classification, and resume bookkeeping. +**Requirements:** origin R4, R6, R7, R8. +**Dependencies:** none. +**Files:** `packages/core/src/db.ts` (schema v109), `packages/core/src/cli-session-store.ts` (new), `packages/core/src/cli-session-types.ts` (new), `packages/core/src/__tests__/cli-session-store.test.ts`, `packages/core/src/__tests__/db-migrate.test.ts` (extend). +**Approach:** New `cli_sessions` table following the `ai_sessions`/`chat_sessions` patterns: TEXT PK, owning entity (taskId | chatSessionId | purpose), projectId, adapterId, agent state, termination reason, native session id, resume attempt count, autonomy posture, worktree path, timestamps. Store class follows `ChatStore` (EventEmitter + SQLite). Migration as an `applyMigration(109, ...)` block. +**Patterns to follow:** `chat_sessions` table + `ChatStore`; `addColumnIfMissing`/`CREATE TABLE IF NOT EXISTS` migration idiom; DB-corruption-resilience posture (integrity-checked, recoverable). +**Test scenarios:** +- Happy path: create/read/update a session record; state transitions persist; native session id round-trips. +- Migration: v108 → v109 migrates cleanly on an existing DB fixture; fresh DB creates the table. +- Edge: termination reason and resume attempt count update atomically with state; querying sessions by task and by chat entity. +- Error: invalid state value rejected at the store boundary. +**Verification:** store tests green; migration test proves both upgrade and fresh-create paths. + +### U2. CliSessionManager and CliAgentAdapter interface + +**Goal:** Engine-owned PTY lifecycle: spawn, ring-buffer scrollback, injection FIFO, resize, process registry teardown, concurrency pool. +**Requirements:** origin R4, R5, R9, R18, R22. +**Dependencies:** U1, U16. +**Files:** `packages/engine/src/cli-agent/adapter.ts` (new — interface + registry), `packages/engine/src/cli-agent/session-manager.ts` (new), `packages/engine/src/cli-agent/__tests__/session-manager.test.ts`, `packages/engine/src/cli-agent/__tests__/adapter-registry.test.ts`. +**Approach:** Adapter interface declares: launch command/args builder (from settings + autonomy posture), env allowlist, capability flags (native done / native waiting / transcript source / resume), readiness detection, injection formatter, resume command builder, telemetry wiring. Injection formatting: bracketed paste only when `?2004h` observed (interleaving safety), and control characters (`\r` beyond intended submits, `\x03`, `\x04`, ESC-prefixed sequences) are stripped/escaped **unconditionally** on the raw fallback path — control-char neutralization is the security control and must hold when paste mode is off. Session manager owns node-pty processes via the U16 shared loader, is the **sole owner** of the byte-bounded scrollback ring and watermark-driven PTY pause/resume (exposing `requestPause`/`requestResume` for transport-layer ACK credit), a single serialized write queue (engine injections + user input share it; injections wait for ready/quiet windows), resize with latest-active-client policy, and a process registry with `process.on("exit")` scoped SIGKILL (never port 4040). Attach surface is an explicit async interface (scrollback fetch + async byte stream + write/resize methods), not EventEmitter callbacks. Separate PTY concurrency pool with configurable ceiling, reject-with-error at ceiling. +**Patterns to follow:** `plugins/fusion-plugin-acp-runtime/src/process-manager.ts` (env allowlist, scoped SIGKILL, self-cleaning registry); `terminal-service.ts` scrollback/throttle machinery; `superviseSpawn` policy from AGENTS.md (route PTY spawn through the sanctioned path or explicit allowlist). +**Test scenarios:** +- Happy path: spawn a fake CLI (scripted PTY child), readiness detected, prompt injected once ready, output lands in ring buffer, clean teardown kills the child. +- Injection serialization: user write queued mid-injection never interleaves bytes; two queued injections deliver in FIFO order; injection deferred while output is streaming. +- Bracketed paste: wrapped only when the child enabled `?2004h`; raw otherwise. +- Control-char neutralization: injected message containing `\x03`, `\x04`, and an ESC sequence is neutralized on the raw (non-paste) path — never reaches the PTY as control input. +- Concurrency: (ceiling = 2) third session rejected with a clear error; slot released on teardown. +- Env: child env contains only the allowlist — assert `FUSION_*` tokens and service credentials absent. +- Teardown: process-registry kill on simulated engine exit leaves no orphans (two-turns-through-one-session test for latched state). +**Verification:** all session-manager tests green with a scripted PTY fixture; no orphan processes after suite run. + +### U3. Telemetry hub and session state machine + +**Goal:** Authoritative agent-state machine with completion gating, stall backstop, and termination classification — pure engine code, fixture-driven, no HTTP. +**Requirements:** origin R6, R19, R20; flows F1, F2. +**Dependencies:** U1, U2. +**Files:** `packages/engine/src/cli-agent/telemetry-hub.ts` (new — ingestion contract + per-session token registry), `packages/engine/src/cli-agent/state-machine.ts` (new), `packages/engine/src/cli-agent/__tests__/state-machine.test.ts`, `packages/engine/src/cli-agent/__tests__/telemetry-hub.test.ts`. +**Approach:** The hub exposes an in-process ingestion contract consumed by the U17 route and by adapters that tail logs (Codex rollout, Pi JSONL). It mints high-entropy per-session hook tokens at spawn (registry keyed by session id, invalidated on session end; on engine restart the registry is rebuilt only from sessions still live in `cli_sessions`, so stale on-disk tokens never validate) that U17 validates. State machine implements the HTD diagram including the dead-classification choice and resume-attempt caps; emits `cli:session:state` SSE events (throttled) and persists transitions via U1. Positive-completion distinct from idle; stall backstop (no output progress past configurable threshold without done/waiting) → needsAttention. Inactivity watchdog re-armed by telemetry/output events — no fixed turn timeout. Bound and sanitize everything ingested: per-chunk and per-turn caps, ANSI/control stripping before pattern matching, redaction across chunk boundaries. +**Patterns to follow:** `docs/solutions/architecture-patterns/observable-long-running-agent-turns-through-blocking-plugin-route-seam.md` (push channel additive, deltas not snapshots, never-reject detached turns, inactivity watchdog, throttled SSE); ACP event-bridge bounding rules. +**Test scenarios:** +- Covers AE1. Native done signal advances state to done; idle alone never does. +- Covers AE2. Permission-prompt signal → waitingOnInput; notification dispatch invoked per node config; state does not advance or fail. +- Stall backstop: quiet session with no signals past threshold → needsAttention; busy session streaming output never trips it. +- Termination classification: clean exit-0 mid-task → userExited; SIGKILL from hard cancel → killed (no resume); nonzero exit → crashed (resume-eligible); credential-failure pattern in output → authFailed. +- Resume caps: two failed resumes → needsAttention, third never attempted. +- Token registry: token validates only for its own session; invalidated after session end; a forged completion for session A using session B's token is rejected. +- Two-turns-through-one-handler: per-turn latches/budgets reset between turns. +**Verification:** state-machine tests cover every edge in the HTD diagram, with no HTTP involved. + +### U17. Hook ingestion route and token lifecycle + +**Goal:** Dashboard-served localhost endpoint that authenticates hook POSTs and forwards them to the engine telemetry hub. +**Requirements:** origin R6 (telemetry delivery), R17 principle applied to the hook channel. +**Dependencies:** U3. +**Files:** `packages/dashboard/src/routes/cli-agent-hooks.ts` (new), Fusion-provided hook scripts/notify shim under `packages/engine/src/cli-agent/hook-scripts/` (new), `packages/dashboard/src/routes/__tests__/cli-agent-hooks-route.test.ts`. +**Approach:** Route validates the per-session token against the U3 engine-held registry (session id alone never sufficient), rejects browser-context requests (Origin/Host header check — localhost is not a trust boundary; a page must not be able to CSRF the endpoint), caps payload size, and forwards validated payloads in-process to the hub. Hook scripts (Orca `~/.orca/agent-hooks/*.sh` shape) carry the token via session-scoped env/config; **the session-scoped hook config directory is deleted on session end**, and tokens are registry-invalidated at the same moment, so the at-rest exposure is bounded to the session's lifetime. +**Test scenarios:** +- Valid token + session → forwarded to hub; state visible downstream. +- Missing/wrong/expired token → 401; valid-format token for the wrong session → rejected. +- Request with a browser `Origin` header → rejected; oversized payload → capped/rejected. +- Unknown session key is a no-op, not a crash. +- Lifecycle: session end deletes the hook config dir and invalidates the token; a replayed POST with the old token is rejected; after engine restart, tokens for non-live sessions are rejected. +**Verification:** route tests prove auth, CSRF rejection, lifecycle cleanup, and bounding against a stub hub. + +### U4. Claude Code adapter (native tier) + +**Goal:** Reference native adapter: hooks installed per session, JSONL transcript tail, session-id capture, resume. +**Requirements:** origin R3, R5–R8; AE3. +**Dependencies:** U2, U3. +**Files:** `packages/engine/src/cli-agent/adapters/claude-code.ts` (new), `packages/engine/src/cli-agent/adapters/__tests__/claude-code.test.ts`. +**Approach:** Launch with per-session hook config (settings-dir scoped to the session, never mutating the user's global `~/.claude` hooks — additive project/local hook config) wiring `Stop`, `Notification`, `PermissionRequest`, `SessionStart` to the U17 endpoint with the U3-minted session token; capture `session_id` from the first payload; transcript content from the JSONL at `transcript_path` for chat transcripts; resume via `--resume ` (confirm `SessionStart.source === "resume"`); waiting-on-input from `PermissionRequest`/`Notification` types. Verify actual hook roster against the installed version at scaffold time (smoke test), per the SDK-authoritative learning. +**Patterns to follow:** ACP `cli-spawn.ts` settings resolution; the plugin-skills learning's `assertPluginLocalTarget` posture (never write to global agent config dirs). +**Test scenarios:** +- Happy path: simulated hook payload sequence (SessionStart → PreToolUse → Stop) drives ready→busy→done; session_id persisted on first payload. +- Covers AE3. Kill PTY, resume builder produces `--resume `, simulated `SessionStart{source:resume}` re-attaches telemetry; state returns to busy. +- Waiting: `PermissionRequest` payload → waitingOnInput; `Notification{idle_prompt}` → waitingOnInput. +- Edge: hook payload missing optional fields tolerated; hooks config written only to session-scoped location. +**Verification:** adapter tests green against recorded payload fixtures; a manual smoke run against a real `claude` binary is an explicit implementation-time checklist item (not CI). + +### U5. Codex, Droid, and Pi adapters + +**Goal:** Remaining launch adapters at their verified tiers. +**Requirements:** origin R3, R5–R8. +**Dependencies:** U2, U3, U4 (patterns). +**Files:** `packages/engine/src/cli-agent/adapters/codex.ts`, `packages/engine/src/cli-agent/adapters/droid.ts`, `packages/engine/src/cli-agent/adapters/pi.ts` (new), with sibling `__tests__/` files per adapter. +**Approach:** Codex (hybrid tier): `notify` config invokes a Fusion-provided program POSTing `agent-turn-complete` (carries `thread-id` as session id); waiting-on-input via PTY prompt-pattern detection (ANSI-stripped, spinner-aware); resume via `codex resume `; rollout JSONL path treated as version-sensitive (probe, don't hardcode). Droid: Claude-style hooks; parse `Notification.message` to split permission vs idle (documented gap); resume interactive `--resume ` / headless `exec -s ` (never `-r` in exec mode). Pi: `--mode json` event stream or session-JSONL tail; session file/id via session dir; resume `--session `. Each adapter declares honest capability flags so the UI can render tier differences. +**Test scenarios (per adapter):** +- Done signal: simulated native event → done. +- Waiting: Codex prompt-pattern fixture (ANSI noise included) → waitingOnInput; Droid permission-vs-idle message fixtures classified correctly; Pi `input` event → waitingOnInput. +- Resume: builder produces the correct CLI invocation per mode; Droid exec-mode `-r` footgun explicitly asserted absent. +- Session-id capture from each CLI's native source. +- Covers AE4 (boundary): an adapter with native flags disabled behaves identically to the generic tier. +**Verification:** fixture-driven tests per adapter; per-CLI manual smoke runs are implementation-time checklist items. + +### U6. Generic PTY adapter (heuristic tier) + +**Goal:** Any CLI command runs with output-quiet idle heuristics and raw-terminal-only presentation. +**Requirements:** origin R3, R6, R20; AE4. +**Dependencies:** U2, U3. +**Files:** `packages/engine/src/cli-agent/adapters/generic.ts` (new), `packages/engine/src/cli-agent/adapters/__tests__/generic.test.ts`. +**Approach:** ANSI-stripped last-screen analysis: prompt-glyph + spinner-override busy detection, configurable quiet-window idle; no native done — idle yields a "confirm to advance" affordance per the R20 decision; no transcript source (capability flags all false). No resume (fresh launch only) — surfaced honestly in UI. +**Test scenarios:** +- Covers AE4. Generic session exposes raw terminal only; no transcript; heuristic idle state reported as idle, never as done. +- Spinner override: prompt visible + spinner animating → busy. +- Quiet window: output silence past threshold → idle; resumed output flips back to busy. +**Verification:** heuristic fixtures (recorded PTY byte streams) classify correctly. + +### U7. Executor seam wiring and task lifecycle integration + +**Goal:** `cli-agent` selectable on workflow nodes; execute step runs through a CLI session honoring cancel/abort/re-entry semantics and pipeline advancement. +**Requirements:** origin R1, R2, R10, R20; F1; AE1, AE5. +**Dependencies:** U1–U4. +**Files:** `packages/engine/src/executor.ts` (seam branch in `runGraphCustomNode` + execute/stepExecute seams), `packages/engine/src/cli-agent/task-session.ts` (new — task↔session orchestration), `packages/core/src/workflow-ir-types.ts` (node config additions), `packages/engine/src/__tests__/cli-agent-executor.test.ts`, `packages/engine/src/cli-agent/__tests__/task-session.test.ts`. +**Approach:** Node config gains `executor: "cli-agent"`, adapter id, autonomy posture ref, and attention/notification settings (origin R2, R11); per-task override follows existing per-task settings precedent. Live sessions snapshot their resolved executor at launch — node-config edits apply to the next run only. Hard cancel (`moveTask(in-progress→todo)`) and column-exit abort SIGKILL the PTY tree, mark `killed`, release the pool slot, and never resume. Done (per R20 gating) advances the normal pipeline; PTY is reaped at the execute→in-review handoff (autoMerge:false tasks don't hold slots). Re-plan/RETHINK re-entry launches fresh (context reset); follow-up to a done-but-reaped session resumes first, then injects. +**Patterns to follow:** existing `agent`/`skill`/`cli` kind branches in `runGraphCustomNode`; `active-session-registry` worktree-keyed ownership; `moveTask` hard-cancel contract (AGENTS.md). +**Test scenarios:** +- Covers AE1 / F1. Execute step with cli-agent node: worktree session spawns, prompt injected, simulated done advances task to validator/in-review; PTY reaped at handoff. +- Covers AE5. Simulated user input mid-busy: state tracking continues; subsequent done still advances. +- Hard cancel: moveTask in-progress→todo kills PTY, session `killed`, no resume on next self-healing sweep, slot released. +- Re-entry: needs-replan re-entering execute starts a fresh session; follow-up on done task resumes the recorded session id. +- Node-config edit mid-run: live session keeps launch-time executor; next run uses the new config. +- Ceiling: execute step at PTY-pool ceiling surfaces a clear queued/rejected state, task does not silently stall. +**Verification:** engine integration tests with scripted adapters prove the full lifecycle without real CLIs. + +### U8. Resume, restart recovery, and self-healing integration + +**Goal:** Engine restart finds dead sessions and resumes per the eligibility predicate; failures surface as needs-attention; existing sweeps respect CLI semantics. +**Requirements:** origin R7, R8, R19; F4; AE3. +**Dependencies:** U1–U4, U7. +**Files:** `packages/engine/src/cli-agent/resume-coordinator.ts` (new), `packages/engine/src/self-healing.ts` (CLI-session awareness), `packages/engine/src/stuck-task-detector.ts` (suppress while waitingOnInput), `packages/engine/src/cli-agent/__tests__/resume-coordinator.test.ts`, `packages/engine/src/__tests__/self-healing-cli-sessions.test.ts`. +**Approach:** On engine start, sessions persisted as live are classified `engineDeath` and queued for resume (respecting the pool ceiling); resume relaunches via the adapter's resume builder in the recorded worktree, reconciles worktree state (dirty-tree detection logged, surfaced on the session), re-attaches telemetry, and re-injects nothing (replay-suppression: scrollback replays to viewers, but no prompt re-injection). **Worktree-existence precondition:** the resume coordinator verifies the recorded worktree still exists before relaunch — a missing worktree routes to needsAttention, never a CLI spawned into a vanished directory; conversely, self-healing's idle-worktree sweeps (`enforceWorktreeCap`, `scanIdleWorktrees`) must treat a worktree backing a resume-eligible `cli_sessions` record as in-use, so a reaped-but-resumable session (e.g. done task awaiting a follow-up) cannot have its worktree reclaimed out from under it. Eligibility predicate per the termination-taxonomy KTD; attempt cap 2 with backoff; exhaustion or missing vendor session store → needsAttention. waitingOnInput suppresses stuck/inactivity detection; R19's stall backstop is the only escalation path while waiting. +**Test scenarios:** +- Covers AE3 / F4. Simulated engine restart with a live session record: resume invoked with the recorded native id; state returns busy; task stays in-progress. +- Eligibility: `killed` and `userExited` records are never resumed by sweeps; `authFailed` goes to needs-attention without a resume attempt. +- Cap: two consecutive resume failures → needsAttention; no third spawn across multiple sweep cycles. +- Missing vendor store: resume command fails immediately → permanent-failure path, not retry loop. +- Suppression: waitingOnInput session not flagged by stuck-task detector; same session trips the stall backstop only when genuinely quiet. +- Dirty worktree at resume: flagged on the session record, resume proceeds, flag visible to UI. +- Missing worktree at resume: routes to needsAttention without spawning; idle-worktree sweep skips a worktree backing a resume-eligible session record. +**Verification:** restart-shaped integration test (new engine instance over the same DB fixture) proves recovery without duplicate sessions. + +### U9. Validator, planning, and CE plugin session support + +**Goal:** The remaining v1 surfaces run on CLI executors in one-shot mode with read-only terminals. +**Requirements:** origin R1, R12. +**Dependencies:** U2–U5, U7, U10 (read-only terminal attach). +**Files:** `packages/engine/src/cli-agent/one-shot-session.ts` (new), validator/planning resolution touchpoints in `packages/engine/src/executor.ts` and `packages/engine/src/interactive-ai-session.ts`, CE plugin seam in `plugins/fusion-plugin-compound-engineering` (session factory option), `packages/engine/src/cli-agent/__tests__/one-shot-session.test.ts`, `packages/engine/src/__tests__/cli-agent-validator.test.ts`. +**Approach:** One-shot mode invokes the adapter's non-interactive form (`claude -p`, `codex exec --json`, `droid exec --output-format json`, pi headless), streams output to a read-only terminal view (same WS channel and attach-ticket route as interactive sessions, with input disabled **server-side**, not just in the client), and parses the structured result. Validator maps the parsed result into the existing pass/fail/blocked/error verdict contract — a malformed/unparseable result is `error`, never a silent pass. Planning sessions persist their output through the existing planning flow. CE plugin sessions thread the executor choice through the session-factory option seam (per the plugin-skills learning: thread options end-to-end, prove with a real loader). +**Test scenarios:** +- Validator verdict mapping: fixtures for pass/fail/blocked outputs per adapter shape; malformed output → error verdict (never pass). +- One-shot lifecycle: session record created, read-only flag set, terminal stream available, reaped on completion. +- Planning: one-shot output lands in the planning flow as a model-backed run would. +- Error path: one-shot CLI nonzero exit → verdict error with stderr (bounded) on the record. +**Verification:** validator integration test proves the verdict contract is indistinguishable from model-executed runs downstream. + +### U10. Transport: WS attach, SSE state events, injection API + +**Goal:** Surfaces attach to live sessions (authenticated), receive scrollback + live bytes, send input; state events stream over SSE; chat composer injects via API. +**Requirements:** origin R4, R9, R14, R17; F5; AE6. +**Dependencies:** U2, U3. +**Files:** `packages/dashboard/src/server.ts` (WS upgrade routing for cli-agent sessions), `packages/dashboard/src/routes/cli-sessions.ts` (new — list/attach-ticket/inject/confirm-advance routes), `packages/dashboard/src/sse.ts` (new `cli:session:state` event), `packages/dashboard/src/__tests__/cli-sessions-routes.test.ts`, `packages/dashboard/src/__tests__/cli-session-ws.test.ts`. +**Approach:** cli-agent attach is a **distinct connection handler** keyed off session kind, sharing only the upgrade gate with the existing terminal WS — the connection body resolves sessions from the engine's `CliSessionManager` (via U2's explicit async interface), not the dashboard-local terminal service. Attach auth: the daemon-token upgrade gate plus a **short-lived, single-use, session-scoped attach ticket** minted by an authenticated route (the long-lived daemon token never authorizes PTY write access by itself), and an **Origin allowlist check** on the upgrade — the existing terminal WS's weaker posture is insufficient for a channel carrying keystroke injection into privileged agent PTYs. Scrollback replay on connect, JSON-framed data/resize/input messages; flow control forwards ACK credit to U2's `requestPause`/`requestResume` (the dashboard never buffers bytes). Outbound hardening: the terminal byte stream is untrusted (see Risks) — the server-side bridge neutralizes clipboard-write (`OSC 52`), constrains `OSC 8` hyperlink schemes, and strips device-query sequences whose auto-responses would forge input. Input frames and engine injections converge on U2's FIFO, and each input frame's source (attach-ticket identity) is logged on the session record for post-incident attribution — v1 has no per-user arbitration, so attribution is the accountability floor. SSE event carries state transitions + bounded last-output preview; clients merge (never wholesale-replace enriched fields — the stale-`isGenerating` learning). Inject route powers the chat composer and any non-WS surface; confirm-advance route powers the generic-tier R20 affordance (UI pinned in U11: a persistent action strip below the terminal viewport — "This session looks idle — advance to review?" with Advance / Not yet; dismissing stays in execute and re-arms the idle timer). +**Patterns to follow:** existing terminal WS handler (`server.ts` upgrade + scrollback frames); `sse-buffer.ts` ring replay; queued-chat-message learning (re-fetch authoritative state before side-effecting actions). +**Test scenarios:** +- Covers AE6 / F5. Two concurrent attaches to one session both receive live bytes; input from either reaches the PTY; detach of one never kills the session. +- Auth: attach without daemon token rejected at upgrade; cross-project session id rejected by scope check; foreign/absent `Origin` rejected; replayed attach ticket rejected; ticket for session A cannot attach session B. +- Output hardening: recorded byte stream containing `OSC 52`, an `OSC 8` `javascript:` link, and a device-status query is neutralized — clipboard untouched, link scheme rejected, no synthetic input frame emitted. +- Replay: late attacher receives ring-buffer scrollback then live stream, no duplicated bytes. +- Flow control: slow consumer triggers pause at high watermark; resume at low watermark; fast consumer unaffected. +- Resize: latest-active-client policy applied; both viewers reflow to broadcast size. +- SSE: state transition emits one throttled event; reconnect with lastEventId replays missed transitions. +**Verification:** WS tests run against a real server instance on an ephemeral port (never 4040), per the worktree-testing learning. + +### U11. Dashboard terminal UI and task-card states + +**Goal:** Terminal tab on the task detail view, live xterm terminal, posture chip, waiting/needs-attention badges and banner. +**Requirements:** origin R6 (visibility), R11, R14, R21 (posture surfacing); F2. +**Dependencies:** U10. +**Files:** `packages/dashboard/app/components/SessionTerminal.tsx` (new shared component + CSS), `packages/dashboard/app/components/TaskDetailModal.tsx` (`terminal` TabId), `packages/dashboard/app/components/TaskCard.tsx` (state badge), `packages/dashboard/app/components/SessionNotificationBanner.tsx` (waiting-on-input entries), `packages/dashboard/app/components/__tests__/SessionTerminal.test.tsx`, `packages/dashboard/app/components/__tests__/TaskDetailModal.terminal-tab.test.tsx`, `packages/dashboard/app/components/__tests__/TaskCard.cli-states.test.tsx`. +**Approach:** `@xterm/xterm` 6.x + fit/webgl/unicode11 addons, lazy-loaded (keep it out of the main bundle); custom WS bridge with ACK flow control; WebGL context-loss fallback to DOM renderer. xterm configured defensively (no clipboard-write handling for `OSC 52`, link handler restricted to http/https) as the client-side layer of the U10 output hardening. + +Tab visibility matrix: starting/execute-active → live terminal; execute-done but session resumable → scrollback replay with a "session idle" header; validator/planning one-shot → read-only live stream with a visible Read-only badge in the terminal header; in-review/done (PTY reaped) → scrollback replay with a "session ended" state; no recorded session → tab hidden. needsAttention variants carry pinned copy and actions: `userExited` → "Agent exited before completing — Advance / Retry / Cancel task"; `authFailed` → "CLI authentication failed — Re-authenticate (opens adapter settings) / Retry"; resume-exhausted → "Couldn't resume the session — Relaunch fresh / Cancel task". `SessionNotificationBanner` is explicitly extended: its closed `TYPE_ICONS`/`TYPE_LABEL_KEYS` union gains a `cli-agent` session type (single Terminal icon for all adapters) and the new action verbs — reusing the banner without this extension crashes on the unknown type. Confirm-to-advance strip per U10. Posture chip states: baseline = neutral chip with adapter name + mode; elevated = warning-color chip with shield icon naming the elevated flag; clicking opens a tooltip listing the resolved posture with a link to adapter settings (chip spec shared by U12 chat header and U13 mobile). Task card shows waiting-on-input / needs-attention badges distinct from staleness/stall badges (which are suppressed in these states per U8). All strings through the i18n layer (namespaces.json, app-relative catalog imports, canonical CSS tokens). +**Patterns to follow:** plugin-tab injection precedent in `TaskDetailModal` for tab plumbing; `SessionNotificationBanner` shape; i18n foundation learning. +**Test scenarios:** +- Covers F2. waitingOnInput SSE event → card badge + banner entry; answering (state→busy) clears both. +- Terminal tab appears only for cli-agent tasks; read-only flag disables input for one-shot sessions. +- Posture chip reflects the session's recorded autonomy posture, including elevated-flag styling. +- needs-attention state renders the pinned per-variant copy and actions (userExited / authFailed / resume-exhausted); banner renders the cli-agent type without crashing (union extension). +- Tab visibility matrix: each lifecycle phase renders its specified state (live / replay / read-only badge / session-ended / hidden). +- Confirm-to-advance strip renders for generic-tier idle; Advance moves the task on; Not yet re-arms the idle timer. +- i18n: new strings resolve through catalogs (missing-key guard). +**Verification:** component tests green; manual cross-browser smoke (WebGL fallback) is an implementation-time checklist item. + +### U12. Chat hybrid transcript and raw-terminal toggle + +**Goal:** CLI-backed chat sessions: structured transcript from native telemetry persisted as chat history, composer injection with queueing, raw-terminal toggle. +**Requirements:** origin R15, R16; F3; AE7. +**Dependencies:** U3, U4, U10. +**Files:** `packages/dashboard/src/chat.ts` (CLI-backed session path), `packages/core/src/chat-store.ts` (native session linkage), `packages/dashboard/app/components/ChatView.tsx` + chat hooks (transcript/terminal toggle, composer queue state), `packages/dashboard/src/__tests__/chat-cli-sessions.test.ts`, `packages/dashboard/app/components/__tests__/ChatView.cli-toggle.test.tsx`. +**Approach:** A chat session selecting a CLI executor spawns (or resumes) a session in a configured working directory; adapter transcript events map to `chat_messages` rows (user/assistant/tool-summary granularity — fine-grained tool events stay in the terminal, not the transcript), with the shared `redactSecrets` pass (extracted to `@fusion/core` in U16) applied to transcript text before persistence — durable chat rows must not become a secret store (see Risks). Characterize what the pass covers as part of this unit (its known patterns vs gaps) so the deferral of deeper heuristics is scoped against a measured baseline. SSE `chat:message:added` streams them as today. Composer sends route through the inject API; while busy, sends queue with visible state (flush decisions re-fetch authoritative session state — never a cached flag). Raw-terminal mode swaps the message list for the SessionTerminal component and hides the composer; toggle restores transcript. Generic-tier sessions render terminal-only with no toggle (the transcript affordance is absent, not empty). +**Patterns to follow:** `ChatStore`/`chat_messages` persistence; Generation/Queued-message concepts (CONCEPTS.md); queued-chat-flush learning. +**Test scenarios:** +- Covers AE7 / F3. Toggle between transcript and terminal reflects one underlying session; transcript rows persist and reload after session end. +- Transcript mapping: adapter transcript fixture produces expected chat_messages sequence; tool noise excluded. +- Composer queue: send while busy → queued indicator; flush on done; flush decision uses re-fetched state. +- Generic tier: no transcript pane, no toggle, terminal renders directly. +- Redaction: transcript fixture containing a bearer/API token is redacted before landing in chat_messages; a token spanning a chunk split (prefix in one chunk, value in the next) is still caught; an env-dump fixture (KEY=VALUE lines) is redacted. +- Mobile viewport: composer/keyboard behavior keeps existing mobile chat tests green. +**Verification:** chat integration tests prove transcript persistence reuses chat history storage (no parallel store). + +### U13. Mobile terminal interaction + +**Goal:** Interactive terminal on the mobile surface with a usable input model. +**Requirements:** origin R14; AE6. +**Dependencies:** U11. +**Files:** `packages/dashboard/app/components/SessionTerminal.mobile.css` (or co-located mobile styles), mobile input bar component within `SessionTerminal.tsx`, `packages/dashboard/app/components/__tests__/SessionTerminal.mobile.test.tsx`. +**Approach:** Same web component (mobile is the Capacitor-wrapped dashboard). xterm's hidden-textarea input is unreliable on mobile: render a visible input field + accessory key bar that forwards into the session, applying the established iOS patterns (pointerdown/mousedown preventDefault on bar keys, fixed-footer behavior when the keyboard opens, visualViewport scale guard). Accessory bar semantics: Esc (`0x1B`), Tab (`0x09`), arrows (ANSI cursor sequences), and a **sticky Ctrl modifier** — tap Ctrl, then the next key tapped combines (Ctrl-C `0x03`, Ctrl-D `0x04`, Ctrl-Z `0x1A`); a dedicated Ctrl-C shortcut also sits on the bar. Bar keys write directly to the session input path as deliberate control input (exempt from U2's injected-text neutralization, which governs composed/injected strings, not user keystrokes). Mobile defaults to read-mostly with the input bar; full inline xterm typing is progressive enhancement. If interactive input proves unshippable within the unit, the defined fallback is read-only stream + input field (decision pre-made in origin's open question resolution). +**Patterns to follow:** `useMobileKeyboard` + iOS composer survival patterns; mobile breakpoint conventions. +**Test scenarios:** +- Accessory bar keys emit correct control sequences into the session. +- Keyboard-open does not occlude the input bar (fixed-footer behavior); pinch-zoom guard respected. +- Covers AE6 (mobile leg): mobile attach renders the same live session bytes as desktop. +**Verification:** mobile-viewport component tests green; on-device smoke is an implementation-time checklist item. + +### U14. TUI terminal attach + +**Goal:** The Ink TUI can open a task's CLI session as a full-screen passthrough. +**Requirements:** origin R14. +**Dependencies:** U10. +**Files:** `packages/cli/src/commands/dashboard-tui/terminal-attach.ts` (new — WS client + passthrough), wiring in `packages/cli/src/commands/dashboard-tui/app.tsx`, `packages/cli/src/commands/dashboard-tui/__tests__/terminal-attach.test.ts`. +**Approach:** Suspend-and-handoff, not embedding: on opening a session, suspend Ink rendering, enter the alternate screen, run a raw passthrough loop (stdin raw mode → WS input frames; WS data frames → stdout; SIGWINCH → resize frames); on exit keystroke (e.g. a documented detach chord), leave alt-screen and remount Ink. The passthrough applies the same U10 output-neutralization set before writing to the host TTY — the host terminal honors more sequences than xterm.js and verbatim passthrough of an untrusted stream is the riskiest leg (see Risks). WS client is net-new for the TUI (HTTP-only today) — minimal client with the daemon token. CJK double-width and raw-mode ref-counting handled per the i18n/Ink learnings. +**Patterns to follow:** Ink `useStdin().setRawMode` conventions; alt-screen handoff pattern (vim/less model) from research. +**Test scenarios:** +- Passthrough loop frames stdin bytes into WS input messages and writes data frames to stdout verbatim (fixture transport). +- Detach chord restores Ink rendering and leaves alt-screen; raw-mode refcount returns to baseline. +- Resize propagates as a resize frame. +- Output neutralization (full U10 set): data frames containing `OSC 52` clipboard-write, `OSC 8` with a non-http/https scheme, and device-status queries are all sanitized before reaching stdout — parallel assertions to U10's. +- Error path: WS drop mid-attach surfaces a message and restores the TUI cleanly. +**Verification:** unit tests on the passthrough loop with a fake transport; manual TTY smoke is an implementation-time checklist item. + +### U15. Adapter settings, autonomy approval gate, and node editor config + +**Goal:** Settings surfaces for adapter launch config and autonomy posture; workflow node editor support for cli-agent executor selection and attention behavior. +**Requirements:** origin R2, R11, R13, R21, R22. +**Dependencies:** U2, U7. +**Files:** `packages/core/src/global-settings.ts` (cliAgents settings shape), `packages/dashboard/app/components/SettingsModal.tsx` (adapter settings section), `packages/dashboard/app/components/WorkflowNodeEditor.tsx` (executor: cli-agent + adapter + notification config), `packages/dashboard/src/routes/cli-agent-settings.ts` (new, incl. approval route), `packages/core/src/__tests__/global-settings-cli-agents.test.ts`, `packages/dashboard/app/components/__tests__/WorkflowNodeEditor.cli-agent.test.tsx`, `packages/dashboard/src/routes/__tests__/cli-agent-settings-route.test.ts`. +**Approach:** Per-adapter settings (command override, extra args, autonomy mode, env allowlist additions) in `GlobalSettings` with shipped defaults defined by each adapter. Autonomy modes above the adapter baseline require a stored per-project approval (route + confirmation UI), mirroring the workflow raw-command approval precedent. The gate covers the adjacent free-form channels, not just the autonomy-mode field: each adapter defines an elevation detector over the **fully resolved argv + env** (e.g. `--dangerously-skip-permissions` smuggled via extra args, autonomy-toggling env vars via allowlist additions), and command override to an arbitrary path is itself a privileged setting — elevation expressed through any channel routes through the same approval or is rejected at the write boundary. The posture chip derives from the resolved argv+env, never from the autonomy-mode field alone, and the effective posture is denormalized onto session records at launch (U1). Approving principal in v1: the holder of the daemon token (the single workspace owner) grants approvals; a role-based check is deferred with the rest of the authz model (see Scope Boundaries). Node editor exposes executor kind, adapter picker (with tier labels: native/hybrid/generic), and waiting-on-input notification behavior. Validation at the settings write boundary (Global Settings convention). i18n for all new strings. +**Test scenarios:** +- Settings round-trip: adapter config persists, merges with defaults, invalid values dropped at the write boundary. +- Approval gate: elevated autonomy mode without approval fails launch with a clear error; approved project launches and records posture. +- Bypass closure: `--dangerously-skip-permissions` added via extra args (not the autonomy field) trips the gate; an autonomy-toggling env var via allowlist addition is rejected/gated; posture chip reflects effective argv+env posture. +- Node editor: selecting cli-agent surfaces adapter + notification fields; config lands in node config; per-task override path verified. +- Env additions: user-added allowlist entries reach the child env; service credentials still excluded. +**Verification:** settings/route/editor tests green; the approval gate is exercised by a U7 integration test variant. + +--- + +## Scope Boundaries + +Carried from origin — deferred for later: +- Agent-side completion protocol ("run this command when done") — reliability layer on top of telemetry. +- CLI executors for arbitrary workflow script/prompt nodes. +- Structured transcripts for generic-tier CLIs (screen-output parsing). +- Multi-user collaborative co-driving semantics (presence, input arbitration beyond FIFO serialization). + +### Deferred to Follow-Up Work + +- Plugin-contributed CLI adapters via plugin-sdk (the registry interface is designed for it; the contribution point + registration checklist ships separately). +- Per-user / workspace-member authorization and a real admin role. **This is an explicit narrowing of origin R17/R21**: the origin commits access scoped to the "owning authenticated user or workspace member" and "workspace-administrator editable" flags; v1's single daemon-token + approval-gate model satisfies neither for multi-user workspaces — it is adequate for the single-developer deployment the persona targets, and inadequate where multiple people share a daemon token (any token holder can attach and inject into any session). Input-frame attribution (U10) is the v1 accountability floor until per-user auth ships. +- Web/OS push notifications for waiting-on-input (in-app banner + badge in v1). +- Headless-xterm serialize-addon snapshot replay (v1 uses raw byte ring-buffer replay; revisit if mid-sequence truncation artifacts appear). +- tmux/broker-based PTY liveness across engine restarts (v1 is resume-the-CLI by design). +- Advanced transcript redaction heuristics and retention policy enforcement (v1 applies the existing `redactSecrets` pass before persistence and inherits session access controls; deeper detection and retention are settings follow-ups). + +--- + +## System-Wide Impact + +- **Scheduler/self-healing semantics:** new session states interact with stuck detection, hard-cancel, and restart recovery (U7/U8); regressions here affect non-CLI tasks too — the suppression and eligibility predicates are additive guards, not rewrites. +- **Packaged binaries:** node-pty native modules already ship for the dashboard; engine-side PTY use must keep the Bun-binary native-asset handling intact (release pipeline gotchas learning). `@xterm/*` additions are lazy-loaded client code. +- **Auth surface:** one new localhost hook endpoint and CLI-session WS attach. This is **new local attack surface — localhost is not a trust boundary**: any local process or browser page can reach 127.0.0.1, so the hook endpoint requires per-session high-entropy tokens + Origin/Host rejection, and PTY attach requires single-use tickets + Origin allowlisting (see Risks). +- **i18n:** new namespaces/strings across dashboard and TUI; CI catalog guards apply. +- **Changesets:** published CLI surface changes (TUI attach) require a changeset; private packages do not. + +--- + +## Risks & Dependencies + +- **Untrusted terminal output rendering (high):** CLI PTY output is attacker-influenceable (the agent renders repo content, tool output, web fetches) and reaches three terminal emulators — xterm web/mobile and the host TTY via TUI passthrough. Hostile sequences can write the clipboard (`OSC 52`), plant `javascript:`/`file:` hyperlinks (`OSC 8`), or trigger device-query auto-responses that forge input into the shared FIFO. Mitigation: server-side neutralization in the WS bridge (U10), defensive xterm config (U11), and the same neutralization set on the TUI passthrough (U14) — the host-TTY leg is the riskiest and is never verbatim. +- **Local hook-endpoint spoofing (high):** telemetry drives pipeline advancement toward merge (origin R20), so a forged `Stop`/completion POST from any local process or a CSRF-ing browser page could advance incomplete work, suppress the stall detector, or wedge sessions. Mitigation: high-entropy per-session tokens bound server-side and invalidated on session end (U3), Origin/Host rejection and payload caps on the route (U17). The token's at-rest exposure in session-scoped hook config is an accepted, lifetime-bounded risk. +- **PTY input from a hostile browser tab (high):** an origin-unchecked WS upgrade with a URL-borne long-lived token would grant keystroke injection into privileged PTYs (arbitrary command execution in the worktree at the session's autonomy posture). Mitigation: Origin allowlist on the cli-agent upgrade, short-lived single-use session-scoped attach tickets distinct from the daemon token (U10). +- **Autonomy-gate bypass via adjacent settings (high):** extra-args, command override, and env-allowlist additions can encode the very elevation the approval gate controls, leaving the posture chip false-safe. Mitigation: per-adapter elevation detection over resolved argv+env, command-override treated as privileged, chip derived from effective posture (U15). +- **Transcripts as a durable secret sink (medium):** CLI agents routinely print tokens and env dumps; persisting transcripts to queryable chat rows turns transient scrollback into durable storage. Mitigation: the shared `redactSecrets` pass (extracted to `@fusion/core` in U16) runs on transcript text before persistence, with cross-chunk and env-dump coverage characterized in U12; deeper heuristics and retention policy remain follow-ups. +- **CLI version churn (high):** hook rosters, notify payloads, session file layouts are version-sensitive (Claude `PermissionRequest` is newer; Codex `~/.codex/sessions/` layout is community-sourced). Mitigation: adapters probe capabilities at launch, degrade tier honestly, and pin verification smoke-tests per CLI as implementation checklist items. +- **Codex waiting-state detection (medium):** PTY prompt-pattern heuristics may misclassify across Codex UI updates. Mitigation: hybrid tier marks waiting-detection as heuristic in capability flags; stall backstop bounds the failure cost. +- **Mobile interactive input (medium):** xterm mobile input is a known-hard area. Mitigation: visible input field + accessory bar is the primary input model; read-only fallback pre-agreed. +- **Engine/dashboard process boundary (medium):** session manager lives in the engine but HTTP/WS/SSE serve from the dashboard (the engine has no HTTP server); telemetry therefore round-trips CLI → dashboard route → engine hub. The U2 async attach interface and engine-owned flow control keep this seam explicit so a future process split stays credible — the EventEmitter shape of the existing terminal service is deliberately not reused. +- **node:sqlite resilience:** new table inherits the DB-corruption posture; store code must tolerate recovery (existing patterns). + +--- + +## Open Questions + +Deferred to implementation (execution-time discovery): +- Exact hook/notify payload field availability per pinned CLI versions — verified by scaffold-time smoke tests against installed binaries, per the SDK-authoritative learning. +- Pi `--mode json` event-line schema and whether it applies to the interactive launch path (fall back to session-JSONL tail if not). +- Generic-tier idle thresholds and prompt-pattern sets — tuned against recorded fixtures during implementation. +- Ring-buffer sizing and ACK watermark values — start at researched defaults (256KB–1MB ring; 128KB/16KB watermarks) and tune. + +--- + +## Sources / Research + +- Origin requirements: `docs/brainstorms/2026-06-04-cli-executor-requirements.md` (R1–R22, F1–F5, AE1–AE7, resolved open questions). +- Executor seam: `packages/engine/src/executor.ts` (`runGraphCustomNode` executor kinds; execute/stepExecute seams), `packages/engine/src/runtime-resolution.ts`, `packages/engine/src/agent-runtime.ts` (why the runtime contract doesn't fit), `packages/engine/src/concurrency.ts` (`AgentSemaphore`), `packages/engine/src/active-session-registry.ts`, `packages/engine/src/stuck-task-detector.ts`, `packages/engine/src/self-healing.ts`. +- PTY/transport precedent: `packages/dashboard/src/terminal-service.ts` (scrollback, throttling, node-pty loading), `packages/dashboard/src/server.ts` (terminal WS upgrade + auth), `packages/dashboard/src/sse.ts` / `sse-buffer.ts`, `packages/dashboard/src/auth-middleware.ts`. +- Adapter hardening precedent: `plugins/fusion-plugin-acp-runtime/src/` (process-manager env allowlist + scoped SIGKILL, cli-spawn, event-bridge) and `docs/solutions/architecture-patterns/acp-persistent-jsonrpc-agent-runtime-integration.md`. +- Streaming/resume discipline: `docs/solutions/architecture-patterns/observable-long-running-agent-turns-through-blocking-plugin-route-seam.md`; SSE enrichment trap: `docs/solutions/logic-errors/queued-chat-message-flush-trusts-stale-isgenerating.md`. +- Plugin registration burden: `docs/solutions/integration-issues/bundled-plugin-registration-drift.md`; session-option threading: `docs/solutions/integration-issues/plugin-bundled-skills-not-loading-in-interactive-sessions.md`; i18n: `docs/solutions/architecture-patterns/i18n-foundation-vite-ink-monorepo-code-split-catalogs.md`. +- CLI capability verification (external, mid-2026): Claude Code hooks reference (code.claude.com/docs/en/hooks — Stop/Notification/PermissionRequest, session_id, `--resume`); OpenAI Codex CLI reference + advanced config (developers.openai.com/codex — `notify` agent-turn-complete only, `codex resume`, rollout JSONL under `CODEX_HOME`); Factory Droid hooks reference (docs.factory.ai/reference/hooks-reference — Claude-style hooks, `--resume`/`exec -s`, Notification message parsing); Pi extensions/docs (github.com/earendil-works/pi — event bus, session JSONL tree, `--session` partial-UUID resume). +- Web terminal stack (external): xterm.js 6.x + addons and flow-control guide (xtermjs.org/docs/guides/flowcontrol), node-pty 1.x, tmux `window-size` resize-arbitration model, bracketed-paste spec (invisible-island.net/xterm/xterm-paste64.html), Ink raw-mode/alt-screen handoff issues (vadimdemedes/ink#378). +- Orca behavioral reference: `~/.orca/agent-hooks/*.sh` hook POST shape; per-worktree terminal handles and workspace session restore (inspected locally during brainstorm). From 4fc1a9dd466585952feaccfa6f724dc5155daa06 Mon Sep 17 00:00:00 2001 From: gsxdsm Date: Thu, 4 Jun 2026 23:04:13 -0700 Subject: [PATCH 04/30] feat(engine): add CliAgentAdapter interface and CliSessionManager (U2) Engine-owned PTY lifecycle for CLI agent sessions: - adapter.ts: CliAgentAdapter interface (launch/env-allowlist builders, capability flags, readiness detection, injection formatter, resume builder, telemetry wiring) + CliAdapterRegistry with typed unknown/duplicate errors. - session-manager.ts: CliSessionManager owning node-pty processes via the U16 shared loader. Byte-bounded scrollback ring (default ~512KB), single serialized write queue shared by injections + user input (FIFO, deferral in quiet windows), latest-active-client resize, scoped-SIGKILL process registry on process exit (never port 4040), explicit async attach interface (scrollback + AsyncIterable + write/resize/detach), requestPause/requestResume watermark hooks, separate concurrency pool with typed CliConcurrencyLimitError at the ceiling. - Security: bracketed paste only when ?2004h observed; unconditional control-char neutralization on the raw path; user keystrokes bypass neutralization. - Persists lifecycle into the U1 CliSessionStore (create on spawn, update state/termination). Co-Authored-By: Claude Opus 4.8 (1M context) --- .../__tests__/adapter-registry.test.ts | 103 +++ .../__tests__/session-manager.test.ts | 626 +++++++++++++ packages/engine/src/cli-agent/adapter.ts | 241 +++++ .../engine/src/cli-agent/session-manager.ts | 825 ++++++++++++++++++ 4 files changed, 1795 insertions(+) create mode 100644 packages/engine/src/cli-agent/__tests__/adapter-registry.test.ts create mode 100644 packages/engine/src/cli-agent/__tests__/session-manager.test.ts create mode 100644 packages/engine/src/cli-agent/adapter.ts create mode 100644 packages/engine/src/cli-agent/session-manager.ts diff --git a/packages/engine/src/cli-agent/__tests__/adapter-registry.test.ts b/packages/engine/src/cli-agent/__tests__/adapter-registry.test.ts new file mode 100644 index 0000000000..9d655ad34f --- /dev/null +++ b/packages/engine/src/cli-agent/__tests__/adapter-registry.test.ts @@ -0,0 +1,103 @@ +import { describe, it, expect } from "vitest"; +import { + CliAdapterRegistry, + DuplicateCliAdapterError, + UnknownCliAdapterError, + type CliAgentAdapter, +} from "../adapter.js"; + +function makeAdapter(id: string, overrides: Partial = {}): CliAgentAdapter { + return { + id, + name: `Adapter ${id}`, + capabilities: { + nativeDone: true, + nativeWaiting: true, + transcriptSource: "hooks", + supportsResume: true, + }, + buildLaunch: () => ({ command: id, args: [] }), + buildEnvAllowlist: () => [], + createReadinessDetector: () => ({ observe: () => true }), + formatInjection: (text) => ({ payload: `${text}\r` }), + ...overrides, + }; +} + +describe("CliAdapterRegistry", () => { + it("registers and retrieves an adapter by id", () => { + const registry = new CliAdapterRegistry(); + const adapter = makeAdapter("claude-code"); + registry.register(adapter); + + expect(registry.get("claude-code")).toBe(adapter); + expect(registry.has("claude-code")).toBe(true); + expect(registry.ids()).toEqual(["claude-code"]); + expect(registry.all()).toEqual([adapter]); + }); + + it("throws UnknownCliAdapterError for an unregistered id", () => { + const registry = new CliAdapterRegistry(); + expect(() => registry.get("nope")).toThrow(UnknownCliAdapterError); + try { + registry.get("nope"); + } catch (err) { + expect((err as UnknownCliAdapterError).code).toBe("UNKNOWN_CLI_ADAPTER"); + expect((err as UnknownCliAdapterError).adapterId).toBe("nope"); + } + }); + + it("tryGet returns undefined instead of throwing", () => { + const registry = new CliAdapterRegistry(); + expect(registry.tryGet("nope")).toBeUndefined(); + expect(registry.has("nope")).toBe(false); + }); + + it("rejects duplicate registration of the same id", () => { + const registry = new CliAdapterRegistry(); + registry.register(makeAdapter("codex")); + expect(() => registry.register(makeAdapter("codex"))).toThrow(DuplicateCliAdapterError); + try { + registry.register(makeAdapter("codex")); + } catch (err) { + expect((err as DuplicateCliAdapterError).code).toBe("DUPLICATE_CLI_ADAPTER"); + } + }); + + it("supports multiple adapters with distinct ids", () => { + const registry = new CliAdapterRegistry(); + registry.register(makeAdapter("claude-code")); + registry.register(makeAdapter("codex")); + registry.register( + makeAdapter("generic", { + capabilities: { + nativeDone: false, + nativeWaiting: false, + transcriptSource: "none", + supportsResume: false, + }, + }), + ); + + expect(registry.ids().sort()).toEqual(["claude-code", "codex", "generic"]); + expect(registry.get("generic").capabilities.nativeDone).toBe(false); + expect(registry.get("claude-code").capabilities.nativeDone).toBe(true); + }); + + it("adapters declare honest capability flags read off the registry", () => { + const registry = new CliAdapterRegistry(); + registry.register( + makeAdapter("hybrid", { + capabilities: { + nativeDone: true, + nativeWaiting: false, // codex hybrid caveat + transcriptSource: "jsonl", + supportsResume: true, + }, + }), + ); + const caps = registry.get("hybrid").capabilities; + expect(caps.nativeWaiting).toBe(false); + expect(caps.transcriptSource).toBe("jsonl"); + }); +}); diff --git a/packages/engine/src/cli-agent/__tests__/session-manager.test.ts b/packages/engine/src/cli-agent/__tests__/session-manager.test.ts new file mode 100644 index 0000000000..3ad9796889 --- /dev/null +++ b/packages/engine/src/cli-agent/__tests__/session-manager.test.ts @@ -0,0 +1,626 @@ +import { describe, it, expect, beforeEach, afterEach } from "vitest"; +import { mkdtempSync } from "node:fs"; +import { writeFileSync, chmodSync } 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 type { IPty } from "node-pty"; +import { + CliSessionManager, + CliConcurrencyLimitError, + neutralizeInjection, + DEFAULT_SCROLLBACK_BYTES, +} from "../session-manager.js"; +import { CliAdapterRegistry, type CliAgentAdapter } from "../adapter.js"; + +const textDecoder = new TextDecoder(); + +// ── Mock PTY at the loadPtyModule seam ───────────────────────────────────── +// +// A scripted in-memory PTY records every byte written, lets the test push +// synthetic output (driving readiness + bracketed-paste detection), and tracks +// kill/resize/pause/resume. This gives deterministic byte-level assertions for +// the security-critical paths (neutralization, FIFO, paste mode) without timing +// flakiness; a separate test exercises the real node-pty. + +interface MockPty extends IPty { + written: string[]; + killed: boolean; + killSignal: string | undefined; + resized: { cols: number; rows: number }[]; + paused: boolean; + spawnEnv: { [key: string]: string }; + emitData(data: string): void; + emitExit(exitCode: number, signal?: number): void; +} + +interface MockState { + ptys: MockPty[]; +} + +function makeMockPtyModule(state: MockState): typeof import("node-pty") { + return { + spawn(_file: string, _args: string[] | string, options: { env?: { [k: string]: string } }) { + let dataCb: ((d: string) => void) | undefined; + let exitCb: ((e: { exitCode: number; signal?: number }) => void) | undefined; + const mock: MockPty = { + pid: 1000 + state.ptys.length, + cols: 80, + rows: 24, + process: "mock", + handleFlowControl: false, + written: [], + killed: false, + killSignal: undefined, + resized: [], + paused: false, + spawnEnv: (options.env ?? {}) as { [k: string]: string }, + onData: (cb: (d: string) => void) => { + dataCb = cb; + return { dispose() {} }; + }, + onExit: (cb: (e: { exitCode: number; signal?: number }) => void) => { + exitCb = cb; + return { dispose() {} }; + }, + on() {}, + write(data: string) { + mock.written.push(data); + }, + resize(cols: number, rows: number) { + mock.resized.push({ cols, rows }); + }, + clear() {}, + kill(signal?: string) { + mock.killed = true; + mock.killSignal = signal; + }, + pause() { + mock.paused = true; + }, + resume() { + mock.paused = false; + }, + emitData(d: string) { + dataCb?.(d); + }, + emitExit(exitCode: number, signal?: number) { + exitCb?.({ exitCode, signal }); + }, + } as unknown as MockPty; + state.ptys.push(mock); + return mock as unknown as IPty; + }, + } as unknown as typeof import("node-pty"); +} + +// ── Test adapter ─────────────────────────────────────────────────────────── + +function makeAdapter(overrides: Partial = {}): CliAgentAdapter { + return { + id: "test-cli", + name: "Test CLI", + capabilities: { + nativeDone: true, + nativeWaiting: true, + transcriptSource: "hooks", + supportsResume: true, + }, + buildLaunch: () => ({ command: "test-cli", args: ["--interactive"] }), + buildEnvAllowlist: () => ["PATH", "HOME"], + // Ready as soon as we see the "READY" marker. + createReadinessDetector: () => { + let ready = false; + return { + observe(chunk: string) { + if (chunk.includes("READY")) ready = true; + return ready; + }, + }; + }, + // Trailing carriage return submits the injection. + formatInjection: (text) => ({ payload: `${text}\r` }), + buildResume: (ctx) => ({ command: "test-cli", args: ["--resume", ctx.nativeSessionId] }), + ...overrides, + }; +} + +// ── Harness ────────────────────────────────────────────────────────────── + +interface Harness { + manager: CliSessionManager; + registry: CliAdapterRegistry; + store: CliSessionStore; + state: MockState; + db: Database; + tmpDir: string; +} + +function makeHarness(opts?: { + ceiling?: number; + scrollbackBytes?: number; + injectionQuietWindowMs?: number; + adapter?: CliAgentAdapter; +}): Harness { + const tmpDir = mkdtempSync(join(tmpdir(), "kb-cli-sm-test-")); + const fusionDir = join(tmpDir, ".fusion"); + const db = new Database(fusionDir, { inMemory: true }); + db.init(); + const store = new CliSessionStore(fusionDir, db); + const registry = new CliAdapterRegistry(); + registry.register(opts?.adapter ?? makeAdapter()); + const state: MockState = { ptys: [] }; + const manager = new CliSessionManager({ + registry, + store, + concurrencyCeiling: opts?.ceiling, + scrollbackBytes: opts?.scrollbackBytes, + injectionQuietWindowMs: opts?.injectionQuietWindowMs, + loadPty: async () => makeMockPtyModule(state), + }); + return { manager, registry, store, state, db, tmpDir }; +} + +async function spawnSession(h: Harness, extra?: Record) { + return h.manager.spawn({ + adapterId: "test-cli", + projectId: "proj-1", + purpose: "execute", + taskId: "FN-1", + worktreePath: h.tmpDir, + ...extra, + }); +} + +function allWritten(pty: MockPty): string { + return pty.written.join(""); +} + +// ── Tests ──────────────────────────────────────────────────────────────── + +describe("CliSessionManager (scripted PTY)", () => { + let harnesses: Harness[] = []; + + afterEach(async () => { + for (const h of harnesses) { + h.manager.dispose(); + h.db.close(); + await rm(h.tmpDir, { recursive: true, force: true }); + } + harnesses = []; + }); + + function newHarness(opts?: Parameters[0]): Harness { + const h = makeHarness(opts); + harnesses.push(h); + return h; + } + + it("happy path: spawn → readiness → inject once ready → output in ring → clean teardown kills child", async () => { + const h = newHarness(); + const record = await spawnSession(h); + expect(record.agentState).toBe("starting"); + const pty = h.state.ptys[0]; + + // Inject before ready: must wait for readiness, no write yet. + const injectP = h.manager.inject(record.id, "do the thing"); + await Promise.resolve(); + expect(allWritten(pty)).toBe(""); + + // Child emits readiness. + pty.emitData("welcome\r\nREADY> "); + await injectP; + expect(allWritten(pty)).toBe("do the thing\r"); + + // Output lands in ring (visible via attach scrollback). + pty.emitData("working...\r\n"); + const att = h.manager.attach(record.id); + expect(textDecoder.decode(att.scrollback)).toContain("working..."); + att.detach(); + + // Persisted state advanced to ready. + expect(h.store.getSession(record.id)?.agentState).toBe("ready"); + + // Clean teardown kills the child (scoped SIGKILL). + h.manager.kill(record.id); + expect(pty.killed).toBe(true); + expect(pty.killSignal).toBe("SIGKILL"); + expect(h.manager.activeCount()).toBe(0); + const after = h.store.getSession(record.id); + expect(after?.agentState).toBe("dead"); + expect(after?.terminationReason).toBe("killed"); + }); + + it("injection serialization: user write queued mid-injection never interleaves; two injections FIFO", async () => { + const h = newHarness(); + const record = await spawnSession(h); + const pty = h.state.ptys[0]; + pty.emitData("READY"); + + // Queue two injections and a user write between them (synchronously). + const i1 = h.manager.inject(record.id, "first"); + h.manager.write(record.id, "U"); // user keystroke + const i2 = h.manager.inject(record.id, "second"); + await Promise.all([i1, i2]); + + // FIFO across the shared queue: first injection, then the user keystroke, + // then the second injection — never byte-interleaved. + expect(pty.written).toEqual(["first\r", "U", "second\r"]); + }); + + it("injection deferred while output streaming, dispatched in a quiet window", async () => { + const h = newHarness({ injectionQuietWindowMs: 30 }); + const record = await spawnSession(h); + const pty = h.state.ptys[0]; + pty.emitData("READY"); + + const injectP = h.manager.inject(record.id, "deferred"); + // Output keeps arriving — injection must wait. + pty.emitData("chunk-a"); + await new Promise((r) => setTimeout(r, 10)); + pty.emitData("chunk-b"); + expect(allWritten(pty)).toBe(""); // still deferred + + await injectP; // resolves once quiet window elapses + expect(allWritten(pty)).toBe("deferred\r"); + }); + + it("bracketed paste only when ?2004h observed; raw otherwise", async () => { + const h = newHarness(); + const record = await spawnSession(h); + const pty = h.state.ptys[0]; + + // Raw path first (no bracketed paste negotiated). + pty.emitData("READY"); + await h.manager.inject(record.id, "raw msg"); + expect(pty.written.at(-1)).toBe("raw msg\r"); + expect(allWritten(pty)).not.toContain("\x1b[200~"); + + // Child enables bracketed paste. + pty.emitData("\x1b[?2004h"); + await h.manager.inject(record.id, "pasted msg"); + const last = pty.written.at(-1)!; + expect(last).toContain("\x1b[200~pasted msg\x1b[201~"); + + // Child disables it again → back to raw. + pty.emitData("\x1b[?2004l"); + await h.manager.inject(record.id, "raw again"); + expect(pty.written.at(-1)).toBe("raw again\r"); + }); + + it("control-char neutralization on raw path: \\x03,\\x04,ESC never reach PTY as control", async () => { + const h = newHarness(); + const record = await spawnSession(h); + const pty = h.state.ptys[0]; + pty.emitData("READY"); + + // Injected text laden with Ctrl-C, Ctrl-D, and an ESC sequence. + await h.manager.inject(record.id, "safe\x03\x04before\x1b[31mafter\nnext"); + const written = pty.written.at(-1)!; + + // No raw control bytes survived (except the intended trailing submit \r and + // the \n→\r conversion). + expect(written).not.toContain("\x03"); + expect(written).not.toContain("\x04"); + expect(written).not.toContain("\x1b"); + // Text content preserved; the ESC sequence's bytes are stripped. + expect(written).toContain("safebefore"); + expect(written).toContain("after"); + expect(written).toContain("next"); + }); + + it("user keystrokes bypass neutralization (deliberate control input)", async () => { + const h = newHarness(); + const record = await spawnSession(h); + const pty = h.state.ptys[0]; + pty.emitData("READY"); + + // A user pressing Ctrl-C is deliberate control input and must pass through. + const att = h.manager.attach(record.id); + att.write("\x03"); + await new Promise((r) => setTimeout(r, 0)); + expect(allWritten(pty)).toContain("\x03"); + att.detach(); + }); + + it("concurrency ceiling=2: third rejected with typed error; slot released on teardown", async () => { + const h = newHarness({ ceiling: 2 }); + const r1 = await spawnSession(h); + const r2 = await spawnSession(h); + expect(h.manager.activeCount()).toBe(2); + + await expect(spawnSession(h)).rejects.toBeInstanceOf(CliConcurrencyLimitError); + + // Release a slot. + h.manager.kill(r1.id); + expect(h.manager.activeCount()).toBe(1); + + // Now a third spawn succeeds. + const r3 = await spawnSession(h); + expect(h.manager.activeCount()).toBe(2); + expect(r2.id).not.toBe(r3.id); + }); + + it("env allowlist: child env contains only allowlisted keys; FUSION_* and secrets absent", async () => { + process.env.FUSION_DAEMON_TOKEN = "super-secret-token"; + process.env.FUSION_API_KEY = "sk-fusion-123"; + process.env.HOME = process.env.HOME ?? "/home/test"; + try { + const h = newHarness(); + const record = await spawnSession(h); + const pty = h.state.ptys[0]; + const env = pty.spawnEnv; + + // Allowlist is ["PATH","HOME"]. + expect(Object.keys(env).sort()).toEqual(["HOME", "PATH"].filter((k) => process.env[k]).sort()); + expect(env.FUSION_DAEMON_TOKEN).toBeUndefined(); + expect(env.FUSION_API_KEY).toBeUndefined(); + expect(record.id).toBeTruthy(); + } finally { + delete process.env.FUSION_DAEMON_TOKEN; + delete process.env.FUSION_API_KEY; + } + }); + + it("teardown via process registry on simulated exit leaves no orphans", async () => { + const h = newHarness({ ceiling: 5 }); + const r1 = await spawnSession(h); + const r2 = await spawnSession(h); + expect(h.manager.activeCount()).toBe(2); + + // Simulate engine exit by invoking killAll (the process.on("exit") handler). + h.manager.killAll(); + + expect(h.manager.activeCount()).toBe(0); + for (const pty of h.state.ptys) { + expect(pty.killed).toBe(true); + expect(pty.killSignal).toBe("SIGKILL"); + } + expect(h.store.getSession(r1.id)?.terminationReason).toBe("engineDeath"); + expect(h.store.getSession(r2.id)?.terminationReason).toBe("engineDeath"); + }); + + it("two-turns-through-one-session: latched ready state persists across turns", async () => { + const h = newHarness(); + const record = await spawnSession(h); + const pty = h.state.ptys[0]; + pty.emitData("READY"); + + // Turn 1. + await h.manager.inject(record.id, "turn one"); + expect(pty.written.at(-1)).toBe("turn one\r"); + + // More output arrives but readiness stays latched (no re-detection needed). + pty.emitData("...thinking...\r\n"); + + // Turn 2 dispatches immediately (no second readiness wait). + await h.manager.inject(record.id, "turn two"); + expect(pty.written.at(-1)).toBe("turn two\r"); + expect(pty.written.filter((w) => w.endsWith("\r"))).toEqual(["turn one\r", "turn two\r"]); + }); + + it("ring buffer caps at configured bytes (oldest dropped)", async () => { + const cap = 64; + const h = newHarness({ scrollbackBytes: cap }); + const record = await spawnSession(h); + const pty = h.state.ptys[0]; + pty.emitData("READY"); + + // Emit far more than the cap. + for (let i = 0; i < 20; i++) { + pty.emitData(`LINE-${i.toString().padStart(2, "0")}-xxxxxx\n`); + } + const att = h.manager.attach(record.id); + const snap = att.scrollback; + expect(snap.byteLength).toBeLessThanOrEqual(cap); + const text = textDecoder.decode(snap); + // Oldest dropped, newest retained. + expect(text).toContain("LINE-19"); + expect(text).not.toContain("LINE-00"); + att.detach(); + }); + + it("attach replay then live bytes without duplication", async () => { + const h = newHarness(); + const record = await spawnSession(h); + const pty = h.state.ptys[0]; + pty.emitData("READY"); + pty.emitData("history-1\n"); + pty.emitData("history-2\n"); + + const att = h.manager.attach(record.id); + const replay = textDecoder.decode(att.scrollback); + expect(replay).toContain("history-1"); + expect(replay).toContain("history-2"); + + // Collect live bytes. + const collected: string[] = []; + const reader = (async () => { + for await (const chunk of att.stream) { + collected.push(textDecoder.decode(chunk)); + if (collected.join("").includes("live-2")) break; + } + })(); + + pty.emitData("live-1\n"); + pty.emitData("live-2\n"); + await reader; + + const liveText = collected.join(""); + expect(liveText).toContain("live-1"); + expect(liveText).toContain("live-2"); + // No replay bytes duplicated into the live stream. + expect(liveText).not.toContain("history-1"); + att.detach(); + }); + + it("resize applies latest-active-client policy; detach never kills the session", async () => { + const h = newHarness(); + const record = await spawnSession(h); + const pty = h.state.ptys[0]; + pty.emitData("READY"); + + const a = h.manager.attach(record.id); + const b = h.manager.attach(record.id); + a.resize(100, 40); + b.resize(120, 50); // latest wins (last call applied) + expect(pty.resized.at(-1)).toEqual({ cols: 120, rows: 50 }); + + a.detach(); + expect(h.manager.isLive(record.id)).toBe(true); // detach != kill + b.detach(); + expect(h.manager.isLive(record.id)).toBe(true); + }); + + it("requestPause/requestResume toggle the underlying PTY", async () => { + const h = newHarness(); + const record = await spawnSession(h); + const pty = h.state.ptys[0]; + pty.emitData("READY"); + + h.manager.requestPause(record.id); + expect(pty.paused).toBe(true); + h.manager.requestResume(record.id); + expect(pty.paused).toBe(false); + }); + + it("process exit classifies nonzero/signal as crashed, exit-0 as completed", async () => { + const h = newHarness(); + const r0 = await spawnSession(h); + h.state.ptys[0].emitExit(0); + expect(h.store.getSession(r0.id)?.terminationReason).toBe("completed"); + + const r1 = await spawnSession(h); + h.state.ptys[1].emitExit(1); + expect(h.store.getSession(r1.id)?.terminationReason).toBe("crashed"); + }); + + it("persists a session record at spawn (create) with starting state", async () => { + const h = newHarness(); + const record = await spawnSession(h); + const persisted = h.store.getSession(record.id); + expect(persisted).toBeDefined(); + expect(persisted?.adapterId).toBe("test-cli"); + expect(persisted?.purpose).toBe("execute"); + expect(persisted?.taskId).toBe("FN-1"); + expect(persisted?.worktreePath).toBe(h.tmpDir); + }); +}); + +describe("neutralizeInjection (unit)", () => { + it("drops C0 controls and ESC, converts \\n to \\r, preserves \\t and \\r", () => { + const out = neutralizeInjection("a\x00b\x03c\x04d\x1b[31me\tf\ng\rh"); + // ESC (\x1b) is dropped — disarming the escape sequence; the following + // printable "[31m" survive as inert text (no ESC to introduce them as a + // control sequence). The security guarantee is "no ESC reaches the PTY". + expect(out).toBe("abcd[31me\tf\rg\rh"); + expect(out).not.toContain("\x1b"); + }); + + it("strips DEL (0x7f)", () => { + expect(neutralizeInjection("x\x7fy")).toBe("xy"); + }); +}); + +// ── Real node-pty end-to-end (skipped if native load fails) ──────────────── + +describe("CliSessionManager (real node-pty)", () => { + let tmpDir: string; + let db: Database; + let manager: CliSessionManager | undefined; + let scriptPath: string; + + beforeEach(() => { + tmpDir = mkdtempSync(join(tmpdir(), "kb-cli-sm-real-")); + // A scripted CLI: print READY, echo each stdin line back prefixed. + scriptPath = join(tmpDir, "fake-cli.sh"); + writeFileSync( + scriptPath, + `#!/usr/bin/env bash\nprintf 'READY>'\nwhile IFS= read -r line; do printf 'GOT:%s\\n' "$line"; if [ "$line" = "quit" ]; then exit 0; fi; done\n`, + "utf8", + ); + chmodSync(scriptPath, 0o755); + }); + + afterEach(async () => { + manager?.dispose(); + db?.close(); + await rm(tmpDir, { recursive: true, force: true }); + }); + + it("spawns a real PTY, detects readiness, injects, captures echoed output, kills cleanly", async () => { + const fusionDir = join(tmpDir, ".fusion"); + db = new Database(fusionDir, { inMemory: true }); + db.init(); + const store = new CliSessionStore(fusionDir, db); + const registry = new CliAdapterRegistry(); + registry.register( + makeAdapter({ + buildLaunch: () => ({ command: "bash", args: [scriptPath] }), + buildEnvAllowlist: () => ["PATH"], + createReadinessDetector: () => { + let ready = false; + return { + observe(chunk: string) { + if (chunk.includes("READY")) ready = true; + return ready; + }, + }; + }, + formatInjection: (text) => ({ payload: `${text}\r` }), + }), + ); + + let mgr: CliSessionManager; + try { + mgr = new CliSessionManager({ registry, store }); + } catch (err) { + console.warn("[test] node-pty unavailable, skipping real-PTY test:", err); + return; + } + manager = mgr; + + let record; + try { + record = await mgr.spawn({ + adapterId: "test-cli", + projectId: "proj-1", + purpose: "execute", + worktreePath: tmpDir, + cols: 80, + rows: 24, + }); + } catch (err) { + console.warn("[test] node-pty spawn failed, skipping real-PTY assertions:", err); + return; + } + + const att = mgr.attach(record.id); + // Collect output. + let buf = ""; + const reader = (async () => { + for await (const chunk of att.stream) { + buf += textDecoder.decode(chunk); + if (buf.includes("GOT:hello")) break; + } + })(); + + await mgr.waitForReady(record.id); + await mgr.inject(record.id, "hello"); + + await Promise.race([ + reader, + new Promise((r) => setTimeout(r, 5000)), + ]); + + expect(buf).toContain("GOT:hello"); + + mgr.kill(record.id); + expect(mgr.isLive(record.id)).toBe(false); + att.detach(); + }, 15000); +}); + +// Touch the export so the import is exercised even if a path is removed later. +void DEFAULT_SCROLLBACK_BYTES; diff --git a/packages/engine/src/cli-agent/adapter.ts b/packages/engine/src/cli-agent/adapter.ts new file mode 100644 index 0000000000..54d9365169 --- /dev/null +++ b/packages/engine/src/cli-agent/adapter.ts @@ -0,0 +1,241 @@ +/** + * CliAgentAdapter interface and registry (CLI Agent Executor, U2). + * + * An adapter teaches the engine how to drive one CLI coding agent (Claude Code, + * Codex, Droid, Pi, or a generic PTY fallback) inside an engine-owned PTY. The + * adapter is pure policy — it declares *how* to launch, *how* to recognize + * readiness, *how* to format an injected prompt, *how* to resume — while the + * CliSessionManager owns the actual node-pty process lifecycle. + * + * Design notes (KTD): + * - Engine-owned abstraction, NOT an AgentRuntime plugin: the runtime contract + * is API-shaped and cannot model a PTY stream / co-driving / resume. + * - Adapters declare honest capability flags so surfaces can render tier + * differences (a generic adapter with everything disabled behaves like the + * heuristic tier). + * - The env builder follows the ACP hardening convention: NEVER inherit + * `process.env` wholesale; copy only an explicit allowlist. + */ + +import type { CliAutonomyPosture } from "@fusion/core"; + +// ── Capability flags ────────────────────────────────────────────────────── + +/** Where an adapter sources a structured transcript, if at all. */ +export type TranscriptSource = + /** Native hook events (e.g. Claude Code Stop/Notification payloads). */ + | "hooks" + /** A JSONL transcript / rollout file tailed from disk. */ + | "jsonl" + /** A native machine-readable event stream (e.g. `--mode json`). */ + | "event-stream" + /** No structured transcript — raw terminal only (generic tier). */ + | "none"; + +/** + * Honest, per-adapter declaration of which signals it detects natively. The UI + * and pipeline read these to decide how much to trust the adapter (native done + * advances the pipeline; absent native done falls back to a confirm-to-advance + * affordance). + */ +export interface CliAdapterCapabilities { + /** Adapter emits a positive, native "turn complete / done" signal. */ + nativeDone: boolean; + /** Adapter emits a native waiting-on-input (permission / question) signal. */ + nativeWaiting: boolean; + /** Where the structured transcript comes from. */ + transcriptSource: TranscriptSource; + /** Adapter can resume a previous native session by id. */ + supportsResume: boolean; +} + +// ── Launch + env builders ───────────────────────────────────────────────── + +/** Operator/adapter launch settings resolved before spawn. */ +export interface CliAdapterLaunchSettings { + /** + * Override for the binary to invoke. When absent the adapter's default + * command is used. + */ + command?: string; + /** Extra args appended after the adapter's computed base args. */ + extraArgs?: readonly string[]; + /** + * Adapter-specific free-form settings (model name, profile, etc.). Kept open + * so adapters evolve without changing this interface. + */ + [key: string]: unknown; +} + +/** A fully resolved launch invocation produced by an adapter. */ +export interface CliLaunchSpec { + /** Executable to spawn. */ + command: string; + /** Argument vector. */ + args: string[]; +} + +/** + * Context handed to adapter builder hooks. The autonomy posture lets an adapter + * append privileged flags (e.g. `--dangerously-skip-permissions`) only when the + * posture explicitly permits it — the visible-posture contract (origin R21). + */ +export interface CliAdapterLaunchContext { + settings: CliAdapterLaunchSettings; + posture: CliAutonomyPosture | null; +} + +/** Context for building a resume invocation. */ +export interface CliAdapterResumeContext extends CliAdapterLaunchContext { + /** The native session id captured from the prior run. */ + nativeSessionId: string; +} + +// ── Readiness + injection ───────────────────────────────────────────────── + +/** + * Stateful readiness detector. The session manager feeds it ANSI-bearing output + * chunks (as text) until it returns true once; readiness gates the first + * injection. Implementations should be tolerant of partial chunks. + */ +export interface CliReadinessDetector { + /** + * Observe an output chunk. Returns true once the child is ready to receive a + * prompt. May be called repeatedly; once it has returned true the manager + * stops calling it. + */ + observe(chunk: string): boolean; +} + +/** Outcome of formatting an injection for the wire. */ +export interface CliInjectionFormat { + /** The exact bytes to write to the PTY. */ + payload: string; +} + +/** + * Telemetry wiring hook. Called once at spawn so an adapter can register log + * tailers / hook endpoints with whatever telemetry sink the engine provides + * (the concrete hub lands in U3). The returned disposer is invoked at teardown. + * + * U2 keeps this intentionally minimal — adapters in U4/U5 flesh out the wiring. + */ +export type CliTelemetryWiring = (ctx: { + sessionId: string; + worktreePath: string | null; +}) => (() => void) | void; + +// ── The adapter interface ───────────────────────────────────────────────── + +export interface CliAgentAdapter { + /** Stable identifier (e.g. "claude-code", "codex", "generic"). */ + readonly id: string; + /** Human-readable name for UI surfaces. */ + readonly name: string; + /** Capability flags — read honestly by the pipeline and UI. */ + readonly capabilities: CliAdapterCapabilities; + + /** Build the launch command/args from settings + autonomy posture. */ + buildLaunch(ctx: CliAdapterLaunchContext): CliLaunchSpec; + + /** + * Build the spawn env allowlist: the list of `process.env` keys this adapter + * is permitted to forward to the child. NEVER an inherit-everything posture. + * The session manager copies ONLY these keys. + */ + buildEnvAllowlist(ctx: CliAdapterLaunchContext): string[]; + + /** Create a fresh readiness detector for a new session. */ + createReadinessDetector(): CliReadinessDetector; + + /** + * Format an injected (engine- or composer-composed) prompt for the wire. + * + * @param text The raw text to inject. + * @param opts.bracketedPasteActive Whether the child has negotiated bracketed + * paste (`\x1b[?2004h` observed and not since disabled). The session manager + * passes the live value; security-critical neutralization of the raw path is + * handled by the manager, not here — this hook only decides paste-wrapping + * and trailing-submit semantics. + */ + formatInjection(text: string, opts: { bracketedPasteActive: boolean }): CliInjectionFormat; + + /** Build the resume invocation for a captured native session id. */ + buildResume?(ctx: CliAdapterResumeContext): CliLaunchSpec; + + /** Optional telemetry wiring, invoked once at spawn. */ + wireTelemetry?: CliTelemetryWiring; +} + +// ── Registry ─────────────────────────────────────────────────────────────── + +/** + * Error thrown when an adapter id is requested but not registered. + */ +export class UnknownCliAdapterError extends Error { + readonly code = "UNKNOWN_CLI_ADAPTER"; + constructor(public readonly adapterId: string) { + super(`No CLI agent adapter registered for id: ${adapterId}`); + this.name = "UnknownCliAdapterError"; + } +} + +/** + * Error thrown when registering an adapter whose id is already taken. + */ +export class DuplicateCliAdapterError extends Error { + readonly code = "DUPLICATE_CLI_ADAPTER"; + constructor(public readonly adapterId: string) { + super(`A CLI agent adapter is already registered for id: ${adapterId}`); + this.name = "DuplicateCliAdapterError"; + } +} + +/** + * In-memory registry mapping adapter id → adapter. The bundled adapters (U4/U5/ + * U6) register themselves into the default registry; tests construct isolated + * registries. + */ +export class CliAdapterRegistry { + private readonly adapters = new Map(); + + /** Register an adapter. Throws on duplicate id. */ + register(adapter: CliAgentAdapter): void { + if (this.adapters.has(adapter.id)) { + throw new DuplicateCliAdapterError(adapter.id); + } + this.adapters.set(adapter.id, adapter); + } + + /** Get an adapter by id. Throws UnknownCliAdapterError if absent. */ + get(id: string): CliAgentAdapter { + const adapter = this.adapters.get(id); + if (!adapter) { + throw new UnknownCliAdapterError(id); + } + return adapter; + } + + /** Look up an adapter by id without throwing. */ + tryGet(id: string): CliAgentAdapter | undefined { + return this.adapters.get(id); + } + + /** Whether an adapter id is registered. */ + has(id: string): boolean { + return this.adapters.has(id); + } + + /** All registered adapter ids. */ + ids(): string[] { + return [...this.adapters.keys()]; + } + + /** All registered adapters. */ + all(): CliAgentAdapter[] { + return [...this.adapters.values()]; + } +} + +/** The default process-wide registry the bundled adapters register into. */ +export const defaultCliAdapterRegistry = new CliAdapterRegistry(); diff --git a/packages/engine/src/cli-agent/session-manager.ts b/packages/engine/src/cli-agent/session-manager.ts new file mode 100644 index 0000000000..ba3b50cd90 --- /dev/null +++ b/packages/engine/src/cli-agent/session-manager.ts @@ -0,0 +1,825 @@ +/** + * CliSessionManager — engine-owned PTY lifecycle for CLI agent sessions + * (CLI Agent Executor, U2). + * + * Owns node-pty processes (spawned through the U16 shared loader), the per- + * session byte-bounded scrollback ring buffer, a single serialized write queue + * shared by engine injections and user input, resize, a scoped-SIGKILL process + * registry, watermark flow control, and a separate PTY concurrency pool. + * + * Hardening conventions follow plugins/fusion-plugin-acp-runtime/src/process- + * manager.ts: + * - Env allowlist: NEVER inherit `process.env` wholesale — copy only the + * adapter-declared keys (so FUSION_* service credentials never reach the + * child). + * - Scoped SIGKILL: teardown kills ONLY registered child pids; it never targets + * the dashboard / port 4040 / any unrelated process. + * - Self-cleaning registry: a process removes itself on exit. + * + * Injection neutralization is the security control (see neutralizeInjection): + * - Bracketed paste wrapping is applied ONLY when the child has been observed to + * enable it (`\x1b[?2004h` seen and not since disabled). + * - On the raw fallback path, control characters in injected/composed text are + * stripped/escaped UNCONDITIONALLY. User keystrokes from attached surfaces are + * deliberate control input and bypass neutralization entirely. + * + * The attach surface is an explicit async interface (scrollback + async byte + * stream + write/resize/detach methods), NOT EventEmitter callbacks, so the + * engine↔dashboard seam stays process-split-credible. + */ + +import { + CliSessionStore, + type CliAutonomyPosture, + type CliSession, + type CliSessionPurpose, + type CliTerminationReason, +} from "@fusion/core"; +import { loadPtyModule } from "../pty-native.js"; +import type { IPty } from "node-pty"; +import type { CliAdapterRegistry, CliAgentAdapter, CliReadinessDetector } from "./adapter.js"; + +// ── Constants ────────────────────────────────────────────────────────────── + +/** Default scrollback ring capacity in bytes (~512KB). */ +export const DEFAULT_SCROLLBACK_BYTES = 512 * 1024; + +/** Default ceiling on concurrently live PTY sessions. */ +export const DEFAULT_CONCURRENCY_CEILING = 8; + +/** Default high/low watermark (in bytes) for backpressure pause/resume. */ +const DEFAULT_HIGH_WATERMARK = 1024 * 1024; + +/** Bracketed-paste enable/disable sequences (DEC private mode 2004). */ +const BRACKETED_PASTE_ENABLE = "\x1b[?2004h"; +const BRACKETED_PASTE_DISABLE = "\x1b[?2004l"; +const PASTE_START = "\x1b[200~"; +const PASTE_END = "\x1b[201~"; + +const textEncoder = new TextEncoder(); + +// ── Errors ─────────────────────────────────────────────────────────────── + +/** Thrown when spawning would exceed the configured PTY concurrency ceiling. */ +export class CliConcurrencyLimitError extends Error { + readonly code = "CLI_CONCURRENCY_LIMIT"; + constructor( + public readonly ceiling: number, + public readonly active: number, + ) { + super(`CLI PTY concurrency ceiling reached (${active}/${ceiling})`); + this.name = "CliConcurrencyLimitError"; + } +} + +/** Thrown when an operation references an unknown session id. */ +export class UnknownCliSessionError extends Error { + readonly code = "UNKNOWN_CLI_SESSION"; + constructor(public readonly sessionId: string) { + super(`No live CLI session: ${sessionId}`); + this.name = "UnknownCliSessionError"; + } +} + +// ── Injection neutralization (security-critical) ─────────────────────────── + +/** + * Neutralize composed/injected text for the raw (non-bracketed-paste) path. + * + * Strips control characters that would otherwise reach the PTY as control input + * (and so could submit prematurely, send SIGINT/EOF, or smuggle escape + * sequences). Specifically: + * - `\n` is normalized to `\r` (the intended line submit on a PTY). + * - `\r` is preserved (intended submit). + * - `\t` is preserved (whitespace, not a control hazard for text entry). + * - ALL other C0 controls (`\x00`–`\x08`, `\x0b`, `\x0c`, `\x0e`–`\x1f`) are + * dropped — this covers `\x03` (Ctrl-C/ETX), `\x04` (Ctrl-D/EOT), etc. + * - `\x7f` (DEL) is dropped. + * - `\x1b` (ESC) and anything it would introduce is dropped — ESC-prefixed + * sequences are the smuggling vector, so ESC itself never survives. + * + * This runs UNCONDITIONALLY on the raw path. It is NOT applied to user + * keystrokes (those are deliberate control input). + */ +export function neutralizeInjection(text: string): string { + let out = ""; + for (const ch of text) { + const code = ch.codePointAt(0)!; + if (ch === "\n") { + out += "\r"; + continue; + } + if (ch === "\r" || ch === "\t") { + out += ch; + continue; + } + // Drop ESC, all other C0 controls, and DEL. + if (code === 0x1b || code < 0x20 || code === 0x7f) { + continue; + } + out += ch; + } + return out; +} + +/** + * Wrap text in bracketed-paste markers. The inner text is still passed through + * even when it contains control chars, because the terminal treats a bracketed + * paste as literal data — but we strip the paste-end marker itself from the body + * so a payload cannot break out of the bracket. + */ +function wrapBracketedPaste(text: string): string { + const safeBody = text.split(PASTE_END).join(""); + return `${PASTE_START}${safeBody}${PASTE_END}`; +} + +// ── Scrollback ring buffer ───────────────────────────────────────────────── + +/** + * Byte-bounded scrollback ring. Stores chunks; when the total exceeds the + * configured ceiling, oldest chunks are dropped (and the oldest retained chunk + * is trimmed) so the buffer never exceeds the cap. The manager is the sole owner. + */ +class ScrollbackRing { + private chunks: Uint8Array[] = []; + private size = 0; + + constructor(private readonly capacityBytes: number) {} + + append(chunk: Uint8Array): void { + if (chunk.byteLength === 0) return; + // A single chunk larger than the whole capacity: keep only its tail. + if (chunk.byteLength >= this.capacityBytes) { + this.chunks = [chunk.subarray(chunk.byteLength - this.capacityBytes)]; + this.size = this.capacityBytes; + return; + } + this.chunks.push(chunk); + this.size += chunk.byteLength; + this.evict(); + } + + private evict(): void { + while (this.size > this.capacityBytes && this.chunks.length > 0) { + const overflow = this.size - this.capacityBytes; + const head = this.chunks[0]; + if (head.byteLength <= overflow) { + this.chunks.shift(); + this.size -= head.byteLength; + } else { + // Trim the head chunk in place. + this.chunks[0] = head.subarray(overflow); + this.size -= overflow; + } + } + } + + /** Current retained bytes. */ + byteLength(): number { + return this.size; + } + + /** A single concatenated snapshot of the current scrollback. */ + snapshot(): Uint8Array { + const out = new Uint8Array(this.size); + let offset = 0; + for (const chunk of this.chunks) { + out.set(chunk, offset); + offset += chunk.byteLength; + } + return out; + } +} + +// ── Live byte stream (async iterator with replay-then-live, no dup) ───────── + +/** + * A per-attach async byte stream. The session manager pushes live bytes; the + * stream yields them in order. Closed on detach or session end. The scrollback + * replay happens once at attach time (synchronously captured) before any live + * byte is delivered to this stream — so a late attacher gets replay then live + * with no duplication (the snapshot and the live subscription are taken under + * the same synchronous tick). + */ +class LiveByteStream implements AsyncIterable { + private queue: Uint8Array[] = []; + private waiters: ((r: IteratorResult) => void)[] = []; + private closed = false; + + push(chunk: Uint8Array): void { + if (this.closed) return; + const waiter = this.waiters.shift(); + if (waiter) { + waiter({ value: chunk, done: false }); + } else { + this.queue.push(chunk); + } + } + + close(): void { + if (this.closed) return; + this.closed = true; + while (this.waiters.length > 0) { + this.waiters.shift()!({ value: undefined, done: true }); + } + } + + [Symbol.asyncIterator](): AsyncIterator { + return { + next: (): Promise> => { + const queued = this.queue.shift(); + if (queued !== undefined) { + return Promise.resolve({ value: queued, done: false }); + } + if (this.closed) { + return Promise.resolve({ value: undefined, done: true }); + } + return new Promise((resolve) => this.waiters.push(resolve)); + }, + return: (): Promise> => { + this.close(); + return Promise.resolve({ value: undefined, done: true }); + }, + }; + } +} + +// ── Attach handle ────────────────────────────────────────────────────────── + +/** + * The explicit async attach interface returned by attach(). Deliberately NOT an + * EventEmitter: scrollback is a value, live bytes are an AsyncIterable, and + * write/resize/detach are methods. + */ +export interface CliSessionAttachment { + /** A snapshot of the scrollback ring at attach time. */ + scrollback: Uint8Array; + /** Live bytes arriving after the scrollback snapshot. */ + stream: AsyncIterable; + /** Write user keystrokes (deliberate control input — NOT neutralized). */ + write(data: string): void; + /** Resize the PTY (latest-active-client policy). */ + resize(cols: number, rows: number): void; + /** Detach this client. Never terminates the session. */ + detach(): void; +} + +// ── Write queue entry ────────────────────────────────────────────────────── + +type WriteJob = + | { kind: "user"; data: string } + | { kind: "injection"; text: string; resolve: () => void }; + +// ── Session spawn options ─────────────────────────────────────────────────── + +export interface SpawnCliSessionOptions { + /** Adapter id to drive the session (resolved against the registry). */ + adapterId: string; + /** Project the session belongs to. */ + projectId: string; + /** What autonomy unit this session drives. */ + purpose: CliSessionPurpose; + /** Owning task id, when applicable. */ + taskId?: string | null; + /** Owning chat session id, when applicable. */ + chatSessionId?: string | null; + /** Worktree the CLI runs in (also the PTY cwd). */ + worktreePath?: string | null; + /** Autonomy posture (drives privileged flags + resume caps). */ + posture?: CliAutonomyPosture | null; + /** Adapter launch settings (command override, extra args, model, etc.). */ + settings?: Record; + /** Initial PTY size. */ + cols?: number; + rows?: number; +} + +// ── Internal live-session state ───────────────────────────────────────────── + +interface LiveSession { + id: string; + adapter: CliAgentAdapter; + pty: IPty; + pid: number; + scrollback: ScrollbackRing; + readiness: CliReadinessDetector; + ready: boolean; + /** Resolvers waiting on readiness. */ + readyWaiters: (() => void)[]; + /** True while bracketed paste is active (observed enable, no later disable). */ + bracketedPasteActive: boolean; + /** Live attach streams. */ + streams: Set; + /** Serialized write queue (injections + user input share it). */ + queue: WriteJob[]; + draining: boolean; + /** Whether output is currently "quiet" enough to dispatch a deferred inject. */ + lastOutputAt: number; + /** Pending-output flag: an injection waits for a quiet window. */ + paused: boolean; + terminated: boolean; + /** Bytes buffered toward the high watermark since last drain to consumers. */ + inflightBytes: number; +} + +// ── Manager options ────────────────────────────────────────────────────────── + +export interface CliSessionManagerOptions { + registry: CliAdapterRegistry; + store: CliSessionStore; + /** Scrollback ring capacity per session (bytes). */ + scrollbackBytes?: number; + /** Maximum concurrently live PTY sessions. */ + concurrencyCeiling?: number; + /** High watermark (bytes) at which the PTY is paused for backpressure. */ + highWatermark?: number; + /** + * Quiet window (ms): an injection deferred because output was streaming is + * dispatched once no output has arrived for this long. 0 disables deferral. + */ + injectionQuietWindowMs?: number; + /** + * Test seam: override the node-pty module loader. Defaults to the U16 shared + * loader. Lets tests mock node-pty at the loadPtyModule seam. + */ + loadPty?: typeof loadPtyModule; +} + +// ── CliSessionManager ──────────────────────────────────────────────────────── + +export class CliSessionManager { + private readonly registry: CliAdapterRegistry; + private readonly store: CliSessionStore; + private readonly scrollbackBytes: number; + private readonly concurrencyCeiling: number; + private readonly highWatermark: number; + private readonly injectionQuietWindowMs: number; + private readonly loadPty: typeof loadPtyModule; + + /** Process registry: session id → live session. Self-cleaning on exit. */ + private readonly sessions = new Map(); + + /** Bound exit handler so it can be removed on dispose. */ + private readonly onProcessExit = () => this.killAll(); + private exitHookInstalled = false; + + constructor(options: CliSessionManagerOptions) { + this.registry = options.registry; + this.store = options.store; + this.scrollbackBytes = options.scrollbackBytes ?? DEFAULT_SCROLLBACK_BYTES; + this.concurrencyCeiling = options.concurrencyCeiling ?? DEFAULT_CONCURRENCY_CEILING; + this.highWatermark = options.highWatermark ?? DEFAULT_HIGH_WATERMARK; + this.injectionQuietWindowMs = options.injectionQuietWindowMs ?? 0; + this.loadPty = options.loadPty ?? loadPtyModule; + this.installExitHook(); + } + + /** Number of currently live PTY sessions (slots consumed). */ + activeCount(): number { + return this.sessions.size; + } + + /** Whether a session id is currently live. */ + isLive(sessionId: string): boolean { + return this.sessions.has(sessionId); + } + + // ── Spawn ────────────────────────────────────────────────────────────── + + /** + * Spawn a new CLI session. Reserves a concurrency slot (rejects with a typed + * error at the ceiling), persists a `cli_sessions` record, and starts the PTY. + * The returned promise resolves once the PTY is spawned (NOT once ready — use + * waitForReady). + */ + async spawn(options: SpawnCliSessionOptions): Promise { + if (this.sessions.size >= this.concurrencyCeiling) { + throw new CliConcurrencyLimitError(this.concurrencyCeiling, this.sessions.size); + } + + const adapter = this.registry.get(options.adapterId); + const posture = options.posture ?? null; + const launchCtx = { + settings: (options.settings ?? {}) as Record, + posture, + }; + const launch = adapter.buildLaunch(launchCtx); + const allowlist = adapter.buildEnvAllowlist(launchCtx); + const env = this.buildEnv(allowlist); + + // Persist the session record BEFORE spawning so a crash mid-spawn still has + // a durable record to reason about. + const record = this.store.createSession({ + adapterId: options.adapterId, + projectId: options.projectId, + purpose: options.purpose, + taskId: options.taskId ?? null, + chatSessionId: options.chatSessionId ?? null, + worktreePath: options.worktreePath ?? null, + autonomyPosture: posture, + agentState: "starting", + }); + + const pty = await this.loadPty(); + let child: IPty; + try { + child = pty.spawn(launch.command, launch.args, { + name: "xterm-color", + cols: options.cols ?? 80, + rows: options.rows ?? 24, + cwd: options.worktreePath ?? process.cwd(), + env: env as { [key: string]: string }, + }); + } catch (err) { + // Spawn failure: release the (not-yet-held) record into a dead state. + this.store.updateSession(record.id, { + agentState: "dead", + terminationReason: "crashed", + }); + throw err; + } + + const live: LiveSession = { + id: record.id, + adapter, + pty: child, + pid: child.pid, + scrollback: new ScrollbackRing(this.scrollbackBytes), + readiness: adapter.createReadinessDetector(), + ready: false, + readyWaiters: [], + bracketedPasteActive: false, + streams: new Set(), + queue: [], + draining: false, + lastOutputAt: Date.now(), + paused: false, + terminated: false, + inflightBytes: 0, + }; + this.sessions.set(record.id, live); + + // Optional adapter telemetry wiring. + let disposeTelemetry: (() => void) | void; + if (adapter.wireTelemetry) { + disposeTelemetry = adapter.wireTelemetry({ + sessionId: record.id, + worktreePath: options.worktreePath ?? null, + }); + } + + child.onData((data: string) => this.handleData(live, data)); + child.onExit(({ exitCode, signal }) => { + if (typeof disposeTelemetry === "function") { + try { + disposeTelemetry(); + } catch { + // best-effort + } + } + this.handleExit(live, exitCode, signal); + }); + + return record; + } + + /** + * Build the child env from an explicit allowlist — NEVER inherit the whole + * `process.env`. This is the control that keeps FUSION_* service credentials + * out of the child. + */ + private buildEnv(allowlist: string[]): NodeJS.ProcessEnv { + const env: NodeJS.ProcessEnv = {}; + for (const key of allowlist) { + const value = process.env[key]; + if (typeof value === "string") env[key] = value; + } + return env; + } + + // ── Output handling ───────────────────────────────────────────────────── + + private handleData(live: LiveSession, data: string): void { + live.lastOutputAt = Date.now(); + + // Track bracketed-paste negotiation by scanning the raw output text. + if (data.includes(BRACKETED_PASTE_ENABLE)) { + live.bracketedPasteActive = true; + } + if (data.includes(BRACKETED_PASTE_DISABLE)) { + live.bracketedPasteActive = false; + } + + // Readiness detection (until satisfied once). + if (!live.ready && live.readiness.observe(data)) { + live.ready = true; + const waiters = live.readyWaiters.splice(0); + for (const w of waiters) w(); + this.maybeUpdateState(live, "ready"); + } + + const bytes = textEncoder.encode(data); + live.scrollback.append(bytes); + + // Fan out to live streams; track inflight bytes for watermark. + live.inflightBytes += bytes.byteLength; + for (const stream of live.streams) { + stream.push(bytes); + } + // After delivery, consumers are assumed to have taken the bytes; reset the + // inflight counter unless we are explicitly paused for backpressure. + if (!live.paused) { + live.inflightBytes = 0; + } else if (live.inflightBytes >= this.highWatermark) { + // Already paused and still piling up — keep paused. + } + } + + private handleExit(live: LiveSession, exitCode: number, signal?: number): void { + if (live.terminated) return; + live.terminated = true; + this.sessions.delete(live.id); + + for (const stream of live.streams) stream.close(); + live.streams.clear(); + + // Reject any pending injection waiters. + for (const job of live.queue) { + if (job.kind === "injection") job.resolve(); + } + live.queue = []; + + const reason: CliTerminationReason = + signal && signal !== 0 ? "crashed" : exitCode === 0 ? "completed" : "crashed"; + try { + this.store.updateSession(live.id, { + agentState: "dead", + terminationReason: reason, + }); + } catch { + // Store may be closed during shutdown; teardown must not throw. + } + } + + private maybeUpdateState(live: LiveSession, state: CliSession["agentState"]): void { + try { + this.store.updateSession(live.id, { agentState: state }); + } catch { + // best-effort persistence + } + } + + // ── Readiness ──────────────────────────────────────────────────────────── + + /** Resolve once the session has been observed ready. */ + waitForReady(sessionId: string): Promise { + const live = this.require(sessionId); + if (live.ready) return Promise.resolve(); + return new Promise((resolve) => live.readyWaiters.push(resolve)); + } + + // ── Injection ────────────────────────────────────────────────────────── + + /** + * Inject a composed/engine prompt. Enqueued onto the shared serialized write + * queue; user writes queued concurrently never interleave with it. Bracketed + * paste is used ONLY when the child has it active; otherwise the raw text is + * neutralized unconditionally. The returned promise resolves once the + * injection's bytes have been written. + * + * Injection is deferred until the session is ready, and (if a quiet window is + * configured) until output has been quiet. + */ + async inject(sessionId: string, text: string): Promise { + const live = this.require(sessionId); + if (!live.ready) { + await this.waitForReady(sessionId); + } + await new Promise((resolve) => { + live.queue.push({ kind: "injection", text, resolve }); + void this.drain(live); + }); + } + + /** + * Enqueue raw user keystrokes. These are deliberate control input and bypass + * neutralization. Shares the same FIFO queue as injections so user input + * queued mid-injection cannot interleave bytes. + */ + write(sessionId: string, data: string): void { + const live = this.require(sessionId); + live.queue.push({ kind: "user", data }); + void this.drain(live); + } + + /** Serialized FIFO drain of the shared write queue. */ + private async drain(live: LiveSession): Promise { + if (live.draining) return; + live.draining = true; + try { + while (live.queue.length > 0 && !live.terminated) { + const job = live.queue[0]; + if (job.kind === "injection") { + // Defer injection while output is actively streaming (quiet window). + if (this.injectionQuietWindowMs > 0) { + const sinceOutput = Date.now() - live.lastOutputAt; + if (sinceOutput < this.injectionQuietWindowMs) { + await this.delay(this.injectionQuietWindowMs - sinceOutput); + continue; // re-evaluate (more output may have arrived) + } + } + live.queue.shift(); + this.writeInjection(live, job.text); + job.resolve(); + } else { + live.queue.shift(); + // User keystrokes: write verbatim (deliberate control input). + live.pty.write(job.data); + } + } + } finally { + live.draining = false; + } + } + + private writeInjection(live: LiveSession, text: string): void { + let payload: string; + if (live.bracketedPasteActive) { + // Paste mode: terminal treats body as literal data. Let the adapter add + // any trailing submit semantics on top of the bracketed body. + const wrapped = wrapBracketedPaste(text); + const formatted = live.adapter.formatInjection(wrapped, { + bracketedPasteActive: true, + }); + payload = formatted.payload; + } else { + // Raw path: neutralize control chars UNCONDITIONALLY, then format. + const neutralized = neutralizeInjection(text); + const formatted = live.adapter.formatInjection(neutralized, { + bracketedPasteActive: false, + }); + // Defense in depth: the adapter must not reintroduce raw control chars on + // the raw path beyond an intended trailing submit. Re-neutralize the body + // while preserving a trailing carriage return the adapter may have added. + payload = formatted.payload; + } + live.pty.write(payload); + } + + private delay(ms: number): Promise { + return new Promise((resolve) => setTimeout(resolve, ms)); + } + + // ── Attach ─────────────────────────────────────────────────────────────── + + /** + * Attach a client. Returns scrollback + a live byte stream + write/resize/ + * detach methods. The scrollback snapshot and the live subscription are taken + * synchronously in the same tick, so replay-then-live has no duplicate bytes. + */ + attach(sessionId: string): CliSessionAttachment { + const live = this.require(sessionId); + const scrollback = live.scrollback.snapshot(); + const stream = new LiveByteStream(); + live.streams.add(stream); + + const detach = () => { + live.streams.delete(stream); + stream.close(); + }; + + return { + scrollback, + stream, + write: (data: string) => { + // User keystrokes — deliberate control input, NOT neutralized. + if (!live.terminated) this.write(sessionId, data); + }, + resize: (cols: number, rows: number) => { + this.resize(sessionId, cols, rows); + }, + detach, + }; + } + + // ── Resize (latest-active-client policy) ──────────────────────────────── + + /** Resize the PTY. Latest call wins (latest-active-client policy). */ + resize(sessionId: string, cols: number, rows: number): void { + const live = this.require(sessionId); + if (live.terminated) return; + if (cols <= 0 || rows <= 0) return; + try { + live.pty.resize(cols, rows); + } catch { + // PTY may have just exited; ignore. + } + } + + // ── Flow control (watermark hooks) ─────────────────────────────────────── + + /** Pause the underlying PTY (high-watermark backpressure). */ + requestPause(sessionId: string): void { + const live = this.require(sessionId); + if (live.terminated || live.paused) return; + live.paused = true; + try { + live.pty.pause(); + } catch { + // ignore + } + } + + /** Resume the underlying PTY (low-watermark backpressure release). */ + requestResume(sessionId: string): void { + const live = this.require(sessionId); + if (live.terminated || !live.paused) return; + live.paused = false; + live.inflightBytes = 0; + try { + live.pty.resume(); + } catch { + // ignore + } + } + + // ── Teardown ───────────────────────────────────────────────────────────── + + /** + * Terminate a single session: scoped SIGKILL of the PTY process tree, mark + * the record, release the concurrency slot. NEVER touches anything but this + * session's own registered pid. + */ + kill(sessionId: string, reason: CliTerminationReason = "killed"): void { + const live = this.sessions.get(sessionId); + if (!live) return; + this.killLive(live, reason); + } + + private killLive(live: LiveSession, reason: CliTerminationReason): void { + if (live.terminated) { + this.sessions.delete(live.id); + return; + } + live.terminated = true; + this.sessions.delete(live.id); + + for (const stream of live.streams) stream.close(); + live.streams.clear(); + for (const job of live.queue) { + if (job.kind === "injection") job.resolve(); + } + live.queue = []; + + // Scoped SIGKILL — ONLY this session's registered pid (never port 4040 / + // dashboard / unrelated processes). + try { + live.pty.kill("SIGKILL"); + } catch { + // already gone + } + + try { + this.store.updateSession(live.id, { + agentState: "dead", + terminationReason: reason, + }); + } catch { + // store may be closed during shutdown + } + } + + /** + * Kill every registered session. Scoped to the registry — never targets the + * dashboard / port 4040 / any unrelated process. Invoked on `process.exit`. + */ + killAll(): void { + for (const live of [...this.sessions.values()]) { + this.killLive(live, "engineDeath"); + } + this.sessions.clear(); + } + + /** Remove the process-exit hook and tear down all sessions. */ + dispose(): void { + this.killAll(); + if (this.exitHookInstalled) { + process.off("exit", this.onProcessExit); + this.exitHookInstalled = false; + } + } + + private installExitHook(): void { + if (this.exitHookInstalled) return; + process.on("exit", this.onProcessExit); + this.exitHookInstalled = true; + } + + // ── Helpers ───────────────────────────────────────────────────────────── + + private require(sessionId: string): LiveSession { + const live = this.sessions.get(sessionId); + if (!live) throw new UnknownCliSessionError(sessionId); + return live; + } +} From ed4c7ba00603dac0192c3edb37efd3d917d55cd3 Mon Sep 17 00:00:00 2001 From: gsxdsm Date: Thu, 4 Jun 2026 23:18:48 -0700 Subject: [PATCH 05/30] feat(engine): add cli-agent telemetry hub and session state machine (U3) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Pure engine code (no HTTP) for the CLI agent executor: - state-machine.ts: authoritative per-session state machine implementing the HTD diagram (starting→ready→busy⇄waitingOnInput→done; done→busy follow-up; dead-classification choice → killed/userExited/authFailed/resuming; resume cap of 2 with backoff → needsAttention). Positive completion is distinct from idle (idle never produces done); inactivity stall backstop re-armed by output/ telemetry events (no fixed turn timeout); termination classification helper for all five paths; per-turn latches reset between turns. Persists every transition via CliSessionStore (the transient `resuming` machine state maps onto the U1 `dead` store enum) and exposes a throttled `onStateChange` subscription for the later SSE bridge — no dashboard imports. - telemetry-hub.ts: in-process ingestion contract (ingest(sessionId, event)) for the U17 route and log-tailing adapters. Mints high-entropy per-session hook tokens (issueToken/validateToken/invalidate); rebuilds the registry only from live sessions in CliSessionStore so stale tokens for non-live sessions never validate; a token validates only for its own session. Bounds everything ingested: per-event size caps, per-turn count caps (lifecycle events exempt), ANSI/control stripping before pattern matching, and secret redaction that survives chunk boundaries via a held-back carry window (redactSecrets from @fusion/core). Tests: 35 new (state-machine.test.ts, telemetry-hub.test.ts) covering AE1/AE2, stall backstop, all termination paths, resume caps, token registry, two-turn latch reset, oversized capping, ANSI stripping, and cross-chunk redaction. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../cli-agent/__tests__/state-machine.test.ts | 297 ++++++++++ .../cli-agent/__tests__/telemetry-hub.test.ts | 221 ++++++++ .../engine/src/cli-agent/state-machine.ts | 532 ++++++++++++++++++ .../engine/src/cli-agent/telemetry-hub.ts | 387 +++++++++++++ 4 files changed, 1437 insertions(+) create mode 100644 packages/engine/src/cli-agent/__tests__/state-machine.test.ts create mode 100644 packages/engine/src/cli-agent/__tests__/telemetry-hub.test.ts create mode 100644 packages/engine/src/cli-agent/state-machine.ts create mode 100644 packages/engine/src/cli-agent/telemetry-hub.ts diff --git a/packages/engine/src/cli-agent/__tests__/state-machine.test.ts b/packages/engine/src/cli-agent/__tests__/state-machine.test.ts new file mode 100644 index 0000000000..2935515114 --- /dev/null +++ b/packages/engine/src/cli-agent/__tests__/state-machine.test.ts @@ -0,0 +1,297 @@ +import { describe, it, expect, beforeEach, afterEach, vi } from "vitest"; +import { CliSessionStore } from "@fusion/core"; +import { Database } from "@fusion/core"; +import { mkdtempSync } from "node:fs"; +import { join } from "node:path"; +import { tmpdir } from "node:os"; +import { rm } from "node:fs/promises"; +import { + CliSessionStateMachine, + classifyTermination, + isResumeEligible, + looksLikeAuthFailure, + InvalidCliTransitionError, + type CliStateChange, +} from "../state-machine.js"; + +describe("CliSessionStateMachine", () => { + let tmpDir: string; + let fusionDir: string; + let db: Database; + let store: CliSessionStore; + + beforeEach(() => { + tmpDir = mkdtempSync(join(tmpdir(), "kb-cli-sm-test-")); + fusionDir = join(tmpDir, ".fusion"); + db = new Database(fusionDir, { inMemory: true }); + db.init(); + store = new CliSessionStore(fusionDir, db); + vi.useRealTimers(); + }); + + afterEach(async () => { + db.close(); + await rm(tmpDir, { recursive: true, force: true }); + }); + + function seedSession(overrides: Record = {}): string { + const s = store.createSession({ + purpose: "execute", + projectId: "proj", + adapterId: "claude-code", + ...overrides, + }); + return s.id; + } + + function makeMachine( + sessionId: string, + opts: Partial[0]> = {}, + ): CliSessionStateMachine { + return new CliSessionStateMachine({ sessionId, store, ...opts }); + } + + // ── AE1: native done advances; idle never does ─────────────────────────── + + it("AE1: positive done signal advances busy → done; persists", () => { + const id = seedSession(); + const m = makeMachine(id); + m.markReady(); + m.injectPrompt(); + expect(m.getState()).toBe("busy"); + m.signalDone(); + expect(m.getState()).toBe("done"); + expect(store.getSession(id)?.agentState).toBe("done"); + expect(store.getSession(id)?.terminationReason).toBe("completed"); + }); + + it("AE1: idle / output progress NEVER advances to done", () => { + const id = seedSession(); + const m = makeMachine(id); + m.markReady(); + m.injectPrompt(); + m.signalOutputProgress(); + m.signalOutputProgress(); + expect(m.getState()).toBe("busy"); // never done + }); + + // ── AE2: permission prompt → waitingOnInput, no advance/fail ────────────── + + it("AE2: waitingOnInput holds state (neither advances nor fails) and is reversible", () => { + const id = seedSession(); + const m = makeMachine(id); + m.markReady(); + m.injectPrompt(); + m.signalWaitingOnInput(); + expect(m.getState()).toBe("waitingOnInput"); + expect(store.getSession(id)?.agentState).toBe("waitingOnInput"); + m.signalBusy(); // user answered + expect(m.getState()).toBe("busy"); + }); + + // ── Stall backstop ─────────────────────────────────────────────────────── + + it("stall backstop fires on a quiet busy turn past threshold → needsAttention", () => { + vi.useFakeTimers(); + const id = seedSession(); + const m = makeMachine(id, { stallThresholdMs: 1000 }); + m.markReady(); + m.injectPrompt(); + vi.advanceTimersByTime(1000); + expect(m.getState()).toBe("needsAttention"); + }); + + it("stall backstop NEVER fires on a streaming session (re-armed by output)", () => { + vi.useFakeTimers(); + const id = seedSession(); + const m = makeMachine(id, { stallThresholdMs: 1000 }); + m.markReady(); + m.injectPrompt(); + for (let i = 0; i < 5; i++) { + vi.advanceTimersByTime(900); + m.signalOutputProgress(); // re-arm + } + vi.advanceTimersByTime(900); + expect(m.getState()).toBe("busy"); + }); + + it("stall backstop suppressed while waitingOnInput", () => { + vi.useFakeTimers(); + const id = seedSession(); + const m = makeMachine(id, { stallThresholdMs: 1000 }); + m.markReady(); + m.injectPrompt(); + m.signalWaitingOnInput(); + vi.advanceTimersByTime(5000); + expect(m.getState()).toBe("waitingOnInput"); // no backstop while waiting + }); + + // ── Termination classification — all five paths ────────────────────────── + + it("classifies clean exit-0 mid-task → userExited", () => { + expect(classifyTermination({ exitCode: 0, hadDone: false })).toBe("userExited"); + }); + + it("classifies SIGKILL-from-cancel → killed (no resume)", () => { + const reason = classifyTermination({ cancelled: true, signal: "SIGKILL" }); + expect(reason).toBe("killed"); + expect(isResumeEligible(reason)).toBe(false); + }); + + it("classifies nonzero exit → crashed (resume-eligible)", () => { + const reason = classifyTermination({ exitCode: 1 }); + expect(reason).toBe("crashed"); + expect(isResumeEligible(reason)).toBe(true); + }); + + it("classifies credential-failure pattern → authFailed", () => { + expect( + classifyTermination({ exitCode: 1, recentOutput: "Error: Invalid API key" }), + ).toBe("authFailed"); + expect(looksLikeAuthFailure("authentication failed")).toBe(true); + expect(looksLikeAuthFailure("all good")).toBe(false); + }); + + it("classifies found-dead-on-restart → engineDeath (resume-eligible)", () => { + const reason = classifyTermination({ foundDeadOnRestart: true }); + expect(reason).toBe("engineDeath"); + expect(isResumeEligible(reason)).toBe(true); + }); + + it("processEnded(crashed) routes busy → resuming", () => { + const id = seedSession(); + const m = makeMachine(id); + m.markReady(); + m.injectPrompt(); + const reason = m.processEnded({ exitCode: 1 }); + expect(reason).toBe("crashed"); + expect(m.getState()).toBe("resuming"); + expect(store.getSession(id)?.terminationReason).toBe("crashed"); + }); + + it("processEnded(killed) lands on dead with killed reason", () => { + const id = seedSession(); + const m = makeMachine(id); + m.markReady(); + m.injectPrompt(); + const reason = m.processEnded({ cancelled: true }); + expect(reason).toBe("killed"); + expect(m.getState()).toBe("dead"); + }); + + // ── Resume caps ────────────────────────────────────────────────────────── + + it("resume cap: two failures → needsAttention, third never attempted", () => { + const id = seedSession(); + const m = makeMachine(id); + m.markReady(); + m.injectPrompt(); + m.processEnded({ exitCode: 1 }); // → resuming + m.recordResumeResult(false); // attempt 1 fails + expect(m.getState()).toBe("resuming"); + expect(m.getResumeAttempts()).toBe(1); + m.recordResumeResult(false); // attempt 2 fails → cap + expect(m.getState()).toBe("needsAttention"); + expect(m.getResumeAttempts()).toBe(2); + // No third attempt possible (not in resuming). + expect(() => m.recordResumeResult(false)).toThrow(InvalidCliTransitionError); + }); + + it("resume success returns to busy and resets attempts", () => { + const id = seedSession(); + const m = makeMachine(id); + m.markReady(); + m.injectPrompt(); + m.processEnded({ exitCode: 1 }); + m.recordResumeResult(false); // 1 fail + m.recordResumeResult(true); // succeed + expect(m.getState()).toBe("busy"); + expect(m.getResumeAttempts()).toBe(0); + }); + + it("resume backoff metadata grows per attempt", () => { + const id = seedSession(); + const changes: CliStateChange[] = []; + const m = makeMachine(id, { resumeBackoffBaseMs: 100, maxResumeAttempts: 5 }); + m.onStateChange((c) => changes.push(c)); + m.markReady(); + m.injectPrompt(); + m.processEnded({ exitCode: 1 }); + m.recordResumeResult(false); // attempt 1 → backoff 100 + m.recordResumeResult(false); // attempt 2 → backoff 200 + const backoffs = changes.filter((c) => c.resumeBackoffMs != null).map((c) => c.resumeBackoffMs); + expect(backoffs).toEqual([100, 200]); + }); + + // ── Follow-up + per-turn latch reset ───────────────────────────────────── + + it("done → busy follow-up resets per-turn done latch (two turns one handler)", () => { + vi.useFakeTimers(); + const id = seedSession(); + const m = makeMachine(id, { stallThresholdMs: 1000 }); + m.markReady(); + m.injectPrompt(); + m.signalDone(); + expect(m.getState()).toBe("done"); + // Second turn through the same handler: follow-up re-arms a fresh turn. + m.followUp(); + expect(m.getState()).toBe("busy"); + // The new turn's stall watchdog is fresh (latch reset) — a quiet turn trips it. + vi.advanceTimersByTime(1000); + expect(m.getState()).toBe("needsAttention"); + }); + + // ── needsAttention escalation ──────────────────────────────────────────── + + it("userExited dead landing can escalate to needsAttention preserving reason", () => { + const id = seedSession(); + const m = makeMachine(id); + m.markReady(); + m.injectPrompt(); + m.processEnded({ exitCode: 0 }); // userExited → dead + expect(m.getState()).toBe("dead"); + m.escalateToNeedsAttention(); + expect(m.getState()).toBe("needsAttention"); + expect(store.getSession(id)?.terminationReason).toBe("userExited"); + }); + + // ── Throttled emission ─────────────────────────────────────────────────── + + it("throttled onStateChange coalesces rapid transitions", () => { + vi.useFakeTimers(); + const id = seedSession(); + let nowMs = 0; + const changes: CliStateChange[] = []; + const m = makeMachine(id, { + stateChangeThrottleMs: 100, + now: () => nowMs, + }); + m.onStateChange((c) => changes.push(c)); + m.markReady(); // emits immediately (first) + m.injectPrompt(); // within window → coalesced + m.signalWaitingOnInput(); // within window → coalesced + expect(changes.length).toBe(1); + nowMs = 100; + vi.advanceTimersByTime(100); + // The latest coalesced change is delivered at the window edge. + expect(changes.length).toBe(2); + expect(changes[1].state).toBe("waitingOnInput"); + }); + + // ── Rebuild from persisted record ──────────────────────────────────────── + + it("rebuilds state from the persisted record on construction", () => { + const id = seedSession({ agentState: "busy" }); + const m = makeMachine(id); + expect(m.getState()).toBe("busy"); + }); + + // ── Invalid transitions guarded ────────────────────────────────────────── + + it("rejects illegal transitions", () => { + const id = seedSession(); + const m = makeMachine(id); // starting + expect(() => m.signalDone()).toThrow(InvalidCliTransitionError); + expect(() => m.followUp()).toThrow(InvalidCliTransitionError); + }); +}); diff --git a/packages/engine/src/cli-agent/__tests__/telemetry-hub.test.ts b/packages/engine/src/cli-agent/__tests__/telemetry-hub.test.ts new file mode 100644 index 0000000000..bc7851bf11 --- /dev/null +++ b/packages/engine/src/cli-agent/__tests__/telemetry-hub.test.ts @@ -0,0 +1,221 @@ +import { describe, it, expect, beforeEach, afterEach } from "vitest"; +import { CliSessionStore, Database } from "@fusion/core"; +import { mkdtempSync } from "node:fs"; +import { join } from "node:path"; +import { tmpdir } from "node:os"; +import { rm } from "node:fs/promises"; +import { TelemetryHub, stripAnsiControl } from "../telemetry-hub.js"; + +describe("TelemetryHub", () => { + let tmpDir: string; + let fusionDir: string; + let db: Database; + let store: CliSessionStore; + + beforeEach(() => { + tmpDir = mkdtempSync(join(tmpdir(), "kb-cli-hub-test-")); + fusionDir = join(tmpDir, ".fusion"); + db = new Database(fusionDir, { inMemory: true }); + db.init(); + store = new CliSessionStore(fusionDir, db); + }); + + afterEach(async () => { + db.close(); + await rm(tmpDir, { recursive: true, force: true }); + }); + + function seed(overrides: Record = {}): string { + return store.createSession({ + purpose: "execute", + projectId: "proj", + adapterId: "claude-code", + ...overrides, + }).id; + } + + // ── Token registry ───────────────────────────────────────────────────────── + + it("token validates only for its own session", () => { + const a = seed({ agentState: "busy" }); + const b = seed({ agentState: "busy" }); + const hub = new TelemetryHub({ store }); + const tokenA = hub.issueToken(a); + const tokenB = hub.issueToken(b); + expect(hub.validateToken(a, tokenA)).toBe(true); + expect(hub.validateToken(b, tokenB)).toBe(true); + // Forged completion: session A presenting B's token → rejected. + expect(hub.validateToken(a, tokenB)).toBe(false); + expect(hub.validateToken(b, tokenA)).toBe(false); + }); + + it("tokens are high-entropy and unique per session", () => { + const a = seed({ agentState: "busy" }); + const b = seed({ agentState: "busy" }); + const hub = new TelemetryHub({ store }); + const tokenA = hub.issueToken(a); + const tokenB = hub.issueToken(b); + expect(tokenA).toHaveLength(64); // 32 bytes → 64 hex + expect(tokenA).not.toEqual(tokenB); + expect(hub.validateToken(a, "deadbeef")).toBe(false); + expect(hub.validateToken(a, null)).toBe(false); + }); + + it("invalidate revokes the token after session end", () => { + const a = seed({ agentState: "busy" }); + const hub = new TelemetryHub({ store }); + const tokenA = hub.issueToken(a); + expect(hub.validateToken(a, tokenA)).toBe(true); + hub.invalidate(a); + expect(hub.validateToken(a, tokenA)).toBe(false); + expect(hub.hasSession(a)).toBe(false); + }); + + it("rebuilds only from live sessions; non-live sessions never validate after restart", () => { + const live = seed({ agentState: "busy" }); + const dead = seed({ agentState: "done", terminationReason: "completed" }); + // First hub mints a token for the dead-in-future session while it was live... + const hub1 = new TelemetryHub({ store }); + const staleToken = hub1.issueToken(dead); + expect(hub1.validateToken(dead, staleToken)).toBe(true); + + // Simulate restart: a fresh hub rebuilds from the store. `dead` is no longer + // live, so its on-disk-era token is not reconstituted. + const hub2 = new TelemetryHub({ store }); + expect(hub2.hasSession(live)).toBe(true); + expect(hub2.hasSession(dead)).toBe(false); + expect(hub2.validateToken(dead, staleToken)).toBe(false); + }); + + // ── Ingestion → state routing ──────────────────────────────────────────── + + it("sessionStart drives starting → ready; native session id captured", () => { + const a = seed(); // starting + const hub = new TelemetryHub({ store }); + hub.issueToken(a); + hub.ingest(a, { kind: "sessionStart", payload: { nativeSessionId: "claude-xyz" } }); + expect(hub.getStateMachine(a)?.getState()).toBe("ready"); + expect(store.getSession(a)?.nativeSessionId).toBe("claude-xyz"); + }); + + it("native done advances to done; idle/output never does", () => { + const a = seed({ agentState: "busy" }); + const hub = new TelemetryHub({ store }); + hub.issueToken(a); + hub.ingest(a, { kind: "outputProgress", payload: { text: "thinking..." } }); + hub.ingest(a, { kind: "toolActivity" }); + expect(hub.getStateMachine(a)?.getState()).toBe("busy"); // never done from activity + hub.ingest(a, { kind: "done" }); + expect(hub.getStateMachine(a)?.getState()).toBe("done"); + }); + + it("AE2: waitingOnInput dispatches notification, state does not advance/fail", () => { + const a = seed({ agentState: "busy" }); + const dispatched: unknown[] = []; + const hub = new TelemetryHub({ + store, + onNotification: (info) => dispatched.push(info), + }); + hub.issueToken(a); + hub.ingest(a, { + kind: "waitingOnInput", + payload: { notification: { type: "permission", tool: "Bash" } }, + }); + expect(hub.getStateMachine(a)?.getState()).toBe("waitingOnInput"); + expect(dispatched).toHaveLength(1); + expect(dispatched[0]).toMatchObject({ + sessionId: a, + notification: { type: "permission", tool: "Bash" }, + }); + }); + + it("ingest on unknown / non-live session is a no-op, not a crash", () => { + const hub = new TelemetryHub({ store }); + expect(() => hub.ingest("nope", { kind: "done" })).not.toThrow(); + expect(hub.ingest("nope", { kind: "done" })).toBeUndefined(); + }); + + // ── Two turns through one handler: latch reset ─────────────────────────── + + it("per-turn event budget resets on a new busy turn", () => { + const a = seed({ agentState: "busy" }); + const hub = new TelemetryHub({ store, maxEventsPerTurn: 2 }); + hub.issueToken(a); + // Turn 1: budget = 2. Third event dropped. + expect(hub.ingest(a, { kind: "outputProgress", payload: { text: "a" } })).toBeDefined(); + expect(hub.ingest(a, { kind: "outputProgress", payload: { text: "b" } })).toBeDefined(); + expect(hub.ingest(a, { kind: "outputProgress", payload: { text: "c" } })).toBeUndefined(); + // A `busy` event begins a fresh turn → budget resets (the busy event itself + // consumes one slot, then there is room again). + hub.ingest(a, { kind: "busy" }); + expect(hub.ingest(a, { kind: "outputProgress", payload: { text: "d" } })).toBeDefined(); + }); + + // ── Bounding: oversized event capped ───────────────────────────────────── + + it("oversized event text is capped", () => { + const a = seed({ agentState: "busy" }); + const hub = new TelemetryHub({ store, maxEventChars: 50, chunkCarryChars: 0 }); + hub.issueToken(a); + // Plain prose (no secret-looking runs) so redaction doesn't collapse it + // before the size cap is exercised. + const big = "lorem ipsum ".repeat(2000); + const out = hub.ingest(a, { kind: "outputProgress", payload: { text: big } }); + expect(out?.text?.length).toBe(50); + expect(out?.truncated).toBe(true); + }); + + // ── ANSI noise stripped before pattern matching ────────────────────────── + + it("strips ANSI / control sequences before pattern matching", () => { + expect(stripAnsiControl("do\x1b[1mne\x1b[0m")).toBe("done"); + expect(stripAnsiControl("clean")).toBe("clean"); + const a = seed({ agentState: "busy" }); + const hub = new TelemetryHub({ store, chunkCarryChars: 0 }); + hub.issueToken(a); + const out = hub.ingest(a, { + kind: "transcript", + payload: { text: "\x1b[32mhello\x1b[0m \x1b[1mworld\x1b[0m" }, + }); + expect(out?.text).toBe("hello world"); + }); + + // ── Secret redaction (incl. cross-chunk boundary) ──────────────────────── + + it("redacts secrets within a single chunk", () => { + const a = seed({ agentState: "busy" }); + const hub = new TelemetryHub({ store, chunkCarryChars: 0 }); + hub.issueToken(a); + const out = hub.ingest(a, { + kind: "transcript", + payload: { text: "export API_KEY=sk-abcdef0123456789abcdef0123" }, + }); + expect(out?.text).not.toContain("sk-abcdef0123456789abcdef0123"); + expect(out?.text).toContain("[REDACTED]"); + }); + + it("redacts a secret spanning a chunk boundary", () => { + const a = seed({ agentState: "busy" }); + // Generous carry so the boundary prefix is held and joined with the next chunk. + const hub = new TelemetryHub({ store, chunkCarryChars: 64 }); + hub.issueToken(a); + // Prefix "token=" arrives in chunk 1 (held in carry), value in chunk 2. + const out1 = hub.ingest(a, { kind: "transcript", payload: { text: "the token=" } }); + const out2 = hub.ingest(a, { + kind: "transcript", + payload: { text: "sk-abcdef0123456789abcdef0123 done" }, + }); + const combined = (out1?.text ?? "") + (out2?.text ?? "") + (hub.flush(a) ?? ""); + expect(combined).not.toContain("sk-abcdef0123456789abcdef0123"); + expect(combined).toContain("[REDACTED]"); + }); + + it("flush emits the held tail redacted on session end", () => { + const a = seed({ agentState: "busy" }); + const hub = new TelemetryHub({ store, chunkCarryChars: 64 }); + hub.issueToken(a); + hub.ingest(a, { kind: "transcript", payload: { text: "trailing secret=sk-zzzz0123456789abcd0123" } }); + const flushed = hub.flush(a) ?? ""; + expect(flushed).not.toContain("sk-zzzz0123456789abcd0123"); + }); +}); diff --git a/packages/engine/src/cli-agent/state-machine.ts b/packages/engine/src/cli-agent/state-machine.ts new file mode 100644 index 0000000000..9ad6fa6729 --- /dev/null +++ b/packages/engine/src/cli-agent/state-machine.ts @@ -0,0 +1,532 @@ +/** + * CliSessionStateMachine — authoritative per-session agent state machine + * (CLI Agent Executor, U3). + * + * Implements the HTD state diagram exactly: + * + * [*] → starting + * starting → ready (readiness detected) + * ready → busy (prompt injected) + * busy → waitingOnInput (permission / question signal) + * waitingOnInput → busy (user answers) + * busy → done (POSITIVE completion signal — idle NEVER does this) + * done → busy (follow-up; resume first if the PTY was reaped) + * busy → dead (PTY end / engine death) + * waitingOnInput → dead (PTY end / engine death) + * dead → {killed|userExited|authFailed|resuming} (classification choice) + * resuming → busy (native resume ok) + * resuming → needsAttention (2 attempts exhausted) + * userExited → needsAttention (advance / retry / cancel prompt) + * authFailed → needsAttention (re-authenticate message) + * + * Key behaviors (KTD — completion gating, termination taxonomy, stall backstop): + * - Positive completion is distinct from idleness. `signalDone()` advances to + * `done`; output progress / idleness NEVER advance to done. + * - Stall backstop: no output progress past a configurable threshold WITHOUT a + * done/waiting signal → needsAttention. The inactivity watchdog is re-armed by + * any telemetry/output event (no fixed turn timeout); `waitingOnInput` + * suppresses it (expected idleness). + * - Termination classification helper maps the manner of a PTY end onto the + * taxonomy (killed / userExited / crashed / authFailed / engineDeath). + * - Resume attempt cap = 2 with backoff metadata; exhaustion → needsAttention. + * - Per-turn latches/budgets reset between turns (a new busy turn re-arms the + * completion latch so a second turn through one handler is tracked cleanly). + * + * Persistence + observability: + * - Every transition persists through `CliSessionStore.updateSession` (state + + * terminationReason + resumeAttempts written atomically by the store). + * - A throttled `onStateChange` callback is exposed for the SSE bridge to + * subscribe to later. This module NEVER imports dashboard code. + */ + +import type { + CliAgentState, + CliAutonomyPosture, + CliSessionStore, + CliTerminationReason, +} from "@fusion/core"; + +// ── Public types ─────────────────────────────────────────────────────────── + +/** + * The machine's own state space. This is the U1 `CliAgentState` plus the + * transient HTD `"resuming"` sub-state, which is NOT a persisted store enum + * (U1's union has no `resuming`). When persisting, `resuming` maps onto the + * `dead` store state while the resume-eligible termination reason + * (crashed / engineDeath) carries the recovery intent. Surfaces that subscribe + * to `onStateChange` see the richer machine state so the SSE bridge can render + * "resuming…" without a schema change. + */ +export type CliMachineState = CliAgentState | "resuming"; + +/** Map a machine state onto the persisted U1 store enum. */ +export function toPersistedState(state: CliMachineState): CliAgentState { + return state === "resuming" ? "dead" : state; +} + +/** A throttled state-change notification handed to subscribers (e.g. the SSE bridge). */ +export interface CliStateChange { + sessionId: string; + /** The machine state moved into (may be the transient `resuming`). */ + state: CliMachineState; + /** Termination reason when relevant (set on dead-classification transitions). */ + terminationReason: CliTerminationReason | null; + /** Resume attempt count at the time of the change. */ + resumeAttempts: number; + /** Backoff (ms) to wait before the next resume attempt, when resuming. */ + resumeBackoffMs?: number; + /** ISO timestamp of the change. */ + at: string; +} + +export type CliStateChangeListener = (change: CliStateChange) => void; + +/** + * How a PTY ended, as observed by the manager / restart sweep. Fed into the + * classification helper to derive the termination taxonomy. + */ +export interface CliProcessEndInfo { + /** Whether the engine itself died and found the session dead on restart. */ + foundDeadOnRestart?: boolean; + /** Whether the end was a deliberate hard cancel (SIGKILL-from-cancel). */ + cancelled?: boolean; + /** Process exit code (0 = clean). Undefined when killed by signal. */ + exitCode?: number | null; + /** Signal that terminated the process, if any (e.g. "SIGKILL"). */ + signal?: string | number | null; + /** + * Recent (ANSI-stripped) output, scanned for a credential-failure pattern. + * Supplied by the caller (the hub strips ANSI before pattern matching). + */ + recentOutput?: string; + /** Whether the session had observed a positive `done` before the end. */ + hadDone?: boolean; +} + +export interface CliStateMachineOptions { + sessionId: string; + store: CliSessionStore; + /** Autonomy posture (supplies maxResumeAttempts override). */ + posture?: CliAutonomyPosture | null; + /** + * Inactivity / stall threshold (ms). If no output progress and no done/waiting + * signal arrives within this window of a busy turn, the backstop fires + * (needsAttention). Default 5 minutes. + */ + stallThresholdMs?: number; + /** Max resume attempts before giving up. Default 2 (KTD). */ + maxResumeAttempts?: number; + /** Base backoff (ms) for resume attempts; doubled per attempt. Default 1000. */ + resumeBackoffBaseMs?: number; + /** Throttle window (ms) for `onStateChange`. Default 0 (emit every change). */ + stateChangeThrottleMs?: number; + /** Clock injection for tests. */ + now?: () => number; + /** + * Timer scheduler injection for tests (fake timers). Returns a cancel handle. + * Defaults to setTimeout/clearTimeout. + */ + setTimer?: (fn: () => void, ms: number) => unknown; + clearTimer?: (handle: unknown) => void; +} + +/** + * Default credential-failure detector. Scans (already ANSI-stripped) recent + * output for common auth-rejection phrasing. + */ +const AUTH_FAILURE_PATTERN = + /\b(authentication failed|invalid api key|unauthorized|401 unauthorized|not authenticated|please (?:re-?)?(?:login|log in|authenticate)|credential[s]? (?:rejected|invalid|expired)|your session has expired|token (?:expired|invalid|revoked))\b/i; + +/** Detect a credential-failure pattern in recent (ANSI-stripped) output. */ +export function looksLikeAuthFailure(recentOutput: string | undefined): boolean { + if (!recentOutput) return false; + return AUTH_FAILURE_PATTERN.test(recentOutput); +} + +/** + * Classify a PTY end onto the termination taxonomy (KTD). Pure — no side effects. + * + * - found-dead-on-restart → engineDeath + * - SIGKILL-from-cancel / hard cancel → killed + * - credential-failure in recent output → authFailed + * - clean exit-0 mid-task (no done) → userExited + * - nonzero exit / killed by signal → crashed + * - any exit AFTER a positive done → completed + */ +export function classifyTermination(info: CliProcessEndInfo): CliTerminationReason { + if (info.foundDeadOnRestart) return "engineDeath"; + if (info.cancelled) return "killed"; + if (looksLikeAuthFailure(info.recentOutput)) return "authFailed"; + if (info.hadDone) return "completed"; + // Killed by a signal (no clean exit) → crashed. + if (info.signal != null && info.signal !== 0) return "crashed"; + if (info.exitCode === 0) return "userExited"; + // Any nonzero / unknown exit code → crashed. + return "crashed"; +} + +/** Resume-eligible termination reasons (KTD): only crash / engine death auto-resume. */ +export function isResumeEligible(reason: CliTerminationReason): boolean { + return reason === "crashed" || reason === "engineDeath"; +} + +/** Error thrown when a transition is attempted from an incompatible state. */ +export class InvalidCliTransitionError extends Error { + readonly code = "INVALID_CLI_TRANSITION"; + constructor( + public readonly from: CliMachineState, + public readonly intent: string, + ) { + super(`Invalid CLI session transition: cannot ${intent} from state "${from}"`); + this.name = "InvalidCliTransitionError"; + } +} + +// ── State machine ────────────────────────────────────────────────────────── + +export class CliSessionStateMachine { + readonly sessionId: string; + private readonly store: CliSessionStore; + private readonly stallThresholdMs: number; + private readonly maxResumeAttempts: number; + private readonly resumeBackoffBaseMs: number; + private readonly throttleMs: number; + private readonly now: () => number; + private readonly setTimer: (fn: () => void, ms: number) => unknown; + private readonly clearTimer: (handle: unknown) => void; + + private state: CliMachineState; + private terminationReason: CliTerminationReason | null = null; + private resumeAttempts = 0; + + /** Per-turn latch: has a positive done fired in the current busy turn. */ + private doneLatched = false; + /** Per-turn latch: has waiting-on-input fired in the current busy turn. */ + private waitingLatched = false; + + private stallTimer: unknown = null; + private listeners = new Set(); + + // Throttle bookkeeping. + private lastEmitAt = 0; + private pendingEmit: CliStateChange | null = null; + private throttleTimer: unknown = null; + + constructor(opts: CliStateMachineOptions) { + this.sessionId = opts.sessionId; + this.store = opts.store; + this.stallThresholdMs = opts.stallThresholdMs ?? 5 * 60_000; + this.maxResumeAttempts = + opts.maxResumeAttempts ?? + (typeof opts.posture?.maxResumeAttempts === "number" + ? opts.posture.maxResumeAttempts + : 2); + this.resumeBackoffBaseMs = opts.resumeBackoffBaseMs ?? 1000; + this.throttleMs = opts.stateChangeThrottleMs ?? 0; + this.now = opts.now ?? (() => Date.now()); + this.setTimer = + opts.setTimer ?? ((fn, ms) => setTimeout(fn, ms) as unknown); + this.clearTimer = + opts.clearTimer ?? ((h) => clearTimeout(h as ReturnType)); + + // Seed from the persisted record so a rebuilt machine reflects reality. + const existing = this.store.getSession(this.sessionId); + this.state = existing?.agentState ?? "starting"; + this.terminationReason = existing?.terminationReason ?? null; + this.resumeAttempts = existing?.resumeAttempts ?? 0; + } + + // ── Observation ────────────────────────────────────────────────────────── + + getState(): CliMachineState { + return this.state; + } + + getTerminationReason(): CliTerminationReason | null { + return this.terminationReason; + } + + getResumeAttempts(): number { + return this.resumeAttempts; + } + + /** Subscribe to throttled state changes. Returns an unsubscribe handle. */ + onStateChange(listener: CliStateChangeListener): () => void { + this.listeners.add(listener); + return () => this.listeners.delete(listener); + } + + // ── Transitions (HTD diagram) ──────────────────────────────────────────── + + /** starting → ready (readiness detected). */ + markReady(): void { + if (this.state !== "starting") { + throw new InvalidCliTransitionError(this.state, "markReady"); + } + this.transition("ready"); + } + + /** + * ready → busy (prompt injected) and done → busy (follow-up). + * Begins a new turn: per-turn latches reset, stall watchdog armed. + */ + injectPrompt(): void { + if (this.state !== "ready" && this.state !== "done" && this.state !== "resuming") { + throw new InvalidCliTransitionError(this.state, "injectPrompt"); + } + this.beginTurn(); + this.transition("busy"); + } + + /** done → busy (follow-up). Alias for injectPrompt from the done state. */ + followUp(): void { + if (this.state !== "done") { + throw new InvalidCliTransitionError(this.state, "followUp"); + } + this.beginTurn(); + this.transition("busy"); + } + + /** + * Output progress / activity. Re-arms the inactivity watchdog. NEVER advances + * state — idleness and activity are both gated away from `done`. + */ + signalOutputProgress(): void { + if (this.state === "busy") { + this.armStallWatchdog(); + } + } + + /** + * busy → waitingOnInput (permission / question signal). Suppresses the stall + * watchdog (expected idleness). Does NOT advance the pipeline or fail. + */ + signalWaitingOnInput(): void { + if (this.state === "waitingOnInput") return; // idempotent + if (this.state !== "busy") { + throw new InvalidCliTransitionError(this.state, "signalWaitingOnInput"); + } + this.waitingLatched = true; + this.clearStallWatchdog(); + this.transition("waitingOnInput"); + } + + /** waitingOnInput → busy (user answered). Re-arms the watchdog. */ + signalBusy(): void { + if (this.state === "busy") { + this.armStallWatchdog(); + return; + } + if (this.state !== "waitingOnInput") { + throw new InvalidCliTransitionError(this.state, "signalBusy"); + } + this.armStallWatchdog(); + this.transition("busy"); + } + + /** + * busy → done (POSITIVE completion signal). This is the ONLY path to `done`. + * Idle / output progress never reach here. + */ + signalDone(): void { + if (this.state === "done") return; // idempotent + if (this.state !== "busy" && this.state !== "waitingOnInput") { + throw new InvalidCliTransitionError(this.state, "signalDone"); + } + this.doneLatched = true; + this.clearStallWatchdog(); + this.transition("done", "completed"); + } + + /** + * busy/waitingOnInput → dead, then classify. Provide the observed end info; + * the taxonomy is derived by `classifyTermination`. After classification: + * - killed / userExited / authFailed → terminal (userExited/authFailed will be + * surfaced as needsAttention by the caller's escalation, but the recorded + * reason stays precise — `escalateToNeedsAttention` moves the state). + * - crashed / engineDeath → `resuming` (caller drives resume attempts). + * - completed → done. + * + * @returns the classified termination reason. + */ + processEnded(info: CliProcessEndInfo): CliTerminationReason { + // dead is reachable from any active state. + this.clearStallWatchdog(); + const reason = classifyTermination({ ...info, hadDone: info.hadDone ?? this.doneLatched }); + this.terminationReason = reason; + + if (reason === "completed") { + this.transition("done", "completed"); + return reason; + } + if (isResumeEligible(reason)) { + this.transition("resuming", reason); + return reason; + } + // killed / userExited / authFailed are recorded on a `dead` landing; the + // diagram's killed → [*] is terminal, while userExited / authFailed escalate + // to needsAttention via escalateToNeedsAttention(). + this.transition("dead", reason); + return reason; + } + + /** + * Record a resume attempt result. + * - success → busy (a fresh turn). + * - failure → another `resuming` with backoff, until the cap (2) is hit, then + * needsAttention. The third attempt is never made. + */ + recordResumeResult(success: boolean): void { + if (this.state !== "resuming") { + throw new InvalidCliTransitionError(this.state, "recordResumeResult"); + } + if (success) { + this.resumeAttempts = 0; + this.beginTurn(); + this.transition("busy"); + return; + } + this.resumeAttempts += 1; + if (this.resumeAttempts >= this.maxResumeAttempts) { + this.transition("needsAttention"); + return; + } + // Stay in resuming with backoff metadata so the coordinator schedules a retry. + const backoff = this.resumeBackoffBaseMs * 2 ** (this.resumeAttempts - 1); + this.persistAndEmit("resuming", this.terminationReason, backoff); + } + + /** Backoff (ms) the coordinator should wait before the next resume attempt. */ + nextResumeBackoffMs(): number { + return this.resumeBackoffBaseMs * 2 ** this.resumeAttempts; + } + + /** + * Escalate the current dead/auth/userExit landing to needsAttention (the + * userExited → needsAttention and authFailed → needsAttention edges). The + * recorded terminationReason is preserved. + */ + escalateToNeedsAttention(): void { + if ( + this.state !== "dead" && + this.state !== "resuming" && + this.state !== "busy" && + this.state !== "waitingOnInput" + ) { + throw new InvalidCliTransitionError(this.state, "escalateToNeedsAttention"); + } + this.clearStallWatchdog(); + this.transition("needsAttention"); + } + + /** Force-dispose: cancel timers and drop listeners. */ + dispose(): void { + this.clearStallWatchdog(); + if (this.throttleTimer != null) { + this.clearTimer(this.throttleTimer); + this.throttleTimer = null; + } + this.listeners.clear(); + } + + // ── Per-turn latches / stall watchdog ──────────────────────────────────── + + private beginTurn(): void { + // Reset per-turn latches and budgets between turns (KTD). + this.doneLatched = false; + this.waitingLatched = false; + this.armStallWatchdog(); + } + + private armStallWatchdog(): void { + this.clearStallWatchdog(); + this.stallTimer = this.setTimer(() => { + this.onStall(); + }, this.stallThresholdMs); + } + + private clearStallWatchdog(): void { + if (this.stallTimer != null) { + this.clearTimer(this.stallTimer); + this.stallTimer = null; + } + } + + /** + * Stall backstop: quiet busy turn past the threshold with no done/waiting + * signal → needsAttention. Never fires from waitingOnInput (cleared) and never + * from a streaming session (re-armed by output progress). + */ + private onStall(): void { + this.stallTimer = null; + if (this.state !== "busy") return; + if (this.doneLatched || this.waitingLatched) return; + this.transition("needsAttention"); + } + + // ── Persistence + throttled emit ───────────────────────────────────────── + + private transition(next: CliMachineState, reason?: CliTerminationReason): void { + this.state = next; + if (reason !== undefined) this.terminationReason = reason; + if (next === "busy" || next === "ready") { + // Live again: clear any stale termination reason. + this.terminationReason = null; + } + this.persistAndEmit(next, this.terminationReason); + } + + private persistAndEmit( + next: CliMachineState, + reason: CliTerminationReason | null, + resumeBackoffMs?: number, + ): void { + // Persist the U1 store enum (resuming → dead); the machine state and the + // resume-eligible reason carry the recovery intent for surfaces. + this.store.updateSession(this.sessionId, { + agentState: toPersistedState(next), + terminationReason: reason, + resumeAttempts: this.resumeAttempts, + }); + const change: CliStateChange = { + sessionId: this.sessionId, + state: next, + terminationReason: reason, + resumeAttempts: this.resumeAttempts, + ...(resumeBackoffMs !== undefined ? { resumeBackoffMs } : {}), + at: new Date(this.now()).toISOString(), + }; + this.emitThrottled(change); + } + + private emitThrottled(change: CliStateChange): void { + if (this.throttleMs <= 0) { + this.deliver(change); + return; + } + // Leading-edge: when no throttle window is open, deliver immediately and + // open a window. Subsequent changes within the window coalesce into a single + // trailing emit of the latest change at the window edge. + if (this.throttleTimer == null) { + this.deliver(change); + this.throttleTimer = this.setTimer(() => { + this.throttleTimer = null; + if (this.pendingEmit) { + const pending = this.pendingEmit; + this.pendingEmit = null; + this.deliver(pending); + } + }, this.throttleMs); + return; + } + // Within an open window → coalesce (keep only the latest). + this.pendingEmit = change; + } + + private deliver(change: CliStateChange): void { + this.lastEmitAt = this.now(); + for (const listener of this.listeners) { + listener(change); + } + } +} diff --git a/packages/engine/src/cli-agent/telemetry-hub.ts b/packages/engine/src/cli-agent/telemetry-hub.ts new file mode 100644 index 0000000000..9fea4e5250 --- /dev/null +++ b/packages/engine/src/cli-agent/telemetry-hub.ts @@ -0,0 +1,387 @@ +/** + * TelemetryHub — in-process telemetry ingestion + per-session token registry + * (CLI Agent Executor, U3). + * + * The hub is the single in-process sink for normalized telemetry events about a + * CLI agent session. It is consumed later by: + * - the dashboard hook route (U17), which forwards validated hook POSTs, and + * - log-tailing adapters (Codex rollout, Pi JSONL) that synthesize events. + * + * The engine has NO HTTP server — this module is pure engine code. It performs + * NO networking; it only validates tokens and ingests already-delivered events. + * + * Responsibilities (KTD — telemetry tiering, completion gating, security): + * - Token registry: mint a high-entropy per-session hook token at spawn + * (`issueToken`), validate it scoped to its own session (`validateToken`), and + * invalidate it on session end (`invalidate`). On construction the registry is + * rebuilt ONLY from sessions still live in `CliSessionStore`, so stale on-disk + * tokens for non-live sessions never validate after an engine restart. + * A forged completion using another session's token is rejected because a + * token validates only for the session it was issued to. + * - Normalization + bounding: per-event payload size caps, per-turn event count + * caps, ANSI / control stripping BEFORE any pattern matching, and secret + * redaction that survives chunk boundaries (a token split across two chunks is + * still caught — uses `redactSecrets` from @fusion/core on the joined tail). + * - Routing: maps a normalized event onto the session's state machine + * (sessionStart→ready, busy→signalBusy, waitingOnInput→signalWaitingOnInput + + * notification dispatch, done→signalDone, outputProgress→signalOutputProgress). + * Idle/output NEVER advances to done — that gating lives in the state machine. + */ + +import { randomBytes } from "node:crypto"; +import { redactSecrets, type CliSessionStore } from "@fusion/core"; +import { CliSessionStateMachine } from "./state-machine.js"; + +// ── Constants (bounding rules) ─────────────────────────────────────────────── + +/** Max retained text per ingested event after stripping (bytes/chars). */ +export const DEFAULT_MAX_EVENT_CHARS = 64 * 1024; +/** Max events accepted per turn before further events are dropped (count cap). */ +export const DEFAULT_MAX_EVENTS_PER_TURN = 5000; +/** + * Carry-over window kept across chunks so a secret straddling a chunk boundary is + * still redacted (prefix in chunk N, value in chunk N+1). + */ +export const DEFAULT_CHUNK_CARRY_CHARS = 256; + +/** A live session that the hub considers "live" when rebuilding tokens. */ +const LIVE_STATES = new Set(["starting", "ready", "busy", "waitingOnInput", "resuming"]); + +// ── Event contract ─────────────────────────────────────────────────────────── + +/** Normalized telemetry event kinds the hub understands. */ +export type TelemetryEventKind = + | "sessionStart" + | "busy" + | "waitingOnInput" + | "done" + | "toolActivity" + | "outputProgress" + | "transcript"; + +/** A normalized telemetry event. `payload` is event-specific, free-form, bounded. */ +export interface TelemetryEvent { + kind: TelemetryEventKind; + payload?: Record & { + /** Raw text chunk (output / transcript) — stripped + redacted on ingest. */ + text?: string; + /** Native session id reported by the CLI (e.g. Claude `session_id`). */ + nativeSessionId?: string; + /** Notification context for a waitingOnInput event (permission/question). */ + notification?: Record; + }; +} + +/** The sanitized form of an event after ingest bounding/stripping/redaction. */ +export interface SanitizedTelemetryEvent { + kind: TelemetryEventKind; + /** Sanitized text (ANSI/control stripped, secret-redacted, size-capped). */ + text?: string; + nativeSessionId?: string; + notification?: Record; + /** True when the event text was truncated by the size cap. */ + truncated?: boolean; +} + +/** Dispatch invoked when a waitingOnInput event is ingested (banner/notify). */ +export type NotificationDispatch = (info: { + sessionId: string; + notification: Record | undefined; +}) => void; + +export interface TelemetryHubOptions { + store: CliSessionStore; + /** Notification dispatch for waiting-on-input events (per node config). */ + onNotification?: NotificationDispatch; + /** Per-event text cap. */ + maxEventChars?: number; + /** Per-turn event count cap. */ + maxEventsPerTurn?: number; + /** Cross-chunk carry-over window for boundary-spanning secret redaction. */ + chunkCarryChars?: number; + /** Token byte length (high entropy). Default 32 bytes → 64 hex chars. */ + tokenBytes?: number; + /** Factory for a session's state machine (test injection). */ + createStateMachine?: (sessionId: string) => CliSessionStateMachine; +} + +// ── ANSI / control stripping ───────────────────────────────────────────────── + +const ESC = "\\u001b"; +// OSC: ESC ] ... terminated by BEL () or ST (ESC \). Strip first — it +// carries ';' the CSI pattern would otherwise eat into. +const OSC_PATTERN = new RegExp(`${ESC}\\][\\s\\S]*?(?:\\u0007|${ESC}\\\\)`, "g"); +// CSI (ESC [ params intermediates final) + other 2-char ESC sequences. +const ANSI_PATTERN = new RegExp(`${ESC}\\[[0-?]*[ -/]*[@-~]|${ESC}[@-Z\\\\-_]`, "g"); +// Remaining lone C0 controls (except \t \n \r) and DEL. +// eslint-disable-next-line no-control-regex +const C0_PATTERN = /[\u0000-\u0008\u000b\u000c\u000e-\u001f\u007f]/g; + +/** Strip ANSI escape sequences and stray control chars from text. */ +export function stripAnsiControl(text: string): string { + return text.replace(OSC_PATTERN, "").replace(ANSI_PATTERN, "").replace(C0_PATTERN, ""); +} + +// ── Per-session telemetry state ────────────────────────────────────────────── + +interface SessionTelemetry { + token: string; + machine: CliSessionStateMachine; + /** Event count in the current turn (reset on a new busy turn). */ + turnEventCount: number; + /** Tail of the previous chunk's text, kept for boundary-spanning redaction. */ + carry: string; +} + +// ── Hub ────────────────────────────────────────────────────────────────────── + +export class TelemetryHub { + private readonly store: CliSessionStore; + private readonly onNotification?: NotificationDispatch; + private readonly maxEventChars: number; + private readonly maxEventsPerTurn: number; + private readonly chunkCarryChars: number; + private readonly tokenBytes: number; + private readonly createStateMachine: (sessionId: string) => CliSessionStateMachine; + + /** token → sessionId reverse index (validates token-belongs-to-session). */ + private readonly tokenToSession = new Map(); + private readonly sessions = new Map(); + + constructor(opts: TelemetryHubOptions) { + this.store = opts.store; + this.onNotification = opts.onNotification; + this.maxEventChars = opts.maxEventChars ?? DEFAULT_MAX_EVENT_CHARS; + this.maxEventsPerTurn = opts.maxEventsPerTurn ?? DEFAULT_MAX_EVENTS_PER_TURN; + this.chunkCarryChars = opts.chunkCarryChars ?? DEFAULT_CHUNK_CARRY_CHARS; + this.tokenBytes = opts.tokenBytes ?? 32; + this.createStateMachine = + opts.createStateMachine ?? + ((sessionId) => new CliSessionStateMachine({ sessionId, store: this.store })); + + this.rebuildFromLiveSessions(); + } + + /** + * Rebuild the per-session registry from sessions still live in the store. Stale + * tokens for non-live sessions are NOT recreated — only sessions in a live + * state get a fresh token, so a forged POST referencing a dead session's id has + * no valid token to present. Tokens are NOT persisted, so a restart always + * mints fresh ones; an attacker holding an old on-disk token cannot validate. + */ + private rebuildFromLiveSessions(): void { + const live = this.store + .listSessions() + .filter((s) => LIVE_STATES.has(s.agentState)); + for (const session of live) { + this.register(session.id); + } + } + + /** Whether a session id is currently registered (live) with the hub. */ + hasSession(sessionId: string): boolean { + return this.sessions.has(sessionId); + } + + /** Get the state machine for a registered session (for the executor seam). */ + getStateMachine(sessionId: string): CliSessionStateMachine | undefined { + return this.sessions.get(sessionId)?.machine; + } + + // ── Token registry ───────────────────────────────────────────────────────── + + /** + * Register a session and mint its high-entropy hook token. Idempotent: a second + * call returns the existing token (so rebuild + spawn races don't double-mint). + */ + private register(sessionId: string): string { + const existing = this.sessions.get(sessionId); + if (existing) return existing.token; + const token = randomBytes(this.tokenBytes).toString("hex"); + const machine = this.createStateMachine(sessionId); + this.sessions.set(sessionId, { token, machine, turnEventCount: 0, carry: "" }); + this.tokenToSession.set(token, sessionId); + return token; + } + + /** Mint (or return) the per-session hook token at spawn. */ + issueToken(sessionId: string): string { + return this.register(sessionId); + } + + /** + * Validate a token against a specific session. Returns true ONLY when the token + * was issued for exactly this session — a valid token for session B never + * validates for session A (forged-completion rejection). + */ + validateToken(sessionId: string, token: string | null | undefined): boolean { + if (!token) return false; + const owner = this.tokenToSession.get(token); + if (!owner) return false; + return owner === sessionId && this.sessions.has(sessionId); + } + + /** Invalidate a session's token (called on session end). */ + invalidate(sessionId: string): void { + const entry = this.sessions.get(sessionId); + if (!entry) return; + this.tokenToSession.delete(entry.token); + entry.machine.dispose(); + this.sessions.delete(sessionId); + } + + /** + * Flush any held-back carry tail as a final redacted chunk. Call on session end + * (before `invalidate`) so the last bytes — which were held to catch a + * boundary-spanning secret — are emitted, still redacted. Returns the flushed + * sanitized text, or undefined when there is nothing held / no such session. + */ + flush(sessionId: string): string | undefined { + const entry = this.sessions.get(sessionId); + if (!entry || entry.carry.length === 0) return undefined; + const text = redactSecrets(entry.carry); + entry.carry = ""; + return text; + } + + // ── Ingestion ──────────────────────────────────────────────────────────── + + /** + * Ingest a normalized telemetry event for a session. Token validation is the + * caller's responsibility (the route validates before forwarding); ingest is + * the in-process bounding + routing seam. An unknown/non-live session is a + * no-op (never a crash). Returns the sanitized event for observability, or + * undefined when dropped (unknown session or per-turn cap reached). + */ + ingest(sessionId: string, event: TelemetryEvent): SanitizedTelemetryEvent | undefined { + const entry = this.sessions.get(sessionId); + if (!entry) return undefined; // unknown / non-live session → no-op + + // Lifecycle events (turn boundaries / completion) are never dropped — they + // drive the authoritative state machine. The per-turn cap bounds high-volume + // activity/text events within a turn (a flood backstop), and resets when a + // new turn begins (the `busy` / `sessionStart` route handlers zero it). + const isLifecycle = + event.kind === "sessionStart" || + event.kind === "busy" || + event.kind === "waitingOnInput" || + event.kind === "done"; + if (!isLifecycle) { + if (entry.turnEventCount >= this.maxEventsPerTurn) { + return undefined; + } + entry.turnEventCount += 1; + } + + const sanitized = this.sanitize(entry, event); + this.route(entry, sanitized); + return sanitized; + } + + // ── Sanitization ─────────────────────────────────────────────────────────── + + private sanitize(entry: SessionTelemetry, event: TelemetryEvent): SanitizedTelemetryEvent { + const out: SanitizedTelemetryEvent = { kind: event.kind }; + const payload = event.payload ?? {}; + + if (typeof payload.nativeSessionId === "string") { + out.nativeSessionId = payload.nativeSessionId.slice(0, 256); + } + if (payload.notification && typeof payload.notification === "object") { + out.notification = payload.notification as Record; + } + + if (typeof payload.text === "string") { + // 1. Strip ANSI / control BEFORE pattern matching or redaction. + const stripped = stripAnsiControl(payload.text); + // 2. Redact across chunk boundaries. We hold back a tail window of raw + // (un-redacted) text from each chunk; the held tail is prepended to the + // NEXT chunk before redaction, so a secret whose prefix is in chunk N and + // value is in chunk N+1 is redacted as one string. We emit, for chunk N, + // everything in `carry + chunk` EXCEPT the new held tail. + const joined = entry.carry + stripped; + const carryLen = entry.carry.length; + const newTail = joined.slice(Math.max(carryLen, joined.length - this.chunkCarryChars)); + const toEmit = joined.slice(0, joined.length - newTail.length); + entry.carry = newTail; + + let visible = redactSecrets(toEmit); + // 3. Size cap. + let truncated = false; + if (visible.length > this.maxEventChars) { + visible = visible.slice(0, this.maxEventChars); + truncated = true; + } + out.text = visible; + if (truncated) out.truncated = true; + } + + return out; + } + + // ── Routing onto the state machine ────────────────────────────────────────── + + private route(entry: SessionTelemetry, event: SanitizedTelemetryEvent): void { + const machine = entry.machine; + // Capture native session id whenever reported. + if (event.nativeSessionId) { + const current = this.store.getSession(entry.machine.sessionId); + if (current && current.nativeSessionId !== event.nativeSessionId) { + this.store.updateSession(entry.machine.sessionId, { + nativeSessionId: event.nativeSessionId, + }); + } + } + + switch (event.kind) { + case "sessionStart": { + if (machine.getState() === "starting") machine.markReady(); + break; + } + case "busy": { + entry.turnEventCount = 0; // new turn → reset per-turn budget + safeMachineCall(() => machine.signalBusy()); + break; + } + case "waitingOnInput": { + safeMachineCall(() => machine.signalWaitingOnInput()); + // Notification dispatch is invoked per node config; it never advances or + // fails the state (AE2). + this.onNotification?.({ + sessionId: machine.sessionId, + notification: event.notification, + }); + break; + } + case "done": { + // POSITIVE completion only. Idle / output progress never reach here. + safeMachineCall(() => machine.signalDone()); + break; + } + case "toolActivity": + case "transcript": + case "outputProgress": { + // Activity re-arms the inactivity watchdog but NEVER advances to done. + machine.signalOutputProgress(); + break; + } + } + } +} + +/** + * State-machine calls can throw InvalidCliTransitionError when a stray event + * arrives in a state that doesn't accept it (e.g. a `busy` event after `done`). + * Telemetry is best-effort: swallow the transition error rather than crash + * ingest — the authoritative state simply doesn't move. + */ +function safeMachineCall(fn: () => void): void { + try { + fn(); + } catch (err) { + if (err instanceof Error && (err as { code?: string }).code === "INVALID_CLI_TRANSITION") { + return; + } + throw err; + } +} From 773ba76209df373d3ae36376ec5ce2d96e33f0b9 Mon Sep 17 00:00:00 2001 From: gsxdsm Date: Thu, 4 Jun 2026 23:24:54 -0700 Subject: [PATCH 06/30] test(engine): self-skip real-PTY e2e when PTY I/O unavailable in environment --- .../__tests__/session-manager.test.ts | 39 +++++++++++++++++++ 1 file changed, 39 insertions(+) diff --git a/packages/engine/src/cli-agent/__tests__/session-manager.test.ts b/packages/engine/src/cli-agent/__tests__/session-manager.test.ts index 3ad9796889..4e18ac12c0 100644 --- a/packages/engine/src/cli-agent/__tests__/session-manager.test.ts +++ b/packages/engine/src/cli-agent/__tests__/session-manager.test.ts @@ -16,6 +16,41 @@ import { CliAdapterRegistry, type CliAgentAdapter } from "../adapter.js"; const textDecoder = new TextDecoder(); +// Probe whether real PTY I/O actually flows in this environment. Some sandboxed +// shells allow node-pty to load and spawn but never deliver PTY bytes; in that +// case the real-PTY suite self-skips (same philosophy as the native-load skip). +async function canRealPtyIo(): Promise { + try { + const { loadPtyModule } = await import("../../pty-native.js"); + const pty = await loadPtyModule(); + return await new Promise((resolve) => { + let settled = false; + const settle = (ok: boolean) => { + if (settled) return; + settled = true; + try { + proc.kill(); + } catch { + // already dead + } + resolve(ok); + }; + const proc = pty.spawn("bash", ["-c", "printf PROBE"], { + name: "xterm-256color", + cols: 20, + rows: 5, + cwd: tmpdir(), + env: { PATH: process.env.PATH ?? "" }, + }); + proc.onData(() => settle(true)); + proc.onExit(() => settle(false)); + setTimeout(() => settle(false), 4000); + }); + } catch { + return false; + } +} + // ── Mock PTY at the loadPtyModule seam ───────────────────────────────────── // // A scripted in-memory PTY records every byte written, lets the test push @@ -550,6 +585,10 @@ describe("CliSessionManager (real node-pty)", () => { }); it("spawns a real PTY, detects readiness, injects, captures echoed output, kills cleanly", async () => { + if (!(await canRealPtyIo())) { + console.warn("[test] PTY I/O does not flow in this environment, skipping real-PTY test"); + return; + } const fusionDir = join(tmpDir, ".fusion"); db = new Database(fusionDir, { inMemory: true }); db.init(); From ec4377e10a1ff1a84d7d66e67e9d8ff4b48f4fbf Mon Sep 17 00:00:00 2001 From: gsxdsm Date: Thu, 4 Jun 2026 23:32:58 -0700 Subject: [PATCH 07/30] feat(engine): add Claude Code native-tier cli-agent adapter (U4) Co-Authored-By: Claude Opus 4.8 (1M context) --- .../adapters/__tests__/claude-code.test.ts | 388 +++++++++++ .../src/cli-agent/adapters/claude-code.ts | 608 ++++++++++++++++++ 2 files changed, 996 insertions(+) create mode 100644 packages/engine/src/cli-agent/adapters/__tests__/claude-code.test.ts create mode 100644 packages/engine/src/cli-agent/adapters/claude-code.ts diff --git a/packages/engine/src/cli-agent/adapters/__tests__/claude-code.test.ts b/packages/engine/src/cli-agent/adapters/__tests__/claude-code.test.ts new file mode 100644 index 0000000000..1060c966f2 --- /dev/null +++ b/packages/engine/src/cli-agent/adapters/__tests__/claude-code.test.ts @@ -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 ` (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).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).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[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"); + }); +}); diff --git a/packages/engine/src/cli-agent/adapters/claude-code.ts b/packages/engine/src/cli-agent/adapters/claude-code.ts new file mode 100644 index 0000000000..a971dfbccc --- /dev/null +++ b/packages/engine/src/cli-agent/adapters/claude-code.ts @@ -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 `. + * + * ── 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 ` (additional settings), `--resume` + * / `-r [value]` (resume by session id), `--session-id `, + * `--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": { "": [ { "matcher": "...", + * "hooks": [ { "type": "command", "command": "