UNPKG

agentfootprint

Version:

The explainable agent framework — backtrack a wrong answer to the exact context that caused it (evidence, not guesses). Built on footprintjs.

2,099 lines • 115 kB
"use strict";
/**
 * hosting/standingAgent — an agent that stays up and remembers.
 *
 *   await standingAgent({
 *     agent,
 *     sessions: memorySessions(),
 *     host: nodeHost({ port: 8080 }),
 *     durability: 'sync',          // optional; 'exit' is the default
 *   });
 *
 * One request at a time it does four things: wake and hydrate the session,
 * continue that session or start a fresh one, persist what the run leaves
 * behind, then reply. Everything else is somebody else's job — the host carries
 * bytes, the store keeps them, the agent thinks.
 *
 * ── A run has three ends, and this composer honours all three ────────────────
 * It answered, it asked a person something, or it failed. An answer completes
 * the reply and stores a conversation. A QUESTION stores the paused run as
 * `'flowchart-v1'` and leaves through `reply.awaiting(...)` — its own terminal,
 * never `fail`, because a pause is unfinished work and reporting it as a failure
 * tells every dashboard downstream something untrue. A later request for that
 * session carrying `decision` continues the run from exactly where it stopped.
 *
 * ── "Start a fresh one" is an answer it must EARN ───────────────────────────
 * A session with nothing stored is answered fresh, and that is right. A session
 * whose stored conversation cannot be READ is not: an unreadable stored
 * conversation and an absent one are different facts, and only one of them is
 * safe to answer with a fresh start. So an `UnreadableEnvelopeError` out of the
 * store fails THIS REQUEST, naming the session, and the fresh-start path is
 * never reached. The alternative is a reply that looks perfect to everyone
 * involved while a user's conversation quietly stops existing.
 *
 * ── Resuming a CONVERSATION is a REPLAY, and that has a cost ─────────────────
 * A stored conversation is restored through `agent.run({ message, continueFrom })`
 * — the public conversation door since 9.2.0. This composer used to assemble the
 * continuation by hand (append the turn, rewrite `originalInput`, hand the result
 * to `resumeOnError`); that hand-assembly IS the door now, so a server and a
 * script continue a conversation the same way and the identity travels with it.
 * The caveat below is stated here in the words the Agent states it in, because a
 * composition that hides the caveat of the thing it composes is worse than no
 * composition at all:
 *
 *   > **Tool re-execution / idempotency**: tool side effects from the FAILED
 *   > iteration are not in the checkpoint. The model re-decides from the
 *   > restored history and may re-issue those tool calls — they WILL execute
 *   > again (there is no built-in toolCallId dedup). Mutating tools (payments,
 *   > emails, DB writes) must be idempotent — key on stable call content, not
 *   > `ctx.toolCallId` (a re-issued call gets a new id).
 *
 * `durability` is the dial that bounds how much of that a crash can cost you.
 * Resuming a PAUSED run is different in kind: it is not a replay at all — the
 * engine continues from its own checkpoint, and no earlier tool call re-runs.
 *
 * ── Why one run at a time, and where that bound actually is ─────────────────
 * An Agent instance holds per-run state on itself. Two runs overlapping on ONE
 * instance do not crash — which is precisely the danger. They both finish, and
 * the state the composer reads afterwards belongs to whichever started last, so
 * one session's envelope can end up holding another session's conversation.
 * Nothing in the recording would say so. **Runs on one instance are therefore
 * serialized, and that is a correctness requirement rather than a tuning
 * choice.**
 *
 * What CHANGED in 9.10.0 is not that rule but its scope. `{ agent }` hands this
 * composer one instance to share, so the serialization is global — the shape
 * every earlier release had, unchanged to the byte. `{ agentFactory }` hands it
 * a way to MAKE instances, so each session gets its own and the same rule binds
 * per session: sessions run in parallel, each session's turns queue behind that
 * session's own instance.
 *
 *   standingAgent({ agent })          → one instance,  global queue
 *   standingAgent({ agentFactory })   → one per session, a queue each
 *
 * Everything else is identical between the two: the same stores, the same
 * durability modes, the same pause/resume contract, the same refusals. The
 * session store is keyed per session already, which is why the pool can evict a
 * session's instance without the person on the other end noticing — the
 * CONVERSATION was never in the instance.
 *
 * `onConcurrentInvoke` is the separate question of what to do when a second
 * turn of the SAME conversation arrives while the first is running: refuse it
 * (default) or queue it. It means the same thing in both shapes. A request for
 * a DIFFERENT session is never refused — there is nothing wrong with it; in the
 * shared shape it waits its turn, and in the pooled shape it does not have to.
 *
 * ── The pool, in one paragraph ──────────────────────────────────────────────
 * One instance per ACTIVE session, built on first sight by the factory, bounded
 * by `maxActiveSessions` (default 100) and evicted least-recently-used. An
 * evicted session's agent has its tool sessions closed with reason `'evicted'`
 * — the 9.7.0 vocabulary, not a new one — and is then shut down; its
 * conversation stays in the store, so its next request re-hydrates onto a fresh
 * instance and nothing was lost. A session that is RUNNING is never evicted:
 * the bound is on retained idle instances, and a cache policy does not get to
 * end somebody's turn. Requests with NO session share one fallback instance,
 * because there is no conversation to isolate and an instance per anonymous
 * request would be an instance per request.
 *
 * Pattern: Composition root. It owns wiring and ordering; it invents no
 * mechanism of its own.
 */
Object.defineProperty(exports, "__esModule", { value: true });
exports.standingAgent = void 0;
const footprintjs_1 = require("footprintjs");
const capability_js_1 = require("../artifacts/capability.js");
const inputRequest_js_1 = require("../tools/inputRequest.js");
const pause_js_1 = require("../core/pause.js");
const artifactWire_js_1 = require("./artifactWire.js");
const answerAccounts_js_1 = require("./answerAccounts.js");
const naming_js_1 = require("../artifacts/naming.js");
const recordingArtifact_js_1 = require("../artifacts/recordingArtifact.js");
const wireOps_js_1 = require("./wireOps.js");
const durability_js_1 = require("./durability.js");
const pendingAsk_js_1 = require("./pendingAsk.js");
const answeredPause_js_1 = require("./answeredPause.js");
const envelope_js_1 = require("./envelope.js");
const errors_js_1 = require("./errors.js");
const doorGuard_js_1 = require("./doorGuard.js");
const servingConversation_js_1 = require("../core/agent/servingConversation.js");
const identityVerification_js_1 = require("./identityVerification.js");
const admission_js_1 = require("./admission.js");
const ingressRecord_js_1 = require("./ingressRecord.js");
const requestSealing_js_1 = require("./requestSealing.js");
const turnArtifacts_js_1 = require("./turnArtifacts.js");
const sessionWire_js_1 = require("./sessionWire.js");
const types_js_1 = require("./types.js");
const runnerLive_js_1 = require("../core/runnerLive.js");
/**
 * The keys this composer invents live in a namespace no client string can
 * reach (recheck RB1 / N9). A session id is whatever a caller sent — `#` and
 * all — so every key built FROM one is prefixed `session:`, and every key the
 * host mints for a request with no session is spelled without that prefix. The
 * two spaces are disjoint by construction, not by what a wire happens to send.
 *
 *  - {@link laneKeyOf} — the pool key: `session:<id>`, or {@link ANONYMOUS_LANE}.
 *  - {@link latchKeyOf} — the concurrency latch: `session:<id>`, or
 *    `anonymous:<n>` (unique per request).
 */
const ANONYMOUS_LANE = 'anonymous';
function laneKeyOf(sessionId) {
    return sessionId === undefined ? ANONYMOUS_LANE : `session:${sessionId}`;
}
/**
 * Serve an agent, with per-session conversation memory, on any
 * {@link AgentHost}.
 *
 * Resolves once the host is live. Closing the returned handle closes the host,
 * detaches the listeners this composer added, and removes its durability
 * wiring.
 *
 * Two shapes, one composer — see the file header for the full comparison:
 *
 *  - `{ agent }` — one instance for every session, runs serialized globally.
 *    Correct, and unchanged from every earlier release.
 *  - `{ agentFactory }` — one instance per active session, sessions in
 *    PARALLEL, each session's turns serialized on its own instance.
 *
 * @example  One agent, every session
 *   const handle = await standingAgent({
 *     agent,
 *     sessions: memorySessions(),
 *     host: nodeHost({ port: 0 }),
 *     onConcurrentInvoke: 'enqueue',
 *     durability: 'sync',
 *   });
 *   process.on('SIGTERM', () => void handle.close());
 *
 * @example  One agent per session — they answer at the same time
 *   const handle = await standingAgent({
 *     agentFactory: () => Agent.create({ provider, model }).system('…').build(),
 *     sessions: sqliteSessions({ file: './sessions.db' }),
 *     host: nodeHost({ port: 8080 }),
 *     maxActiveSessions: 200,
 *   });
 */
