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:
7
.changeset/cli-agent-generic-adapter.md
Normal file
7
.changeset/cli-agent-generic-adapter.md
Normal 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.
|
||||
@@ -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[] = [];
|
||||
|
||||
369
packages/engine/src/cli-agent/adapters/__tests__/generic.test.ts
Normal file
369
packages/engine/src/cli-agent/adapters/__tests__/generic.test.ts
Normal 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");
|
||||
});
|
||||
});
|
||||
393
packages/engine/src/cli-agent/adapters/generic.ts
Normal file
393
packages/engine/src/cli-agent/adapters/generic.ts
Normal 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();
|
||||
@@ -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);
|
||||
|
||||
@@ -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": {
|
||||
|
||||
Reference in New Issue
Block a user