From f3b700aa5d12d4016dff2d97a9aca6200c48305a Mon Sep 17 00:00:00 2001 From: gsxdsm Date: Thu, 4 Jun 2026 23:34:33 -0700 Subject: [PATCH] feat(engine): add generic heuristic-tier cli-agent adapter (U6) Co-Authored-By: Claude Opus 4.8 (1M context) --- .changeset/cli-agent-generic-adapter.md | 7 + .../cli-agent/__tests__/telemetry-hub.test.ts | 12 + .../adapters/__tests__/generic.test.ts | 369 ++++++++++++++++ .../engine/src/cli-agent/adapters/generic.ts | 393 ++++++++++++++++++ .../engine/src/cli-agent/state-machine.ts | 62 ++- .../engine/src/cli-agent/telemetry-hub.ts | 19 +- 6 files changed, 846 insertions(+), 16 deletions(-) create mode 100644 .changeset/cli-agent-generic-adapter.md create mode 100644 packages/engine/src/cli-agent/adapters/__tests__/generic.test.ts create mode 100644 packages/engine/src/cli-agent/adapters/generic.ts diff --git a/.changeset/cli-agent-generic-adapter.md b/.changeset/cli-agent-generic-adapter.md new file mode 100644 index 0000000000..d102776127 --- /dev/null +++ b/.changeset/cli-agent-generic-adapter.md @@ -0,0 +1,7 @@ +--- +"@runfusion/fusion": patch +--- + +Add the generic heuristic-tier CLI agent adapter (U6). + +Arbitrary user-configured CLI commands can now run as engine-owned PTY sessions. The generic adapter declares every native capability disabled (no native done/waiting signal, no transcript) and infers state purely from the terminal byte stream: busy while output progresses or a spinner animates, and a synthetic idle after a configurable quiet window when a prompt-like glyph is showing and no spinner overrides it. Per the completion-gating decision (origin R20) the generic tier NEVER reports done — idle surfaces a "looks idle — confirm to advance" affordance via a new busy-equivalent idle sub-state and never advances the pipeline. diff --git a/packages/engine/src/cli-agent/__tests__/telemetry-hub.test.ts b/packages/engine/src/cli-agent/__tests__/telemetry-hub.test.ts index bc7851bf11..42132ecf93 100644 --- a/packages/engine/src/cli-agent/__tests__/telemetry-hub.test.ts +++ b/packages/engine/src/cli-agent/__tests__/telemetry-hub.test.ts @@ -109,6 +109,18 @@ describe("TelemetryHub", () => { expect(hub.getStateMachine(a)?.getState()).toBe("done"); }); + it("idle event surfaces a busy-equivalent idle state, never done (R20)", () => { + const a = seed({ agentState: "busy" }); + const hub = new TelemetryHub({ store }); + hub.issueToken(a); + hub.ingest(a, { kind: "idle" }); + expect(hub.getStateMachine(a)?.getState()).toBe("idle"); + expect(store.getSession(a)?.agentState).toBe("busy"); // persists as busy + // Resumed output flips back to busy; never reaches done. + hub.ingest(a, { kind: "busy" }); + expect(hub.getStateMachine(a)?.getState()).toBe("busy"); + }); + it("AE2: waitingOnInput dispatches notification, state does not advance/fail", () => { const a = seed({ agentState: "busy" }); const dispatched: unknown[] = []; diff --git a/packages/engine/src/cli-agent/adapters/__tests__/generic.test.ts b/packages/engine/src/cli-agent/adapters/__tests__/generic.test.ts new file mode 100644 index 0000000000..3aa91f8f46 --- /dev/null +++ b/packages/engine/src/cli-agent/adapters/__tests__/generic.test.ts @@ -0,0 +1,369 @@ +import { describe, it, expect, beforeEach, afterEach, vi } 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 { + GenericCliAdapter, + GenericHeuristicAnalyzer, + GenericCommandMissingError, + DEFAULT_QUIET_WINDOW_MS, +} from "../generic.js"; +import type { CliAgentAdapter } from "../../adapter.js"; +import { TelemetryHub, type TelemetryEvent } from "../../telemetry-hub.js"; + +// ── Capability flags (AE4) ─────────────────────────────────────────────────── + +describe("GenericCliAdapter capabilities", () => { + const adapter = new GenericCliAdapter(); + + it("declares the heuristic tier: every native capability disabled", () => { + expect(adapter.id).toBe("generic"); + expect(adapter.capabilities).toEqual({ + nativeDone: false, + nativeWaiting: false, + transcriptSource: "none", + supportsResume: false, + }); + }); + + it("exposes no resume builder (fresh launch only)", () => { + const asInterface: CliAgentAdapter = adapter; + expect(asInterface.buildResume).toBeUndefined(); + }); +}); + +// ── buildLaunch / env ──────────────────────────────────────────────────────── + +describe("GenericCliAdapter buildLaunch", () => { + const adapter = new GenericCliAdapter(); + + it("builds from a configured command + args, appending extraArgs", () => { + const spec = adapter.buildLaunch({ + settings: { command: "mytool", args: ["run", "--fast"], extraArgs: ["-v"] }, + posture: null, + }); + expect(spec).toEqual({ command: "mytool", args: ["run", "--fast", "-v"] }); + }); + + it("throws GenericCommandMissingError when no command is configured", () => { + expect(() => adapter.buildLaunch({ settings: {}, posture: null })).toThrow( + GenericCommandMissingError, + ); + expect(() => adapter.buildLaunch({ settings: { command: " " }, posture: null })).toThrow( + GenericCommandMissingError, + ); + }); + + it("env allowlist is a minimal explicit set, extensible but never inherit-all", () => { + const base = adapter.buildEnvAllowlist({ settings: { command: "x" }, posture: null }); + expect(base).toContain("PATH"); + expect(base).toContain("TERM"); + expect(base).not.toContain("FUSION_DAEMON_TOKEN"); + + const extended = adapter.buildEnvAllowlist({ + settings: { command: "x", envAllowlist: ["MY_VAR", "PATH"] }, + posture: null, + }); + expect(extended).toContain("MY_VAR"); + // De-duped. + expect(extended.filter((k) => k === "PATH")).toHaveLength(1); + }); +}); + +// ── Readiness ──────────────────────────────────────────────────────────────── + +describe("GenericCliAdapter readiness", () => { + const adapter = new GenericCliAdapter(); + + it("becomes ready on a prompt-like trailing glyph", () => { + const det = adapter.createReadinessDetector(); + expect(det.observe("starting up...\n")).toBe(false); + expect(det.observe("user@host:~$ ")).toBe(true); + }); + + it("is not ready immediately on first non-prompt output (grace window)", () => { + const det = adapter.createReadinessDetector(); + // No prompt glyph and within the grace window → not yet ready. + expect(det.observe("loading")).toBe(false); + }); + + it("ignores empty / control-only chunks for readiness", () => { + const det = adapter.createReadinessDetector(); + expect(det.observe("\x1b[2K")).toBe(false); + }); +}); + +// ── Heuristic analyzer (fake timers) ───────────────────────────────────────── + +describe("GenericHeuristicAnalyzer", () => { + let now = 0; + let events: TelemetryEvent[]; + + function makeAnalyzer(quietWindowMs = DEFAULT_QUIET_WINDOW_MS) { + events = []; + return new GenericHeuristicAnalyzer({ + quietWindowMs, + now: () => now, + setTimer: (fn, ms) => { + const at = now + ms; + return { fn, at }; + }, + clearTimer: () => {}, + emit: (e) => events.push(e), + }); + } + + // A trivial deterministic timer: we drive `now` forward and manually fire the + // pending quiet timer by invoking its captured fn when `now >= at`. + function fireDue(analyzer: GenericHeuristicAnalyzer, pending: { fn: () => void; at: number }[]) { + void analyzer; + for (const t of pending.splice(0)) { + if (now >= t.at) t.fn(); + } + } + + beforeEach(() => { + now = 1_000; + }); + + it("emits outputProgress while streaming", () => { + const a = makeAnalyzer(); + a.observe("Building the project...\n"); + expect(events.some((e) => e.kind === "outputProgress")).toBe(true); + const op = events.find((e) => e.kind === "outputProgress"); + expect(op?.payload?.text).toContain("Building the project"); + }); + + it("quiet window past threshold with a prompt glyph emits a synthetic idle", () => { + const pending: { fn: () => void; at: number }[] = []; + events = []; + const a = new GenericHeuristicAnalyzer({ + quietWindowMs: DEFAULT_QUIET_WINDOW_MS, + now: () => now, + setTimer: (fn, ms) => { + const entry = { fn, at: now + ms }; + pending.push(entry); + return entry; + }, + clearTimer: (h) => { + const i = pending.indexOf(h as { fn: () => void; at: number }); + if (i >= 0) pending.splice(i, 1); + }, + emit: (e) => events.push(e), + }); + a.observe("All done.\nuser@host:~$ "); + // Advance past the quiet window and fire the timer. + now += DEFAULT_QUIET_WINDOW_MS + 1; + fireDue(a, pending); + expect(events.some((e) => e.kind === "idle")).toBe(true); + }); + + it("spinner override: prompt glyph visible + spinner animating → busy, not idle", () => { + const pending: { fn: () => void; at: number }[] = []; + events = []; + const a = new GenericHeuristicAnalyzer({ + quietWindowMs: DEFAULT_QUIET_WINDOW_MS, + now: () => now, + setTimer: (fn, ms) => { + const entry = { fn, at: now + ms }; + pending.push(entry); + return entry; + }, + clearTimer: (h) => { + const i = pending.indexOf(h as { fn: () => void; at: number }); + if (i >= 0) pending.splice(i, 1); + }, + emit: (e) => events.push(e), + }); + // Two distinct spinner frames close in time → animating, with a prompt glyph + // visible in the same window. + a.observe("⠋ Working ❯"); + now += 100; + a.observe("⠙ Working ❯"); + now += DEFAULT_QUIET_WINDOW_MS - 50; // still within spinner-animation memory + fireDue(a, pending); + expect(events.some((e) => e.kind === "idle")).toBe(false); + }); + + it("resumed output after idle flips back to busy", () => { + const pending: { fn: () => void; at: number }[] = []; + events = []; + const a = new GenericHeuristicAnalyzer({ + quietWindowMs: DEFAULT_QUIET_WINDOW_MS, + now: () => now, + setTimer: (fn, ms) => { + const entry = { fn, at: now + ms }; + pending.push(entry); + return entry; + }, + clearTimer: (h) => { + const i = pending.indexOf(h as { fn: () => void; at: number }); + if (i >= 0) pending.splice(i, 1); + }, + emit: (e) => events.push(e), + }); + a.observe("done\n$ "); + now += DEFAULT_QUIET_WINDOW_MS + 1; + fireDue(a, pending); + expect(events.some((e) => e.kind === "idle")).toBe(true); + events.length = 0; + // New output arrives → busy event emitted (idle withdrawn). + a.observe("running more work...\n"); + expect(events.some((e) => e.kind === "busy")).toBe(true); + }); + + it("idle is never inferred from silence alone (no prompt glyph)", () => { + const pending: { fn: () => void; at: number }[] = []; + events = []; + const a = new GenericHeuristicAnalyzer({ + quietWindowMs: DEFAULT_QUIET_WINDOW_MS, + now: () => now, + setTimer: (fn, ms) => { + const entry = { fn, at: now + ms }; + pending.push(entry); + return entry; + }, + clearTimer: (h) => { + const i = pending.indexOf(h as { fn: () => void; at: number }); + if (i >= 0) pending.splice(i, 1); + }, + emit: (e) => events.push(e), + }); + a.observe("partial output with no prompt glyph"); + now += DEFAULT_QUIET_WINDOW_MS + 1; + fireDue(a, pending); + expect(events.some((e) => e.kind === "idle")).toBe(false); + }); + + it("classifies ANSI-noise-laden output correctly (strip before pattern match)", () => { + const pending: { fn: () => void; at: number }[] = []; + events = []; + const a = new GenericHeuristicAnalyzer({ + quietWindowMs: DEFAULT_QUIET_WINDOW_MS, + now: () => now, + setTimer: (fn, ms) => { + const entry = { fn, at: now + ms }; + pending.push(entry); + return entry; + }, + clearTimer: (h) => { + const i = pending.indexOf(h as { fn: () => void; at: number }); + if (i >= 0) pending.splice(i, 1); + }, + emit: (e) => events.push(e), + }); + // Colored prompt with cursor/clear sequences and a trailing ❯ glyph. + a.observe("\x1b[2K\x1b[32mAll set.\x1b[0m\r\n\x1b[1m❯\x1b[0m "); + now += DEFAULT_QUIET_WINDOW_MS + 1; + fireDue(a, pending); + expect(events.some((e) => e.kind === "idle")).toBe(true); + // The emitted progress text is ANSI-stripped. + const op = events.find((e) => e.kind === "outputProgress"); + expect(op?.payload?.text).not.toContain("\x1b"); + expect(op?.payload?.text).toContain("All set."); + }); +}); + +// ── End-to-end through the hub + state machine: idle never reaches done ─────── + +describe("generic heuristic idle via TelemetryHub never advances to done (R20/AE4)", () => { + let tmpDir: string; + let fusionDir: string; + let db: Database; + let store: CliSessionStore; + + beforeEach(() => { + tmpDir = mkdtempSync(join(tmpdir(), "kb-cli-generic-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 seedBusy(): string { + return store.createSession({ + purpose: "execute", + projectId: "proj", + adapterId: "generic", + agentState: "busy", + }).id; + } + + it("idle event maps to a busy-equivalent idle state, never done", () => { + const id = seedBusy(); + const hub = new TelemetryHub({ store }); + hub.issueToken(id); + + hub.ingest(id, { kind: "outputProgress", payload: { text: "working" } }); + expect(hub.getStateMachine(id)?.getState()).toBe("busy"); + + hub.ingest(id, { kind: "idle" }); + // Machine surfaces the transient idle sub-state... + expect(hub.getStateMachine(id)?.getState()).toBe("idle"); + // ...but it is NEVER done, and persists as busy (honestly live). + expect(store.getSession(id)?.agentState).toBe("busy"); + + // Resumed output flips idle → busy. + hub.ingest(id, { kind: "busy" }); + expect(hub.getStateMachine(id)?.getState()).toBe("busy"); + }); + + it("no sequence of generic signals (output/idle) ever reaches done", () => { + const id = seedBusy(); + const hub = new TelemetryHub({ store }); + hub.issueToken(id); + + for (let i = 0; i < 20; i++) { + hub.ingest(id, { kind: "outputProgress", payload: { text: `chunk ${i}` } }); + hub.ingest(id, { kind: "idle" }); + hub.ingest(id, { kind: "toolActivity" }); + } + const state = hub.getStateMachine(id)?.getState(); + expect(state).not.toBe("done"); + expect(store.getSession(id)?.agentState).not.toBe("done"); + }); + + it("analyzer-emitted events drive the hub without ever advancing to done", () => { + const id = seedBusy(); + const hub = new TelemetryHub({ store }); + hub.issueToken(id); + + let now = 1_000; + const pending: { fn: () => void; at: number }[] = []; + const analyzer = new GenericHeuristicAnalyzer({ + quietWindowMs: DEFAULT_QUIET_WINDOW_MS, + now: () => now, + setTimer: (fn, ms) => { + const entry = { fn, at: now + ms }; + pending.push(entry); + return entry; + }, + clearTimer: (h) => { + const i = pending.indexOf(h as { fn: () => void; at: number }); + if (i >= 0) pending.splice(i, 1); + }, + emit: (e) => hub.ingest(id, e), + }); + + analyzer.observe("compiling...\n"); + expect(hub.getStateMachine(id)?.getState()).toBe("busy"); + + analyzer.observe("done.\n$ "); + now += DEFAULT_QUIET_WINDOW_MS + 1; + for (const t of pending.splice(0)) if (now >= t.at) t.fn(); + expect(hub.getStateMachine(id)?.getState()).toBe("idle"); + expect(store.getSession(id)?.agentState).not.toBe("done"); + + // Resume. + analyzer.observe("more work\n"); + expect(hub.getStateMachine(id)?.getState()).toBe("busy"); + }); +}); diff --git a/packages/engine/src/cli-agent/adapters/generic.ts b/packages/engine/src/cli-agent/adapters/generic.ts new file mode 100644 index 0000000000..7c5c2884a9 --- /dev/null +++ b/packages/engine/src/cli-agent/adapters/generic.ts @@ -0,0 +1,393 @@ +/** + * GenericCliAdapter — heuristic-tier adapter for arbitrary CLI commands + * (CLI Agent Executor, U6). + * + * The generic adapter drives ANY user-configured CLI command inside an + * engine-owned PTY when no native-tier adapter (Claude Code / Codex / Droid / + * Pi) applies. It has NO native completion signal, NO native waiting signal, and + * NO structured transcript — every capability flag is disabled and its + * transcript source is `"none"`. Surfaces render the honest heuristic tier from + * these flags (origin R3, AE4). + * + * Because the command is opaque, state is inferred purely from the terminal byte + * stream by {@link GenericHeuristicAnalyzer}: + * - BUSY when output is progressing OR a spinner is animating (braille spinner + * glyphs, `/-\|` rotation, "Working"/"Thinking" tickers, an elapsed-time + * counter). + * - IDLE after a configurable quiet window (default ~8s) of no output AND a + * prompt-like trailing glyph (`>`, `❯`, `$`, `:`, `?`, …) AND no active + * spinner override. + * + * Completion gating (origin R20, KTD — the generic tier NEVER reports done): + * idle is emitted as the telemetry `"idle"` kind, which the state machine maps + * to a busy-equivalent idle sub-state surfacing a "looks idle — confirm to + * advance" affordance. It NEVER advances to `done`. Resumed output flips back to + * busy. The only positive completion path is operator confirmation downstream. + * + * No resume (`supportsResume: false`) — a fresh launch only; the lack of resume + * is surfaced honestly in the UI (U8 routes generic sessions to needs-attention + * on engine death rather than auto-resuming). + */ + +import type { + CliAdapterCapabilities, + CliAdapterLaunchContext, + CliAgentAdapter, + CliInjectionFormat, + CliLaunchSpec, + CliReadinessDetector, +} from "../adapter.js"; +import { stripAnsiControl, type TelemetryEvent } from "../telemetry-hub.js"; + +// ── Capability flags (all disabled — honest heuristic tier) ───────────────── + +const GENERIC_CAPABILITIES: CliAdapterCapabilities = { + nativeDone: false, + nativeWaiting: false, + transcriptSource: "none", + supportsResume: false, +}; + +// ── Heuristic detection constants ─────────────────────────────────────────── + +/** Default quiet window (ms) of no output before idle is considered. */ +export const DEFAULT_QUIET_WINDOW_MS = 8_000; +/** + * Default small grace (ms) after the FIRST output before the session is treated + * as ready when no prompt-like glyph has appeared yet. + */ +export const DEFAULT_READY_AFTER_FIRST_OUTPUT_MS = 750; +/** How many trailing chars of the screen window to inspect for a prompt glyph. */ +const PROMPT_TAIL_CHARS = 8; +/** Max chars retained in the analyzer's screen-ish window. */ +const DEFAULT_SCREEN_WINDOW_CHARS = 4_096; +/** + * Window (ms) over which spinner-glyph CHANGES count as "animating". If two + * distinct spinner glyphs are observed within this window the spinner is treated + * as active and overrides any prompt-glyph idle inference. + */ +const SPINNER_ANIMATION_WINDOW_MS = 2_000; + +/** + * Spinner glyphs: braille dots (the de-facto CLI spinner), the classic + * `/ - \ |` rotation, and a few common block/arc spinners. + */ +const SPINNER_GLYPHS = new Set([ + // Braille spinner frames. + "⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏", + "⣾", "⣽", "⣻", "⢿", "⡿", "⣟", "⣯", "⣷", + // ASCII rotation. + "/", "-", "\\", "|", + // Arc / circle / block spinners. + "◜", "◝", "◞", "◟", "◐", "◓", "◑", "◒", + "▖", "▘", "▝", "▗", "▌", "▐", "▀", "▄", +]); + +/** Words that signal active work even without a glyph spinner. */ +const WORKING_PATTERN = /\b(working|thinking|processing|loading|generating|running|compiling|building|esc to interrupt)\b/i; +/** Elapsed-time ticker, e.g. "(12s)", "0:42", "elapsed 3.2s". */ +const ELAPSED_TICKER_PATTERN = /(\b\d{1,2}:\d{2}\b|\(\s*\d+(?:\.\d+)?\s*s\s*\)|\b\d+(?:\.\d+)?s\b|elapsed)/i; + +/** Prompt-like trailing glyphs that suggest the CLI is waiting at a prompt. */ +const PROMPT_GLYPHS = [">", "❯", "$", "#", ":", "?", "➜", "»", "▶", "λ"]; + +// ── Heuristic analyzer ────────────────────────────────────────────────────── + +export interface GenericHeuristicOptions { + /** Quiet window (ms) of no output before idle is emitted. */ + quietWindowMs?: number; + /** Max chars retained in the screen-ish window. */ + screenWindowChars?: number; + /** Clock injection for tests. */ + now?: () => number; + /** Timer scheduler injection for tests (fake timers). Returns a cancel handle. */ + setTimer?: (fn: () => void, ms: number) => unknown; + clearTimer?: (handle: unknown) => void; + /** Sink for synthesized telemetry events. */ + emit: (event: TelemetryEvent) => void; +} + +/** + * Stateful heuristic output analyzer. Fed ANSI-bearing PTY output chunks via + * {@link observe}; emits normalized {@link TelemetryEvent}s onto the configured + * sink: + * - `outputProgress` (with stripped text) while output streams, + * - `busy` when a spinner / working signal is detected after a quiet stretch, + * - `idle` once the quiet window elapses with a prompt-like glyph and no spinner. + * + * The synthetic `idle` event is the ONLY idle signal; it maps (via the hub / + * state machine) to a busy-equivalent idle sub-state and NEVER to `done`. + */ +export class GenericHeuristicAnalyzer { + private readonly quietWindowMs: number; + private readonly screenWindowChars: number; + private readonly now: () => number; + private readonly setTimer: (fn: () => void, ms: number) => unknown; + private readonly clearTimer: (handle: unknown) => void; + private readonly emit: (event: TelemetryEvent) => void; + + /** ANSI-stripped sliding screen window. */ + private window = ""; + /** Last time output was observed. */ + private lastOutputAt = 0; + /** Whether we have currently emitted idle (de-dupe; re-armed on new output). */ + private idleEmitted = false; + /** Whether ANY output has been seen yet. */ + private sawOutput = false; + /** Pending quiet-window timer handle. */ + private quietTimer: unknown = null; + + /** Recent spinner-glyph observations: glyph + timestamp (animation detection). */ + private spinnerHistory: { glyph: string; at: number }[] = []; + + constructor(opts: GenericHeuristicOptions) { + this.quietWindowMs = opts.quietWindowMs ?? DEFAULT_QUIET_WINDOW_MS; + this.screenWindowChars = opts.screenWindowChars ?? DEFAULT_SCREEN_WINDOW_CHARS; + 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)); + this.emit = opts.emit; + } + + /** Observe a raw (ANSI-bearing) output chunk. */ + observe(rawChunk: string): void { + const stripped = stripAnsiControl(rawChunk); + const at = this.now(); + this.lastOutputAt = at; + + // Track spinner glyphs present in this chunk for animation detection. + this.recordSpinnerGlyphs(stripped, at); + + // Append to the sliding window and trim. + if (stripped.length > 0) { + this.window = (this.window + stripped).slice(-this.screenWindowChars); + } + + // Fresh output → progressing. If we had previously gone idle, this resumes + // work; emit a `busy` so the state machine flips idle → busy. Always emit + // outputProgress so the inactivity watchdog is re-armed. + const wasIdle = this.idleEmitted; + this.idleEmitted = false; + + if (stripped.length > 0) { + this.emit({ kind: "outputProgress", payload: { text: stripped } }); + } + if (wasIdle) { + this.emit({ kind: "busy" }); + } + this.sawOutput = true; + + // (Re)arm the quiet-window timer. + this.armQuietTimer(); + } + + /** Whether a spinner is currently animating (recent distinct glyph changes). */ + isSpinnerActive(at: number = this.now()): boolean { + const recent = this.spinnerHistory.filter((s) => at - s.at <= SPINNER_ANIMATION_WINDOW_MS); + if (recent.length < 2) return false; + const distinct = new Set(recent.map((s) => s.glyph)); + // Animation = at least two distinct frames in the window, OR repeated frames + // arriving (a static `/` once is not a spinner, but several glyph updates are). + return distinct.size >= 2 || recent.length >= 3; + } + + /** The trailing non-empty line of the screen window (whitespace-trimmed). */ + private trailingLine(): string { + const lines = this.window.split(/\r?\n/); + for (let i = lines.length - 1; i >= 0; i--) { + if (lines[i].trim().length > 0) return lines[i].trimEnd(); + } + return ""; + } + + /** Whether the trailing screen content looks like a prompt waiting for input. */ + hasPromptGlyph(): boolean { + const tail = this.trailingLine(); + if (tail.length === 0) return false; + const lastChars = tail.slice(-PROMPT_TAIL_CHARS); + return PROMPT_GLYPHS.some((g) => lastChars.endsWith(g) || lastChars.endsWith(g + " ")); + } + + /** + * Whether the CURRENT trailing line shows an active-work textual signal. Scoped + * to the trailing line so a stale "compiling…" earlier in the scrollback does + * not pin the session busy once a fresh prompt line has rendered. (In a real + * terminal the spinner line is overwritten in place; the accumulated window + * keeps history, so recency must come from the trailing line / spinner timing.) + */ + hasWorkingSignal(): boolean { + const tail = this.trailingLine(); + return WORKING_PATTERN.test(tail) || ELAPSED_TICKER_PATTERN.test(tail); + } + + /** Dispose pending timers. */ + dispose(): void { + this.clearQuietTimer(); + } + + // ── Internal ────────────────────────────────────────────────────────────── + + private recordSpinnerGlyphs(stripped: string, at: number): void { + for (const ch of stripped) { + if (SPINNER_GLYPHS.has(ch)) { + this.spinnerHistory.push({ glyph: ch, at }); + } + } + // Bound history to the animation window plus a small margin. + const cutoff = at - SPINNER_ANIMATION_WINDOW_MS * 2; + if (this.spinnerHistory.length > 0 && this.spinnerHistory[0].at < cutoff) { + this.spinnerHistory = this.spinnerHistory.filter((s) => s.at >= cutoff); + } + } + + private armQuietTimer(): void { + this.clearQuietTimer(); + this.quietTimer = this.setTimer(() => { + this.quietTimer = null; + this.onQuietWindow(); + }, this.quietWindowMs); + } + + private clearQuietTimer(): void { + if (this.quietTimer != null) { + this.clearTimer(this.quietTimer); + this.quietTimer = null; + } + } + + /** + * Quiet-window elapsed: classify idle vs still-busy. + * - Spinner animating OR a working/elapsed signal present → still busy; re-arm. + * - Prompt-like trailing glyph and no spinner override → emit synthetic idle. + * - Otherwise (quiet but no prompt glyph) → ambiguous; stay busy, re-arm so a + * later prompt render can still flip to idle. Idle is NEVER inferred from + * silence alone — a prompt-like affordance is required (origin R20). + */ + private onQuietWindow(): void { + const at = this.now(); + if (this.isSpinnerActive(at) || this.hasWorkingSignal()) { + // Spinner override: visible prompt + animating spinner → busy. + this.armQuietTimer(); + return; + } + if (this.hasPromptGlyph()) { + if (!this.idleEmitted) { + this.idleEmitted = true; + this.emit({ kind: "idle" }); + } + return; + } + // Quiet but no prompt glyph: re-arm rather than assert idle. + this.armQuietTimer(); + } +} + +// ── Readiness detector ────────────────────────────────────────────────────── + +/** + * Generic readiness detector: ready on the first prompt-like glyph observed, or + * (fallback) on the first output after a small configurable delay. Tolerant of + * partial chunks — it accumulates a small tail window. + */ +class GenericReadinessDetector implements CliReadinessDetector { + private buffer = ""; + private firstOutputAt: number | null = null; + private readonly readyAfterMs: number; + private readonly now: () => number; + + constructor(opts: { readyAfterFirstOutputMs?: number; now?: () => number } = {}) { + this.readyAfterMs = opts.readyAfterFirstOutputMs ?? DEFAULT_READY_AFTER_FIRST_OUTPUT_MS; + this.now = opts.now ?? (() => Date.now()); + } + + observe(chunk: string): boolean { + const stripped = stripAnsiControl(chunk); + if (stripped.length === 0) return false; + if (this.firstOutputAt === null) this.firstOutputAt = this.now(); + this.buffer = (this.buffer + stripped).slice(-256); + + // Prompt-like glyph at the trailing edge → ready immediately. + const tail = this.buffer.replace(/[ \t\r\n]+$/g, "").slice(-PROMPT_TAIL_CHARS); + if (PROMPT_GLYPHS.some((g) => tail.endsWith(g))) return true; + + // Fallback: first output plus the small grace window. + return this.now() - this.firstOutputAt >= this.readyAfterMs; + } +} + +// ── Adapter ───────────────────────────────────────────────────────────────── + +/** + * Settings the generic adapter reads off the launch context. The command is + * mandatory (the operator configures it); args are optional. `extraArgs` from + * the shared launch settings are appended after the configured args. + */ +export interface GenericAdapterSettings { + /** The binary to invoke (required for the generic adapter). */ + command?: string; + /** Argument vector for the configured command. */ + args?: readonly string[]; + /** Extra args appended after `args` (shared launch-settings convention). */ + extraArgs?: readonly string[]; + /** Env keys the operator opts to forward (allowlist; never inherit-all). */ + envAllowlist?: readonly string[]; +} + +/** Error thrown when the generic adapter is launched without a configured command. */ +export class GenericCommandMissingError extends Error { + readonly code = "GENERIC_COMMAND_MISSING"; + constructor() { + super("Generic CLI adapter requires a configured `command` in launch settings"); + this.name = "GenericCommandMissingError"; + } +} + +export class GenericCliAdapter implements CliAgentAdapter { + readonly id = "generic"; + readonly name = "Generic CLI"; + readonly capabilities = GENERIC_CAPABILITIES; + + buildLaunch(ctx: CliAdapterLaunchContext): CliLaunchSpec { + const settings = ctx.settings as GenericAdapterSettings & { command?: string }; + const command = settings.command; + if (!command || command.trim().length === 0) { + throw new GenericCommandMissingError(); + } + const baseArgs = Array.isArray(settings.args) ? [...settings.args] : []; + const extraArgs = Array.isArray(settings.extraArgs) ? [...settings.extraArgs] : []; + return { command, args: [...baseArgs, ...extraArgs] }; + } + + buildEnvAllowlist(ctx: CliAdapterLaunchContext): string[] { + const settings = ctx.settings as GenericAdapterSettings; + // Honest minimal default: only the terminal-shaping vars a CLI needs to + // render. The operator may extend the allowlist explicitly. NEVER an + // inherit-everything posture. + const base = ["PATH", "HOME", "TERM", "LANG", "LC_ALL", "SHELL"]; + const extra = Array.isArray(settings.envAllowlist) + ? settings.envAllowlist.filter((k): k is string => typeof k === "string") + : []; + return [...new Set([...base, ...extra])]; + } + + createReadinessDetector(): CliReadinessDetector { + return new GenericReadinessDetector(); + } + + formatInjection(text: string, opts: { bracketedPasteActive: boolean }): CliInjectionFormat { + // Submit with a carriage return. Bracketed-paste wrapping is handled by the + // session manager's security path; the generic adapter only decides submit + // semantics. When the child negotiated bracketed paste, wrap so multi-line + // text is delivered atomically before the submit CR. + if (opts.bracketedPasteActive) { + return { payload: `\x1b[200~${text}\x1b[201~\r` }; + } + return { payload: `${text}\r` }; + } + + // No buildResume: supportsResume is false (fresh launch only). +} + +/** Shared singleton instance for registration. */ +export const genericCliAdapter = new GenericCliAdapter(); diff --git a/packages/engine/src/cli-agent/state-machine.ts b/packages/engine/src/cli-agent/state-machine.ts index 9ad6fa6729..da7247b96b 100644 --- a/packages/engine/src/cli-agent/state-machine.ts +++ b/packages/engine/src/cli-agent/state-machine.ts @@ -9,6 +9,8 @@ * ready → busy (prompt injected) * busy → waitingOnInput (permission / question signal) * waitingOnInput → busy (user answers) + * busy → idle (heuristic quiet-window — generic tier; NEVER done) + * idle → busy (output resumes) * 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) @@ -49,19 +51,29 @@ import type { // ── 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. + * The machine's own state space. This is the U1 `CliAgentState` plus two + * transient sub-states that are NOT persisted store enums (U1's union has + * neither): + * - `"resuming"` (HTD) maps onto the persisted `dead` store state while the + * resume-eligible termination reason (crashed / engineDeath) carries the + * recovery intent. + * - `"idle"` (generic heuristic tier, U6) maps onto the persisted `busy` store + * state. The generic adapter has no native done signal; a quiet output window + * yields an "looks idle — confirm to advance" affordance (origin R20) WITHOUT + * advancing the pipeline. Persisting `busy` keeps the session honestly live — + * idle NEVER reaches `done`; resumed output flips back to `busy`. + * + * Surfaces that subscribe to `onStateChange` see the richer machine state so the + * SSE bridge can render "resuming…" / the idle confirm-advance affordance + * without a schema change. */ -export type CliMachineState = CliAgentState | "resuming"; +export type CliMachineState = CliAgentState | "resuming" | "idle"; /** Map a machine state onto the persisted U1 store enum. */ export function toPersistedState(state: CliMachineState): CliAgentState { - return state === "resuming" ? "dead" : state; + if (state === "resuming") return "dead"; + if (state === "idle") return "busy"; + return state; } /** A throttled state-change notification handed to subscribers (e.g. the SSE bridge). */ @@ -290,13 +302,37 @@ export class CliSessionStateMachine { /** * Output progress / activity. Re-arms the inactivity watchdog. NEVER advances * state — idleness and activity are both gated away from `done`. + * + * From the generic-tier `idle` sub-state, fresh output means the agent resumed + * work → flip back to `busy` (the confirm-advance affordance is withdrawn). */ signalOutputProgress(): void { + if (this.state === "idle") { + this.transition("busy"); + this.armStallWatchdog(); + return; + } if (this.state === "busy") { this.armStallWatchdog(); } } + /** + * busy → idle (generic heuristic quiet-window). The generic tier has no native + * done signal; a quiet output window past the configured threshold surfaces an + * "looks idle — confirm to advance" affordance (origin R20). This NEVER + * advances to `done` and persists as `busy` (the session stays honestly live). + * Clears the stall watchdog: a detected idle is expected quiet, so it must not + * also trip the stall backstop into needsAttention. Idempotent. From any state + * other than `busy` it is a no-op (idle is only meaningful mid-turn). + */ + signalIdle(): void { + if (this.state === "idle") return; // idempotent + if (this.state !== "busy") return; // only busy turns can go idle + this.clearStallWatchdog(); + this.transition("idle"); + } + /** * busy → waitingOnInput (permission / question signal). Suppresses the stall * watchdog (expected idleness). Does NOT advance the pipeline or fail. @@ -311,13 +347,13 @@ export class CliSessionStateMachine { this.transition("waitingOnInput"); } - /** waitingOnInput → busy (user answered). Re-arms the watchdog. */ + /** waitingOnInput → busy (user answered) and idle → busy (output resumed). */ signalBusy(): void { if (this.state === "busy") { this.armStallWatchdog(); return; } - if (this.state !== "waitingOnInput") { + if (this.state !== "waitingOnInput" && this.state !== "idle") { throw new InvalidCliTransitionError(this.state, "signalBusy"); } this.armStallWatchdog(); @@ -469,8 +505,8 @@ export class CliSessionStateMachine { 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. + if (next === "busy" || next === "ready" || next === "idle") { + // Live again (idle persists as busy): clear any stale termination reason. this.terminationReason = null; } this.persistAndEmit(next, this.terminationReason); diff --git a/packages/engine/src/cli-agent/telemetry-hub.ts b/packages/engine/src/cli-agent/telemetry-hub.ts index 9fea4e5250..535794d20b 100644 --- a/packages/engine/src/cli-agent/telemetry-hub.ts +++ b/packages/engine/src/cli-agent/telemetry-hub.ts @@ -24,8 +24,12 @@ * 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. + * notification dispatch, done→signalDone, outputProgress→signalOutputProgress, + * idle→signalIdle). Idle/output NEVER advances to done — that gating lives in + * the state machine. The `idle` kind is the generic heuristic tier's + * quiet-window signal (U6): it surfaces the "looks idle — confirm to advance" + * affordance (origin R20) and is mapped to a busy-equivalent idle sub-state, + * never to `done`. */ import { randomBytes } from "node:crypto"; @@ -55,6 +59,7 @@ export type TelemetryEventKind = | "busy" | "waitingOnInput" | "done" + | "idle" | "toolActivity" | "outputProgress" | "transcript"; @@ -265,7 +270,8 @@ export class TelemetryHub { event.kind === "sessionStart" || event.kind === "busy" || event.kind === "waitingOnInput" || - event.kind === "done"; + event.kind === "done" || + event.kind === "idle"; if (!isLifecycle) { if (entry.turnEventCount >= this.maxEventsPerTurn) { return undefined; @@ -358,6 +364,13 @@ export class TelemetryHub { safeMachineCall(() => machine.signalDone()); break; } + case "idle": { + // Generic heuristic quiet-window (U6). Surfaces the confirm-advance + // affordance via a busy-equivalent idle sub-state; NEVER advances to + // done. A no-op from any non-busy state (signalIdle guards internally). + safeMachineCall(() => machine.signalIdle()); + break; + } case "toolActivity": case "transcript": case "outputProgress": {