async function standingAgent(options) {
    const { sessions, host } = options;
    const policy = options.onConcurrentInvoke ?? 'reject';
    const durability = options.durability ?? 'exit';
    const sharedAgent = options.agent;
    const factory = options.agentFactory;
    /** The badge check (9.26.0), or `undefined` — the zero-delta path. */
    const identityOptions = options.identity;
    /** The admission policy (9.26.0), or `undefined`. */
    const admission = options.admission;
    /**
     * The app's sealer for a request's own credential, checked once per run, or
     * `undefined` — no run carries a request, as before. Refused at
     * construction when it is not a function.
     */
    const sealRequest = (0, requestSealing_js_1.requestSealing)(options.sealRequest, 'standingAgent');
    /**
     * The rolling-window accountant — built ONLY when a policy exists, because
     * with no policy nobody would ever read it and keeping a per-caller map of a
     * server's whole traffic would be a cost nobody asked for.
     */
    const ledger = admission !== undefined ? (0, admission_js_1.spendLedger)() : undefined;
    /**
     * Where ingress decisions go (9.32.0), or `undefined` — the zero-delta path.
     * With no sink nothing is wrapped and the handler uses the host's own reply
     * object, which is what makes this option free for everyone who never asks
     * for it.
     */
    const ingressSink = options.onIngressDecision;
    // ── Refused at construction, before a socket exists ──────────────────
    //
    // Two spellings of one decision, and nothing could make the winner visible
    // afterwards: an operator reading `{ agent, agentFactory }` in a config would
    // have no way to know whether their sessions were isolated or not.
    if (sharedAgent !== undefined && factory !== undefined) {
        throw new Error(`[hosting] standingAgent was given both 'agent' and 'agentFactory'. They are two ` +
            `answers to one question — whether sessions SHARE an instance or each get their ` +
            `own — and honouring either would leave the other silently ignored. Pass 'agent' ` +
            `for one shared instance (runs serialized globally, the original shape), or ` +
            `'agentFactory' for one instance per active session (sessions run in parallel).`);
    }
    if (sharedAgent === undefined && factory === undefined) {
        throw new Error(`[hosting] standingAgent needs something to answer with: pass 'agent' (one instance ` +
            `shared by every session) or 'agentFactory' (a new instance per active session). ` +
            `Neither was given, so there is nothing to serve.`);
    }
    // Two options whose shape TypeScript enforces and plain JavaScript does not.
    // Both fail CLOSED at runtime if left half-spelled — an `identity` with no
    // `verify` would refuse every caller as unverifiable, an `admission` with no
    // `decide` would throw on the first turn — and a door that refuses everybody
    // for a configuration reason is exactly the outage that takes an hour to
    // diagnose from the outside. Named here instead, before a socket exists.
    if (identityOptions !== undefined &&
        typeof identityOptions.verify !== 'function' &&
        typeof identityOptions.signIn?.identify !== 'function') {
        throw new Error(`[hosting] standingAgent was given 'identity' without a \`verify\` function (or a ` +
            `\`signIn\` source). It is the ` +
            `strategy that turns a caller's bearer token into a proven user, so there is nothing ` +
            `to verify with — and with it half-spelled every request would be refused as ` +
            `unverifiable. Pass identity: { verify: jwksIdentity({ jwksUrl, issuer, audience }` +
            `).verify } — or any IdentityVerifier's verify — or drop the option and read ` +
            `HostRequest.userId as the transport's own claim, exactly as before.`);
    }
    if (admission !== undefined && typeof admission.decide !== 'function') {
        throw new Error(`[hosting] standingAgent was given 'admission' without a \`decide\` function. It is the ` +
            `whole policy — one method answering 'allow' | { queue: true } | { refuse: '<why>' } ` +
            `— so a bag without it decides nothing and would throw on the first turn. Pass ` +
            `admission: turnsPerHour({ limit }), or your own { decide }, or drop the option.`);
    }
    // A pool bound with no pool is a caller who believes something untrue about
    // how their process will behave under load. Named rather than ignored.
    if (factory === undefined && options.maxActiveSessions !== undefined) {
        throw new Error(`[hosting] standingAgent was given 'maxActiveSessions' without 'agentFactory'. ` +
            `With a single shared 'agent' there is no pool to bound — every session already ` +
            `uses the one instance you passed. Drop it, or switch to 'agentFactory' to get ` +
            `the per-session pool it describes.`);
    }
    // A bound on the artifact hand-over that could never fire is not a bound —
    // and a hand-over that may hold a lane forever is the outage it exists to
    // prevent. Refused here, before a socket exists.
    const turnArtifactsTimeoutMs = options.turnArtifactsTimeoutMs ?? types_js_1.TURN_ARTIFACTS_TIMEOUT_MS;
    if (!Number.isFinite(turnArtifactsTimeoutMs) || turnArtifactsTimeoutMs <= 0) {
        throw new Error(`[hosting] standingAgent was given turnArtifactsTimeoutMs: ${String(options.turnArtifactsTimeoutMs)}. It is how long a turn's artifact hand-over (reply.turnArtifacts) may hold the ` +
            `reply, so it has to be a positive, finite number of milliseconds — a bound that ` +
            `can never fire would let one host's hung store hold every other session. Drop it ` +
            `for the default (${types_js_1.TURN_ARTIFACTS_TIMEOUT_MS} ms).`);
    }
    // The `answer-account` op (explain-answer): built — and its declarations,
    // ceiling and cache size validated — only when the host opted in, so a bad
    // declaration throws HERE, at boot, and never meets a request. Absent, the
    // op answers the unknown-op refusal and nothing below is ever built.
    const accounts = options.answerAccounts === undefined || options.answerAccounts === false
        ? undefined
        : (0, answerAccounts_js_1.answerAccounts)(options.answerAccounts);
    // How many artifact ops (head / get / answer-account) one session may have
    // in flight at once. A bound that can never be met, or never fire, is
    // refused here; `Infinity` is the explicit "no bound".
    const artifactOpsPerSession = options.artifactOpsPerSession ?? types_js_1.DEFAULT_ARTIFACT_OPS_PER_SESSION;
    if (artifactOpsPerSession !== Number.POSITIVE_INFINITY &&
        (!Number.isInteger(artifactOpsPerSession) || artifactOpsPerSession < 1)) {
        throw new Error(`[hosting] standingAgent was given artifactOpsPerSession: ${String(options.artifactOpsPerSession)}. It is how many artifact operations (artifact-head, artifact-get, ` +
            `answer-account) one session may have in flight at once, so it has to be a ` +
            `positive whole number — or Infinity for no bound. Drop it for the default ` +
            `(${types_js_1.DEFAULT_ARTIFACT_OPS_PER_SESSION}).`);
    }
    /** Session id → artifact ops in flight right now. An entry at zero is removed. */
    const artifactOpsInFlight = new Map();
    const maxActiveSessions = options.maxActiveSessions ?? types_js_1.DEFAULT_MAX_ACTIVE_SESSIONS;
    if (factory !== undefined && (!Number.isInteger(maxActiveSessions) || maxActiveSessions < 1)) {
        throw new Error(`[hosting] standingAgent was given maxActiveSessions: ${String(options.maxActiveSessions)}. ` +
            `It is how many sessions may hold an agent at once, so it has to be a positive ` +
            `integer — a pool that can hold no sessions could never serve one.`);
    }
    /** Session id → its lane. Empty and unused in the shared shape. */
    const pool = new Map();
    /**
     * Every instance the factory has handed over. A WeakSet so a retired agent is
     * collectable — the check is "have I seen this object", and nothing here
     * needs to keep it alive to answer that.
     */
    const handedOver = new WeakSet();
    /** The single lane of the shared shape, or `undefined` in the pooled one. */
    const shared = sharedAgent !== undefined ? makeLane(sharedAgent, '#shared') : undefined;
    /** The pooled shape's reader — see `redeemerFor`. Built on first need only. */
    let reader;
    /** Instances retired from the pool — a seam binding to one is revoked. */
    const retired = new WeakSet();
    let anonymous = 0;
    /**
     * Attach this composer's wiring to one agent and hand back its lane.
     *
     * Everything installed here is undone by `lane.detach()` — on `close()`, and
     * on eviction. An agent whose lane was detached has nothing of this
     * composer's left on it.
     */
    function makeLane(agent, poolKey, sessionId) {
        const lane = {
            poolKey,
            ...(sessionId !== undefined && { sessionId }),
            agent,
            chain: Promise.resolve(),
            queued: [],
            admitted: 0,
            lastUsedMs: Date.now(),
            detach: () => undefined,
        };
        // ── The lane's own events, as the run made them ──────────────────
        // Every subscription below reads the agent's REAL-value path
        // (`core/runnerLive.ts` · `runnerLive`), never the served stream: the reply is the
        // caller's own answer and the spend ledger is an admission decision, so an
        // agent's `redact` — which governs what the RECORD keeps — must reach
        // neither (`src/redaction/runRedaction.ts`). One subscription, one switch.
        //
        // Tokens reach the caller only if the host streams; `emit` on a host that
        // does not is a no-op by design, so this needs no capability check. The
        // subscription is per LANE because a token event names no session — the
        // agent that produced it is the only thing that identifies whose it is,
        // and with a pool that is the whole reason two sessions' tokens cannot mix.
        //
        // ── Spend accounting (9.26.0) ────────────────────────────────────
        // Only with an admission policy. Without one the two spend arms are never
        // taken — the agent is untouched and the events cost their usual nothing.
        //
        // Two sources, because they answer two different questions honestly:
        // `llm_end` always carries TOKENS, and `cost.tick` carries MONEY only
        // where a pricing table turned tokens into it. Summing an invented rate
        // here would be a number that looks like a bill.
        // Read live (`core/runnerLive.ts`): the reply goes to the person who asked
        // and the ledger counts what was spent — neither is a record, so neither
        // is served under the agent's `redact`.
        // Every `Agent` is a runner of this library, so its live taps exist; one
        // that does not is refused here rather than served placeholders or nothing.
        const live = (0, runnerLive_js_1.runnerLive)(agent);
        if (live === undefined) {
            throw new TypeError('standingAgent: the agent was not built by agentfootprint (Agent.create(...).build()), ' +
                'so its run cannot be read live — the reply, the spend ledger and the session store ' +
                'need it.');
        }
        const offLane = live.onRealEvent((event) => {
            switch (event.type) {
                case 'agentfootprint.agent.turn_start':
                    lane.activeRunId = event.meta?.runId;
                    return;
                case 'agentfootprint.stream.token': {
                    const content = event.payload?.content;
                    if (typeof content === 'string' && content.length > 0)
                        lane.activeReply?.emit?.(content);
                    return;
                }
                case 'agentfootprint.stream.llm_end': {
                    if (ledger === undefined)
                        return;
                    const key = lane.activeSpendKey;
                    if (key === undefined)
                        return;
                    const usage = event
                        .payload?.usage;
                    ledger.add(key, {
                        inputTokens: usage?.input ?? 0,
                        outputTokens: usage?.output ?? 0,
                    });
                    return;
                }
                case 'agentfootprint.cost.tick': {
                    if (ledger === undefined)
                        return;
                    const key = lane.activeSpendKey;
                    if (key === undefined)
                        return;
                    const usd = event.payload?.estimatedUsd;
                    if (typeof usd === 'number' && Number.isFinite(usd))
                        ledger.add(key, { usd });
                    return;
                }
                default:
                    return;
            }
        });
        // Under 'exit' NOTHING is built: no observer on the agent, no barrier, no
        // per-commit work. That is what "the default is byte-identical" means here —
        // not a mode that happens to write once, but wiring that is never installed.
        const writer = durability === 'exit'
            ? undefined
            : (0, durability_js_1.durableWriter)({
                mode: durability,
                session: () => lane.activeSessionId,
                runId: () => lane.activeRunId,
                // The conversation is read from the run's LIVE committed state,
                // never from the commit event's (served) values: the store holds
                // what the next turn resumes from (`hosting/durability.ts`).
                state: () => live.liveState(),
                write: (sessionId, conversation) => sessions.persist(sessionId, (0, envelope_js_1.toEnvelope)(conversation)),
            });
        const detachWriter = writer ? agent.attach(writer.recorder) : undefined;
        const uninstallBarrier = writer?.install(agent);
        lane.writer = writer;
        lane.detach = () => {
            offLane();
            uninstallBarrier?.();
            detachWriter?.();
        };
        return lane;
    }
    /**
     * The lane this request runs on.
     *
     * Shared shape: always the one lane, so every request queues behind every
     * other — the global serialization, unchanged. Pooled shape: this session's
     * lane, built on first sight, with anonymous traffic sharing one.
     *
     * SYNCHRONOUS on purpose, and called with no `await` between it and the
     * `serialize` that admits the request. A gap there would let the pool evict
     * the very lane the caller is about to run on.
     */
    function laneFor(sessionId) {
        if (shared !== undefined)
            return shared;
        const key = laneKeyOf(sessionId);
        const held = pool.get(key);
        if (held !== undefined) {
            held.lastUsedMs = Date.now();
            return held;
        }
        evictToFit();
        const built = makeLane(fromFactory(), key, sessionId);
        pool.set(key, built);
        return built;
    }
    /**
     * One new agent out of the factory — or a refusal naming what went wrong.
     *
     * The repeat-instance check is the load-bearing one. A factory that closes
     * over a single agent and returns it every time type-checks perfectly and
     * destroys the only property the pool exists for: two sessions would share
     * per-run state, both runs would finish, and one person's conversation would
     * end up holding another person's turn with nothing in the recording to say
     * so. That is the exact failure the shared shape serializes to avoid, and it
     * is worth a loud refusal at the moment it becomes possible.
     */
    function fromFactory() {
        const made = factory?.();
        if (made === undefined || made === null) {
            throw new Error(`[hosting] standingAgent's agentFactory returned nothing. It is called once per ` +
                `active session and must return a NEW Agent each time — build it inside the ` +
                `factory: agentFactory: () => Agent.create({ provider, model }).build().`);
        }
        if (handedOver.has(made)) {
            throw new Error(`[hosting] standingAgent's agentFactory returned an Agent it has already returned. ` +
                `One instance per session is the entire point: an Agent holds per-run state on ` +
                `itself, so two sessions sharing one would each finish while the composer read ` +
                `the other's state — one person's conversation quietly holding another's turn. ` +
                `Build a NEW Agent inside the factory rather than closing over one; if you meant ` +
                `every session to share a single instance, pass it as 'agent' instead and get ` +
                `the global serialization that makes it safe.`);
        }
        handedOver.add(made);
        return made;
    }
    /**
     * Make room for one more session, if room is needed.
     *
     * Least recently used, and never a lane with work: `admitted` counts requests
     * that are queued or running, and retiring one of those would abandon a turn
     * a person is waiting on. When EVERY lane is busy the pool grows past its
     * bound instead — stated on the option, and the honest trade. It comes back
     * under the bound as soon as a run finishes and another session arrives.
     */
    function evictToFit() {
        while (pool.size >= maxActiveSessions) {
            let victim;
            for (const lane of pool.values()) {
                if (lane.admitted > 0)
                    continue;
                if (victim === undefined || lane.lastUsedMs < victim.lastUsedMs)
                    victim = lane;
            }
            if (victim === undefined)
                return;
            // Out of the pool FIRST, so a request arriving for that session during
            // the teardown below builds itself a fresh lane rather than racing one
            // that is being dismantled.
            pool.delete(victim.poolKey);
            void retire(victim);
        }
    }
    /**
     * Retire an evicted lane: detach, close what its tools were holding, stop it.
     *
     * The conversation is NOT touched — it is in the session store, which is why
     * eviction is invisible to the person on the other end. What is torn down is
     * the instance: tool sessions first, under the `'evicted'` reason the tool
     * teardown vocabulary already has (9.7.0), so a sandbox or a browser context
     * this session was holding is released and SAYS which firing site released
     * it. Then the agent itself, stopped rather than merely drained — nothing can
     * reach it again, so leaving its timers running would be a leak with no
     * owner.
     *
     * Failures here are swallowed, and this is the only place that happens: the
     * agent's own event channel already reports a teardown that threw
     * (`tools.session_close_failed`, with its error class), and failing the
     * UNRELATED request that happened to trigger the eviction would blame one
     * person's turn for another session's broken cleanup.
     */
    async function retire(lane) {
        retired.add(lane.agent);
        lane.detach();
        const sessionId = lane.sessionId;
        try {
            await lane.agent.closeToolSessions({
                ...(sessionId !== undefined && { sessionId }),
                reason: 'evicted',
            });
        }
        catch {
            // Reported on the agent's own channel; see above.
        }
        try {
            await lane.agent.shutdown({ stop: true });
        }
        catch {
            // Same.
        }
    }
    /** Queue `work` behind everything already waiting on this lane, FIFO. */
    function serialize(lane, sessionKey, work, queueAnyway = false) {
        if (policy === 'reject' &&
            !queueAnyway &&
            (lane.activeSession === sessionKey || lane.queued.includes(sessionKey))) {
            return Promise.reject(new errors_js_1.ConcurrentRunError(sessionKey.startsWith('session:') ? sessionKey.slice('session:'.length) : '(anonymous)', lane.activeRunId));
        }
        lane.queued.push(sessionKey);
        // Admitted from here, so the pool cannot retire this lane out from under a
        // request that is merely waiting its turn.
        lane.admitted += 1;
        lane.lastUsedMs = Date.now();
        const mine = lane.chain.then(async () => {
            lane.queued.splice(lane.queued.indexOf(sessionKey), 1);
            lane.activeSession = sessionKey;
            try {
                await work();
            }
            finally {
                lane.activeSession = undefined;
                lane.activeRunId = undefined;
                lane.admitted -= 1;
                lane.lastUsedMs = Date.now();
            }
        });
        // The chain must never reject, or one failed request would wedge every
        // request behind it.
        lane.chain = mine.then(() => undefined, () => undefined);
        return mine;
    }
    /**
     * The composer's whole request path — with the reply it is handed already
     * wrapped, when an ingress record is being kept.
     *
     * Split out from {@link handler} for exactly one reason: the record's funnel
     * has to be ONE place. Every exit below ends at a reply terminal, so wrapping
     * the reply once above this function covers all of them — a refusal added
     * later cannot forget to report, because reporting is not something any exit
     * does.
     */
    const answerRequest = async (request, reply, note) => {
        // ── The badge, before anything else ───────────────────────────────
        // FIRST, and once — so a turn, a claim-ticket redemption and a session
        // read all inherit one answer to "who is this", and a door added later
        // cannot accidentally be the lenient one. With no verifier configured this
        // returns `undefined` without touching the request: every earlier
        // release's behaviour, to the byte.
        let verified;
        try {
            verified = await (0, identityVerification_js_1.verifyRequestIdentity)(identityOptions, request.headers, request.userId, request.signInKey);
        }
        catch (err) {
            reply.fail(err instanceof Error ? err : new Error(String(err)));
            return;
        }
        // The PROVEN id, on the record — never the claimed one, which is the whole
        // distinction the verifier exists to draw. Nothing is recorded when nobody
        // was proven; absent and "anonymous" are the same fact here.
        note?.identified(verified);
        // The user id that reaches every downstream composition. With a verifier
        // it is the one the TOKEN proved (the request's own claim was already
        // reconciled or refused); without one it is exactly what the transport
        // said, unchanged since 9.12.0.
        const userId = verified?.userId ?? request.userId;
        const identified = userId === request.userId ? request : { ...request, userId };
        // ── Reading history is not a turn either ──────────────────────────
        if (identified.session !== undefined) {
            try {
                await answerSessionOp(identified.session, verified, reply);
            }
            catch (err) {
                reply.fail(err instanceof Error ? err : new Error(String(err)));
            }
            return;
        }
        // ── A claim-ticket redemption is not a turn ────────────────────────
        // Its presence is the discriminant, exactly as `decision`'s is one line
        // down: a request carrying `artifact` redeems a ref and never starts,
        // queues behind, or is refused as, a run. Read-only against the store, so
        // it deliberately bypasses the lane's serialization — a screen describing
        // a chart while the model is mid-turn must not be refused as a
        // "concurrent run", and must not make the turn wait either.
        if (identified.artifact !== undefined) {
            try {
                await answerArtifact(identified.artifact, identified, verified, reply);
            }
            catch (err) {
                reply.fail(err instanceof Error ? err : new Error(String(err)));
            }
            return;
        }
        const sessionId = identified.sessionId;
        // A request with no session has nothing to hydrate and nowhere to persist
        // that the caller could ask for again, so it gets its own key and can never
        // collide with anyone. In the pooled shape those requests still SHARE one
        // fallback instance — there is no conversation to isolate — and this unique
        // key is what keeps them from ever refusing each other as a "same session"
        // collision, which they are not.
        const sessionKey = sessionId !== undefined ? `session:${sessionId}` : `anonymous:${++anonymous}`;
        try {
            // ── Admission, before a lane is even chosen ────────────────────
            // The cheapest refusal is the one made before any work: no hydrate, no
            // model call, no store write. With no policy this is a single
            // `undefined` check and nothing else runs.
            const spendKey = (0, admission_js_1.spendKeyFor)(verified);
            let queueAnyway = false;
            if (admission !== undefined && ledger !== undefined) {
                const verdict = await admission.decide({
                    ...(verified !== undefined && { identity: verified }),
                    ...(sessionId !== undefined && { sessionId }),
                    recentSpend: ledger.read(spendKey),
                });
                if (typeof verdict === 'object' && verdict !== null && 'refuse' in verdict) {
                    note?.admitted('refuse');
                    reply.fail(new errors_js_1.AdmissionRefusedError(verdict.refuse, userId));
                    return;
                }
                queueAnyway = typeof verdict === 'object' && verdict !== null && 'queue' in verdict;
                // An ALLOW is recorded too. A stream of refusals alone cannot answer
                // "was anybody turned away?" — only a census can, which is the same
                // reason an empty audit bundle is not evidence of an empty door.
                note?.admitted(queueAnyway ? 'queue' : 'allow');
                // Counted at ADMISSION, not at completion: a turn that is running has
                // been spent whether or not it ever answers, and counting on the way
                // out would let a caller hold N runs open under a limit of one.
                //
                // The stated consequence: a request the LANE then refuses as a
                // concurrent run still counts against the window. Deferring the count
                // to the moment a run actually starts would fix that and open a
                // bigger hole — a burst of simultaneous requests would each be decided
                // against a window none of them had joined, and walk straight through
                // the ceiling. A bound that a burst defeats is not a bound.
                ledger.admit(spendKey);
            }
            const lane = laneFor(sessionId);
            await serialize(lane, sessionKey, () => answerOne(lane, sessions, identified, reply, sessionId, verified, spendKey, note), queueAnyway);
        }
        catch (err) {
            reply.fail(err instanceof Error ? err : new Error(String(err)));
        }
    };
    /**
     * What the host calls — {@link answerRequest} with the ingress record's one
     * funnel around it (9.32.0).
     *
     * With no sink configured this is the identity function over the host's own
     * reply: no wrapper object, no bookkeeping, no branch inside any exit path.
     *
     * `settle()` covers the one case a terminal cannot: a request that leaves
     * without ending its reply at all. The composer never does that, and the
     * `finally` is there because "never" is a claim about today's code rather
     * than a guarantee — a census with a hole in it is what this replaces.
     */
    const handler = async (request, hostReply) => {
        const note = ingressSink === undefined ? undefined : (0, ingressRecord_js_1.beginIngress)(request, ingressSink);
        const reply = note === undefined ? hostReply : note.watch(hostReply);
        try {
            await answerRequest(request, reply, note);
        }
        finally {
            note?.settle();
        }
    };
    /**
     * Answer "which conversations are mine?" and "what did we say?" — the two
     * session-history ops (9.26.0).
     *
     * ── Three refusals before an answer is even attempted ────────────────────
     *  1. no verifier on this door → {@link SessionOpNeedsIdentityError}. Reading
     *     "your" sessions off an unverified header is enumeration.
     *  2. a verifier, and this caller proved nothing (an anonymous request at a
     *     door built with `allowAnonymous`) → the same refusal shape: anonymous
     *     owns nothing, and answering an empty list would imply the question was
     *     honoured.
     *  3. the session store keeps no owner index →
     *     {@link SessionIndexUnavailableError}, naming the missing member. An
     *     unanswerable question is not an empty result.
     *
     * ── And ONE indistinguishable not-found ──────────────────────────────────
     * A transcript for a session that does not exist, one owned by somebody
     * else, and one whose stored conversation names no owner all end the same
     * way. Told apart, they would be an oracle for which session ids are real.
     *
     * Read-only and lane-free: history is answered out of the session store,
     * which no run holds, so nothing here queues behind a turn or is refused as
     * a concurrent one.
     */
    async function answerSessionOp(op, verified, reply) {
        if (op.op === 'pending') {
            // Reloading the current question uses the same session admission as a
            // reply. It neither enumerates sessions nor exposes a checkpoint.
            await sessions.onWake?.(op.sessionId, 'transcript');
            const stored = await sessions.hydrate(op.sessionId);
            if (stored !== undefined && !mayOpenSession(stored, verified)) {
                reply.fail(new errors_js_1.SessionNotFoundError(op.sessionId));
                return;
            }
            // The one reading every door uses (`answeredPause.ts`): a pause whose
            // decision a resume already used is not pending, and neither is one no
            // answer can reach any more — the next message closes it.
            const held = stored === undefined ? undefined : (0, answeredPause_js_1.readStoredSession)(stored);
            const waiting = held?.waiting !== undefined && (0, answeredPause_js_1.unanswerablePause)(held.waiting, sharedAgent) === undefined
                ? held.waiting
                : undefined;
            deliverSessions(reply, 'session-pending', {
                op: 'pending',
                pending: waiting?.pending ?? null,
            });
            return;
        }
        const opName = op.op === 'list' ? sessionWire_js_1.SESSION_LIST_OP : sessionWire_js_1.SESSION_TRANSCRIPT_OP;
        if (identityOptions === undefined || verified === undefined) {
            reply.fail(new errors_js_1.SessionOpNeedsIdentityError(opName));
            return;
        }
        if (op.op === 'list') {
            if (sessions.listByUser === undefined) {
                reply.fail(new errors_js_1.SessionIndexUnavailableError(opName, 'listByUser'));
                return;
            }
            const page = await sessions.listByUser(verified.userId);
            deliverSessions(reply, opName, { op: 'list', sessions: page.sessions });
            return;
        }
        if (sessions.ownerOf === undefined) {
            reply.fail(new errors_js_1.SessionIndexUnavailableError(opName, 'ownerOf'));
            return;
        }
        const owner = await sessions.ownerOf(op.sessionId);
        if (owner === undefined || owner !== verified.userId) {
            // The ONE not-found. Missing, foreign and owner-less are one answer.
            reply.fail(new errors_js_1.SessionNotFoundError(op.sessionId));
            return;
        }
        await sessions.onWake?.(op.sessionId, 'transcript');
        let stored;
        try {
            stored = await sessions.hydrate(op.sessionId);
        }
        catch (err) {
            throw err instanceof errors_js_1.UnreadableEnvelopeError ? err.withSession(op.sessionId) : err;
        }
        if (stored === undefined) {
            // The index said one thing and the store another — a session forgotten
            // between the two calls. Same not-found: the caller learns nothing it
            // did not already know.
            reply.fail(new errors_js_1.SessionNotFoundError(op.sessionId));
            return;
        }
        deliverSessions(reply, opName, {
            op: 'transcript',
            sessionId: op.sessionId,
            messages: (0, envelope_js_1.envelopeTranscript)(stored),
        });
    }
    /**
     * Redeem one claim ticket for the screen: `artifact-head` (metadata — the
     * render-by-ref decision) or `artifact-get` (metadata + payload), against
     * the store of the agent that serves this session.
     *
     * ── The scope is the run's own, re-composed — one isolation rule ────────
     * A ref is redeemed under EXACTLY the scope the run's tools minted it in:
     * `sessionArtifactScope(userId, sessionId, storedIdentity)` — the same
     * function a turn's hand-over (`reply.turnArtifacts`) is bound with, so a
     * host's filing and this redemption compose one tuple from the same inputs
     * for the same caller. It is `identityForRequest` when the request names a
     * user — the same call, on the same inputs, that composed the run's
     * identity — else the session rung
     * `{ conversationId: sessionId }` (what seed derives for an identity-less
     * served run). No new spelling exists to drift. The stored identity is read
     * only when a `userId` arrived (it is the only rung that needs the stored
     * tenant/conversation tuple), through the same wake → hydrate →
     * refuse-unreadable steps a turn takes.
     *
     * A ref from another session — or a right session presented with the wrong
     * identity — composes a different tuple, and the store answers a wrong
     * tuple with "no data", never with a distinguishable error. So the wire's
     * not-found is ONE shape for missing, expired and foreign alike:
     * {@link ArtifactNotFoundError}, byte-identical but for the ref itself.
     *
     * ── On the record, once ─────────────────────────────────────────────────
     * Resolution rides the existing `artifacts.resolved` / `artifacts.refused`
     * events through the capability's own fact sink (the raw store emits
     * nothing, so binding is the ONE emitter — no double-emit), delivered on
     * the serving agent's dispatcher with no `tool` field: the redeemer was the
     * hosting door, and a phantom tool name would be an actor that does not
     * exist.
     *
     * ── Nothing a stranger chose reaches the record ──────────────────────────
     * Every fact this door emits is stamped with the session the request NAMED
     * (`emitArtifactFact`), and that stamp decides which run's recording keeps
     * it (`eventBelongsToRun`). So the door checks, before it emits anything or
     * builds a lane:
     *
     *  1. **The ref's shape** (`isArtifactRef`) — free text posing as a ref would
     *     be text written into a live recording (the wire readers refuse it too;
     *     this covers a host that builds `HostRequest.artifact` itself).
     *  2. **At a verifying door, ownership** — the turn door's own rule
     *     (`mayOpenSession`) for a stored conversation; for one whose FIRST turn
     *     is still in flight (nothing stored yet), only the caller that turn is
     *     serving. Anyone else gets the ONE not-found, and nothing is emitted —
     *     a stranger can neither write into another person's recording nor flood
     *     it until its own events are evicted.
     *  3. **With no live lane** (the reader answers — `redeemerFor`), whether the
     *     store HOLDS the ref under the session's scope, asked silently
     *     (`holds`; `account` asks it in `explainAnswer`). A made-up session id
     *     is the one not-found with nothing emitted; a real one is redeemed,
     *     whether or not its conversation was ever stored.
     *
     * At a door with no verifier the session id IS the key, by law: whoever
     * presents it may redeem under it, and its facts are that session's.
     *
     * Zero-cost when unused: an agent without a store answers with the teaching
     * refusal naming the attach, and nothing else in the composer changes.
     */
    async function answerArtifact(op, request, verified, reply) {
        // The account op is spoken only by a host that opted in — and refused
        // before anything is read, so a host that never asked for it pays nothing
        // and reveals nothing.
        if (op.op === 'account' && accounts === undefined)
            (0, wireOps_js_1.refuseUnknownWireOp)(artifactWire_js_1.ANSWER_ACCOUNT_OP);
        const sessionId = request.sessionId;
        if (sessionId === undefined) {
            // An anonymous run scopes its artifacts to its own runId — a name no
            // later request can present — so there is genuinely nothing a
            // session-less redemption could ever resolve. Taught, not 404'd: this
            // is a caller gap, not a missing ref.
            reply.fail(new errors_js_1.ArtifactSessionRequiredError(op.op));
            return;
        }
        if (!(0, naming_js_1.isArtifactRef)(op.ref)) {
            reply.fail(new errors_js_1.InvalidWireOpError(`'${(0, artifactWire_js_1.artifactOpWireName)(op.op)}' ${artifactWire_js_1.NOT_A_REF}`));
            return;
        }
        // Read the stored conversation once, and only when it decides something:
        // its OWNER (a verifying door) and its identity tuple (any request naming
        // a user). Whether a ticket EXISTS is never its question — that is the
        // artifact store's, asked under the composed scope below.
        const needsStored = identityOptions !== undefined || request.userId !== undefined;
        const envelope = needsStored ? await storedFor(sessionId) : undefined;
        if (identityOptions !== undefined && !mayRedeemFrom(sessionId, envelope, verified)) {
            reply.fail(new errors_js_1.ArtifactNotFoundError(op.ref));
            return;
        }
        // Outlived the composer across an await: answer like the door after close.
        if (closing !== undefined) {
            reply.fail(new errors_js_1.HostClosedError(host.name));
            return;
        }
        // The per-session bound (after ownership, so a stranger can never spend
        // someone else's slots): artifact ops are lane-free and turn admission
        // does not see them, so without it one session could keep the event loop
        // every session shares busy with payload reads and parses. Refused by
        // name, never queued — a screen retries; a queue would only move the wait.
        const busy = artifactOpsInFlight.get(sessionId) ?? 0;
        if (busy >= artifactOpsPerSession) {
            reply.fail(new errors_js_1.ArtifactOpsBusyError(op.op, artifactOpsPerSession));
            return;
        }
        // NEVER a new lane, never an eviction (R2-12): the session's live
        // instance, else the reader — see `redeemerFor`.
        const redeemer = redeemerFor(sessionId);
        if (redeemer === undefined) {
            reply.fail(new errors_js_1.ArtifactNotFoundError(op.ref));
            return;
        }
        const lane = redeemer.lane;
        // Admitted for the duration: a lane with work is never evicted, and a
        // resolution mid-flight is work — retiring the instance under it would
        // tear down the very store being read.
        if (lane !== undefined)
            lane.admitted += 1;
        // Counted with no await since the check above, so a burst cannot all pass it.
        artifactOpsInFlight.set(sessionId, busy + 1);
        try {
            const store = redeemer.agent.getArtifactStore();
            if (store === undefined) {
                // The fail-closed law at the wire: the refusal lands on the record
                // (`no-store`, the same reason `ctx.artifacts` teaches with) and the
                // reply names the attach.
                // (An account would have been READ with `get`, so that is the verb its
                // refusal names — no new `ArtifactOp` member.)
                // On a live lane only: a lane-less session has proved nothing exists
                // under its name, so the reader emits nothing for it (R2-12).
                if (lane !== undefined) {
                    emitArtifactFact(redeemer.agent, {
                        type: 'refused',
                        op: op.op === 'account' ? 'get' : op.op,
                        reason: 'no-store',
                        ref: op.ref,
                    }, { sessionId });
                }
                reply.fail(new errors_js_1.NoArtifactStoreError(op.op));
                return;
            }
            const stored = request.userId !== undefined ? storedIdentityOf(envelope, sessionId) : undefined;
            const scope = sessionArtifactScope(request.userId, sessionId, stored);
            // A lane-less session (the reader answers) emits only for a ref its scope
            // HOLDS: asked silently first — the `explainAnswer` precedent, which
            // already covers `account` — so a made-up session id is the one
            // not-found with nothing emitted (R2-12), and a real one is redeemed.
            if (lane === undefined && op.op !== 'account' && !(await holds(store, scope, op.ref))) {
                reply.fail(new errors_js_1.ArtifactNotFoundError(op.ref));
                return;
            }
            const bound = (0, capability_js_1.bindArtifacts)(store, scope, {
                onEvent: (fact) => emitArtifactFact(redeemer.agent, fact, { sessionId }),
            });
            if (op.op === 'account') {
                // `accounts` is defined: the first line of this function refused otherwise.
                await explainAnswer(accounts, store, scope, op.ref, bound, reply);
            }
            else if (op.op === 'head') {
                const meta = await bound.head(op.ref);
                if (meta === null) {
                    reply.fail(new errors_js_1.ArtifactNotFoundError(op.ref));
                    return;
                }
                deliverArtifact(reply, { op: 'head', ref: op.ref, meta });
            }
            else {
                // A digest mismatch throws here (ArtifactIntegrityError) rather than
                // returning corrupt bytes as whole — it propagates to the handler's
                // catch as this request's failure, already on the record via the sink.
                const record = await bound.get(op.ref);
                if (record === null) {
                    reply.fail(new errors_js_1.ArtifactNotFoundError(op.ref));
                    return;
                }
                deliverArtifact(reply, { op: 'get', ref: op.ref, meta: record.meta, data: record.data });
            }
        }
        finally {
            if (lane !== undefined) {
                lane.admitted -= 1;
                lane.lastUsedMs = Date.now();
            }
            const left = (artifactOpsInFlight.get(sessionId) ?? 1) - 1;
            if (left <= 0)
                artifactOpsInFlight.delete(sessionId);
            else
                artifactOpsInFlight.set(sessionId, left);
        }
    }
    /**
     * The `answer-account` branch, after ownership (explain-answer af-3).
     *
     * Everything before this line is `artifact-get`'s own path — the same
     * ownership check, the same lane admission (never the run queue), the same
     * composed scope. What the branch adds, in order:
     *
     *  1. **A SILENT head** — `bindArtifacts(store, scope)` with no sink, so it
     *     emits nothing. It runs BEFORE the cache (review R3-S1): whether the
     *     record still exists is the store's answer, so a swept or expired
     *     recording is "not available" even while its account is cached, and a
     *     kind change cannot be masked.
     *  2. **Kind, then size** — not a `recording/run` → the ONE not-found (and
     *     nothing was emitted); over the ceiling → the named 413, before a byte
     *     of the payload is read (and again, by the service, on the payload's
     *     REAL size before any parse — a store may under-report `bytes`). Both before the cache: neither can ever have
     *     an entry there (failures are never cached; the ceiling is per host).
     *  3. **The cache, then single-flight** — a hit reads nothing more and emits
     *     nothing; a miss starts or joins the ONE computation for its key.
     *  4. **The one door fact** — the leader's `get` goes through the sinked
     *     binding, which emits the single `artifacts.resolved` (`via: 'get'`),
     *     stamped with this session: the fact truthfully says the record was
     *     read, and no new `ArtifactOp` exists to say it differently.
     *  5. **Parse + fold + show-me** — `explainRecording`. Anything that cannot
     *     be explained (the record vanished between head and get, it does not
     *     parse, it is not a recording) is the one not-found, and is not cached.
     */
    async function explainAnswer(service, store, scope, ref, bound, reply) {
        const meta = await (0, capability_js_1.bindArtifacts)(store, scope).head(ref);
        if (meta === null || meta.kind !== recordingArtifact_js_1.RECORDING_ARTIFACT_KIND) {
            reply.fail(new errors_js_1.ArtifactNotFoundError(ref));
            return;
        }
        if (meta.bytes > service.maxRecordingBytes) {
            reply.fail(new errors_js_1.RecordingTooLargeForAccountError(service.maxRecordingBytes));
            return;
        }
        const key = service.keyFor(scope, ref);
        const answer = service.cached(key) ??
            (await service.compute(key, async () => {
                const record = await bound.get(ref);
                if (record === null || record.meta.kind !== recordingArtifact_js_1.RECORDING_ARTIFACT_KIND)
                    return null;
                // The payload's REAL size is checked by the service before any parse.
                const runId = record.meta.origin?.runId;
                return { data: record.data, ...(runId !== undefined && { runId }) };
            }));
        if (answer === null) {
            reply.fail(new errors_js_1.ArtifactNotFoundError(ref));
            return;
        }
        deliverArtifact(reply, { op: 'account', ref, meta, answer });
    }
    /** The stored conversation for a redemption — woken and hydrated the way a
     *  turn does it, the unreadable-envelope law included. */
    async function storedFor(sessionId) {
        await sessions.onWake?.(sessionId, 'artifact');
        try {
            return await sessions.hydrate(sessionId);
        }
        catch (err) {
            throw err instanceof errors_js_1.UnreadableEnvelopeError ? err.withSession(sessionId) : err;
        }
    }
    /**
     * The identity a stored conversation carries — the `stored` input of
     * `sessionArtifactScope`. The same law as a turn: an unreadable stored
     * conversation is a different fact from an absent one, and only one of them
     * is safe to compose a scope from, so it throws by name.
     */
    function storedIdentityOf(envelope, sessionId) {
        if (envelope === undefined)
            return undefined;
        try {
            const held = (0, answeredPause_js_1.readStoredSession)(envelope);
            return (held.waiting?.conversation ?? held.prior)?.identity;
        }
        catch (err) {
            throw err instanceof errors_js_1.UnreadableEnvelopeError ? err.withSession(sessionId) : err;
        }
    }
    /**
     * `handle.artifactsForRequest` — the redemption door's composition, handed
     * to the host for a path that is not a turn (see
     * {@link StandingAgentHandle.artifactsForRequest}).
     *
     * Every step is the one `answerArtifact` takes, in the same order and with
     * the same instances — the badge (`verifyRequestIdentity`), the stored
     * conversation (`storedFor`), ownership (`mayRedeemFrom`), the serving
     * agent's store, the ONE composer (`sessionArtifactScope`) and the fact sink
     * stamped with the session — so the scope it binds is the scope a wire
     * redemption by the same caller reads. What differs is only what is handed
     * back: the bound verbs instead of one resolved ref. Refusals are VALUES,
     * because the host decides its own status codes; nothing is emitted before
     * ownership is settled, and a refused caller builds no lane.
     */
    async function artifactsForRequest(request) {
        if (closing !== undefined)
            throw new errors_js_1.HostClosedError(host.name);
        // Two credentials are ambiguous: refused by name, never read as none.
        if (identityOptions !== undefined && repeatsAuthorization(request.headers)) {
            return {
                bound: false,
                reason: 'unverified',
                error: new errors_js_1.IdentityNotVerifiedError('unverifiable', false),
            };
        }
        let verified;
        try {
            // The turn door's four arguments — the sign-in key included, so a
            // person signed in by the cookie is who they are here too.
            verified = await (0, identityVerification_js_1.verifyRequestIdentity)(identityOptions, singleValuedHeaders(request.headers), request.userId, request.signInKey);
        }
        catch (err) {
            const error = err instanceof Error ? err : new Error(String(err));
            // An identity-provider outage judged nobody's credential: its own
            // reason (a 503), never 'unverified' (a 401 that sends every client to
            // re-authenticate against a provider that is down).
            return {
                bound: false,
                reason: err instanceof errors_js_1.VerifierUnavailableError ? 'unavailable' : 'unverified',
                error,
            };
        }
        // The same answer after an await as before one: a composer that closed
        // while this call waited is closed (the wire's HostClosedError, too).
        if (closing !== undefined)
            throw new errors_js_1.HostClosedError(host.name);
        const userId = verified?.userId ?? request.userId;
        const sessionId = request.sessionId;
        if (sessionId === undefined)
            return { bound: false, reason: 'no-session' };
        // The wire refuses these ids at the adapter (`doorGuard.ts`); the seam
        // has no adapter in front of it, so it asks the same check itself.
        const invalid = (0, doorGuard_js_1.checkSessionId)(sessionId, host.name);
        if (invalid !== undefined)
            return { bound: false, reason: 'invalid-session', error: invalid };
        const needsStored = identityOptions !== undefined || userId !== undefined;
        const envelope = needsStored ? await storedFor(sessionId) : undefined;
        if (identityOptions !== undefined && !mayRedeemFrom(sessionId, envelope, verified)) {
            return { bound: false, reason: 'not-found' };
        }
        // Every await above could have outlived the composer (RS5): a call that
        // lost the race to `close()` binds nothing, builds nothing, and says so the
        // way a call made after close does.
        if (closing !== undefined)
            throw new errors_js_1.HostClosedError(host.name);
        const stored = userId !== undefined ? storedIdentityOf(envelope, sessionId) : undefined;
        // Never a new lane, never an eviction — the redemption door's own rule. A
        // session with no live lane binds through the reader: what its scope holds
        // is the store's answer, and a session nobody has chatted in yet is where
        // an app-owned route files a guide before the first turn.
        const redeemer = redeemerFor(sessionId);
        if (redeemer === undefined)
            return { bound: false, reason: 'not-found' };
        const agent = redeemer.agent;
        const store = agent.getArtifactStore();
        if (store === undefined)
            return { bound: false, reason: 'no-store' };
        const bound = (0, capability_js_1.bindArtifacts)(store, sessionArtifactScope(userId, sessionId, stored), {
            onEvent: (fact) => emitArtifactFact(agent, fact, { sessionId }),
        });
        return { bound: true, artifacts: heldBinding(bound, sessionId, agent, redeemer.lane) };
    }
    /**
     * A seam binding's verbs, held to the same terms as the wire's redemptions
     * and a turn's hand-over (S5):
     *
     *  - **Revoked** when the instance it is bound to is retired from the pool or
     *    the host closes — a write after that would land on a stopped agent and
     *    its fact on a record nobody holds. `RequestArtifactsRevokedError`,
     *    created already handled. Checked per call against `retired`, so a
     *    binding that is never used again costs nothing to keep.
     *  - **Bounded** by `artifactOpsPerSession`, counted with the wire's
     *    redemptions of the same session (`ArtifactOpsBusyError`).
     *  - **Admitted** to its lane for the duration of each call, so the pool
     *    cannot retire the instance under an operation in flight.
     */
    function heldBinding(bound, sessionId, agent, lane) {
        const refused = (error) => {
            const refusal = Promise.reject(error);
            refusal.then(undefined, () => undefined);
            return refusal;
        };
        function held(verb, start) {
            if (closing !== undefined || retired.has(agent)) {
                return refused(new errors_js_1.RequestArtifactsRevokedError(verb));
            }
            const busy = artifactOpsInFlight.get(sessionId) ?? 0;
            if (busy >= artifactOpsPerSession) {
                return refused(new errors_js_1.ArtifactOpsBusyError(verb, artifactOpsPerSession));
            }
            artifactOpsInFlight.set(sessionId, busy + 1);
            if (lane !== undefined)
                lane.admitted += 1;
            let running;
            try {
                running = start();
            }
            catch (err) {
                running = Promise.reject(err);
            }
            return running.finally(() => {
                if (lane !== undefined) {
                    lane.admitted -= 1;
                    lane.lastUsedMs = Date.now();
                }
                const left = (artifactOpsInFlight.get(sessionId) ?? 1) - 1;
                if (left <= 0)
                    artifactOpsInFlight.delete(sessionId);
                else
                    artifactOpsInFlight.set(sessionId, left);
            });
        }
        return Object.freeze({
            put: (input) => held('put', () => bound.put(input)),
            head: (ref) => held('head', () => bound.head(ref)),
            get: (ref) => held('get', () => bound.get(ref)),
            delete: (ref) => held('delete', () => bound.delete(ref)),
            list: (options) => held('list', () => bound.list(options)),
        });
    }
    /**
     * May this caller redeem under `sessionId` at a VERIFYING door?
     *
     * A stored conversation answers with the turn door's own rule
     * (`mayOpenSession`) — asked leniently, before any strict reader, for the
     * same reason the turn door asks it first. With nothing stored yet the only
     * conversation that can hold artifacts is one whose FIRST turn is in flight
     * right now (a chart presented mid-turn), and then only the caller that turn
     * is serving may redeem. Read from the lane WITHOUT building one: a refused
     * caller never makes the pool grow or evict.
     */
    function mayRedeemFrom(sessionId, envelope, verified) {
        if (envelope !== undefined)
            return mayOpenSession(envelope, verified);
        const serving = shared ?? pool.get(laneKeyOf(sessionId));
        return serving?.activeSessionId === sessionId && serving.activeOwner === verified?.userId;
    }
    /** The lane serving `sessionId` right now — never built, never refreshed. */
    function liveLane(sessionId) {
        return shared ?? pool.get(laneKeyOf(sessionId));
    }
    /**
     * Whose store answers a redemption (or `artifactsForRequest`) for
     * `sessionId` — WITHOUT building a lane or evicting one (R2-12).
     *
     * Before, both doors called `laneFor`, so a caller naming session ids —
     * anybody, at a door with no verifier, where the id is the key by law —
     * built a pooled instance per id and evicted the least recently used idle
     * session to make room, closing its tool sessions as `'evicted'`. A read is
     * not a reason to take a person's instance away.
     *
     *  - **A live lane** (the shared agent, or this session's pooled instance) →
     *    its agent. The lane is refreshed and admitted by the caller.
     *  - **No live lane** (no turn yet, its instance was evicted, or the process
     *    restarted) → the READER: one instance from the factory, held OUTSIDE
     *    the pool, built on first need and shared by every lane-less session. It
     *    never counts toward `maxActiveSessions` and never evicts, so the pool is
     *    exactly what the turns made it. It answers from the factory's store,
     *    which is the only store a lane-less session's artifacts can be in — the
     *    one-shared-store shape. (With a store built per instance, an evicted
     *    instance's artifacts left with it, and the reader's not-found is the
     *    true answer.) The wire asks the store silently before it emits anything
     *    for a lane-less session (`holds`), so a flood of made-up ids costs one
     *    artifact-store read each, one reader per process, and nothing else.
     *  - **Closing** → `undefined`: the one not-found.
     *
     * Whether a ticket EXISTS is the artifact store's answer, asked under the
     * session's own scope — never the pool's, never the session store's.
     * Rejected (shipped from 9.117.0, then found false): the one not-found for
     * a lane-less session with no STORED conversation, on the premise that such
     * a session has no turn that could have minted anything. The library itself
     * falsifies it — an app-owned route files beside a conversation before its
     * first turn (`artifactsForRequest`, built for exactly that), and a tool
     * mints during a first turn that then throws, which persists nothing — and
     * the shared shape redeemed the same filing, so the answer depended on the
     * deployment's shape.
     */
    function redeemerFor(sessionId) {
        const live = liveLane(sessionId);
        if (live !== undefined) {
            live.lastUsedMs = Date.now();
            return { agent: live.agent, lane: live };
        }
        // Never built once the composer is closing: `close()` stops the reader it
        // can see, and one built after that would run with no owner (RS5).
        if (closing !== undefined)
            return undefined;
        reader ??= fromFactory();
        return { agent: reader };
    }
    /**
     * Whose conversation is this — and may THIS caller open it? (9.26.0)
     *
     * The ownership question, asked ONCE and answered the same way at every door
     * that opens a session. `session-transcript` asks it of the store's index;
     * a TURN asks it of the conversation it just hydrated — the same derivation
     * (`envelopeOwner`, the `principal` the composer itself wrote), so a store
     * with no index is protected exactly as well as one with a fast one, and no
     * second round-trip is spent to learn something already in hand.
     *
     * That a turn had to ask at all is the lesson: the ops that READ history were
     * gated from the day they were written, while the ordinary door beside them
     * hydrated any named session, answered from it, and re-stamped its owner —
     * one handler, two answers, and the strict one was the decoration. A gate
     * that only some doors honour is not a gate.
     *
     * The rule, whole:
     *
     *  - **No verifier configured → no question asked.** A door that reads
     *    `userId` off a header has no proof to compare against, and inventing
     *    one would refuse turns that worked in every earlier release. Byte-
     *    identical, deliberately: how much a header is worth is the deployment's
     *    answer, and this is the option that says so.
     *  - **Verified caller, owned session → the owner, and only the owner.**
     *  - **Verified caller, session that names no owner → refused.** The same
     *    answer `session-transcript` gives, for the same reason: a conversation
     *    nobody signed for is not evidence that it is yours. A store written
     *    before this door verified anything therefore holds sessions that cannot
     *    be continued — a stated migration boundary, and a loud one, rather than
     *    a door that hands over conversations to whoever names them first.
     *  - **Anonymous caller (`allowAnonymous`) → only a session nobody owns.**
     *    Which is the same rule spelled with `undefined` on both sides.
     *
     * A session that does not exist YET is not this function's business: the
     * caller's own first turn creates it, and the ownership fact is written when
     * it persists.
     *
     * ── A conversation this runtime cannot read has no owner it can name ───────
     * The question is asked BEFORE the strict readers, so a stored session in a
     * format this build does not know refuses as "not one you can open" rather
     * than describing the state of somebody's storage to a stranger. The
     * consequence, stated: at a verifying door that is also the answer its real
     * owner gets, because a runtime that cannot read a conversation genuinely
     * cannot tell whose it is, and "this is yours, but broken" is a claim it
     * would be making up. A door with no verifier is untouched — it still fails
     * loudly by name (`UnreadableEnvelopeError`), which is where an operator
     * mid-migration meets that fact.
     *
     * ── The one thing this cannot hide ─────────────────────────────────────────
     * A refused turn tells the caller the id is taken, because the alternative
     * (starting a fresh conversation over somebody else's) destroys it. That is
     * the only fact a door which lets callers choose their own session ids can
     * never conceal — and it is a long way from the conversation itself, which is
     * what the refusal keeps.
     */
    function mayOpenSession(stored, verified) {
        if (identityOptions === undefined)
            return true;
        return (0, envelope_js_1.envelopeOwner)(stored) === verified?.userId;
    }
    async function answerOne(lane, store, request, reply, sessionId, verified, spendKey, note) {
        const runner = lane.agent;
        lane.activeReply = reply;
        // The writer only ever writes for the run it is inside; an anonymous
        // request has nowhere to write to and gets nothing.
        lane.activeSessionId = sessionId;
        lane.activeOwner = verified?.userId;
        // Whose usage this lane's next token and cost events belong to. Set only
        // with an admission policy; the subscriptions that read it do not exist
        // otherwise.
        if (ledger !== undefined)
            lane.activeSpendKey = spendKey;
        try {
            let prior;
            let paused;
            // A pause whose decision a resume already used (`answeredPause.ts`): no
            // decision answers it again, and the next message continues the
            // conversation that resume left.
            let answered;
            if (sessionId !== undefined) {
                // A request carrying a decision is waking this session to CONTINUE a
                // run, which is a different thing to ask a store to be ready for.
                await store.onWake?.(sessionId, request.decision !== undefined ? 'resume' : 'invoke');
                try {
                    const stored = await store.hydrate(sessionId);
                    if (stored !== undefined) {
                        // WHOSE conversation this is, before a single line of it is read
                        // into this run. A turn is the door that hydrates history into a
                        // model's context AND the door that re-persists who owns it, so it
                        // is the door where getting this wrong costs the most: see
                        // `mayOpenSession` for the rule and its one honest leak.
                        //
                        // Placed before the readers on purpose — an unreadable envelope
                        // belonging to somebody else must answer "not yours", not describe
                        // the state of a stranger's storage.
                        if (!mayOpenSession(stored, verified)) {
                            reply.fail(new errors_js_1.SessionNotFoundError(sessionId));
                            return;
                        }
                        // Both readers throw by name on a format this runtime cannot read —
                        // better a loud refusal than an agent answering from half a session.
                        // The one reading every door uses: an answered pause is never
                        // offered for an answer (`answeredPause.ts`).
                        const held = (0, answeredPause_js_1.readStoredSession)(stored);
                        paused = held.waiting;
                        prior = held.prior;
                        answered = held.answered;
                    }
                }
                catch (err) {
                    // A stored conversation this runtime cannot read fails THIS REQUEST,
                    // naming the session. It must never fall through to the fresh-start
                    // path below: the conversation exists, and answering it as if the
                    // caller were new is the one failure nobody can see from the outside.
                    // A store that knows the session already named it; one that does not
                    // gets the name added here, so the guarantee does not depend on which
                    // store you chose.
                    throw err instanceof errors_js_1.UnreadableEnvelopeError ? err.withSession(sessionId) : err;
                }
            }
            // The signal reaches tool execution, tool discovery and skill-entry
            // scoring. It does NOT currently reach the LLM call, so a caller who
            // hangs up mid-generation stops the tools, not the token stream.
            //
            // `sessionId` (9.4.0) rides the same bag onto every event this run
            // emits, so a shipped telemetry stream can be asked "what happened in
            // this conversation?" and not only "what happened in this run?". Since
            // 9.10.0 it also decides the memory namespace when the caller named no
            // identity — a session IS a conversation, so a served session recalls its
            // own earlier turns with no configuration. This is the only place that
            // knows the answer — the agent is handed a message, not a session. An
            // ANONYMOUS request has no session and gets no key: `sessionKey` above is
            // a concurrency latch this host invented, and stamping it on telemetry
            // would be publishing a fact nobody can join to.
            //
            // `identity` (9.12.0) is the WHO beside that WHICH-conversation: a
            // transport that named the end user gets a principal on the run, which is
            // what puts a real person in `EventMeta.principal`, in `ctx.identity`
            // inside a tool, and in the identity a credential provider scopes its
            // token vault on — the whole chain, from the front door to the downstream
            // API, without anybody configuring it. See `identityForRequest` for what
            // each field is composed from and why. A transport that named nobody
            // produces `undefined` here and every earlier release's behaviour is
            // unchanged.
            const identity = identityForRequest(request.userId, sessionId, prior?.identity ?? paused?.conversation.identity);
            // What this turn offers a host that files its own artifacts
            // (`reply.turnArtifacts`): WHERE its claim tickets are redeemed — the one
            // composition the artifact door re-composes for the same caller, built
            // only if a host asks — and where a hook failure is recorded. No session,
            // no scope: nothing a later request presents could ever redeem an
            // anonymous run's artifacts, so there is nothing to bind.
            const stored = prior?.identity ?? paused?.conversation.identity;
            const offer = {
                ...(sessionId !== undefined && {
                    session: {
                        id: sessionId,
                        scope: () => sessionArtifactScope(request.userId, sessionId, stored),
                    },
                }),
                timeoutMs: turnArtifactsTimeoutMs,
                ...(request.signal !== undefined && { signal: request.signal }),
                note,
            };
            const optionsForRun = request.signal !== undefined || sessionId !== undefined || identity !== undefined
                ? {
                    ...(request.signal !== undefined && { env: { signal: request.signal } }),
                    ...(sessionId !== undefined && { sessionId }),
                    ...(identity !== undefined && { identity }),
                }
                : undefined;
            // A request with no session is its OWN conversation for self-explain
            // (B1, recheck RB1): keyed by a random UUID minted for this request, in
            // the `hosted:` key space no session id can enter — so no other caller,
            // signed in or not, can name it. It is not a session: memory, telemetry
            // and teardown are untouched (`core/agent/servingConversation.ts`).
            const runOptions = sessionId === undefined
                ? (0, servingConversation_js_1.withHostedConversation)(optionsForRun, globalThis.crypto.randomUUID())
                : optionsForRun;
            /**
             * The options THIS run is handed, with the credential the request
             * carries sealed by the app (`sealRequest`) — asked only on the two
             * paths that run, after every refusal, so a refused request seals
             * nothing. The handle rides the run's options alone: never the reply,
             * the stored session or the checkpoint.
             */
            const withSealedRequest = async () => {
                const sealed = await sealRequest?.({ headers: request.headers ?? {} });
                return sealed === undefined ? runOptions : { ...runOptions, request: sealed };
            };
            // ── The one discriminant ─────────────────────────────────────────
            // A request carrying `decision` answers a pending question; a request
            // without one is a new message. Never inferred from the text: reading
            // approval out of prose is a guess, and a consent gate may not be built
            // on a guess.
            if (request.decision !== undefined) {
                if (answered !== undefined) {
                    // One decision runs one call, and this request's decision was used.
                    reply.fail((0, answeredPause_js_1.answeredRefusal)(answered));
                    return;
                }
                if (!paused) {
                    reply.fail(new errors_js_1.NoPendingAskError(sessionId ?? '(anonymous)'));
                    return;
                }
                const inputRequest = (0, pause_js_1.pauseDemandsDecision)(paused.checkpoint.pauseData) === undefined
                    ? (0, inputRequest_js_1.readAwaitingInput)(paused.checkpoint.pauseData)
                    : undefined;
                if (inputRequest !== undefined) {
                    const response = (0, inputRequest_js_1.applyInputResponse)(inputRequest, request.decision);
                    if ('cancel' in response) {
                        // Data collection, never permission: settle unanswered calls
                        // without executing their handlers or consulting the model.
                        const history = [...paused.conversation.history];
                        for (const call of (0, answeredPause_js_1.openToolCalls)(paused.conversation.history))
                            history.push({
                                role: 'tool',
                                toolName: call.name,
                                toolCallId: call.id,
                                ...(paused.checkpoint.sharedState.findingsServe !== undefined && {
                                    toolResultOrigin: 'input-cancelled',
                                }),
                                content: JSON.stringify({
                                    status: 'input_cancelled',
                                    requestId: response.requestId,
                                }),
                            });
                        const output = 'Input request cancelled.';
                        history.push({ role: 'assistant', content: output });
                        const { skillCursor: _cursor, ...conversation } = paused.conversation;
                        void _cursor;
                        await lane.writer?.settle();
                        if (sessionId !== undefined)
                            await store.persist(sessionId, (0, envelope_js_1.toEnvelope)({ ...conversation, history, checkpointedAt: Date.now() }));
                        runner.abandonPause();
                        if (sessionId !== undefined)
                            await runner.closeToolSessions({ scope: 'run', sessionId });
                        // A turn that ends, so the host is offered its binding like any
                        // other — but no run happened, so there is no run id to stamp.
                        await offerTurnArtifacts(runner, reply, offer, () => undefined);
                        reply.complete(output);
                        return;
                    }
                }
                const resumeOptions = await withSealedRequest();
                // Opened before the answer is handed over: what it sees says whether a
                // failure below may have used the decision (`answeredPause.ts`).
                const watch = (0, answeredPause_js_1.watchAnswer)(paused);
                try {
                    await deliver(lane, await runner.resume(paused.checkpoint, request.decision, resumeOptions), store, reply, sessionId, offer, paused);
                }
                catch (err) {
                    // A failure in the resume or in storing its result: when the
                    // decision may have been used, the stored pause is answered before
                    // anyone hears of it (`answeredPause.ts`).
                    if (sessionId !== undefined) {
                        await (0, answeredPause_js_1.afterFailedAnswer)({
                            store,
                            sessionId,
                            paused,
                            watch,
                            answer: request.decision,
                            error: err,
                            settle: () => lane.writer?.settle(),
                            runner,
                        });
                    }
                    throw err;
                }
                finally {
                    watch?.release();
                }
                return;
            }
            if (paused && sessionId !== undefined) {
                if ((0, answeredPause_js_1.unanswerablePause)(paused, runner) === undefined) {
                    // The message is not run and the pause is not discarded. Both of the
                    // other options lose something a person cared about.
                    reply.fail(new errors_js_1.AwaitingDecisionError(sessionId, paused.pending));
                    return;
                }
                // …unless no answer can ever reach it: paused before requests were
                // named, its call changed after the ask, or an ask made under rules
                // this agent no longer runs. Held, it would hold the session for
                // good — so it is closed, the message continues the conversation, and
                // the calls it left open are recorded as not run only when the ledger
                // the request was minted under proves it, else as possibly run
                // (`answeredPause.ts`).
                prior = await (0, answeredPause_js_1.closedConversation)(paused, { runner, store });
                paused = undefined;
            }
            const turnOptions = await withSealedRequest();
            const output = prior
                ? await runner.run({ message: request.input, continueFrom: prior }, turnOptions)
                : await runner.run({ message: request.input }, turnOptions);
            await deliver(lane, output, store, reply, sessionId, offer);
        }
        catch (err) {
            // A run that threw still has to drain. A write left in flight would race
            // the NEXT turn's terminal write and could land after it. The drain's own
            // failure is swallowed here and only here: the caller is already being
            // told about the run's error, and replacing it with a storage error would
            // hide the thing that actually went wrong.
            await lane.writer?.settle().catch(() => undefined);
            throw err;
        }
        finally {
            lane.activeReply = undefined;
            lane.activeSessionId = undefined;
            lane.activeOwner = undefined;
            lane.activeSpendKey = undefined;
        }
    }
    /** Store what the run left behind, then end the reply with the right terminal. */
    async function deliver(lane, output, store, reply, sessionId, offer, priorPause) {
        const runner = lane.agent;
        // Mid-run writes settle FIRST. Ordering, not tidiness: an 'async' write
        // still in flight would otherwise land after the terminal envelope and
        // overwrite it — a stored pause quietly demoted back to a conversation,
        // and the question a person was asked gone with it. It also rethrows a
        // store that refused this run's progress, so a broken store fails the
        // request rather than being reported as a clean answer.
        await lane.writer?.settle();
        if ((0, pause_js_1.isPaused)(output)) {
            const pending = (0, pendingAsk_js_1.describePause)(output, sessionId);
            const conversation = sessionId === undefined
                ? undefined
                : output.awaitingInput !== undefined &&
                    output.awaitingInput.requestId === priorPause?.pending.awaitingInput?.requestId
                    ? priorPause.conversation
                    : runner.checkpoint();
            try {
                if (sessionId !== undefined && conversation) {
                    await store.persist(sessionId, (0, envelope_js_1.toPausedEnvelope)({ checkpoint: output.checkpoint, conversation, pending }));
                    if (reply.awaiting) {
                        // The run id for `origin` comes from the conversation just
                        // snapshotted. When that is the PRIOR pause's — re-used because
                        // the same question is still open, which is what a partial answer
                        // does WITHOUT running anything — no origin is stamped: this
                        // request ran nothing, and the instance's last run may be another
                        // session's. Absent is never wrong; a borrowed run id would be.
                        await offerTurnArtifacts(runner, reply, offer, () => conversation === priorPause?.conversation ? undefined : conversation?.runId);
                        reply.awaiting(pending);
                        return;
                    }
                }
                // Either there was nowhere to store it, or the host cannot describe a
                // question. Both are refusals about THIS reply, not about the run.
                reply.fail(new errors_js_1.PauseNotCarriedError(pending.tool, sessionId, conversation !== undefined));
                return;
            }
            finally {
                // ── OWNERSHIP MOVES HERE ────────────────────────────────────────
                // A pause belongs to a SESSION. In the shared shape one Agent carries
                // every session, and the Agent's own pending-question guard (9.2.0) is
                // right for a script driving one instance and wrong here: the instance
                // would go on holding session A's question, and session B's next
                // message — a different conversation, a different person — would be
                // refused on behalf of an answer B was never asked for.
                //
                // Once the pause is in the STORE, the store owns it: a later request
                // for that session carrying `decision` continues it from exactly
                // where it stopped, and nothing about that consults the instance. So
                // the instance is released, here, at the moment ownership transfers.
                // On the failure path above it is released for the opposite reason —
                // the question could not be carried at all, and blocking every other
                // session on one nobody can ever answer is strictly worse.
                //
                // The pooled shape releases it too, and for a reason of its own: this
                // session's instance can be EVICTED while the question is outstanding,
                // and a pause held only in an instance nobody can reach again is a
                // question nobody could ever answer. The store is the one place it can
                // survive that, so the store is the only place it is kept.
                runner.abandonPause();
            }
        }
        let conversation;
        if (sessionId !== undefined) {
            conversation = runner.checkpoint();
            // Persist BEFORE answering: the caller learns the answer only once the
            // conversation that produced it is durable, so a queued next turn can
            // never read state older than the answer already given.
            if (conversation)
                await store.persist(sessionId, (0, envelope_js_1.toEnvelope)(conversation));
        }
        // The snapshot just persisted names the run that answered — the same id
        // the run's own recording carries on its `origin` — so stamping a host's
        // filing costs no second snapshot.
        await offerTurnArtifacts(runner, reply, offer, () => conversation?.runId);
        reply.complete(typeof output === 'string' ? output : String(output));
    }
    // The HOST's own refusals — a forged request its door guard turned away, a
    // session id over the bound — never reach `handler`, so they would never
    // reach the record either. Subscribed only when a sink is set (the zero-delta
    // path stays zero), and only on a host that reports them (feature-detected).
    // Before `serve`, so a refusal in the first instant of serving is not lost.
    const stopHostRefusals = ingressSink !== undefined && typeof host.onRefusal === 'function'
        ? host.onRefusal((refusal) => (0, ingressRecord_js_1.recordHostRefusal)(refusal, ingressSink))
        : undefined;
    let handle;
    try {
        handle = await host.serve(handler);
    }
    catch (err) {
        stopHostRefusals?.();
        throw err;
    }
    // ── Shutdown ────────────────────────────────────────────────────────
    //
    // Telemetry drains on close, because a batching exporter that loses its
    // last batch when the server stops is the most common way a shutdown lies
    // about what happened. Draining is safe on an agent this composer only
    // borrowed; RELEASING it is not, so that is opt-in.
    //
    // A POOLED instance is not borrowed — this composer made it, and nothing can
    // reach it once the pool is dropped. So `'flush'` and `'flush-and-stop'` both
    // stop a pooled agent (drain, then release), because the alternative is
    // leaving timers running that nobody owns. `'none'` still means what it says:
    // touch nothing.
    const shutdownMode = options.shutdown ?? 'flush';
    const installedSignals = [];
    function removeSignalListeners() {
        for (const { signal, listener } of installedSignals)
            process.removeListener(signal, listener);
        installedSignals.length = 0;
    }
    // Idempotent: the first call owns the shutdown, later ones await it — the
    // same rule `httpHost.close()` follows, and what lets a signal handler and
    // an explicit close coexist.
    let closing;
    function closeOnce() {
        closing ??= (async () => {
            await handle.close();
            stopHostRefusals?.();
            if (shared !== undefined) {
                shared.detach();
                if (shutdownMode !== 'none') {
                    // `stop: false` under 'flush' — drain without releasing what the
                    // caller still owns.
                    await shared.agent.shutdown({ stop: shutdownMode === 'flush-and-stop' });
                }
            }
            else {
                const lanes = [...pool.values()];
                pool.clear();
                for (const lane of lanes)
                    lane.detach();
                if (shutdownMode !== 'none') {
                    // allSettled, not all: one instance whose exporter refuses to drain
                    // must not strand the drain of every other session's. The reader is
                    // this composer's too, so it stops with them.
                    const owned = [...lanes.map((lane) => lane.agent), ...(reader ? [reader] : [])];
                    await Promise.allSettled(owned.map((agent) => agent.shutdown({ stop: true })));
                }
            }
            removeSignalListeners();
        })();
        return closing;
    }
    for (const signal of options.shutdownOn ?? []) {
        const listener = () => {
            void closeOnce().finally(() => {
                // Our listeners are gone by now (closeOnce removes them), so
                // re-raising lands on the DEFAULT disposition: the process ends the
                // way the platform meant it to, not by an exit code we invented.
                removeSignalListeners();
                process.kill(process.pid, signal);
            });
        };
        process.on(signal, listener);
        installedSignals.push({ signal, listener });
    }
    // Keep whatever the adapter put on its own handle (nodeHost's bound `url`,
    // say) and replace only `close`, which now also detaches this composer's
    // listeners. The cast is the one place TypeScript cannot see that a spread
    // plus one override is still the adapter's handle type.
    return {
        ...handle,
        close: closeOnce,
        artifactsForRequest,
    };
}
exports.standingAgent = standingAgent;
/**
 * WHO this request is for, when the transport said (9.12.0) — and `undefined`
 * when it did not, which leaves every earlier release's behaviour untouched to
 * the byte.
 *
 * The composition, field by field, because each one has a different rightful
 * source:
 *
 *  - `principal` — {@link HostRequest.userId}, the fact THIS request carried.
 *    It is the only field the transport supplies and the only one this function
 *    ever introduces.
 *  - `conversationId` — the namespace the conversation already runs in, else
 *    the session id. Composed with the 9.10.0 derivation rather than replacing
 *    it: a session IS a conversation, and moving a live conversation's
 *    namespace because a header appeared would lose its memory mid-thread.
 *  - `tenant` — whatever the stored identity carried. No transport field
 *    supplies one, so there is nothing here to override it with.
 *
 * **Why the header wins on `principal` specifically.** Every other field
 * belongs to the conversation and survives untouched; the actor belongs to the
 * request. Before this release `standingAgent` named no identity at all, so the
 * only principal a stored conversation can realistically carry is one an
 * EARLIER turn of this same mechanism put there — and preferring it would pin a
 * whole session to whoever spoke first, which is precisely the audit trail that
 * "looks complete and names the wrong party".
 *
 * **Nothing is invented.** With no session and no stored conversation there is
 * no conversation to name, and this returns `undefined` rather than fabricating
 * one; the id still reaches a custom handler on `HostRequest`. The managed
 * runtimes that forward a user require a session id on the same call, so that
 * gap is a guard rather than the shape this exists for.
 *
 * One consequence worth stating: naming an identity here takes the run off the
 * 9.10.0 derived rung, so `runIdentitySource: 'session'` is not written on this
 * path. The namespace is identical either way — the marker distinguishes a
 * namespace the caller chose from one the library derived, and on this path the
 * caller did choose.
 */
