/** * Argv construction: pure functions turning a descriptor + launch options * into the exact argv a spawner would exec. Ordering rules (positional * prompt before tool grants) and the spawn-boundary refusals live here so * no caller re-derives them. */ import type { AccessValue, HarnessDescriptor, JsonSettingsFragment, StreamingGranularity, } from "../knowledge/descriptor.js"; import type { NativeSettingsSnapshot } from "../knowledge/native-settings.js"; import { defaultDescriptors } from "../knowledge/overrides.js"; import { hintFor } from "./hints.js"; import { renderArgvParts, renderInlineSettings } from "./inline-settings.js"; import { assertIsolationCombination } from "./isolation.js"; import { promptAfterSeparator, renderVerifiedNativeSettings } from "./native-settings-argv.js"; import { ArgvRefusalError } from "./refusal.js"; import { assertAccessExclusivity } from "./resolve-options.js"; import { assertUsableSessionId, SESSION_ID_MAX, SessionIdRefusalError } from "./session-id.js"; import { renderSkillsSelection, type SkillsSelection } from "./skills-selection.js"; import { supportedBy } from "./support.js"; import { renderToolSelection } from "./tool-selection.js"; import type { ToolMap } from "./tool-vocabulary.js"; import { renderTurnOptions } from "./turn-options.js"; import { validateModel } from "./vocabulary.js"; export type { RefusalIssue } from "./refusal.js"; export { ArgvRefusalError, buildRefusalMessage, REFUSAL_ISSUES } from "./refusal.js"; /** A prompt carries its own provenance (RFC-02 change 13): a plain string * is an implicit, positional prompt; the object form came from an explicit * flag or file, so a leading dash is the caller's intent, not a flag. */ export type Prompt = string | { readonly text: string; readonly explicit: boolean }; /** The one accessor for the prompt's text, for builders, the runner, and * redaction alike. */ export const promptTextOf = (opts: { readonly prompt: Prompt }): string => typeof opts.prompt === "string" ? opts.prompt : opts.prompt.text; /** The same prompt with new text: the composed form keeps its provenance. */ export const withPromptText = (prompt: Prompt, text: string): Prompt => typeof prompt === "string" ? text : { ...prompt, text }; /** One guard for every builder that places a positional prompt: an * implicit prompt may not start with '-' (it would be parsed as a flag), * while an explicit one - `hcn run --prompt "-bad"` - passes. Selector * hygiene (session ids) lives in session-id.ts; model selectors go through * validateModel - both refuse, never sanitize. */ const assertCleanPrompt = (h: HarnessDescriptor, prompt: Prompt): void => { if (typeof prompt !== "string" && prompt.explicit) return; if (promptTextOf({ prompt }).startsWith("-")) { throw new ArgvRefusalError({ issue: "prompt-flag-injection", harness: h.name, supported: ["prompt must not start with '-'"], detail: `it would be parsed as a flag by ${h.bin}`, }); } }; export interface DiscoveryOptions { readonly tools?: boolean; readonly instructionFiles?: boolean; readonly extensions?: boolean; readonly skills?: boolean; } export interface TurnOptions { readonly isolation?: "tool-free"; readonly prompt: Prompt; readonly tools?: readonly string[]; readonly excludeTools?: readonly string[]; /** Caller-directed skills allowlist: the resolved picks and the * registry's known names. Rendered per descriptor by * renderSkillsSelection (pi loads each pick with discovery off; claude * and codex turn the complement off); muse refuses. */ readonly skills?: SkillsSelection; readonly model?: string; readonly autonomy?: boolean; readonly effort?: string; readonly sandbox?: string; readonly contextWindow?: number; readonly provider?: string; /** Named-agent selection (map #288): the caller picks among agents the * harness defines natively. claude and antigravity render the native * `--agent `; harnesses without a spec refuse. The name passes * through - an unknown name is the harness's own native error, never an * hcn refusal (no stable machine roster exists to validate against). */ readonly agent?: string; readonly discovery?: DiscoveryOptions; readonly write?: boolean; readonly shell?: boolean; /** Persistent cross-session memory (ratified 2026-08-26). false = the * harness loads no cross-session memory into context (profile default); * true = opt back in. claude renders a spawn env var, codex a feature * flag, pi is vacuously off, muse refuses explicit values (no off * switch exists - memory stays on, reported as divergence). */ readonly memory?: boolean; readonly maxSteps?: number; /** issue #48: replaces the harness's built-in system prompt (opt-in-only, * no profile entry). claude/pi: flag-value (claude pairs the dynamic-section * exclusion); codex: config-kv `instructions` (literal or path); muse: * refuses with hint. */ readonly systemPrompt?: string; /** issue #48: appends to the built-in prompt (claude/pi only). */ readonly appendSystemPrompt?: string; /** RFC 35 (Lucid): extension files the harness loads for this turn. */ readonly extensions?: readonly string[]; /** question mode: which preamble hcn injects (ask/assume/none). * A BEHAVIOR INSTRUCTION, not a turn option. It never renders into any * harness argv; the CLI layer turns it into the prompt preamble and * arms question-block detection. Undefined means the default: "ask". */ readonly questions?: import("./question.js").QuestionMode; /** The merged toolMap (every harness, native plus tier), the one shape * past option resolution (RFC-02 change 8). */ readonly toolMap?: ToolMap; readonly access?: AccessValue; } export interface ResumeOptions extends TurnOptions { readonly sessionId: string; } export type LaunchOptions = TurnOptions; /** Returns the exact text to pipe, or null when it travels in argv. */ export function stdinPromptOf(h: HarnessDescriptor, opts: TurnOptions): string | null { const transport = h.launch.stdinPrompt; if (!transport) return null; const text = promptTextOf(opts); if (text.length <= transport.aboveBytes / 3) return null; return text.length > transport.aboveBytes || new TextEncoder().encode(text).byteLength > transport.aboveBytes ? text : null; } /** The shared tail of a headless turn: inline prompt, stream flags and * validated selections. Tool names travel in one joined argument; inline * prompt text precedes variadic tool grants. */ const turnTail = ( h: HarnessDescriptor, opts: TurnOptions, settings: readonly JsonSettingsFragment[], prompt: "inline" | "after-separator" = "inline", ): string[] => { const stdinPrompt = stdinPromptOf(h, opts); if (stdinPrompt === null) assertCleanPrompt(h, opts.prompt); const promptToken = stdinPrompt === null ? promptTextOf(opts) : (h.launch.stdinPrompt?.argument ?? ""); const tail = prompt === "inline" ? [promptToken] : []; tail.push(...h.launch.streamFlags); if (opts.model !== undefined) { const validated = validateModel(h, opts.model); if (!validated.ok) { const supported = [...h.vocabulary.models, ...Object.keys(h.vocabulary.aliases)]; throw new ArgvRefusalError({ issue: "unknown-model", harness: h.name, supported: supported.length ? supported : ["no curated models"], detail: opts.model, }); } tail.push(h.vocabulary.modelFlag, validated.id); } if (opts.autonomy === true) { if (h.autonomy === null) { const by = supportedBy(defaultDescriptors(), "autonomy"); throw new ArgvRefusalError({ issue: "no-autonomy-mode", harness: h.name, supported: by.map((e) => `${e.harness} ${e.spelling}`), supportedBy: by, hint: "pi has no unattended-run flag; approximate with a per-tool allowlist (--tools read,bash) if you need unattended behavior on pi", }); } tail.push(h.autonomy.flag); } if (opts.tools !== undefined || opts.excludeTools !== undefined) { const rendered = renderToolSelection(h, { include: opts.tools, exclude: opts.excludeTools, toolMap: opts.toolMap, }); tail.push(...rendered.tokens); } const skillParts = opts.skills === undefined ? undefined : renderSkillsSelection(h, opts.skills); tail.push(...(skillParts?.tokens ?? [])); tail.push(...renderInlineSettings([...settings, ...(skillParts?.settings ?? [])])); return tail; }; /** The prompt that follows every flag, behind the harness's own `--`. */ const separatedPrompt = (h: HarnessDescriptor, opts: TurnOptions): string[] => { const stdinPrompt = stdinPromptOf(h, opts); return ["--", stdinPrompt === null ? promptTextOf(opts) : (h.launch.stdinPrompt?.argument ?? "")]; }; const renderTurnArgv = ( h: HarnessDescriptor, opts: TurnOptions, phase: "launch" | "resume", nativeSettingsArgs: readonly string[] = [], prompt: "inline" | "after-separator" = "inline", ): string[] => { const before = renderTurnOptions(h, opts, phase, "before-prompt"); const after = renderTurnOptions(h, opts, phase, "after-prompt"); return [ ...before.tokens, ...nativeSettingsArgs, ...turnTail(h, opts, [...before.settings, ...after.settings], prompt), ...after.tokens, ]; }; export const buildLaunchArgv = (h: HarnessDescriptor, opts: LaunchOptions): string[] => [ h.bin, ...h.launch.baseFlags, ...renderTurnArgv(h, opts, "launch"), ]; /** Descriptor-derived spawn-env assignments for a turn (claude's memory * disable is an env var, not a flag). Pure data: merge OVER the caller's * env at spawn - an explicit normalized option beats a contradicting raw * variable. Phase matches how the argv was built; env-kind renders * contribute no tokens, so the argv builders ignore them by construction. * The input is Pick because memory is the only * dimension that can render env today - widen deliberately if a second * env-kind option lands. */ export const buildTurnEnv = ( h: HarnessDescriptor, opts: Pick, phase: "launch" | "resume", ): Record => { const { env } = renderTurnOptions(h, { prompt: "", memory: opts.memory }, phase); return env; }; /** A session id that fails the shape rule is a spawn-boundary refusal like * any other: typed, so streamTurn turns it into failure + done and the CLI * exits 2, instead of a bare SessionIdRefusalError escaping the runner. */ const refuseUnusableSessionId = (h: HarnessDescriptor, sessionId: string): void => { try { assertUsableSessionId(sessionId); } catch (e) { if (!(e instanceof SessionIdRefusalError)) throw e; throw new ArgvRefusalError({ issue: "invalid-option-value", harness: h.name, message: `${h.name} cannot resume ${e.message}`, supported: [ `a session id of letters, digits, '.', '_', ':', '@', '-' only, starting with a letter or digit, at most ${SESSION_ID_MAX} chars`, ], detail: e.message, }); } }; const resumeArgv = ( h: HarnessDescriptor, opts: ResumeOptions & Pick, nativeSettingsArgs: readonly string[], ): string[] => { refuseUnusableSessionId(h, opts.sessionId); assertAccessExclusivity(h, opts); // A Pi settings fingerprint refuses passthrough, so Pi's own `--` is free: // a prompt such as `--no-extensions` stays the message and cannot become // a flag the fingerprint guard never saw (RFC 35). const prompt = promptAfterSeparator(opts) ? "after-separator" : "inline"; // Subcommands lead, then the resume token and id, then the flags the // RESUME grammar accepts (never inherited launch flags - codex exec // resume rejects --sandbox). One shape serves both styles: // `claude --resume -p `, `codex exec resume --json // `, `muse exec --session-id `. return [ h.bin, ...h.launch.subcommands, h.resume.flag, opts.sessionId, ...h.resume.extraFlags, ...renderTurnArgv(h, opts, "resume", nativeSettingsArgs, prompt), ...(prompt === "after-separator" ? separatedPrompt(h, opts) : []), ]; }; export const buildResumeArgv = (h: HarnessDescriptor, opts: ResumeOptions): string[] => resumeArgv(h, opts, []); /** What a spawn needs beyond the turn options: the session to resume, if * any, and the raw passthrough tail (ADR 0003). */ export interface SpawnArgvOptions extends TurnOptions { readonly resume?: string; /** Resume the harness's most recent session without naming an id (RFC-06). * Mutually exclusive with `resume`; renderable only when the descriptor's * `resumeLast.headless` is true, else an unsupported-option refusal. */ readonly resumeLast?: true; readonly passthrough?: readonly string[]; /** Internal read result. Execution independently checks the source before using it. */ readonly verifiedNativeSettings?: NativeSettingsSnapshot; } /** Refuse an id-less most-recent turn on a harness with no renderable * most-recent grammar (RFC-06). One owner for the refusal shape: the * resume-last builder and planTurn both raise it from here, so the plan * fires first with the same unsupported-option/resumeLast shape the * builder would raise at spawn time. */ export const assertResumeLastRenderable = ( h: HarnessDescriptor, ): Extract, { readonly headless: true }> => { const resumeLast = h.resumeLast; if (resumeLast?.headless === true) return resumeLast; const by = supportedBy(defaultDescriptors(), "resumeLast"); throw new ArgvRefusalError({ issue: "unsupported-option", harness: h.name, option: "resumeLast", supported: by.map((e) => `${e.harness} ${e.spelling}`), supportedBy: by, hint: hintFor(h.name, "resumeLast"), }); }; /** The resume-last argv: most-recent resolution stays inside the harness, * hcn only renders the native grammar (RFC-06). A `--continue` harness * (flag-style resume) gets the launch grammar plus the flag, with the fork * flag when the descriptor holds one (claude only, read from the single * `contextInspection.forkFlag` - no harness-name branch). The positional * resume harness (codex) gets the `exec resume` grammar with `--last` in * the id slot. Phase is always resume: no defaults profile runs and * `resumeRender` refusals apply. */ const resumeLastArgv = ( h: HarnessDescriptor, opts: SpawnArgvOptions, nativeSettingsArgs: readonly string[], ): string[] => { const resumeLast = assertResumeLastRenderable(h); assertAccessExclusivity(h, opts); const tail = renderTurnArgv(h, opts, "resume", nativeSettingsArgs); if (h.resume.style === "positional") { return [ h.bin, ...h.launch.subcommands, h.resume.flag, resumeLast.flag, ...h.resume.extraFlags, ...tail, ]; } const resumePrefix = resumeLast.flagPlacement === "before-extra-flags" ? [resumeLast.flag, ...h.resume.extraFlags] : [...h.resume.extraFlags, resumeLast.flag]; return [ h.bin, ...h.launch.subcommands, ...resumePrefix, ...(h.contextInspection?.forkFlag !== undefined ? [h.contextInspection.forkFlag] : []), ...tail, ]; }; /** The argv a turn spawns: launch or resume per `resume`, then the * passthrough tail at the descriptor's placement (ADR 0003). The bare * `--` is hcn's own command-line split, never a harness token: every * probed harness parses trailing native flags once the separator is gone, * so it is not rendered. One owner, so the CLI's preview and the runner's * spawn agree by construction (RFC-02 change 10). */ export const buildSpawnArgv = (h: HarnessDescriptor, opts: SpawnArgvOptions): string[] => { assertIsolationCombination(h, opts); const placement = h.launch.passthrough ?? "after-argv"; const tail = opts.passthrough ?? []; // A harness that declares prompt-joins passthrough takes a variadic // positional prompt no placement parses into, so every post-`--` token // would join the prompt as text and succeed with exit 0. Refuse before // spawn, on launch and resume alike; the offending tokens ride detail. if (placement === "prompt-joins" && tail.length > 0) { throw new ArgvRefusalError({ issue: "unsupported-passthrough", harness: h.name, supported: [], hint: `remove the tokens after \`--\` and re-run on ${h.name}`, detail: tail.join(" "), bin: h.bin, }); } // Most-recent and a named id are two answers to one question: both at // once refuses here as well as in the widened `resumeIdOf` check. if (opts.resume !== undefined && opts.resumeLast === true) { throw new ArgvRefusalError({ issue: "mutually-exclusive-options", harness: h.name, supported: [ "--resume or --resume-last, not both (most-recent and a named id are two answers to one question)", ], detail: "both --resume and --resume-last given", }); } const nativeSettingsArgs = renderVerifiedNativeSettings(h, opts); const base = opts.resumeLast === true ? resumeLastArgv(h, opts, nativeSettingsArgs) : opts.resume === undefined ? buildLaunchArgv(h, opts) : resumeArgv(h, { ...opts, sessionId: opts.resume }, nativeSettingsArgs); return placement === "after-argv" && tail.length > 0 ? [...base, ...tail] : base; }; export interface SessionOptions { readonly sessionId: string; readonly model?: string; /** Effort level, validated against the ladder that applies to the pick * (the model's own ladder where the harness constrains per model, else * the harness-wide one) - the same validation a one-shot turn applies. * No profile entry: a session without --effort runs at the harness's * own default effort, never a pinned one. */ readonly effort?: string; /** Provider selector (pi). A harness with no provider selector refuses, * the same way a one-shot turn does. */ readonly provider?: string; /** Named-agent selection (map #288): rendered at session spawn exactly * as a one-shot launch renders it. A harness with no spec refuses. */ readonly agent?: string; readonly contextWindow?: number; /** True when this argv should resume an existing conversation, false for a * fresh session. Controls which descriptor flag is rendered: resumeFlag * vs idFlag. Only consumers that alias --resume/--session-id set this. */ readonly isResume?: boolean; /** Caller-directed skills allowlist (issue #332), resolved by the CLI * against the registry root before it gets here. Rendered by the same * renderSkillsSelection a one-shot launch uses, so the spelling and the * refusal (a null-skills harness refuses) stay identical across run and * session. A spawn property, not a turn option: the flags ride the one * session spawn, so the allowlist holds for the whole session across * every turn - and on resume too, where the resumed process is re-spawned * with the same narrowing. */ readonly skills?: SkillsSelection; } export const buildSessionArgv = (h: HarnessDescriptor, opts: SessionOptions): string[] => { if (!h.sessionMode) { const supported = Object.values(defaultDescriptors()) .filter((d): d is HarnessDescriptor => d !== undefined && d.sessionMode !== null) .map((d) => d.name); throw new ArgvRefusalError({ issue: "no-session-mode", harness: h.name, supported, }); } refuseUnusableSessionId(h, opts.sessionId); const flag = opts.isResume ? h.sessionMode.resumeFlag : h.sessionMode.idFlag; const argv = [h.bin, ...h.sessionMode.flags, ...(flag !== null ? [flag, opts.sessionId] : [])]; const settings: JsonSettingsFragment[] = []; if (opts.model !== undefined) { const validated = validateModel(h, opts.model); if (!validated.ok) { const supported = [...h.vocabulary.models, ...Object.keys(h.vocabulary.aliases)]; throw new ArgvRefusalError({ issue: "unknown-model", harness: h.name, supported: supported.length ? supported : ["no curated models"], detail: opts.model, }); } // A session whose harness has no --model session flag renders the // model through the descriptor's session modelRender instead (codex // app-server: -c model=...). Same tokensFor rule the turn options // use, so the config-kv quoting stays identical. const modelRender = h.sessionMode.modelRender; if (modelRender !== undefined) { const parts = renderArgvParts(modelRender, validated.id); argv.push(...parts.tokens); settings.push(...parts.settings); } else argv.push(h.vocabulary.modelFlag, validated.id); } if ( opts.agent !== undefined || opts.provider !== undefined || opts.effort !== undefined || opts.contextWindow !== undefined ) { // All dimensions render through the same code path a launch argv uses, // so the flag spelling and the refusal (with supportedBy) stay // identical. The phase is RESUME, not launch, for two reasons. First, // a session argv must be accepted by the harness's session grammar, // which is the constrained one: codex app-server rejects --sandbox // outright (verified 0.159.2: "unexpected argument"). Second, the // enum profile defaults (codex sandbox workspace-write) are pinned to // the launch phase, and sessions pin no profile defaults - a codex // session emits no sandbox flag and codex's own default applies, the // same way no profile effort is pinned onto sessions. For every other // session-eligible spec the resume phase resolves to the same tokens. // The model rides along INERT for rendering - it is not a turnOptions // key - but effort validation reads it, so a per-model effort ladder // (effortsByModel) constrains the session spawn exactly as it // constrains a one-shot turn. const rendered = renderTurnOptions( h, { ...(opts.effort !== undefined ? { effort: opts.effort } : {}), ...(opts.provider !== undefined ? { provider: opts.provider } : {}), ...(opts.agent !== undefined ? { agent: opts.agent } : {}), ...(opts.contextWindow !== undefined ? { contextWindow: opts.contextWindow } : {}), ...(opts.model !== undefined ? { model: opts.model } : {}), } as TurnOptions, "resume", ); argv.push(...rendered.tokens); settings.push(...rendered.settings); } // issue #332: the skills allowlist renders through the one owner a // launch argv uses (renderSkillsSelection), so a null-skills harness // refuses here - before spawn - with the same unsupported-option shape // hcn run refuses with. pi renders its discovery facet off plus one // --skill per pick; claude and codex render the complement-off overrides. if (opts.skills !== undefined) { const parts = renderSkillsSelection(h, opts.skills); argv.push(...parts.tokens); settings.push(...parts.settings); } argv.push(...renderInlineSettings(settings)); return argv; }; /** Canonicalize an argv into a last-wins flag map: `--flag=value` splits, * `--flag value` consumes the following non-flag token as its value, a flag * followed by another flag is boolean. Positional tokens are skipped, so a * value can never satisfy a boolean pin member. Aliases map onto canonical * spellings. Known limitation: a crafted argv giving a pin member as the * value of an unknown variadic flag can still false-positive - full CLI * grammar is unknowable from outside. */ const flagMapOf = (h: HarnessDescriptor, argv: readonly string[]): Map => { const map = new Map(); // Stryker disable next-line UpdateOperator: decrementing cannot terminate this bounded scan. for (let i = 1; i < argv.length; i++) { const token = argv[i]; if (token === undefined || !token.startsWith("-")) continue; const eq = token.indexOf("="); if (eq !== -1) { const name = token.slice(0, eq); map.set(h.output.flagAliases[name] ?? name, token.slice(eq + 1)); continue; } const name = h.output.flagAliases[token] ?? token; const next = argv[i + 1]; if (next !== undefined && !next.startsWith("-")) { map.set(name, next); // Stryker disable next-line UpdateOperator: decrementing cancels the loop increment. i++; } else { map.set(name, true); } } return map; }; const pinSatisfied = ( pin: readonly string[], flags: ReadonlyMap, ): boolean => { // Stryker disable next-line UpdateOperator: decrementing cannot terminate this bounded scan. for (let i = 0; i < pin.length; i++) { const member = pin[i]; if (member === undefined || !member.startsWith("-")) continue; const expected = pin[i + 1]; const actual = flags.get(member); if (actual === undefined) return false; if (expected !== undefined && !expected.startsWith("-") && actual !== expected) { return false; } } return true; }; /** The granularity an invocation will actually emit: pins are checked in * order, first fully-satisfied pin wins (every member present with its * pinned value, last occurrence winning like the CLIs themselves); an argv * satisfying no pin gets the floor. */ export const streamingGranularityOf = ( h: HarnessDescriptor, argv: readonly string[], ): StreamingGranularity => { if (h.output.pins.length === 0) return h.output.floor; const flags = flagMapOf(h, argv); for (const pin of h.output.pins) { if (pinSatisfied(pin.flags, flags)) return pin.granularity; } return h.output.floor; };