import { randomUUID } from "node:crypto"; import { createInterface } from "node:readline/promises"; import { probeExtensionOption } from "../execution/extension-probe.js"; import type { FailureSummary } from "../execution/failure.js"; import { nodeRunnerDeps } from "../execution/node-deps.js"; import { CLOSE_GRACE_MS, openSession } from "../execution/open-session.js"; import { buildTurnEnv } from "../interpretation/argv.js"; import { composeAnswer, isQuestionMode, QUESTION_MODES, type QuestionMode, } from "../interpretation/question.js"; import { ArgvRefusalError, type RefusalOption } from "../interpretation/refusal.js"; import type { BehaviorTier } from "../interpretation/resolve-options.js"; import type { SkillsSelection } from "../interpretation/skills-selection.js"; import type { HarnessDescriptor } from "../knowledge/descriptor.js"; import { defaultDescriptors } from "../knowledge/overrides.js"; import { splitPassthrough } from "./args.js"; import { markJsonCrashStream } from "./crash.js"; import { gateExtensionOptions, writeExtensionProvenance } from "./extension-gate.js"; import { createRenderState, renderEvent } from "./render.js"; import { resolveHarness } from "./resolve-harness.js"; import { listKnownSkills, resolveSkillNames } from "./skills-root.js"; /** Issue #392 / #386: permission dimensions the common parser accepts but a * session never consumes. A polarity pair (write/no-write) is one * dimension and refuses under either spelling. Non-permission options * (--system-prompt, --max-steps, ...) stay out of this table. */ const RUN_ONLY_PERMISSION_OPTIONS: ReadonlyArray<{ readonly valueKey: string; readonly flag: string; readonly option: RefusalOption; }> = [ { valueKey: "tools", flag: "--tools", option: "tools" }, { valueKey: "exclude-tools", flag: "--exclude-tools", option: "excludeTools" }, { valueKey: "no-tools", flag: "--no-tools", option: "discovery.tools" }, { valueKey: "write", flag: "--write", option: "write" }, { valueKey: "no-write", flag: "--no-write", option: "write" }, { valueKey: "shell", flag: "--shell", option: "shell" }, { valueKey: "no-shell", flag: "--no-shell", option: "shell" }, { valueKey: "sandbox", flag: "--sandbox", option: "sandbox" }, { valueKey: "autonomy", flag: "--autonomy", option: "autonomy" }, { valueKey: "no-autonomy", flag: "--no-autonomy", option: "autonomy" }, ]; export const session = async (harnessName: string, rawArgs: string[]): Promise => { // Decided before any refusal can fire: a refused --json session still owes // the stream a failure and a terminal `closed` (RFC-01 rule 3). const jsonMode = rawArgs.includes("--json"); markJsonCrashStream(jsonMode); // issue #44: the gate is the descriptor's sessionMode (claude stream-json, // pi --mode rpc), not a hardcoded name list - a harness that grows a // session mode is available the moment its descriptor declares one. const h = resolveHarness(harnessName); // L8: a session takes no passthrough tail. parseCommonFlags strips it // silently, so check the raw argv first - before any other refusal - and // refuse the way inspect-session does instead of dropping it. if (splitPassthrough(rawArgs).passthrough.length > 0) { const { refusalOf, refuse } = await import("./refuse.js"); refuse( refusalOf( new ArgvRefusalError({ issue: "invalid-option-value", harness: h.name, message: `hcn session takes no passthrough tail; remove the tokens after \`--\` and re-run`, supported: ["hcn session flags only, with no `--` separator"], }), ), jsonMode, "closed", ); return; } if (h.sessionMode === null) { const supported = Object.values(defaultDescriptors()) .filter((d): d is HarnessDescriptor => d !== undefined && d.sessionMode !== null) .map((d) => d.name); const err = new ArgvRefusalError({ issue: "no-session-mode", harness: h.name, supported, detail: `session mode is available on ${supported.join(", ")}; ${harnessName} declares no persistent headless session`, }); const { refusalOf, refuse } = await import("./refuse.js"); refuse(refusalOf(err), jsonMode, "closed"); return; } if (rawArgs.includes("--help") || rawArgs.includes("-h")) { const { SESSION_HELP } = await import("./help.js"); process.stdout.write(SESSION_HELP); return; } const { memoryFlagOf, parseCommonFlags, resumeIdOf } = await import("./args.js"); let parsed: ReturnType; try { parsed = parseCommonFlags(rawArgs); } catch (err) { const message = err instanceof Error ? err.message : String(err); process.stderr.write(`unknown flag: ${message}\n`); if (jsonMode) { const { writeFailurePair } = await import("./refuse.js"); const { failureFromRejected } = await import("../execution/failure.js"); writeFailurePair( failureFromRejected({ issue: "invalid-option-value", detail: `unknown flag: ${message}` }), "closed", ); } process.exitCode = 2; return; } const values = parsed.values as Record; // RFC-06: a session holds one stable identity across turns; // most-recent resolution per turn defeats it. if (values["resume-last"] === true) { const { refusalOf, refuse } = await import("./refuse.js"); refuse( refusalOf( new ArgvRefusalError({ issue: "invalid-option-value", harness: h.name, option: "resumeLast", message: "hcn session cannot re-resolve the most recent session per turn: a session holds one stable identity across turns", supported: ["--resume (or --session-id) to continue one stable session"], }), ), jsonMode, "closed", ); return; } if (values.access !== undefined) { const { refuse, refusalOf } = await import("./refuse.js"); refuse( refusalOf( new ArgvRefusalError({ harness: h.name, message: "access presets are available only on hcn run, not hcn session", issue: "invalid-option-value", option: "access", supported: ["hcn run --access read|write"], }), ), jsonMode, "closed", ); return; } for (const dimension of RUN_ONLY_PERMISSION_OPTIONS) { if (values[dimension.valueKey] !== undefined) { const { refuse, refusalOf } = await import("./refuse.js"); refuse( refusalOf( new ArgvRefusalError({ harness: h.name, message: `${dimension.flag} is available only on hcn run, not hcn session`, issue: "invalid-option-value", option: dimension.option, supported: [`hcn run ${dimension.flag}`], }), ), jsonMode, "closed", ); return; } } if (values.isolation !== undefined) { const { refuse, refusalOf } = await import("./refuse.js"); refuse( refusalOf( new ArgvRefusalError({ harness: h.name, message: "isolation is available only for a fresh hcn run", issue: "invalid-option-value", option: "isolation", supported: ["hcn run --isolation tool-free"], }), ), jsonMode, "closed", ); return; } // issue #332: --skills resolves through the same code path hcn run uses // (names against the registry root, an fs read this CLI layer owns), then // rides the session spawn, where buildSessionArgv renders it through the // one owner a launch argv uses. A harness that cannot enforce an // allowlist refuses at the render, before any spawn - never a session // with the full skill set. An empty --skills= is a selection of nothing // and renders nothing, the same as run. let skills: SkillsSelection | undefined; const rawSkillNames = values.skills; if (rawSkillNames !== undefined) { const names = String(rawSkillNames) .split(",") .map((s) => s.trim()) .filter((s) => s.length > 0); if (names.length > 0) { try { skills = { picks: resolveSkillNames(names, h.name), known: listKnownSkills(), }; } catch (err) { if (!(err instanceof ArgvRefusalError)) throw err; const { refusalOf, refuse } = await import("./refuse.js"); refuse(refusalOf(err), jsonMode, "closed"); return; } process.stderr.write(`provenance: skills = ${names.join(", ")} (allowlist; session spawn)\n`); } } // --resume and --session-id are aliases for one concept; the one check // every command calls decides whether both were given. let resumeId: string | undefined; try { resumeId = resumeIdOf(values); } catch (err) { if (!(err instanceof ArgvRefusalError)) throw err; const { refusalOf, refuse } = await import("./refuse.js"); refuse(refusalOf(err), jsonMode, "closed"); return; } // The two flags are aliases for "use this session". Whether that means // RESUME is decided by the store, not by which spelling was typed: an id // that exists is resumed (resumeFlag rendered), an id that does not exist // names a fresh session (idFlag rendered). --resume on an id that does not // exist is the one combination refused, below - a caller who said "resume" // and would silently get a fresh conversation is issue #86 exactly. const explicitResume = values.resume !== undefined; // A caller-supplied id must be the shape this harness's store files // sessions under (issue #95). hcn run already holds this line through // parse-resume; session only checked the safe-filename rule, so "bogus" // reached the harness, rode the session and identity events, and on a // create-on-unknown harness became a real file nobody can find again. if (resumeId !== undefined && !h.resume.idShape.test(resumeId)) { const flag = explicitResume ? "--resume" : "--session-id"; const { refuse: shapeRefuse } = await import("./refuse.js"); shapeRefuse( { message: `${flag} ${JSON.stringify(resumeId.slice(0, 64))} is not a ${h.name} session id`, issue: "invalid-option-value", supported: [`a session id matching ${String(h.resume.idShape)}`], }, jsonMode, "closed", ); return; } const sessionId = resumeId ?? randomUUID(); let isResume = false; const model = values.model as string | undefined; const cwd = values.cwd as string | undefined; const provider = values.provider as string | undefined; const agent = values.agent as string | undefined; const effort = values.effort as string | undefined; const contextWindow = values["context-window"] !== undefined ? Number(values["context-window"]) : undefined; // question mode: the arg is validated through the one predicate, then // precedence resolves through the one owner (RFC-02 change 6). const rawArgMode = values.questions !== undefined ? String(values.questions) : undefined; if (rawArgMode !== undefined && !isQuestionMode(rawArgMode)) { const { refuse, refusalOf } = await import("./refuse.js"); refuse( refusalOf( new ArgvRefusalError({ issue: "invalid-option-value", harness: h.name, option: "questions", supported: [...QUESTION_MODES], detail: rawArgMode, }), ), jsonMode, "closed", ); return; } let questionMode: QuestionMode; let questionTier: BehaviorTier; let userCfg: import("../interpretation/resolve-options.js").ConfigTier | undefined; let projectCfg: import("../interpretation/resolve-options.js").ConfigTier | undefined; try { const { loadUserConfig, loadProjectConfig } = await import("./config.js"); const { resolveBehavior } = await import("../interpretation/resolve-options.js"); userCfg = loadUserConfig()?.config; projectCfg = loadProjectConfig()?.config; const behavior = resolveBehavior( { questions: rawArgMode !== undefined && isQuestionMode(rawArgMode) ? rawArgMode : undefined, }, { user: userCfg, project: projectCfg }, ); questionMode = behavior.questions.value; questionTier = behavior.questions.tier; } catch (configErr) { process.stderr.write(`config error: ${(configErr as Error).message}\n`); if (jsonMode) { const { writeFailurePair } = await import("./refuse.js"); const { failureFromRejected } = await import("../execution/failure.js"); writeFailurePair( failureFromRejected({ issue: "invalid-option-value", detail: `config error: ${(configErr as Error).message}`, }), "closed", ); } process.exitCode = 2; return; } process.stderr.write(`provenance: questions = ${questionMode} (${questionTier})\n`); // memory resolution (ratified 2026-08-26): arg > project > user > profile // default (false - sessions spawn memory-off like bare runs). Divergence // on a harness without a memory spec is reported, never refused (no // session-mode harness lacks one today; the guard stays generic). const memoryArg = memoryFlagOf(values); const { resolveSessionMemory } = await import("../interpretation/resolve-options.js"); const { memory, tier: memoryTier } = resolveSessionMemory(memoryArg, { user: userCfg, project: projectCfg, }); const memoryExpressible = h.turnOptions.memory !== undefined; if (memoryExpressible) { process.stderr.write(`provenance: memory = ${memory} (${memoryTier})\n`); } else { process.stderr.write( `divergence: profile "memory" not expressible on ${h.name}; harness default applies\n`, ); } // Validate sessionId shape? let openSession handle via assertUsableSessionId // Unknown-id refusal reuses the run resume guard (src/cli/resume-guard.ts): // harnesses that create on unknown ids (onMissing === "create") are // refused before spawn with the same message shape hcn run uses. // claude (onMissing === "error") refuses on its own; no store check. // Sessions take no --env, but the descriptor turn env still overlays the // child, so the guard checks through that same effective environment. if (resumeId !== undefined) { const { effectiveGuardEnv, resumeStore: checkResumeStore } = await import("./resume-guard.js"); // The guard reads the child's effective environment once (sessions take // no --env, but the descriptor turn env still overlays the child), and // home comes from that same env. const guardEnv = effectiveGuardEnv( process.env, {}, memoryExpressible ? buildTurnEnv(h, { memory }, "launch") : {}, ); const { path: storePath, exists } = checkResumeStore(h, { home: guardEnv.HOME ?? guardEnv.USERPROFILE ?? "", cwd: cwd ?? process.cwd(), sessionId, env: guardEnv, }); // Exists in the store: resume it, whichever alias was typed. Absent and the // caller only named an id: a fresh session under that id, as before. isResume = storePath !== null && exists; if (explicitResume && storePath !== null && !exists) { const { refuse: guardRefuse } = await import("./refuse.js"); guardRefuse( { message: `no ${h.name} session ${sessionId} found at ${storePath}`, issue: "invalid-option-value", supported: [`a session id that exists in ${h.name}'s store`], }, jsonMode, "closed", ); return; } } delete (process.env as Record).HERDR_ENV; const wantJson = values.json === true; // Opt-in per-turn inactivity budget. 0 disables; no default. A session turn // can hang with the process alive, which no exit code reports. const rawStall = values.stall as string | undefined; let stallMs: number | undefined; if (rawStall !== undefined) { const seconds = Number(rawStall); if (!Number.isFinite(seconds) || seconds < 0) { process.stderr.write(`invalid --stall ${JSON.stringify(rawStall)}; expected seconds >= 0\n`); if (jsonMode) { const { writeFailurePair } = await import("./refuse.js"); const { failureFromRejected } = await import("../execution/failure.js"); writeFailurePair( failureFromRejected({ issue: "invalid-option-value", detail: `invalid --stall ${JSON.stringify(rawStall)}`, }), "closed", ); } process.exitCode = 2; return; } if (seconds > 0) stallMs = seconds * 1000; } const baseDeps = stallMs === undefined ? nodeRunnerDeps() : nodeRunnerDeps({ stallMs }); // Capture the runner's final exitCode/cause for the --json `closed` event. // A refused session open (#345) or a crash with no open turn (#347) // leaves a session failure for `closed` to carry. const closeInfo: { exitCode: number | null; cause: string; failure?: FailureSummary; } = { exitCode: null, cause: "clean" }; const droppedIds: string[] = []; const deps = wantJson ? { ...baseDeps, log: (e: Record) => { if (e.event === "session_close") { closeInfo.exitCode = (e.exitCode as number | null) ?? null; closeInfo.cause = (e.cause as string) ?? "clean"; if (e.failure !== undefined && typeof e.failure === "object") { closeInfo.failure = e.failure as FailureSummary; } } if (e.event === "sends_dropped" && Array.isArray(e.ids)) { for (const id of e.ids as unknown[]) if (typeof id === "string") droppedIds.push(id); } baseDeps.log?.(e); }, } : baseDeps; let handle: ReturnType; // Map #300 (#302 decision 5): the session surface gates // extension-registered options at open, exactly as the run path does - // a missing extension refuses before the stream opens (failure + // closed pair on stdout in --json mode), a verified pass prints the // provenance line. Sessions take no --env, so the probe inherits the // process environment through the spawn adapter. if (agent !== undefined && h.turnOptions.agent?.probe !== undefined) { try { const verified = await gateExtensionOptions(h, { agent }, { cwd }, (h2, probe, opts) => probeExtensionOption(h2, probe, opts, deps), ); writeExtensionProvenance(verified); } catch (err) { if (err instanceof ArgvRefusalError) { const { refuse, refusalOf } = await import("./refuse.js"); refuse(refusalOf(err), jsonMode, "closed"); return; } throw err; } } try { const { getVersion } = await import("./version.js"); handle = openSession( h, { sessionId, model, cwd, questions: questionMode, provider, agent, effort, ...(contextWindow !== undefined ? { contextWindow } : {}), isResume, clientVersion: getVersion(), ...(memoryExpressible ? { memory } : {}), ...(skills !== undefined ? { skills } : {}), }, deps, ); } catch (err) { if (err instanceof ArgvRefusalError) { const { refusalOf, refuse } = await import("./refuse.js"); refuse(refusalOf(err), jsonMode, "closed"); return; } // S002: the harness binary is missing or would not start. A transport // failure, not a refusal - exit 1, and the stream is still owed its pair. process.stderr.write( `could not open session: ${err instanceof Error ? err.message : String(err)}\n`, ); if (jsonMode) { const { writeFailurePair } = await import("./refuse.js"); const { failureFromTransport } = await import("../execution/failure.js"); writeFailurePair( failureFromTransport(err instanceof Error ? err.message : String(err)), "closed", ); } process.exitCode = 1; return; } if (wantJson) { const { runJsonSession } = await import("./session-json.js"); const { getVersion } = await import("./version.js"); const version = getVersion(); process.exitCode = await runJsonSession({ handle, sessionId, harness: h.name, hcnVersion: version, questions: questionMode, origin: isResume ? "resumed" : "fresh", getCloseInfo: () => closeInfo, getDroppedIds: () => droppedIds, }); markJsonCrashStream(false); return; } process.stdout.write( `interactive ${h.name} session ${sessionId}\n(empty line or "exit" to quit)\n`, ); const rl = createInterface({ input: process.stdin, output: process.stdout }); // One line source for BOTH the you-prompt and the answer menu. A // readline interface buffers at most one pending line per question() // call site; the answer line typically arrives while the interface sits // BETWEEN calls (the turn is still streaming), and those bytes were // dropped (verified live: menu rendered, buffered "2" never delivered). // An explicit async-iterator pull never drops: the iterator parks on the // stream until the next line exists, whenever the caller asks for it. const lines = rl[Symbol.asyncIterator](); const nextLine = async (prompt: string): Promise => { process.stdout.write(prompt); const res = await lines.next(); return res.done ? null : (res.value as string); }; // Handle SIGINT to close session cleanly let closing = false; let sendCount = 0; const doClose = async () => { if (closing) return; closing = true; try { // race with CLOSE_GRACE_MS ? openSession's close does escalation internally, but we add outer guard const timeout = setTimeout(() => { process.stderr.write(`close timed out after ${CLOSE_GRACE_MS}ms\n`); }, CLOSE_GRACE_MS + 1000); await handle.close(); clearTimeout(timeout); } catch (err) { process.stderr.write(`close failed: ${err instanceof Error ? err.message : String(err)}\n`); } rl.close(); }; process.on("SIGINT", doClose); process.on("SIGTERM", doClose); const turns = handle.turns[Symbol.asyncIterator](); try { while (true) { let line: string | null; try { line = await nextLine("you › "); } catch { // stdin closed (Ctrl-D) break; } if (line === null) break; const trimmed = line.trim(); if (trimmed === "" || trimmed === "exit") break; handle.send({ id: `you-${++sendCount}`, text: line }); const turn = (await turns.next()).value as | AsyncIterable | undefined; if (turn === undefined) break; const state = createRenderState(); // issue #44: a turn that ends awaiting-input renders its question as // a pickable menu; the choice (or a custom answer) is the next send, // delivered on the SAME live session - no exit, no resume. let asked: | (import("../execution/events.js").HarnessEvent & { kind: "question"; }) | null = null; for await (const event of turn) { renderEvent(event, state, h); if (event.kind === "question") asked = event; } if (asked !== null) { const q = asked; process.stdout.write(`\nanswer › pick a number, or type your own answer:\n`); for (let i = 0; i < q.options.length; i++) { const opt = q.options[i] as string; const mark = opt === q.recommended ? " (recommended)" : ""; process.stdout.write(` ${i + 1}. ${opt}${mark}\n`); } let answer: string | null = null; while (answer === null) { const a = ((await nextLine("> ")) ?? "").trim(); if (a === "") continue; const n = Number(a); if (Number.isInteger(n) && n >= 1 && n <= q.options.length) { answer = q.options[n - 1] as string; } else { answer = a; } } handle.send({ id: `you-${++sendCount}`, text: composeAnswer(q.question, answer) }); // Drain the answer turn BEFORE prompting again - the pump's // backpressure stalls the harness until the turn iterable is // consumed (verified live: menu answered, you-prompt rendered, no // answer turn ever ran). const answerTurn = (await turns.next()).value as | AsyncIterable | undefined; if (answerTurn === undefined) break; const answerState = createRenderState(); for await (const event of answerTurn) { renderEvent(event, answerState, h); } } } } catch (err) { process.stderr.write(`session error: ${err instanceof Error ? err.message : String(err)}\n`); process.exitCode = 1; } finally { markJsonCrashStream(false); process.off("SIGINT", doClose); process.off("SIGTERM", doClose); try { await doClose(); } catch {} rl.close(); } };