function identityForRequest(userId, sessionId, stored) {
    if (userId === undefined)
        return undefined;
    const conversationId = stored?.conversationId ?? sessionId;
    if (conversationId === undefined)
        return undefined;
    return {
        conversationId,
        ...(stored?.tenant !== undefined && { tenant: stored.tenant }),
        principal: userId,
    };
}
/**
 * The scope a SESSION's claim tickets live in, for the request being served —
 * the ONE composition of that tuple, shared by the two doors that need it.
 *
 * `answerArtifact` redeems under it, and a turn hands its host a store bound
 * to it (`reply.turnArtifacts`). One function rather than two call sites that
 * agree today: the field bug this exists for was a host that composed its own
 * tuple, got the session rung right and the signed-in rung wrong, and filed
 * every ticket where redemption would never look. Deliberately NOT exported —
 * a function a host can call with a user id is a function a host can call
 * with SOMEBODY ELSE's user id.
 *
 * `identityForRequest` when the request names a user, else the session rung
 * `{ conversationId: sessionId }` — what seed derives for an identity-less
 * served run.
 */
function sessionArtifactScope(userId, sessionId, stored) {
    return identityForRequest(userId, sessionId, stored) ?? { conversationId: sessionId };
}
/**
 * Hand this turn's binding to a host that asked for it — AWAITED while the
 * session's lane is still held, BOUNDED, immediately before the turn's
 * terminal.
 *
 * Four guarantees, each one a finding an earlier cut of this seam failed:
 *
 *  - **Inside the turn.** The composer awaits the hook here, so a host files
 *    while no other run can be in flight on this instance.
 *  - **Bounded.** The hook and every operation it started race ONE deadline
 *    (`turnArtifactsTimeoutMs`) and the request's signal. Whichever ends the
 *    wait, the binding is revoked, nothing in flight is cancelled (it may
 *    still land — its facts name their own session and run, so they cannot
 *    enter anyone else's record), the cause is reported and the terminal is
 *    delivered — so one host's hung store or hook can hold neither every other
 *    session (the shared shape), nor a pooled lane, nor `close()`.
 *  - **Never the terminal's decision, never silent.** A hook that throws, an
 *    operation that fails, the bound, the abort and a call after the turn are
 *    reported (`turnArtifactsFailed`) and the terminal is delivered anyway.
 *  - **Revoked when the turn ends.** A verb called later is refused by name
 *    (`TurnArtifactsExpiredError`), already handled, and reported.
 *
 * A host without the hook gets nothing built: no binding, no scope, no store
 * lookup, no run-id read, no timer.
 */
