feat(engine): add generic heuristic-tier cli-agent adapter (U6)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
gsxdsm
2026-06-04 23:34:33 -07:00
parent 773ba76209
commit f3b700aa5d
6 changed files with 846 additions and 16 deletions

View File

@@ -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.

View File

@@ -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[] = [];

View File

@@ -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");
});
});

View File

@@ -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<string>([
// 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<typeof setTimeout>));
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();

View File

@@ -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);

View File

@@ -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": {