/** * operator.ts — the one place that knows what to do when pi's context dies. * * Replacing the session (`/new`, fork, switch, reload) disposes it, after * which every method on `pi` and on every `ctx` it handed out throws * "stale after session replacement". That was handled by eleven separate * try/catch blocks with three different dispositions between them — swallow, * null out `lastCtx`, retire the instance — so which of the three a given * call site got was an accident of when it was written. This module is that * decision, made once: drop our reference to the context, and tell the owner * of the instance lifetime *once*, through `onStale`. * * ## The deferral is a contract, not an implementation detail * * `onStale` is never called synchronously. It is always handed to * `queueMicrotask`, so it runs after the caller's synchronous flow has * finished. This is load-bearing, and the reason is that a stale context is * discovered from inside code that is halfway through a state transition: * `dispatchBatch()` has already taken a batch off the queue and has not yet * assigned `pendingReply`, and it asks the context whether the model accepts * images in exactly that window. Retiring synchronously there means * `retireInstance()` sees no `pendingReply` and no batch on the queue, and * the owner's messages vanish with nothing left to persist. `trySteer()` has * the same shape around `agentIsIdle()`. Deferring costs nothing — retirement * is not more correct for happening a microtask earlier — and it lets every * caller finish putting the state back the way it found it. * * ## Two names because there are two rules * * `report` is for events the channel generates (a login, a worker that died, * an answer that was dropped): it *always* writes the log first, because the * whole point is that such an event must not land nowhere, and only then * tries the remembered context. `respond` is for command output, which is * already on the operator's screen — it logs only when the notify *fails*, * because `/wechat config` printed into a 2 MB rotating log file evicts the * lines someone is debugging the channel with. * * Runtime imports: none, deliberately. Everything here is type-only, which * keeps the module loadable under `node --test`'s type stripper. */ import type { ExtensionContext } from '@earendil-works/pi-coding-agent'; import type { Logger } from '../log.js'; import type { MaybeCtx, NotifyLevel } from './core.js'; /** * Did this error come from a session that pi has already replaced? * * Pure, and asked from every module that touches a context — pi reports the * condition as a message on an ordinary `Error`, so string matching is the * whole of the available signal. */ export declare function isStaleError(err: unknown): boolean; export interface OperatorLinkDeps { log: Pick; /** * Where "this session is gone" is acted on — in practice * `retireInstance()`. Never called synchronously; see the header. */ onStale(reason: string): void; /** * The footer line for the current state. A callback rather than a value * because it is only allowed to be evaluated when there is a context to * paint it into. */ footerText(): string; } export interface OperatorLink { /** Keep the newest context pi handed over, and repaint. No-op once closed. */ remember(ctx: MaybeCtx): void; /** * A channel event, for the operator. Always logged at `level` first, then * notified through the remembered context if there still is one. */ report(message: string, level?: NotifyLevel): void; /** * Command output, through the context pi gave the handler. A null context * is a silent no-op; only a context that *throws* falls back to the log. */ respond(ctx: MaybeCtx, message: string, level?: NotifyLevel): void; /** Write the footer, but only when its text actually changed. */ paint(): void; /** * Clear the footer and close the link. Never throws and never triggers * `onStale`: it runs from inside retirement, where a throw would skip a * worker kill and a callback would re-enter. */ erase(): void; /** * Ask the context for something, with a fallback for all three ways that * can fail: no context, a stale context, or any other throw. `what` names * the question, and is used only in the log line for that last case. */ ask(what: string, of: (ctx: ExtensionContext) => T, fallback: T): T; /** True once the context went stale, or once `erase()` closed the link. */ readonly stale: boolean; } export declare function createOperatorLink(deps: OperatorLinkDeps): OperatorLink; //# sourceMappingURL=operator.d.ts.map