async function offerTurnArtifacts(agent, reply, offer, runIdOf) {
    if (reply.turnArtifacts === undefined)
        return;
    const session = offer.session;
    // WHOSE facts these are, captured NOW — the binding owns "whose run", never
    // the agent's mutable current-run state when a late fact lands.
    const runId = session !== undefined ? runIdOf() : undefined;
    const attribution = session === undefined
        ? undefined
        : { sessionId: session.id, ...(runId !== undefined && { runId }) };
    const failed = (failure) => turnArtifactsFailed(agent, attribution, offer.note, failure);
    const opened = (0, turnArtifacts_js_1.openTurnArtifacts)({
        store: agent.getArtifactStore(),
        scope: session?.scope(),
        runId,
        onEvent: (fact) => {
            // Only a bound hand-over reports facts, and only a session binds.
            if (attribution !== undefined)
                emitArtifactFact(agent, fact, attribution);
        },
        onFailure: (failure) => failed({
            cause: failure.cause,
            op: failure.op,
            ...(failure.cause === 'operation' && errorClassOf(failure.error)),
        }),
    });
    const handOver = (async () => {
        try {
            await reply.turnArtifacts?.(opened.turn);
        }
        catch (err) {
            // A hook that merely re-threw an operation's failure was already counted.
            if (!opened.reported(err))
                failed({ cause: 'hook', ...errorClassOf(err) });
        }
        await opened.drain();
    })();
    try {
        const ended = await withinBound(handOver, offer.timeoutMs, offer.signal);
        if (ended !== 'settled')
            failed({ cause: ended });
    }
    finally {
        opened.revoke();
    }
}
/**
 * Wait for `work`, but no longer than `timeoutMs` and no longer than `signal`
 * allows. `work` is NOT cancelled when the wait ends — there is nothing to
 * cancel a store's in-flight write with, and pretending otherwise would be
 * worse than saying we stopped waiting (the `toolSessions.ts · withTimeout`
 * law). The timer is unref'd, so a pending hand-over can never be the reason a
 * process stays up.
 */
