import type { TranscriptKnowledge } from "./transcript/wire.js"; /** * Descriptor types: the shape of what is KNOWN about a harness CLI, as pure * data. Interpretation functions consume these; nothing here executes. * * Turn option keys and discovery facets use a closed vocabulary for the same * reason `LimitCode` does: a descriptor must not invent an option a consumer * has no field for. Every `TurnOptionKey` maps to a field on `TurnOptions` * and every `DiscoveryFacet` maps to a field on `DiscoveryOptions`; adding * a key without a consumer arm would be dead data that can only drift. */ export const HARNESS_NAMES = [ "claude", "codex", "pi", "muse", "cursor", "antigravity", "popeye", ] as const; export type HarnessName = (typeof HARNESS_NAMES)[number]; /** Descriptors are process-wide defaults shared by reference into merged * override sets - freezing makes an accidental in-place edit throw instead * of corrupting every consumer. */ /** A 36-char UUID - how every supported harness names its sessions. */ export const UUID_SHAPE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; export const deepFreeze = (value: T): T => { if (typeof value === "object" && value !== null) { for (const inner of Object.values(value)) deepFreeze(inner); Object.freeze(value); } return value; }; export const CANONICAL_TOOLS = deepFreeze([ "read", "edit", "write", "shell", "grep", "glob", "list", "web-fetch", "web-search", "subagent", "skill", ] as const); export type CanonicalTool = (typeof CANONICAL_TOOLS)[number]; /** Every closed string vocabulary on a descriptor is declared once as a * runtime array with its type derived (RFC-02 change 9), so a CLI list, an * override validator, or a test reads the array instead of restating it. */ export const STREAMING_GRANULARITIES = deepFreeze(["token", "message", "none"] as const); export type StreamingGranularity = (typeof STREAMING_GRANULARITIES)[number]; export const HARNESS_MODES = deepFreeze([ "headless-turn", "headless-session", "interactive", ] as const); export type HarnessMode = (typeof HARNESS_MODES)[number]; export const STDIN_POLICIES = deepFreeze(["inherit", "close-required"] as const); export type StdinPolicy = (typeof STDIN_POLICIES)[number]; export const CWD_SLUGS = deepFreeze([ "dash-separators", "pi-dash-wrapped", "verbatim", "md5-hex", ] as const); export type CwdSlug = (typeof CWD_SLUGS)[number]; export const RESUME_STYLES = deepFreeze(["flag", "positional"] as const); export type ResumeStyle = (typeof RESUME_STYLES)[number]; /** Where tokens after hcn's bare `--` go in the harness argv. The * separator itself is never rendered: probed harnesses parse trailing * native flags once the `--` is gone (ADR 0003), so `after-argv` appends * past hcn's own argv. `prompt-joins` refuses before spawn on harnesses * where no placement parses. Absent means `after-argv`: trailing flags * keep the established default and never restore the separator that broke * every harness. The * `before-prompt` splice was removed (L5): no harness needed it, and on * claude a variadic tail placed before the prompt would be taken as a * directory by flags like `--add-dir`. */ export const LAUNCH_PASSTHROUGHS = deepFreeze(["after-argv", "prompt-joins"] as const); export type LaunchPassthrough = (typeof LAUNCH_PASSTHROUGHS)[number]; export const RESUME_ON_MISSING = deepFreeze(["error", "create", "unknown"] as const); export type ResumeOnMissing = (typeof RESUME_ON_MISSING)[number]; export const IDENTITY_AUTHORITIES = deepFreeze(["caller-assigned", "harness-minted"] as const); export type IdentityAuthority = (typeof IDENTITY_AUTHORITIES)[number]; export const DENY_SEMANTICS = deepFreeze(["remove-from-set", "policy-gate", "no-lists"] as const); export type DenySemantics = (typeof DENY_SEMANTICS)[number]; export const SKILLS_OVERRIDES_VIA = deepFreeze([ "settings-skilloverrides", "config-skills-array", ] as const); export type SkillsOverridesVia = (typeof SKILLS_OVERRIDES_VIA)[number]; export const VERSION_SOURCE_KINDS = deepFreeze(["npm", "installed"] as const); export type VersionSourceKind = (typeof VERSION_SOURCE_KINDS)[number]; /** The access preset's value vocabulary: `read` narrows to the read-only * tool subset, `write` is no restriction. */ export const ACCESS_VALUES = deepFreeze(["read", "write"] as const); export type AccessValue = (typeof ACCESS_VALUES)[number]; export const SESSION_INPUT_KINDS = [ "claude-sdk-user-message", "pi-rpc-prompt", "antigravity-stream-user", "popeye-rpc-prompt", "codex-jsonrpc", ] as const; export type SessionInputKind = (typeof SESSION_INPUT_KINDS)[number]; /** How the runner observes a pending native approval the harness omits * from its headless stream (issue #179). Closed vocabulary like * LimitCode: a descriptor cannot invent an observer the execution layer * has no arm for. */ export const APPROVAL_OBSERVERS = deepFreeze(["msp-list-pending"] as const); export type ApprovalObserver = (typeof APPROVAL_OBSERVERS)[number]; export interface SessionInputContract { readonly kind: SessionInputKind; } export const SESSION_RESUME_FLAGS = ["--resume", "--session-id", "--conversation"] as const; export type SessionResumeFlag = (typeof SESSION_RESUME_FLAGS)[number]; /** Consumers branch on these (session-limit: wait for reset; weekly-limit: * route elsewhere), so the vocabulary is closed - a descriptor cannot invent * a code a consumer has no arm for. */ export type LimitCode = | "usage-limit" | "session-limit" | "weekly-limit" | "credits" | "quota" | "rate-limit"; /** Auth walls are separate from usage limits because the remedy is entirely * different (re-auth vs wait), and a detached process misreading one as the * other sends the human to fix the wrong thing. */ export type AuthFailureKind = "not-logged-in" | "expired" | "invalid-key"; /** Compaction states (ADR 0009). Closed, like `LimitCode`, so a consumer * branches with no default arm. A member exists only where a caller would do * something different: `started` says a pause is beginning and the silence * that follows is not a stall; `compacted` says earlier history is now a * summary, so context the caller sent is gone; `noop`, `failed` and `aborted` * all leave the context unchanged, and they are separate because "nothing to * compact" and "compaction broke and the run may die" are not the same fact. */ export type CompactionState = "started" | "compacted" | "noop" | "failed" | "aborted"; /** What set the compaction off, where the harness reports it. A * threshold-triggered compaction is automatic, so it normalizes onto `auto`; * `overflow` is pi's separate final-attempt recovery, which means the context * had already overflowed. */ export type CompactionTrigger = "auto" | "manual" | "overflow"; /** Serializable wall matcher: pattern and flags are data, not a RegExp * literal, so an override file can extend them. `compileMatchers` turns * these into RegExps with bounds (pattern length, count, allowed flags) * and a WeakMap cache so the same array instance reuses compiled RegExps. * The input window (first 4096 chars of a line), not pattern analysis, is * the backtracking bound. */ export interface LimitMatcher { readonly pattern: string; readonly flags?: string; readonly code: LimitCode; } export interface AuthMatcher { readonly pattern: string; readonly flags?: string; readonly kind: AuthFailureKind; } /** A bare phrase matcher for wall classes that carry no code of their own * (transport faults, a provider that cannot serve the model): pattern and * flags as data, compiled with the same bounds as the limit matchers. */ export interface PhraseMatcher { readonly pattern: string; readonly flags?: string; } export type TransportMatcher = PhraseMatcher; export type UnavailableMatcher = PhraseMatcher; /** Workspace-trust refusal phrasings (cursor): same bounds as the limit * matchers, compiled by the same compiler. */ export type TrustMatcher = PhraseMatcher; /** The turn-option vocabulary is closed for the same reason `LimitCode` is: * a descriptor must not invent an option a consumer has no field for. Every * key here maps to a field on `TurnOptions`; adding a key without a consumer * arm would be dead data that can only drift. Render order is the tuple * order, so argv is deterministic regardless of caller field order. */ export const TURN_OPTION_KEYS = deepFreeze([ "isolation", "effort", "sandbox", "contextWindow", "provider", "discovery", "write", "shell", // Persistent-memory dimension (ratified 2026-08-26): memory:false asks // the harness not to load or persist cross-session memory. claude // expresses it ONLY as a spawn env var, codex as a feature flag, so the // render union carries an `env` kind. muse cannot express it at all // (no spec, refuses); pi has no built-in memory (vacuous empty render). "memory", "maxSteps", // issue #48 (ratified 2026-08-20): the payload-stripping dimensions. // systemPrompt replaces the built-in prompt; appendSystemPrompt adds to // it. Both are opt-in-only (arg/config, no profile entry) - a default // that strips the payload changes every bare run's semantics. "systemPrompt", "appendSystemPrompt", "access", // RFC 35 (Lucid): explicit extension files, loaded even when discovery is // off. Opt-in only; a harness without the spec refuses. "extensions", // Named-agent selection (map #288, ratified 2026-09-27): the caller picks // among agents the HARNESS defines natively (claude subagents, antigravity // agents); hcn selects, never defines. Opt-in only; a harness without the // spec refuses. Names pass through unvalidated beyond the selector rule - // no harness exposes a stable machine roster (probed 2026-09-27). "agent", ] as const); export type TurnOptionKey = (typeof TURN_OPTION_KEYS)[number]; /** Discovery facets are closed for the same reason as `TurnOptionKey`: each * maps to a field on `DiscoveryOptions` and a concrete flag spelling per * harness. A harness that cannot express a facet simply omits it from its * `turnOptions.discovery.facets` table; a call passing that facet must * refuse. */ export const DISCOVERY_FACETS = deepFreeze([ "tools", "instructionFiles", "extensions", "skills", ] as const); export type DiscoveryFacet = (typeof DISCOVERY_FACETS)[number]; export type JsonValue = string | number | boolean | null | JsonObject | readonly JsonValue[]; export interface JsonObject { readonly [key: string]: JsonValue; } export interface JsonSettingsFragment { readonly flag: string; readonly value: JsonObject; } export type OptionRender = /** `--flag `; `extraFlags` are fixed companion tokens emitted * before the value pair (claude's --system-prompt pairs with * --exclude-dynamic-system-prompt-sections - issue #48). */ | { readonly kind: "flag-value"; readonly flag: string; readonly extraFlags?: readonly string[]; } /** Fixed inline JSON, composed with other hcn-owned fragments before argv * serialization. Companion flags do not participate in JSON composition. */ | (JsonSettingsFragment & { readonly kind: "json-settings"; readonly extraFlags?: readonly string[]; }) /** A fixed multi-token flag set emitted verbatim, value-less. */ | { readonly kind: "flag-list"; readonly flags: readonly string[] } /** A spawn-environment assignment, not an argv token (claude's * CLAUDE_CODE_DISABLE_AUTO_MEMORY): the option renders as `name=value` * merged into the spawn env by the execution layer. Value must be a * closed-vocabulary literal - no caller-supplied interpolation. */ | { readonly kind: "env"; readonly name: string; readonly value: string } /** Effort resolved into the model id (cursor): the effort word renders * through the model flag, so this render emits zero tokens of its own. */ | { readonly kind: "in-model" } /** `-c key=value` - codex's config-override grammar. Permitted only for * closed-vocabulary specs, so no value can need escaping. */ | { readonly kind: "config-kv"; readonly flag: string; readonly key: string }; /** Map #300: the runtime probe an extension-registered option declares. * `argv` is appended to the harness bin ("--help" today); `contains` is * the exact token a present extension registers (the help line carries * it, whitespace-normalized before matching so terminal wrapping cannot * split it); `providedBy` names the extension for refusal text and * provenance. All three are descriptor data - no layer assumes --help is * a universal probe surface (ticket #302 decision 1). */ export interface OptionProbe { readonly argv: readonly string[]; readonly contains: string; readonly providedBy: string; } export interface SpecBase { readonly render: OptionRender; /** Map #300: when set, the option is extension-registered - the spec * renders only after the CLI's pre-spawn gate verifies the probe * against the installed harness (ticket #302 decision 2). Interpretation * itself never probes; a spec with a probe renders exactly like one * without once the gate has passed. */ readonly probe?: OptionProbe; /** The spelling the RESUME grammar accepts. Omitted means "same as * `render`"; an explicit `null` declares the option unexpressible on * resume, and building a resume argv with it must refuse. */ readonly resumeRender?: OptionRender | null; } export interface PositionedSpecBase extends SpecBase { /** Where argv tokens from this option sit relative to the prompt. * Most harness flags precede a positional prompt. Use `after-prompt` * when a prompt flag consumes the immediately following token. */ readonly argvPlacement?: "before-prompt" | "after-prompt"; } /** How one access value renders on one harness (RFC-02 change 3): the * marker for "render the read preset through the tool list", null for * "emit nothing", or a phase-aware render carrying the harness value it * maps to (codex: `read` is `--sandbox read-only`). */ export type AccessRender = | "tool-preset" | null | (PositionedSpecBase & { readonly value?: string }); export type TurnOptionSpec = /** Closed value vocabulary. `default` renders on LAUNCH ONLY. */ | (PositionedSpecBase & { readonly kind: "enum"; readonly values: readonly string[]; readonly default?: string; }) /** The access preset: one kind on every harness that expresses it, a * render per value, and `claims` naming the turn option the preset * displaces when set (codex: sandbox) so exclusivity is data, not a * harness name. */ | { readonly kind: "access"; readonly renders: Readonly>; readonly claims?: TurnOptionKey; /** Map #300: extension-registered access, if a harness ever gates it. */ readonly probe?: OptionProbe; } /** Ladder comes from vocabulary.efforts / effortsByModel, not from here. */ | (PositionedSpecBase & { readonly kind: "effort" }) /** Effort resolved into the model slug (cursor): the ladder comes from * vocabulary.effortSlugs, and the render is always `in-model`. */ | (PositionedSpecBase & { readonly kind: "effort-in-model" }) /** Open selector, CLEAN_SELECTOR-validated. */ | (PositionedSpecBase & { readonly kind: "selector" }) /** Absolute file paths, each rendered as its own flag-value pair * (RFC 35: pi `-e `). */ | (PositionedSpecBase & { readonly kind: "path-list" }) /** Free-form prompt text (issue #48): systemPrompt / appendSystemPrompt. * Values are prose, never a closed vocabulary - no validation beyond * non-emptiness, rendering is verbatim. */ | (PositionedSpecBase & { readonly kind: "prompt-text" }) /** `polarity: "disables"` emits the render when the caller asks for FALSE. */ | (PositionedSpecBase & { readonly kind: "toggle"; readonly polarity: "enables" | "disables"; }) | (PositionedSpecBase & { readonly kind: "integer"; readonly min: number; readonly max: number }) /** Per-facet toggles; a facet absent from the table cannot be expressed. */ | { readonly kind: "discovery"; readonly facets: Readonly< Partial> >; /** Map #300: uniform probe availability across spec kinds. */ readonly probe?: OptionProbe; }; /** Resolve the effective render for a spec at a given phase. * - `launch` always uses `render`. * - `resume` with `resumeRender: null` is unexpressible (returns null). * - `resume` with `resumeRender` omitted resolves to the same render as `render`. * - `resume` with an explicit `resumeRender` uses that spelling. * Discovery facets use the same rule per facet; this helper works for any * `SpecBase` (top-level specs and per-facet specs alike). */ export const resolveRender = (spec: SpecBase, phase: "launch" | "resume"): OptionRender | null => { if (phase === "launch") return spec.render; if (spec.resumeRender === null) return null; return spec.resumeRender ?? spec.render; }; export type Quoting = "toml" | "verbatim"; /** The argv tokens one resolved render produces for one value - the only * place a render kind becomes tokens (RFC-02 change 1). `toml` quoting is * `JSON.stringify`, sufficient for the closed vocabularies that ride * config-kv; `verbatim` passes prose (prompt text) and bare TOML literals * through unchanged. A flag-value or config-kv render with no value is a * descriptor error, never an empty token. */ export const tokensFor = ( render: OptionRender, value?: string, quoting: Quoting = "toml", ): readonly string[] => { switch (render.kind) { case "flag-value": if (value === undefined) { throw new Error(`render ${render.flag} needs a value and none was given`); } return [...(render.extraFlags ?? []), render.flag, value]; case "json-settings": return [...(render.extraFlags ?? []), render.flag, JSON.stringify(render.value)]; case "config-kv": if (value === undefined) { throw new Error(`render ${render.flag} ${render.key} needs a value and none was given`); } return [render.flag, `${render.key}=${quoting === "toml" ? JSON.stringify(value) : value}`]; case "flag-list": return [...render.flags]; case "env": return []; case "in-model": // RFC-05: effort renders through the model flag, so this render // emits zero tokens of its own. return []; default: { const exhaustive: never = render; return exhaustive; } } }; export interface HarnessDescriptor { readonly transcript: TranscriptKnowledge | null; readonly name: HarnessName; readonly bin: string; /** The CLI version every fact in this descriptor - argv shapes, event * vocabularies, capability claims, resume semantics - was verified * against, as ` --version` reports it. This is the anchor for the * harness-update pipeline: CI compares the published/installed version to * this, and a mismatch means the descriptor's facts are unverified for the * new version (drift possible, or a capability the descriptor says is * absent may now exist). Bump it only when the facts have been re-verified * against that version (and the fixtures re-captured). Evidence metadata * never rejects an operation; invocation and native results determine support. */ readonly verifiedAgainst: string; /** Where the latest published version is found, so the update pipeline can * detect a new release WITHOUT installing the CLI or running inference. * `npm` is a pure registry query (credential-free, CI-friendly); * `installed` harnesses (muse ships a shell script, not an npm package) * have no registry to poll, so the check falls back to the locally * installed ` --version` and is skipped where the CLI is absent. */ readonly versionSource: | { readonly kind: Extract; readonly package: string } | { readonly kind: Extract }; /** Headless one-turn launch shape. `promptStyle: "positional"` means the * prompt travels as a bare argv entry (ordering constraints apply). * `streamFlags` is the output flag set a headless turn launches with so * its stream is parseable and token-granular where the harness supports * it - the v1 stall-watchdog scar was launching bare and then waiting for * deltas the invocation could not emit. */ readonly launch: { readonly baseFlags: readonly string[]; /** The subcommand words among baseFlags (codex/muse `exec`), declared * explicitly - resume parsing anchors on them, and deriving them by * filtering non-dash tokens would silently promote flag VALUES like * `workspace-write` into subcommands. */ readonly subcommands: readonly string[]; readonly promptStyle: "positional"; /** A verified alternate transport for prompts too large for argv. */ readonly stdinPrompt?: { readonly argument: string; readonly aboveBytes: number }; readonly streamFlags: readonly string[]; /** The flag that pins a caller-assigned id at LAUNCH (spawn-time * assignment; the execution layer consumes it), or null when the * harness mints its own. */ readonly idFlag: string | null; /** Declared passthrough placement for tokens after hcn's bare `--` * (never rendered with the separator - ADR 0003). Absent means * `"after-argv"`; `"prompt-joins"` refuses any non-empty tail before * spawn instead of silently rewriting the prompt. */ readonly passthrough?: LaunchPassthrough; }; /** How a named session id is resumed. `flag` style: ` `; * `positional` style: ` resume ` (muse) - the resume token must * sit at argv position 1. `idShape` is the shape of the harness's own ids; * parseResumeCommand refuses commands where an id-shaped token appears * anywhere else (the v1 first-UUID-wins scar: a UUID inside quoted prompt * text was returned as the session id, and resuming it started a stranger). */ readonly resume: { readonly style: ResumeStyle; readonly flag: string; readonly aliases: readonly string[]; readonly idShape: RegExp; /** Flags valid in the RESUME grammar - resume argv never inherits * launch flags, because subcommand grammars differ (codex exec resume * rejects --sandbox). */ readonly extraFlags: readonly string[]; /** A parse-only positional spelling (muse's interactive `muse resume * `) recognized when pasted, but never built - the builder uses * `style`/`flag`. */ readonly positionalParseWord?: string; /** What resuming a NONEXISTENT id does: "error" - * claude/codex refuse an unknown session; "create" - pi/muse treat the * id as create-if-missing and silently start a FRESH session under it. * Those values require live evidence; "unknown" leaves the native * result authoritative until the behavior is qualified. * The protocol layer must know this: a consumer resuming a session it * believes exists gets a blank session, not an error, on a "create" * harness. */ readonly onMissing: ResumeOnMissing; }; /** Persistent headless session support: the complete flag list that follows * the binary to open one lucid-owned process serving many turns, or null * when the harness has no such mode. This is the entire argv prefix after * the binary - no launch flags are prepended. `idFlag` pins the * caller-assigned session identity for a FRESH session (naming). `turnEnd` * is the stdout record that delimits one turn (claude: the `result` * record; pi rpc: `agent_settled`). `identityProbe`, when present, names * a command the runner writes at spawn whose response carries the session * id - pi rpc is identity-silent at startup (spike evidence: * test/fixtures/pi-rpc-spike), so identity needs a round trip. */ readonly sessionMode: { readonly flags: readonly string[]; /** The flag that names a fresh session (caller-assigned identity at * spawn). Null only when the harness mints the id itself. */ readonly idFlag: SessionResumeFlag | null; /** The flag that carries an EXISTING session id to resume it in * session mode, distinct from `idFlag` which names a fresh session. * Closed vocabulary: pi reuses `--session-id` for both (verified * phase10, test/fixtures/phase10-pi-rpc-resume); claude uses * `--resume` to restore vs `--session-id` to name (verified phase11, * test/fixtures/phase11-claude-session-resume). Null means the * harness has no resume FLAG because resume is decided inside the * session protocol, not on argv: codex app-server renders the same * argv either way and the open request picks thread/start vs * thread/resume (verified 0.159.2, test/fixtures/codex-0.159.2/session). * Unknown-id behavior is not duplicated here; it reuses * `resume.onMissing` as the single source. */ readonly resumeFlag: SessionResumeFlag | null; readonly input: SessionInputContract; readonly turnEnd: Readonly>; /** The command the runner writes at spawn to learn the session id, and * the dot-path to that id in the response (pi rpc: `get_state`, * `data.sessionId`). Encoded and decoded in interpretation * (session-input.ts); execution holds no field names (ADR 0005). */ readonly identityProbe: { readonly command: string; readonly responseIdField: string } | null; /** How `--model` renders on the session spawn argv when the harness's * one-shot `modelFlag` spelling is not an accepted session flag. * Absent means "vocabulary.modelFlag applies" (every harness today * except codex, whose app-server takes the model as a config override * instead of `--model` - verified 0.159.2). */ readonly modelRender?: OptionRender; } | null; /** Streaming is a property of the INVOCATION, not the harness: each pin * names the flag set that unlocks a granularity, checked in order, first * fully-satisfied pin wins; an argv satisfying none gets `floor`. This * carries claude (token under stream-json), codex (message under --json, * none bare), and muse/pi in one shape - a single conditional tier could * not. `flagAliases` maps alternate spellings onto pin members. */ readonly output: { readonly pins: ReadonlyArray<{ readonly flags: readonly string[]; readonly granularity: StreamingGranularity; }>; readonly floor: StreamingGranularity; readonly flagAliases: Readonly>; }; /** Where native identity appears in the stream, and who mints it. Every * key/value pair in `announce.match` must match the raw event; claude: * {type: "system", subtype: "init"}, re-emitted at every turn start with * the same value (A-001) - consumers dedupe via decodeIdentity. */ readonly identity: { readonly authority: IdentityAuthority; /** `idField` is a dot-path (muse nests its id at `stream.id`); an empty * `match` means "any record carrying the id path". */ readonly announce: { readonly match: Readonly>; readonly idField: string; }; }; /** "Stopped on a limit" vs crash vs clean exit: the harness's own wall * phrasings. First match wins per line. */ readonly limitMatchers: ReadonlyArray; /** Auth-wall phrasings, same scan discipline as limitMatchers. */ readonly authMatchers: ReadonlyArray; /** Workspace-trust refusal phrasings (cursor only in v1): the trust gate * that refuses untrusted workspaces. Absent on harnesses with no such * gate. Compiled under the same bounds as every other matcher. */ readonly trustMatchers?: ReadonlyArray; /** The "run unattended without stops" flag, or null when the harness has * no such mode. */ readonly autonomy: { readonly flag: string } | null; /** Pending-approval observation for one headless turn (muse only in v1): * muse exec omits approvals from stdout, so the runner polls the * read-only MSP approval/listPending operation through a helper serve * process it reaps with the child. Absent on harnesses whose headless * stream already carries (or cannot raise) pending native requests. */ readonly approvalObserver?: ApprovalObserver; /** The harness's own model-id spellings, alias map, and effort ladder. * Curated baseline - pi's registry is runtime-extensible, so validation * against this vocabulary is a default, not a final word (D-008). */ readonly vocabulary: { readonly modelFlag: string; readonly models: readonly string[]; readonly aliases: Readonly>; readonly efforts: readonly string[]; /** Per-model effort ladders where the harness constrains them (codex: * gpt-5.5 tops out at high; gpt-5.6-* starts at medium). Falls back to * the harness-wide `efforts`. */ readonly effortsByModel?: Readonly>; /** D-008: an extensible vocabulary (pi) accepts clean unknown model * selectors at argv time; capability claims for them degrade to * unknown until runtime verification. */ readonly extensible: boolean; /** Stem to effort-to-slug table (cursor only in v1): effort resolves * into the `--model` slug by table lookup, never by concatenation. * Keys are stems (never valid `--model` values on their own); values * map hcn effort words to the exact slugs `agent models` lists. * Absent on harnesses whose effort renders as its own flag. */ readonly effortSlugs?: Readonly>>>; }; /** Where the harness files sessions/transcripts, as a template over * {home}, {cwdSlug} and {sessionId}. Slugging rule is per-harness. */ readonly store: { readonly template: string; /** claude: '/', '.' -> '-'; pi: '/' -> '-' wrapped in leading/trailing * dashes, dots preserved. */ readonly cwdSlug: CwdSlug; /** Environment override chain for the store root: first set entry * wins; `suffix` appends to the value (empty keeps it as is) and may * carry `{cwdSlug}` when the harness files per-cwd under that root * but not under another (pi). Absent on harnesses whose root is a * fixed home path. */ readonly rootEnv?: ReadonlyArray<{ readonly name: string; readonly suffix: string }>; /** Fallback root when no `rootEnv` entry is set, with `{home}`, * `{cwd}` and `{cwdSlug}` expanded. */ readonly defaultRoot?: string; }; /** Disposable native context accounting; the exchange validates support. * Null is unknown support, never a model-window estimate. `forkFlag` has * two consumers: context inspection (a resume there forks, leaving the * original session unchanged) and the resume-last builder (a claude * most-recent turn always forks). */ readonly contextInspection: { readonly flags: readonly string[]; readonly forkFlag: string; readonly kind: "claude-control-v1"; } | null; /** Curated native context handling, not a count or enabled-state observation. * Native-session handling covers the native session; callers must prepare * imported history. Disabled native compaction is not detected here. */ readonly nativeContextManagement: { readonly kind: "auto-compaction" | "native-session-auto-compaction"; readonly modes: readonly HarnessMode[]; } | null; /** ADR 0009: whether this harness REPORTS its compaction to hcn, and on * which channel. Distinct from `nativeContextManagement`, which says * whether the harness compacts at all - a harness can compact and say * nothing, and codex and cursor both do. * * This exists so silence is never read as "no compaction happened". A * caller checks the field once and knows whether to expect events. * `source` is the channel hcn reads: `stream` is the harness's own * stdout, `view` is a side surface hcn already attaches to (muse's MSP * view). `states` are the states this harness can actually produce, not * the whole union. `tokens` says whether token counts arrive. * Null means no live signal on any channel hcn reads. */ readonly compactionReporting: { readonly source: "stream" | "view"; readonly states: readonly CompactionState[]; readonly tokens: boolean; } | null; /** Most-recent resume support, or null. `flag` stays the parse key for * `parse-resume.ts` (shell history carrying `muse resume --last` still * parses); `headless` decides whether a builder may render it. A * parse-only harness (`headless: false`) carries no `warning` - the * field exists only on the renderable arm, so an unused warning text * cannot drift there. `warning` is the verbatim pre-spawn text (RFC-06 * Safety item 4, recorded as errata on 2026-09-17) with `{cwd}` * placeholders for the realpath scope - the CLI layer renders it, the * execution layer only emits the rendered line. The harness owns * most-recent resolution; the pre-spawn warning plus the identity * signal bound the stranger race. */ readonly resumeLast: | { readonly flag: string; readonly headless: true; /** Most harnesses place the most-recent flag after the resume * grammar's extra flags. A prompt-consuming extra flag must stay * adjacent to the prompt, so its most-recent flag goes first. */ readonly flagPlacement?: "before-extra-flags" | "after-extra-flags"; readonly warning: string; } | { readonly flag: string; readonly headless: false; } | null; /** Whether backgrounded headless calls must have stdin closed (pi hangs * without `< /dev/null`). */ readonly stdin: StdinPolicy; /** Presence recognition: how an interactive process for a session id shows * up in a process listing. `headlessMarkers` mark a process as headless * (not interactive presence). Known blind spot, inherent to argv matching: * an interactive session carrying no id in argv (`claude --continue`) is * invisible here - presence corroborates, it never proves. */ readonly presence: { readonly headlessMarkers: readonly string[]; }; /** Curated capability baseline per mode (D-008: a default, never a final * word - runtime verification upgrades source/confidence at attach). */ readonly capabilities: { readonly vision: boolean; readonly images: boolean; readonly streamingByMode: Readonly>; readonly session: boolean; }; /** Escalation provenance: whether this harness and model were observed to * emit the structured hcn-question block when instructed, and when that * observation was made. `supported` is documented as "this harness and * model were observed to emit the structured block when instructed", NOT * as "this harness can ask" - live probes show models ask unprompted, so * a capability-to-ask name would be false. `observedOn` is optional; * absence means no probe exists for this harness/model. Staleness is * derived from version comparison at read time, not stored. */ readonly escalation: { readonly supported: boolean; readonly observedOn?: { readonly harness: string; readonly model: string; readonly version: string; readonly date: string; }; }; /** Per-call turn options this harness can express, keyed by the closed * `TurnOptionKey` vocabulary. Absent keys are unexpressible on this * harness; a call passing them must refuse. Discovery is a table of * per-facet specs rather than a single flag. */ readonly turnOptions: Readonly>>; /** Tool-selection surface, grounded in test/fixtures/phase0/ evidence. * `includeFlag`/`excludeFlag` are the per-tool NAME-LIST flags (null when * the harness has none - codex/muse). `includeIsStrictAllowlist` records * the claude asymmetry: claude's include flag pre-approves without * restricting the visible set, so an exact allowlist must render as a * disallow-complement there; pi's include IS strict (over built-ins). * `builtins`: curated names + default-enabled state - grep/find/ * ls ship off on pi, everything ships on elsewhere. `categories`: * non-list switches (muse disable flags, codex config booleans). * `denySemantics`: whether a deny removes the tool from the model-visible * set (claude/pi), policy-gates execution while the tool stays listed * (muse), or there are no lists to deny with (codex). Extension and MCP * tools register at runtime and are NEVER enumerated here - name * validation is a default, not a refusal authority (same stance as the * pi model registry, D-008). */ /** Caller-directed skills allowlist surface (issue #38). pi: repeatable * load flag. claude: per-name "off" overrides via settings JSON. * codex: per-skill "off" via config-kv array (-c skills.config). * muse: null (structural gap - trust/config scoped only). */ readonly skills: { readonly loadFlag: string | null; readonly overridesVia: SkillsOverridesVia | null; } | null; readonly tools: { readonly includeFlag: string | null; readonly excludeFlag: string | null; readonly includeIsStrictAllowlist: boolean; readonly builtins: ReadonlyArray<{ readonly name: string; readonly defaultEnabled: boolean; readonly canonical: CanonicalTool | null; }>; readonly categories: ReadonlyArray<{ readonly key: "shell" | "write" | "web" | "exec" | "view-image"; readonly disableFlag: string | null; readonly configKey: string | null; readonly canonical: readonly CanonicalTool[]; }>; readonly denySemantics: DenySemantics; }; }