async function withinBound(work, timeoutMs, signal) {
    if (signal?.aborted === true)
        return 'abort';
    let timer;
    let onAbort;
    try {
        return await Promise.race([
            work.then(() => 'settled'),
            new Promise((resolve) => {
                timer = setTimeout(() => resolve('timeout'), timeoutMs);
                timer.unref?.();
            }),
            new Promise((resolve) => {
                if (signal === undefined)
                    return;
                onAbort = () => resolve('abort');
                signal.addEventListener('abort', onAbort, { once: true });
            }),
        ]);
    }
    finally {
        if (timer !== undefined)
            clearTimeout(timer);
        if (onAbort !== undefined)
            signal?.removeEventListener('abort', onAbort);
    }
}
/** The routable half of a failure — class and code, never the message. */
function errorClassOf(err) {
    const code = err?.code;
    return {
        ...(err instanceof Error && { errorClass: err.constructor?.name ?? err.name }),
        ...(typeof code === 'string' && { errorCode: code }),
    };
}
/**
 * Record one failure of a turn's artifact hand-over — on the serving agent's
 * stream as `agentfootprint.artifacts.hand_over_failed`, stamped with the
 * session and run it was FOR; the first one on the ingress record's census
 * (`IngressRecord.turnArtifactsFailure`); and on the console in dev mode. The
 * swallow is the library's decision, so the visibility is its duty: the
 * default deployment keeps no ingress sink, and a missing story must not be
 * silent there (the `toolSessions.ts` law 5, "never throws into the run — but
 * never silent either"). It never reaches the reply.
 */
function turnArtifactsFailed(agent, attribution, note, failure) {
    const payload = {
        cause: failure.cause,
        ...(failure.op !== undefined && { op: failure.op }),
        ...(failure.errorClass !== undefined && { errorClass: failure.errorClass }),
        ...(failure.errorCode !== undefined && { errorCode: failure.errorCode }),
    };
    note?.turnArtifactsFailed(payload);
    if (attribution !== undefined) {
        agent.emitAttributed('agentfootprint.artifacts.hand_over_failed', { ...payload }, attribution);
    }
    if ((0, footprintjs_1.isDevMode)()) {
        // eslint-disable-next-line no-console
        console.error('[hosting] reply.turnArtifacts hand-over failed; the reply was delivered anyway:', payload);
    }
}
/**
 * End the reply with a resolved artifact — through the terminal built for it
 * when the host has one, and through the named refusal when it does not.
 *
 * The `awaiting` fallback law, applied to the other optional terminal: a host
 * that cannot describe the result must say so by name rather than deliver an
 * improvised shape or nothing. The resolution itself already happened and is
 * already on the record either way.
 */
function deliverArtifact(reply, result) {
    if (reply.artifact) {
        reply.artifact(result);
        return;
    }
    reply.fail(new errors_js_1.ArtifactNotCarriedError(result.ref));
}
/**
 * End the reply with resolved session history — through the terminal built for
 * it when the host has one, and through the named refusal when it does not.
 *
 * The {@link deliverArtifact} law, applied to the third optional terminal: a
 * host that cannot describe the result says so by name rather than delivering
 * an improvised shape or nothing.
 */
function deliverSessions(reply, op, result) {
    if (reply.sessions) {
        reply.sessions(result);
        return;
    }
    reply.fail(new errors_js_1.SessionsNotCarriedError(op));
}
/**
 * Does `scope` hold `ref`? Asked through a binding with NO fact sink, so the
 * question itself puts nothing on any record — what lets the redemption door
 * answer a lane-less session (one the reader serves) without emitting a fact
 * under a session id nobody has proved exists (R2-12).
 */
async function holds(store, scope, ref) {
    return (await (0, capability_js_1.bindArtifacts)(store, scope).head(ref)) !== null;
}
/**
 * Put one capability fact on the serving agent's typed record — the hosting
 * door's adapter onto the SAME `artifacts.*` events every tool's use of
 * `ctx.artifacts` rides, minus the `tool` field (the actor was the hosting
 * door or the host's own code, and payloads never carry an actor that does not
 * exist).
 *
 * Two bindings feed it. The wire's redemption (`artifact-head` /
 * `artifact-get`) is read-only and only ever produces `resolved` / `refused`
 * — the wire deliberately has no put. The turn's hand-over
 * (`reply.turnArtifacts`) is the host's own code filing for its turn, so its
 * `put` produces `minted` and, when retention made room, `expired` —
 * tool-less, like the run's own recording mint, and carrying every meta field
 * a tool's mint carries.
 *
 * Every fact is stamped with the SESSION it was produced for
 * (`RunnerBase.emitAttributed`). The door answers outside any run and — for a
 * redemption — outside the lane, so on an instance shared by several sessions
 * a fact can be delivered while ANOTHER session's run is in flight; the stamp
 * is what keeps it out of that run's recording and self-explain evidence
 * (`bridge/eventMeta.ts · eventBelongsToRun`). Delivery to listeners is
 * unchanged. Dropped when nobody listens (the dispatcher's own fast path), so
 * an unobserved agent pays one listener check per fact and nothing more.
 */
function emitArtifactFact(agent, fact, attribution) {
    switch (fact.type) {
        case 'minted': {
            const meta = fact.meta;
            agent.emitAttributed('agentfootprint.artifacts.minted', {
                ref: meta.ref,
                kind: meta.kind,
                mediaType: meta.mediaType,
                bytes: meta.bytes,
                ...(meta.label !== undefined && { label: meta.label }),
                ...(meta.digest !== undefined && { digest: meta.digest }),
                ...(meta.expiresAt !== undefined && { expiresAt: meta.expiresAt }),
                ...(meta.origin !== undefined && { origin: meta.origin }),
                ...(meta.parentRefs !== undefined && { parentRefs: meta.parentRefs }),
                ...(meta.timeAxis !== undefined && { timeAxis: meta.timeAxis }),
            }, attribution);
            return;
        }
        case 'expired':
            agent.emitAttributed('agentfootprint.artifacts.expired', {
                ref: fact.swept.ref,
                reason: fact.swept.reason,
                kind: fact.swept.kind,
                bytes: fact.swept.bytes,
            }, attribution);
            return;
        case 'resolved':
            agent.emitAttributed('agentfootprint.artifacts.resolved', {
                ref: fact.ref,
                via: fact.via,
                kind: fact.kind,
                bytes: fact.bytes,
            }, attribution);
            return;
        case 'refused':
            agent.emitAttributed('agentfootprint.artifacts.refused', {
                op: fact.op,
                reason: fact.reason,
                ...(fact.ref !== undefined && { ref: fact.ref }),
                ...(fact.detail !== undefined && { detail: fact.detail }),
            }, attribution);
            return;
        default:
            return;
    }
}
/**
 * Transport headers as the verifier reads them: string values only. A header
 * that arrived as an array (repeated) is dropped — except `authorization`,
 * which the caller refuses first (`repeatsAuthorization`): two credentials are
 * ambiguous, and dropping them would let the call proceed as anonymous at a
 * door that allows it.
 */
function singleValuedHeaders(headers) {
    if (headers === undefined)
        return undefined;
    const out = {};
    for (const [name, value] of Object.entries(headers)) {
        if (typeof value === 'string')
            out[name] = value;
    }
    return out;
}
/** Did the transport deliver `authorization` (any case) more than once? */
function repeatsAuthorization(headers) {
    if (headers === undefined)
        return false;
    const values = Object.entries(headers).filter(([name]) => name.toLowerCase() === 'authorization');
    return values.length > 1 || values.some(([, value]) => Array.isArray(value));
}
//# sourceMappingURL=standingAgent.js.map