import { type AllowlistCounts, type ApprovalRefusal, type ApprovalRefusalLift, type ApprovalRung, GthConfig, type McpAnnotationTrustChange, type McpAnnotationTrustView, type ResolvedApprovals, type ToolAnnotationHint } from '#src/config.js'; import { BaseCheckpointSaver } from '@langchain/langgraph'; import { AgentResolvers, AgentStreamEvent, type ApprovalOutcomeCallback, type AttackHaltCallback, type GthAdvertisedTools, GthAgentFactory, GthAgentInterface, GthCommand, GthRunStats, type GthTerminationReason, Message, StatusUpdateCallback, ToolApprovalCallback } from '#src/core/types.js'; import { type ApprovalGrant } from '#src/core/approvals/grants.js'; import type { ConversationGrants } from '#src/core/approvals/conversationGrants.js'; import { type GthFinishReasonObservation } from '#src/core/terminationReason.js'; import type { GthOutstandingWork } from '#src/core/outstandingWork.js'; import { type GthRunRecap } from '#src/core/runRecap.js'; import { type ApprovalDecisionCapture } from '#src/core/shell/approvalCapture.js'; import { type NegotiationDisplay } from '#src/core/shell/negotiation.js'; import type { CompactConversationOptions, ConversationCompaction } from '#src/core/compaction.js'; import type { AutocompactStatus } from '#src/core/compactionThreshold.js'; import type { TokenBudget } from '#src/config/tokenBudget.js'; /** * EXT-114 — the page describing the `subagents` config key, pointed at rather than paraphrased in * the notice itself. * * **A GitHub blob URL, matching the other user-facing runtime doc links in this repo** (the * approvals-protection pointer in `config/shell-policy.ts` and the 2.0 migration pointer in * `config/schema.ts`) — a running CLI's user has no checkout for a relative path to resolve in. * `subagentScope.spec.ts` pins that this URL's anchor still resolves to a real heading in the page * it names, because a doc link that silently rots is worse than no link. */ export declare const SUBAGENTS_DOCS_URL = "https://github.com/pukeko-robotics/gaunt-sloth/blob/main/docs/configuration/profiles.md#named-profile-subagents-subagents"; /** Options for {@link GthAgentRunner#init} that qualify the run without changing how it behaves. */ export interface GthAgentRunnerInitOptions { /** * GS2-81 — the CLI verb this run belongs to, for messages only, when `command` is deliberately * `undefined`. It is a SEPARATE input from `command` because `command` is not a label: it selects * the mode prompt (`readModePrompt`), the per-command approvals posture (`resolveApprovals`) and * the command-specific filesystem config, so a helper agent that must run on the chat prompt — * the `gth pr` change-requirements discovery agent — cannot borrow it to say which verb it serves. * * **It is read for the wording of a notice, and for one safety decision.** [[EXT-106]] §4.6 asks * whether this run's human messages are the user's own words, and answers it from * `command ?? owningCommand` ({@link @gaunt-sloth/core!config/shell-policy.commandCarriesUserProvenance | commandCarriesUserProvenance} * at the top of {@link GthAgentRunner#init}) — precisely because a command-less helper agent whose * human turn is content the product FETCHED must not be classified by the absence of a verb. The * key has to be out-of-band metadata like this field: a marker inside the message would be * forgeable by the attacker-controlled text it is supposed to classify. Both that predicate and * the provenance window itself fail closed, so leaving this unset never widens anything. */ owningCommand?: GthCommand; /** * GS2-95 — the name of the command the USER typed, for the run header only (`eval`, `batch`, * `workflow`, `gth-batch`). Forwarded verbatim to the agent's own `displayCommand` init option; * the runner reads nothing from it. * * Separate from `command` for the same reason `owningCommand` is, and a distinct field from it: * `owningCommand` says which verb a command-less helper agent serves, this says what to CALL a * run that does have a verb but does not go by its name. */ displayCommand?: string; /** * GS2-20 — the LangGraph thread this run drives, instead of the fresh one `init` would mint. * * It is what makes a durable checkpointer useful in both directions: a session supplies the id it * has already recorded against its conversation row, so the state it writes can be found again; * and a resume supplies a stored id, so the graph comes back holding what it held. Omitted, the * runner mints a fresh thread exactly as before. * * Note that `/clear` ({@link GthAgentRunner#clearConversation}) deliberately rotates to a NEW thread, so * after one the id recorded against the conversation names the state from before the clear — which * is the correct thing for it to name, since that is the state the user asked to leave behind. */ threadId?: string; } /** * Agent simplifies interaction with LLM and reduces it to calling a few methods * {@link GthAgentRunner#init} and {@link GthAgentRunner#processMessages}. */ export declare class GthAgentRunner { private statusUpdate; private resolvers; private agent; private config; private runConfig; private agentFactory; /** * Consumer hook invoked when a run suspends on a tool-approval interrupt (e.g. the opt-in * `run_shell_command` confirmation). Set via {@link setToolApprovalCallback}; when unset the * runner REJECTS pending tool calls rather than hanging or auto-approving — the safe default * for non-interactive entrypoints (a scripted `exec` run with no TTY to prompt on). */ private toolApprovalCallback; /** * [[EXT-150]] — consumer hook invoked once per human-answered approval, AFTER the answer has been * recorded, carrying the lifetime it landed with. Set via {@link setApprovalOutcomeCallback}; when * unset the outcome is simply not reported, which is where every surface was before this existed. */ private approvalOutcomeCallback; /** * [[TUI-C68]] §6.1 — consumer hook invoked when the rater rates a command an `attack`, so an * interactive surface can show the red banner before the run ends. Set via * {@link setAttackHaltCallback}; **when unset the runner halts immediately**, which is the * behaviour every surface had before a banner existed. A surface that forgets to wire it * therefore keeps the halt rather than losing it. */ private attackHaltCallback; /** * [[TUI-C69]] §5.4/§5.5 — **the surface showing this negotiation while it happens**, when one * is. Set via {@link setNegotiationDisplay}. * * Its presence is the answer to *"is anyone watching?"*, and BOTH halves of the visible * negotiation are keyed on it: the rounds are handed over as they are decided, and a negotiated * approval is held on screen for {@link NEGOTIATED_APPROVAL_COOLDOWN_MS} before it takes effect. * * **`null` means neither happens, and that is the point.** An `exec` or CI run has nobody to * show an approval to, so an 800 ms hold there would tax every headless run and every gate for a * display that does not exist. Deliberately NOT keyed on `toolApprovalCallback`: that one * answers *"is there a human to ASK"*, a different question with a different answer — a piped * readline session can have one wired and no live display, and the §6.2 non-interactive path has * a display and no one to ask. */ private negotiationDisplay; /** The command the runner was initialized for; selects which `devTools` config applies. */ private command; /** * GS2-16 — snapshot of the last turn's analytics (token usage + invoked tools), captured from * the agent at {@link cleanup} time. Needed because {@link runSingleShot} reads stats AFTER it * has already called `cleanup()` (which nulls the agent); interactive callers read live via * {@link getRunStats} before cleanup. Defaults to an empty tally. */ private lastRunStats; /** * BATCH-32 — snapshot of the agent's advertised-tool inventory, captured at {@link cleanup} for * the same reason {@link lastRunStats} is: the single-shot path reads it after cleanup has * already nulled the agent. * * Unlike the run stats this is NOT reset per turn — the inventory is fixed at `init` and * describes the session — so nothing clears it back to `undefined` once observed. `undefined` * therefore means what it means on the agent: no inventory was ever observed. */ private lastAdvertisedTools; /** * [[EXT-159]] — why the current turn ended, as classified by the sites the RUNNER owns (the two * exception wrappers, the approvals re-throws, the empty-response throws, the ordinary end of a * turn). The agent owns the sites inside it and its answer outranks this one; see * {@link getTerminationReason}. */ private terminationReason; /** * [[EXT-159]] — snapshot of the agent's own termination reason, for the same reason * {@link lastRunStats} exists: {@link cleanup} nulls the agent, and the single-shot path reads * the reason afterwards. */ private agentTerminationReason; /** * [[EXT-159]] — snapshot of the agent's per-message `finish_reason` observations, kept for the * same reason {@link agentTerminationReason} is: `/debug-dump` and the non-interactive verbs ask * after {@link cleanup} has already dropped the agent. */ private agentFinishReasons; /** * [[EXT-158]] — snapshot of the agent's outstanding-work fact, kept for the same reason * {@link agentTerminationReason} is: `reviewModule` and `prDiscovery` both read the ending only * after {@link cleanup} has dropped the agent. */ private agentOutstandingWork; /** [[EXT-178]] — the recap inputs snapshotted from the agent, so they survive {@link cleanup}. */ private agentRunRecapSource; /** * [[EXT-178]] — the config kept past {@link cleanup} so a post-cleanup recap has a model to call, * and `null` whenever the recap rung is `off` — see the assignment in `cleanup` for why. */ private recapConfigSnapshot; /** * GS2-23 — how many turns are being driven right now, through either driver. Read by * {@link compactConversation}, which refuses to rewrite the thread underneath a running turn: * the graph would be writing checkpoints for the turn while the compaction wrote a competing one. */ private turnsInFlight; /** * CFG-27 — the runtime, session-scoped approvals posture, seeded at {@link init} from * {@link resolveApprovals} and thereafter switchable for the session by `/approvals `. * **This field, not the interrupt wiring, is where the rung lives.** The agent wires the * interrupt rung-independently, so every tool any rung could gate arrives at the top of * `decideToolApproval` and is judged against the rung recorded here — which is what makes * `/approvals manual` take effect mid-session, and what keeps a config that pre-selects * `bypass` switchable back. Never persisted. * * It does NOT disable the hardline floor — catastrophic commands are still refused at exec time * in `GthDevToolkit.executeCommand` under every rung. */ private sessionApprovals; /** * CFG-26 — the model the AI rater rates with, when `approvals.rater.profile` names an identity * profile. Resolved ONCE at {@link init} (never mid-turn) and handed to `rateShellCommand`; * `undefined` means no profile is configured and the rater uses the session model. */ private raterModel; /** * [[EXT-127]] — the model the ALIGNMENT CHECKER runs on, when `approvals.alignmentChecker` (or, by * its read-site default, `approvals.rater`) names an identity profile. Resolved ONCE at * {@link init}, exactly as {@link raterModel} is; `undefined` means the session model. */ private alignmentCheckerModel; /** * EXT-66 — how many rating calls this session gave up on. Counted so the notice can say "3 times * this session" rather than repeating an identical line, and so a silent drift toward * escalate-everything has a number attached to it. */ private raterTimeouts; /** * [[EXT-82]] — the consecutive-failure tracker behind the session-level signal, per runner and * therefore per session (the ACP surface runs several at once). See * {@link import('#src/core/shell/raterHealth.js').RaterHealth}. */ private readonly raterHealth; /** * EXT-71 §3.1/§6 — what the escalation menu granted at run time, for the life of THIS runner * instance: {@link ApprovalEntry} objects, never prefixes, and never anything from config (the * declared lists are read-only input consulted straight from the posture). Instance-scoped so * concurrent sessions (ACP / AG-UI multi-session) cannot stomp each other's approvals. */ private readonly sessionGrants; /** * CFG-27 §3 — what the escalation menu's *always reject* choice adds at run time, in the same * grammar ([[TUI-C26]] wires that writer; the store speaks it already). The entries DECLARED in * `approvals.deny` are not here — they are read-only config input, matched from the posture — and * both are handed to the same matcher, so a runtime refusal and a declared one are one list. */ private denyGrants; /** * GS2-20 — told whenever the session-scoped contents of {@link sessionGrants} or * {@link denyGrants} change, so the session can write them against its conversation and a resume * can restore them. Set via {@link setSessionGrantsListener}; `null` means nobody records them, * which is where every surface without a conversation row (ACP, AG-UI, a single-shot run) stays. * Deliberately NOT fired by {@link resumeConversation}: what it installs was read from the store * a moment ago, and writing it straight back would be a no-op with a disk write in it. */ private sessionGrantsListener; /** * The persisted (`always`) grant store, loaded lazily on first use from * `.gsloth/.gsloth-settings/shell-allowlist.json`. Null until a gated call actually needs it, and * null when the file cannot be loaded at all (in which case `always` grants degrade to session). */ private persistedGrants; private persistedGrantsLoaded; /** * [[EXT-107]] — the persisted (`always`) **refusal** store, from * `.gsloth/.gsloth-settings/shell-denylist.json`. The mirror of {@link persistedGrants}, and the * reason the menu's most emphatic answer is no longer its most forgetful one. * * Null when the file cannot be loaded at all, in which case an `always` refusal degrades to a * session one. **Not "a re-prompt next session, never an execution":** nothing in the unreadable * file applies to any call, so next session a call it covered is left to the rest of the gate — it * may be refused by another rule, it may be prompted for, or it may run without asking, under * `bypass` or a matching saved allow. The user is told at load time. */ private persistedDenials; private persistedDenialsLoaded; /** * [[EXT-29]] §5 — the state of the agent↔rater negotiation at `auto`: the transcript, §5.3's * consecutive-rejection counter and the reachability bound. Instance-scoped for the same reason * the grant stores are — a concurrent ACP / AG-UI session must not inherit another's argument. */ private readonly negotiation; /** * [[TUI-C27]] — the diagnostic record of every gated decision this session made, for * `/debug-dump`. Instance-scoped for the same reason the negotiation and the grant stores are: a * concurrent ACP / AG-UI session must not inherit another's approvals history, and a dump taken * in one must not describe the other. */ private readonly approvalCaptures; /** * @param agentFactory Produces the {@link GthAgentInterface} the runner drives. * Defaults to the lean {@link GthLangChainAgent} (core). The seam stays parameterised so a * caller can drive the runner with a different graph builder without core depending on it. */ constructor(statusUpdate: StatusUpdateCallback, resolvers?: AgentResolvers, agentFactory?: GthAgentFactory); /** * Register the tool-approval handler the runner calls when a run suspends on a tool-approval * interrupt (the interactive readline session wires a y/n prompt here). Pass `null` to clear. * Without a handler the runner rejects pending tool calls (see `toolApprovalCallback`). */ setToolApprovalCallback(callback: ToolApprovalCallback | null): void; /** * [[EXT-150]] — register the handler that is told **what a human's answer actually landed as**. * Pass `null` to clear. * * Separate from {@link setToolApprovalCallback} for the reason {@link setAttackHaltCallback} is * separate: it is a different question asked at a different moment. The approval callback is * consulted *before* anything is written and returns the human's REQUEST; the scope that survives * is decided here afterwards, because [[EXT-149]] degrades an `always` whose write did not reach * disk to the `session` answer it really is. A surface with no way to hear that can only confirm * the key that was pressed — and core's own ERROR naming the unwritten file then contradicts it. * * It reports; it never decides. Nothing downstream reads it, so a surface that ignores it, or * never wires it, changes no behaviour of the gate. */ setApprovalOutcomeCallback(callback: ApprovalOutcomeCallback | null): void; /** * [[TUI-C68]] §6.1 — register the handler that shows the **attack banner**, the one way a human * gets past an `attack` verdict. Pass `null` to clear. * * Separate from {@link setToolApprovalCallback} because it is a separate question with an * inverted default: an absent approval callback means *this session has nobody to ask*, and an * absent one here means *end the run*. Wiring it is what an interactive surface opts into; every * other surface keeps the halt (see `attackHaltCallback`). */ setAttackHaltCallback(callback: AttackHaltCallback | null): void; /** * [[TUI-C69]] §5.4/§5.5 — **declare that this surface is showing the negotiation as it happens.** * Pass `null` to clear. * * §5.4's requirement is not decoration: *"the spec's own justification for letting the agent * argue with the rater at all is that a human can watch it, and an argument conducted in the dark * is a different thing from one that can be interrupted."* Wiring this is a surface saying it * has somewhere to draw that, which is also what makes §5.5's hold meaningful — see * `negotiationDisplay` for why one seam carries both. */ setNegotiationDisplay(display: NegotiationDisplay | null): void; /** * §6.1 — **the single seam between an `attack` verdict and the end of the run.** Both rating * paths — the §3.2 allow-match tripwire and the ordinary rater decision — go through here, so the * banner cannot be present on one and missing on the other, which is the shape of bug that leaves * a halt answerable in some sessions and not others with nothing on screen to tell them apart. * * It returns a decision for the one answer that grants and throws for everything else: * * - **no callback → throw**, immediately and unchanged. §6.2's rule is that a run with nobody to * ask never blocks and never times out into a grant; the way that is guaranteed is that waiting * is something only a wired surface can cause. * - **`run-anyway` → approve, scope `once`.** Exactly one command runs. `once` is not a default * restated: it is what keeps §6.1's three "never"s true. Returning here is also returning from * *before* the block that records a sticky grant, so no allow-list entry and no session grant * can be written on this path — the next identical call is rated again and reaches this banner * again. Nothing here touches the rung, and nothing disables the rater, the escalation or the * halt for anything else. * - **anything else → throw.** `stop`, and equally a value a surface invents or forgets to * return: the grant is one exact answer and everything else is a refusal. */ private haltOrRunAnyway; /** * CFG-27 — switch the session-scoped rung (`/approvals `). Idempotent; returns the NEW * rung so the caller can render a notice. Session-scoped only — nothing is written to config, * and the declared allow/deny lists are unaffected (they are config input, not session state). */ setSessionApprovalRung(rung: ApprovalRung): ApprovalRung; /** CFG-27 — the session's current approvals posture (rung + rater profile + declared lists). */ getSessionApprovals(): ResolvedApprovals; /** * [[TUI-C27]] — every gated decision this session made, oldest first, for the `/debug-dump` * archive. * * Threaded by each surface into `writeDebugDump`, exactly as `agent.lastModelRequest` is: the * writer redacts it with the same pass it applies to every other artifact, and a surface that * does not thread it simply omits the file. */ getApprovalCaptures(): ApprovalDecisionCapture[]; /** * CFG-26 — the allow-list sizes for the `/approvals` display: how many command prefixes the * human has trusted this session, and how many are persisted in the project file. * * READ-ONLY BY CONSTRUCTION: it reports the persisted count only when the store has ALREADY * been loaded (or persistence is on and it can be read), and never through a path that would * CREATE the store as a side effect of showing a display — a status command must not mutate * session state. `always: undefined` therefore means "not loaded / persistence off", which the * caller renders as `—` rather than a misleading `0`. */ getAllowlistCounts(): AllowlistCounts; /** * CFG-27/[[EXT-107]] — **every refusal in force, and which of the three lists holds it**: the * declared `approvals.deny` entries, the escalation menu's session-scoped refusals, and the ones * saved to the project's deny file. * * The origins are kept apart rather than concatenated into one list of strings, because they have * different lifetimes and different owners and only two of the three can be lifted from here. * A merged list makes {@link liftRefusal} impossible to describe honestly. * * **This is the one list.** {@link liftRefusal} resolves its argument against exactly this * sequence, so the number a user reads and the number they type cannot name different entries. * * Numbering is 1-based, and the order is config → saved → session: the entries a user cannot lift * here come first and stay put, so the numbers of the ones they can are not reshuffled by a * config edit between two renderings. */ getRefusals(): ApprovalRefusal[]; /** * [[EXT-107]] — the refusals in force with their entries attached, which is what * {@link liftRefusal} needs and {@link getRefusals} renders. ONE builder, so the displayed order * and the removal order are the same order by construction rather than by two functions agreeing. * * A saved refusal is held in both runtime stores ({@link recordDenial} writes both), so the * session list is filtered against the saved one by entry identity — the same de-duplication * {@link getGrants} does. A configured entry is NOT de-duplicated against them: it is a different * thing with a different owner, and hiding it would let a lift report success while the config * line went on refusing the call. */ private refusalRecords; /** * [[EXT-107]] — **lift one refusal**, by its number in {@link getRefusals}. The escape hatch, and * the reason persisting a refusal is safe to ship: a saved refusal the user cannot find or undo * is a trap, and the person who hits it first is whoever pressed `[d]` by reflex and needed the * command an hour later. Telling them to delete a file they have not been told exists is not an * answer. * * A saved refusal is removed from the file AND from the in-memory store, because it is in both — * dropping only the file would leave the call refused for the rest of the session by a rule the * display no longer shows. * * **A configured entry is reported, never removed.** `approvals.deny` is something the user * wrote; rewriting their config file out from under them is not a thing a session command may do, * and silently no-oping would be worse. They are told where it lives. * * **`stillSaved` is the deletion that did not reach disk** ([[EXT-149]]). The file rewrite can * fail after the in-memory removal has succeeded — a checkout that is not writable, a settings * directory that has gone — and the entry then comes back in the next session. The store now * answers that question ({@link PersistedApprovalGrants.remove}) instead of reporting every * removal as landed, so the notice can stop promising *it will not come back*. */ liftRefusal(index: number): ApprovalRefusalLift; /** * §3/§4.7.4 — **the grants themselves**, for an approvals view that shows *what* was granted, * *when*, and *under which effective annotations*. The counterpart of {@link getAllowlistCounts}, * which answers only how many. * * The declared config lists are deliberately NOT here. They are something a human wrote and * reviewed, they carry no `grantedAt` and no scope, and `getAllowlistCounts` already counts them * alongside these; mixing them in would present a config line as something the session granted. * * **Read-only in both senses.** It never loads the persisted store — same rule as * {@link getAllowlistCounts}: a display must not create the store in order to show it, so a * session that has not yet needed the file lists its session grants alone. And what it hands back * is private to the caller, because `ApprovalGrantStore.list` hands out copies; this method adds * no copy of its own, so the guarantee has one owner rather than one owner and one consumer that * happened to defend itself. */ getGrants(): ApprovalGrant[]; /** * GS2-20 — **the grants that belong to the conversation**: every `session`-scoped entry in the * two runtime stores, private to the caller for the reason {@link getGrants} gives. * * `always` entries are deliberately left out. They mirror the project's allow-list / deny-list * files, which every run reads for itself; recording them against the conversation as well would * make one decision two records that can disagree, and a resume that restored a lifted one would * resurrect a refusal the person had already removed. What is here is exactly what a fresh * process would otherwise have lost. */ getSessionScopedGrants(): ConversationGrants; /** * GS2-20 — hear about every change to the conversation's grants ({@link getSessionScopedGrants}), * after it has landed. The session records them against its conversation row, which is how a * grant outlives the process and reaches a resume. Pass `null` to stop listening. */ setSessionGrantsListener(listener: (() => void) | null): void; /** Fire the listener; a listener that throws must not turn a recorded grant into a failed turn. */ private notifySessionGrantsChanged; /** * GS2-20 — **re-enter a stored conversation: its thread, and its grants.** The ONE seam both * `--resume ` (at boot, after {@link init}) and `/resume ` (mid-session) go through, so * the two spellings cannot come to mean different things. * * The thread half is {@link resetThread}'s rotation pointed at a stored id instead of a fresh * one: the durable checkpointer is one database serving any thread, so re-entering a conversation * needs no new saver, only `configurable.thread_id` naming the thread whose checkpoint holds its * state. The negotiation window and the rater's noted clarifications go with the old thread for * the reason {@link resetThread} gives — they are context of the conversation being LEFT. * * The grants half replaces the session-scoped grants with the conversation's own. Replaces, not * adds: a grant's lifetime is the conversation, so what the session granted while it was in * another conversation does not follow it here — that stays recorded against the conversation it * was made in. The `always` mirrors stay, because the project files they mirror are still in * force. Every restored grant is stamped `session` whatever the document said, since that is the * only scope this seam is handed and the only one the stores should hold for it. * * The listener is NOT fired: what was installed is what the store already holds. * * **Refuses while a turn is running, and refuses a graph suspended on a pending tool approval** * — the same two refusals as {@link compactConversation}, for the same reason: the runnable * config is what the in-flight work resumes through. Measured without the guard: a mid-turn * call returned normally, the turn's approval `Command({ resume })` then went to the OTHER * thread, the gated tool never ran, the turn died as "Model returned an empty response after * tool execution" — a failure blamed on the model — and the thread being resumed INTO received * a checkpoint from the turn being left. Neither shipped surface can reach this (`/resume` is * idle-only on both), so this is the seam refusing on its own behalf, for its next caller. */ resumeConversation(target: { threadId: string; grants: ConversationGrants; }): Promise; /** * §4.7.1 — **which of each server's annotation hints this session believes**, for display. * * Every key either side names is listed: a configured `mcpServers` key with no policy of its own * (which resolves through `defaults`), and a policy key naming a server the config does not have * (which is what a typo looks like). Resolution is {@link trustedAnnotationHints}, the same * function the gate derives effective annotations through, so the display cannot claim a * relationship the gate does not act on. */ getMcpAnnotationTrust(): McpAnnotationTrustView; /** * §4.7.1 — **believe, or stop believing, specific hints from one server**, for the life of this * session. The runtime half of `approvals.mcp.servers..trustAnnotations` (§9), so a user can * do from the TUI what they can do in config. * * **Per hint, never per server.** `hints` names the hints this call moves and leaves every other * hint of that server's exactly as it was, because believing a server's `readOnlyHint` while * disbelieving its `openWorldHint` is a coherent position and the common one. A "trust this * server" flag is the design §4.7.1 rejects. * * **The previous set is what was IN FORCE, resolved through `defaults`.** A server not named * under `servers` inherits `defaults`, and naming it makes it state its relationship in full (§9) * — so seeding from the empty set would mean that believing one more hint silently withdrew every * hint `defaults` had granted, which is a weakening the user did not ask for and would invalidate * their grants. * * **Session-scoped only.** Nothing is written to config: the declared block is read-only input * (§9.1), exactly as the rung is. * * A trusted external annotation still never grants more than the same annotation grants one of * our own built-ins — that holds in `core/approvals/annotations.ts` by construction, and this * changes only which hints are read. */ setMcpAnnotationTrust(server: string, hints: readonly ToolAnnotationHint[], believe: boolean): McpAnnotationTrustChange; /** * §4.7.4 — which of this server's saved approvals the trust now in force weakens, for the notice * that reports a trust change. **It predicts; it never removes.** The removal stays where Task * 4 put it — at the call being decided — because that is the only moment the tool's declaration * can be read for certain; here a server that is merely offline declares nothing and would read * as having weakened everything. * * It compares through the same two functions the gate does: the effective-annotation source built * from the posture as it stands *after* the change, and `annotationWeakenings`. A second * comparison written for the display is how a warning comes to describe a rule the gate does not * have. */ private grantsWeakenedByCurrentTrust; /** * Init is split into a separate method. This may create a number of connections, * and we'd better have an instance by that moment, for the case things will go wrong, * so we can wrap init into try-catch and then call `#cleanup` within finally. */ init(command: GthCommand | undefined, configIn: GthConfig, checkpointSaver?: BaseCheckpointSaver | undefined, options?: GthAgentRunnerInitOptions): Promise; /** * EXT-114 — `subagents` is configurable but not yet dispatched, and a run that declares them must * say so rather than start with a quietly smaller toolset than the config describes. * * The `task` tool that spawned them belonged to the deepagents runtime, which is gone; the lean * primitive that replaces it is GS2-25. Until then a declared subagent is inert. Keeping the key * valid is deliberate — a config written for GS2-25 should not have to be un-written and * re-written — but inert-and-silent is the failure this notice exists to prevent: the parent * simply does the work itself, on the parent's model, and the only visible symptom is the bill. * * Fires on EVERY run rather than only where a backend could once have honoured it, because there * is no longer any run that can. */ private warnIfSubagentsCannotBeHonored; /** * processMessages deals with both streaming and non-streaming approaches. */ processMessages(messages: Message[]): Promise; /** * The turn itself, separated from the per-turn bookkeeping above so [[EXT-160]] can run it twice. * * `attempt` is 0 for the turn the user asked for and 1 for the single retry that follows a * compaction; nothing else calls this. The retry passes an EMPTY message list, because the user's * message is already in the graph's state — the input step commits before the model step throws * (measured), so re-sending it would append a second copy of the same turn. * * The preamble is deliberately NOT repeated on the retry: resetting the analytics tally, ending * the negotiation and re-recording the crash transcript are things a NEW user turn does, and a * retry is the same turn being attempted again. */ private runTurn; /** * Accumulate a text stream into a single string. Extracted so {@link processMessages} and * the interrupt-resume loop (`resolveToolInterrupts`) drain streams identically. */ private drainTextStream; /** * After a streamed run ends, resolve any tool-approval interrupts it suspended on. For each * pending tool call the `toolApprovalCallback` is consulted (defaulting to REJECT when * no handler is wired, so a non-interactive run never hangs or auto-approves); the collected * decisions are then sent back via the agent's `streamResume` as a LangChain HITL resume * (`{ decisions }`). Because a resumed run can suspend again on the next gated tool call, this * loops until the graph completes with no pending interrupts. Returns the concatenated text * streamed across all resume turns (empty when nothing was resumed), and whether the loop ran out * of rounds with the graph still suspended ([[EXT-203]]: the caller must not re-invoke then). * * No-ops (returns no text, not exhausted) when the agent does not support interrupts * (`getPendingToolInterrupts`/`streamResume` absent) — that is the only exemption. As of EXT-52 the shipped agent gates * `run_shell_command` and exposes the interrupt surface, so the lean agent is exactly the agent * this loop serves; only an agent implementation without those methods (e.g. a test double) * skips it. */ private resolveToolInterrupts; /** * [[EXT-203]] — does the thread this turn runs on end with the model's own turn? * * Asked before the empty-stream fallback re-invokes, because re-invoking such a thread sends the * provider a history whose last message is the model's (see the fallback's comment in * `runTurn`). Answers `false` when it cannot tell — an agent that exposes no thread state, or a * read that fails — which leaves the fallback exactly as it was for those agents. */ private threadEndsWithModelTurn; /** * EXT-71 §3.1/§3.2, EXT-70 §4.7.5 — the subject a pending tool call presents to the rule matcher. * * A gated `run_shell_command` is a **shell** subject and nothing else: it is matched by `shell` * entries, against the command. It is deliberately NOT also offered as a `tool` subject named * `run_shell_command`, which would create a second allow path to every shell command carrying a * different §3.2 `rate` default and a match that never saw the command it was approving. * * **Everything else splits by provenance**, which is the distinction §4.7.1 rests on: `tool` is * the TRUSTED provenance, read verbatim, so an MCP tool arriving as one would be asking the * trusted path for a third party's annotations — a gate any server can opt itself out of. Every * MCP-namespaced name therefore becomes an `mcpTool` subject carrying the user's own `mcpServers` * key, and one whose server cannot be resolved stays an `mcpTool` subject under an unnameable * server rather than falling back to `tool` (see `approvalSubjectForToolName`). * * **The host (§4.7.4)** is attached here, so the one subject the whole decision runs on carries * it: the rule matcher treats a `host` on an entry as an additional exact-match condition, and a * grant the menu writes records it. A call naming no single host has none, which fails toward a * prompt at both sites. * * Widening which tools the gate actually suspends on is still [[EXT-30]]; this decides what a * suspended call *is* whenever one arrives. * * @param hosts Every distinct host the call's arguments name ({@link toolCallHosts}). */ private approvalSubjectFor; /** * §4.7.5 — the user's own `mcpServers` keys, the only identity a server has here. Own enumerable * keys via `Object.keys`, so nothing inherited can pose as a configured server. */ private configuredMcpServerKeys; /** * EXT-70 §4.7.1 — the source a `hint` entry reads a call's EFFECTIVE annotations through, built * from the session's `approvals.mcp` block and the two declared-annotation lookups. * * Built per decision rather than cached at {@link init}, for two reasons that both bite: the * agent registers its tools *inside* `agent.init()`, so an init-time snapshot would be empty; and * a re-init re-resolves the tool list, which for MCP may hand back different declarations. * * The two lookups are deliberately different in kind. `builtIn` reads OUR OWN authored table and * never the bound tool list — the bound list contains every server's tools, and a `builtIn` * lookup over it would read a third party's declaration through the trusted-verbatim path. * `mcp` reads what the servers declared, keyed by the registered tool name so the server key is * never split apart and re-joined differently. */ private effectiveToolAnnotationSource; /** * Decide a single pending tool call. Spec order — **deny → bypass → escalate → allow → rater → * human prompt**, with the hardline floor at exec time regardless. The two adjacencies that carry * the design are that deny comes BEFORE `bypass` and escalate comes AFTER it: * * 1. **deny** (§3) — a declared entry or a runtime *always reject* grant is refused with no * prompt and no rating call. It is consulted FIRST, and it is the one * check that **still applies under `bypass`**: choosing `bypass` says *"stop asking me"*, not * *"forget what I told you never to do"*. A deny entry MAY match a compound command, because a * prohibition that catches something unresolvable errs in the direction that costs nothing. * 2. **`bypass`** — the gate is off for this session; approve at scope `once`. * 3. **escalate** (§3.2) — a declared entry always asks the human, whatever the rung would have * done, **including outranking the automatic grants of `manual` and `write`** and any allow * entry that also matched. It goes straight to the human with **no rating call**, and it never * enters the `auto` negotiation. It is **inert at `bypass`**, which is why it sits below * the rung check: the rung chosen for this session wins, and a stop that must survive `bypass` * is a deny entry and only that. * 4. **allow** (§3, §3.2) — a declared entry or a grant the human made at an earlier prompt this * session (or persisted), matched against the whole normalized command and only when that * command statically resolves. An allow match settles the human's part: no prompt. Whether the rater * still reviews the call is the entry's own `rate` (§3.2) — honored at the rater rungs and * inert at the deterministic ones, so no entry can smuggle a model call into `manual` or * `write` — and a rated allow match is a TRIPWIRE, not a re-adjudication * ({@link mapAllowMatchedVerdictToAction}). * 5. **auto-rater** (`assisted` / `auto` only) — `safe` approves, `destructive` and * `catastrophic` escalate, and `attack` HALTS the run ({@link AttackHaltError}). The other * three rungs consult no model at all. A command whose target the gate cannot statically * resolve is rated **exactly like any other** ([[EXT-81]]), with a neutral note in the rating * prompt naming the shape the parser saw. It used to skip the call and be refused straight * back to the model instead; §6.1's rule is that a deterministic layer fires only where it is * confident something is a threat, and a parser reporting it could not read a string has * detected nothing. At those same two rungs a **tool** * call is instead floored deterministically by §4.7.3's open-world rule * ({@link openWorldToolFloorReason} into {@link applyDestructiveFloor} — the one floor the * shell path also reaches): a call whose effective `openWorldHint` is true is `destructive`, * whatever its `readOnlyHint` says. * 6. **human prompt** — the approval callback; when the human grants `session`/`always` scope, * **that command** is recorded as an `exact` entry (§3.1/§6 — the menu never widens), so the * same command stops re-prompting and a longer variant of it still asks. * * §6.2 — where no human can answer (CI, a one-shot run, a server), an escalation is **not** a * rejection handed back to the model: it is an immediate non-zero exit * ({@link NonInteractiveEscalationError}) carrying the command, the rating and its reason. No * prompt, no waiting, and never a timeout into approval. Declaring commands in `approvals.allow` * is the supported way to make a pipeline pass. * * Hardline catastrophic commands remain refused at exec time regardless of any approval here * (defense in depth in `GthDevToolkit.executeCommand`), so an allow-listed `rm -rf /` still * cannot run. * * **Step 0 is the rung.** The agent wires the interrupt over every tool ANY rung could gate, * because the graph is built once and `/approvals ` moves the rung under it for the rest of * the session. So a call arriving here has not yet been judged against the rung in force: this is * where that happens, on `sessionApprovals.rung`, which a mid-session switch has already updated. * A call the live rung does not gate is approved on the spot — no rule matching, no rating, no * prompt — which is what keeps `assisted`, `auto` and `bypass` behaving exactly as they did * when the interrupt held the shell alone. It sits ABOVE the deny check for the same reason: an * ungated call never reached this method at all before, so a deny entry could not fire on one, and * a security fix for two rungs is not the place to change that. (The shell is gated at every rung * whenever the shell gate is on, so §2.5's rule that the deny list survives `bypass` is untouched.) */ private decideToolApproval; /** * [[EXT-29]] §5.3 / [[TUI-C69]] §5.4 — **a person was reached, so the exchange is over**: the * gate's own transcript is spent and the surface showing it is told, on the same event. * * Every direct `humanReached()` in this class goes through here, which is what keeps the two from * drifting: a new site that spent the transcript without telling the display would leave a * finished argument standing on screen, and at an escalation it would put the same exchange on an * unscrollable dialog twice — once live, once in the prompt about to render all of it. */ private endNegotiation; /** * [[TUI-C69]] §5.4 — **take the finished argument off the screen WITHOUT spending the gate's * transcript.** * * The two are separate on purpose. `humanReached()` clears the rounds *and* the reachability * bound, which is correct only when a person was actually reached; calling it merely to tidy the * panel would hand the agent a fresh {@link MAX_REJECTIONS_BEFORE_HUMAN} budget it had already * spent, turning a display concern into a way to argue indefinitely. [[EXT-108]] made an approved * call stop clearing the transcript for exactly this reason, so the tidy-up cannot be the thing * that puts it back. * * Guarded, like {@link showNegotiationRound}: a surface that throws while clearing must never * change what the gate decided, or become the reason a turn ends. */ private clearNegotiationDisplay; /** * [[TUI-C69]] §5.4 — **tell the agent to forget the tool-call ids the rater bounced**, on every * event that ends a turn's display state: a new turn, and `/clear`. * * The runner decides *when* because it owns the turn boundary; the agent holds the set because it * owns the rendering. Fail-soft in the shape {@link resetRunStats} uses — an agent without the * method (a test double, a renderer-less agent) is simply skipped, and a tone hint must never be * the reason a turn fails to start. */ private clearRaterClarifications; /** * [[TUI-C69]] §5.4 — **hand one round of the argument to the surface that is showing it**, the * moment the gate decided it. * * The round travels raw. Every surface lays it out from the SAME rows the escalation prompt * draws, at its own terminal width, so the rounds a person watches and the rounds they later rule * on cannot be two different renderings of one exchange — and the rater's turns are yellow in * both because the rows carry the voice rather than a colour. * * No-ops when no surface is watching, which is the §5.5 seam as well as this one. */ private showNegotiationRound; /** * [[TUI-C69]] §5.4/§5.5 — **the rater agreeing is the last round of the argument, and it is held * on screen before it takes effect.** * * Two things, in this order, because the order is the requirement: the approving round is drawn, * and only then does the minimum visible interval run. A hold before the draw would be a pause * over nothing. * * **It is a visibility pause, not a reading window**, and it must never be relied on as an * opportunity to evaluate the command — nobody reads a command in 800 ms. What it buys is that * the approving round is on screen as its own event instead of being overwritten by the tool * output that follows it immediately. * * **What it is NOT, on either surface, is a guaranteed abort window — do not restore that claim * without building the mechanism.** Stated precisely, because the previous wording asserted a * mechanism that is not here and a comment like that stops the next reader checking: * * - **Event/TUI path.** An abort raised during the hold does end the run with the tool unrun, but * *this code is not why*. The runner never re-checks the signal after the hold — it issues the * approving resume regardless — and what stops the tool is that LangGraph refuses an * already-aborted signal downstream. True today, and true by someone else's invariant. * - **Plain/readline path.** Not true at all. `resolveToolInterrupts` threads no signal, and * `waitForEscape` is armed inside `streamFromInput` and torn down before the hold begins — the * hold happens *between* streams. Esc during it is not handled, so the command runs. The pause * still buys the visibility above, which is why it is not conditioned on the surface. * * Making the affordance real by construction — checking the signal here, and threading one into * the readline path so there is something to check — is deliberately left out of scope rather * than half-built, since a window honoured on one surface and not the other is the more dangerous * shape: it is what teaches the user the gesture that then silently fails. * * **Both halves are gated on a surface being wired**, so a headless `exec`/CI run neither draws * nor sleeps and pays nothing. See `negotiationDisplay`. */ private showNegotiatedApproval; /** * [[TUI-C27]] — {@link decideToolApprovalInner} with the record closed off on EVERY exit. * * The final action is written here rather than at each of the decision's many returns, because * "what became of the call" is one fact with one source: what this method returns or throws. A * per-return assignment is a list that a new branch joins without noticing, and the branch that * would be forgotten is the one that ends the run. */ private recordedDecision; /** The decision itself; `decideToolApproval` wraps it with §5.3's reset. */ private decideToolApprovalInner; /** * [[EXT-150]] — tell the surface what its own answer landed as, and hand the decision straight * back. * * A one-liner returning the decision, exactly as {@link stage} is, and for the identical reason: * the report happens ON the return that carries the decision rather than on the line above it, so * no early return can later be inserted between the two and leave a surface waiting for an answer * that never comes. There is one return out of {@link decideToolApprovalInner} after the callback * is awaited, and this is it. */ private reportOutcome; /** * [[TUI-C27]] — attribute the deciding stage and hand the decision straight back. * * A one-liner so a stage can be recorded ON the `return` that carries it rather than on the line * above: two statements let an early return be added between them, and the record would then name * a stage that did not decide. */ private stage; /** * One rating call, with EXT-66's timeout reporting attached. Extracted so the §3.2 tripwire (a * rated allow match) and the ordinary rater path cannot drift apart in WHAT they hand the rater — * only in what they do with the answer. */ private rateCommand; /** * [[EXT-127]] — **one alignment check**, with the `user` role fed from the settled provenance * channel and nothing else. * * **`provenance` is `ShellNegotiationState.retainedUserMessages()`, read ONCE by the caller and * handed down**, exactly as §4.6's floor reads it. Three things are true of it and only of it: it * is EMPTY until `admitUserProvenance` positively established that this session's human turns are * the user's own words (so `review` and `pr`, which fold a fetched diff into a human message, * contribute nothing); it is not `noteUserMessages`' raw store, which answers a different question * for a different reader; and it is not `humanMessageTexts`, the unfiltered upstream. Reading any * of the other three here would make the one place this design says provenance is structural the * one place it is not. * * The prior rounds come from the transcript's own alignment decisions, so the checker meets its * earlier turns as its own turns rather than as a quoted summary of them. */ private checkAlignment; /** * §3/§3.3 — the three rule lists this session decides by: the DECLARED entries from config * (read-only input) concatenated with the runtime grants the escalation menu made. One set of * lists, handed to the one comparison engine; the concatenation cannot change any outcome * because `resolveApprovalRules` consults every deny entry before any escalate entry and every * escalate entry before any allow entry. * * The persisted store is loaded here rather than at {@link init} — lazily, once per instance, and * NEVER at `bypass`, where the allow list is moot and a session that has switched the gate off * should not be reading or rewriting the project's grant file. */ private approvalRuleLists; /** * Lazily load (once per instance) the persisted `always` grant store. * * CFG-27 removed the `persistAllowlist` switch: §3 makes persistence a per-decision choice in * the escalation menu (`approve` forgets, `always approve` persists), and a global "never * persist" setting would only duplicate a keystroke. Returns null when the store cannot be * loaded at all, in which case `always` grants degrade to `session` (in-memory only). * * The v1→v2 migration notice is routed to `statusUpdate` from here, which is the only place that * knows how to reach the user. */ private getPersistedGrants; /** * [[EXT-107]] — lazily load (once per instance) the persisted **refusal** store, the mirror of * {@link getPersistedGrants}. Returns null when it cannot be loaded at all, in which case an * `always` refusal degrades to `session`. * * **No `bypass` guard, and no v1 migration**, and the two are the same decision. The allow side * skips the file at `bypass` so a session with the gate switched off neither reads nor *rewrites* * the project's grants; the only thing that rewrites on load is the v1 `prefixes` migration, and * turning it off here makes this store's load a pure read. That is what lets it be consulted at * every rung — which it must be, because a refusal is resolved before the `bypass` return and a * saved refusal that lapsed when the gate was relaxed would be worthless. * * It is also loaded by the `/approvals` DISPLAY, unlike its allow-side twin. The rule there — a * display must not create the store in order to show it — is a rule about writing, and this load * cannot write. The reason to break the symmetry is that the display is the escape hatch: a saved * refusal a user cannot see is one they cannot lift, and a fresh session has made no gated call * yet. */ private getPersistedDenials; /** * §3.1/§4.7.4/§6 — **the grant a sticky choice would write for this call**, or `undefined` when * none is on offer. The one place that question is answered, so the menu's *this is what will be * stored* line (§6) and the store can never disagree. * * - **A shell call** records the command itself as an `exact` entry (§3.1) — never a prefix, * never a pattern. One that does not statically resolve (composition, substitution, * redirection) is not on offer: no allow entry of any matcher matches such a command, so the * entry would be inert, and an inert entry sitting in a list §3 requires to be inspectable * tells the user something is in force when nothing is. * - **A tool call** records identity — the tool, its server, and the host where the call carries * one (§4.7.4, {@link toolGrantEntry}) — never arguments, which would produce a grant that * never matches twice. A call naming no host records the tool alone, which is §6's own example * (*always approve `mcp__jira__create_issue`*, where no host is involved); what keeps that from * being unbounded is §3.2's default that a tool entry is still `rate: true`, so the rater goes * on seeing every call's full arguments. * * Four cases have **no grant on offer at all**, each fail-closed: * * 1. **`bypass`** — the gate is off for this session and nothing is remembered from it. * 2. **`run_shell_command` arriving as a tool subject.** That is what a shell call with no * readable `command` argument presents as, and it names no host, so without this it would take * the tool-only arm and write a `{"type":"tool","pattern":"run_shell_command"}` grant that * auto-approves every future call whose command cannot even be read. This exclusion is what * stops that, not a side effect of anything else, and it must survive [[EXT-30]] widening the * gate. * 3. **A call naming more than one distinct host.** The grammar has no entry for it. `host` is a * single optional string on every tool arm of `approvalEntrySchema`, and every arm is a * `z.strictObject`, so recording the *set* is not a policy this code may choose — a `hosts` * array is an unrecognized-key error, and writing one would be a §3.1 grammar change. Of the * two entries that would parse, the host-bound one displays a bound the grant does not have, * which §6 forbids (the menu shows exactly what will be stored). And a grammar that did record * the set, matching only when all of it recurred, would fail §4.7.4's opening test anyway: a * tool whose host set varies per call would get a grant that never matches a second time — not * a narrower grant, the useless one §4.7.4 rejects by name. * * **What this arm does not claim.** It is not a narrowing. A hostless entry imposes no host * condition at all (`resolveApprovalRules`), so the tool-only grant that any host-less call to * the same tool produces already auto-approves a multi-host one. Refusing here withholds a * grant; it does not close a hole, and the reason to keep it is the grammar above rather than * any breadth it prevents. Asserted, so this cannot drift back into a claim the system does not * support. * 4. **An MCP call whose server could not be resolved** ({@link toolGrantEntry} returns `null`) — * a call nobody can attribute is not one anything can remember. */ private stickyGrantFor; /** * [[TUI-C26]] §6 — **the entry the escalation menu's *always reject* choice would record**, or * `undefined` when the grammar cannot hold one. The deny mirror of {@link stickyGrantFor}, and a * separate function rather than a flag on it, because the two answer different questions. * * **Nearly every reason an allow entry is withheld does not apply here.** §3 has one rule for * this and it runs the other way — *undecidable is a non-match on the allow side and a match on * the deny side* — so: * * - **A command that does not statically resolve gets an entry.** `stickyGrantFor` refuses one * because no allow entry of any matcher would ever match it, making the entry inert; a deny * entry for the same command is matched against the whole normalized command *and* every * segment a shell would run, so it is the opposite of inert. * - **A `catastrophic` verdict changes nothing.** §4.2 withdraws the sticky grants there; it says * nothing about refusals, and refusing more is never the direction that needs withdrawing. * - **`bypass` changes nothing either**, and that is a positive statement rather than a gap. Deny * is resolved at step (1) of {@link decideToolApprovalInner}, *before* the `bypass` return, so * a recorded refusal is in force at every rung — which is why this does not copy the allow * side's `bypass` guard. * - **A call naming several hosts gets the host-less entry.** On the allow side that would show a * bound the grant does not have; here the entry covers every host of that tool, which is * broader than the call and safe in the direction breadth is safe. The menu shows exactly that * entry, so the breadth is on screen rather than inferred. * - **`run_shell_command` arriving as a TOOL subject gets a tool entry** — a shell call whose * `command` argument cannot even be read. On the allow side that entry would auto-approve every * future unreadable shell call, which is why it is excluded there; as a refusal it stops the * shell tool outright, and the dialog says so in the words the entry is written in. * * The one genuine exclusion is an **MCP call whose server could not be attributed** * ({@link toolGrantEntry} returns `null`): the grammar's `server` cannot be the empty string, so * the entry would be dropped by its own validator and the human would be told a refusal had been * recorded when none was. A shell command that normalizes to nothing is excluded for the same * reason — an empty `pattern` is not a legal entry. */ private denyEntryFor; /** * §6/[[EXT-107]] — record the menu's *always reject* choice at the given scope. `once` remembers * nothing; `session` holds the refusal for the life of this runner instance; `always` additionally * writes it to the project's deny file, so it is still in force after a restart. * * It lands in the same lists `approvals.deny` entries are matched from ({@link approvalRuleLists} * concatenates the three), so a refusal the human made at the prompt and one they wrote in their * config are one list to the matcher. * * **The recorded scope is derived from whether the entry REACHED THE FILE** ([[EXT-149]]) — what * {@link PersistedApprovalGrants.add} returns — and not from whether the store was allowed to try. * Those differ on a read-only checkout, where the file is simply absent, so the load did not fail, * `canPersist()` is true, and every write throws: asking permission stamped `always` on an answer * that reached no disk, and nothing told the user. **Do not describe that gap's cost as "one * re-prompt next session":** the entry is not on disk, so next session it applies to nothing and * the call is left to the rest of the gate — another rule may refuse it, it may be prompted for, * or it may run without asking under `bypass` or a matching saved allow. * * **Two records, not one patched afterwards.** {@link ApprovalGrantStore.add} holds the very * object it is handed whenever the grant carries no annotation snapshot — which every refusal * does, since {@link denyEntryFor} builds an entry and never a snapshot — so stamping one object * `always` and correcting it after the write would reach inside whatever {@link denyGrants} * already holds and whatever the display renders from it. The persisted store is handed the * `always` record it will hold if the write lands (and takes back if it does not); the session * store is handed its own record, stamped from the answer. * * The persisted store is still told even when it cannot write, because telling it is what reports * the refused or failed write to the user, and it declines to hold what it did not write. * * **It RETURNS the lifetime it recorded** ([[EXT-150]]) — the same value it stamps the record with, * handed back rather than left for a caller to re-derive. The surface that asked has to say what * happened, and re-deriving it there would put the "did the write land" question in two places * that could come to disagree, which is the whole failure this and [[EXT-149]] are about. */ private recordDenial; /** * §3.1/§6 — record a human-granted approval at the given scope. `once` remembers nothing. * `session` adds the entry to the in-memory store; `always` additionally persists it (falling * back to session-only when the file cannot be written). * * What is recorded was decided by {@link stickyGrantFor} and shown to the human before they * answered; this only stamps it with when and at what scope. * * **The stamped scope says whether this grant REACHED THE FILE**, the mirror of * {@link recordDenial} and derived the same way ([[EXT-149]]): an `always` whose store is absent, * whose file could not be read and so must not be rewritten ([[EXT-144]]), or whose write threw on * a checkout nothing can write, is recorded as the `session` grant it actually is — and the store * reports the last of those rather than swallowing it. * * Two records rather than one patched afterwards, for the reason argued in {@link recordDenial}: * {@link ApprovalGrantStore.add} holds the object it is handed for a grant with no annotation * snapshot — every shell grant — so a scope corrected after the write would be corrected inside * {@link sessionGrants}. * * The store is still loaded only for `always`, unchanged: a display must not create the file in * order to show it, and a `session` grant has no business opening it. * * **It RETURNS the lifetime it recorded** ([[EXT-150]]), the mirror of {@link recordDenial} and for * the same reason: the surface's confirmation is written from this value rather than from the key * the human pressed. */ private recordApproval; /** * §4.7.4 — **drop a tool grant the tool has since weakened out from under, with a notice naming * the tool, the server and the hint that moved.** * * The human approved a tool *as annotated*; a tool that re-annotates itself into a more dangerous * shape is a different proposition wearing the same name, so the grant is invalidated and the next * call prompts again. Only a **trusted** server can produce a weakening — an untrusted server's * effective set is the constant fail-closed default (§4.7.1) and cannot move — which is exactly * where it matters, since the trusted server is the one whose rug-pull would otherwise ride an * existing grant. * * **Scoped to the call being decided, never a sweep of the store.** A sweep would read every held * grant against a source that can only answer for the tools registered right now, so a server that * happened to be offline would read as having weakened everything it ever declared — and the * grants would be deleted for it. * * **The scope is every grant that could auto-approve THIS call, which is at most two.** A grant * with no `host` imposes no host condition, so it matches a call that carries one; looking up only * the entry this call would grant (`host` included) would miss the tool-only grant that is about * to auto-approve it, and the weakening would ride straight through — the exact failure §4.7.4 * exists to stop. The host-bound entry of a DIFFERENT host is deliberately not a candidate: it * does not match this call either, so this call's annotations say nothing about it. * * **Only allow-side grants.** A weakening makes a tool more dangerous, so dropping an *always * reject* over one would be the unsafe direction: the reason to withdraw an approval is the reason * to keep a refusal. */ private invalidateWeakenedGrants; /** * Event-stream counterpart to {@link processMessages}: drives the agent's typed * {@link AgentStreamEvent} path using the runner's own thread-bound `runConfig`, so a * renderer (the Ink TUI) can present the same run the readline path renders via * `consoleUtils` while sharing the checkpointer thread for cross-turn memory. * * Cancellation is via the supplied `signal` (the TUI's Esc → `AbortController`); the * underlying `streamWithEvents` ends cleanly on abort or `interrupt()`. The string * path's empty-stream retry/`invoke` fallback is intentionally NOT duplicated here — the * TUI renders the live event stream directly; revisit if empty-stream retries are needed. * * [[EXT-167]] — the string path's compact-and-retry-once seam IS here, in * `attemptEventTurn`, because this is the driver the default surfaces use and a context * overflow on it ended the turn where the readline surface recovered. The retry is announced * in-band as a `context_compacted` event rather than only through the status callback, because * the consumer has already painted the rows that preceded the fold and is owed a line at that * point in the turn, not a banner over it. * * Tool-approval round-trip (EXT-11): after the stream ends, a gated `run_shell_command` * leaves the graph suspended on a `humanInTheLoopMiddleware` interrupt rather than * completing. This is the event-stream counterpart to the readline path's * `resolveToolInterrupts`: it drains any pending interrupts through * `decideToolApproval` (bypass → allow-list → rater → bridged human prompt), resumes via * `streamWithEventsResume({ decisions })`, and loops until the graph completes with no * pending interrupts — so the executed command's output renders into the TUI. Without * this the TUI silently finalized an empty turn (approval gate was dead code on the * event-stream path). */ processMessagesWithEvents(messages: Message[], signal?: AbortSignal): AsyncGenerator; /** * [[EXT-167]] — the typed-event turn: the agent's stream, then the interrupt drain, and around * both the reactive seam {@link runTurn} has on the string path — catch, classify, compact, retry * once. * * The loop itself is the shared {@link retryEventTurnOnContextOverflow}, and the decision inside * it is the shared `handleContextOverflow` that {@link runTurn} also calls — one seam for every * driver, so none can come to disagree about what an overflow means or how many retries it is * worth ([[EXT-174]] moved both out of this class so the AG-UI server could reach them without * being driven through this method). What this method contributes is the runner's own attempt: * the turn the user asked for on attempt 0, and on the retry an EMPTY message list, because the * user's message is already in the graph's state (the input step commits before the model step * throws) and re-sending it would append a second copy of the same turn. * * The seam sits at THIS level rather than in the driver's outer `catch` because that catch is * outside the `yield*`s: by the time it runs the generator has no way to yield the notice and * then carry on with the same tracker, and a notice committed after the turn would read as though * the fold happened after the answer. A second overflow declines at the seam and is re-thrown * UNCHANGED — this driver wraps nothing, where the string path wraps as `Agent processing * failed: …` — and the driver's own catch classifies it: the seam has already stamped the * exhausted site on the error, so that classification inherits it rather than overwriting it. */ private attemptEventTurn; /** * Event-stream counterpart to `resolveToolInterrupts`: after a streamed run ends, * resolve any tool-approval interrupts it suspended on, yielding the resumed run's typed * {@link AgentStreamEvent}s so the renderer (the Ink TUI) shows the executed command's * output. Each pending tool call is consulted via `decideToolApproval` — the SAME * gate the readline path uses (bypass → allow-list approve → CFG-26 AI rater → * bridged human callback, defaulting to REJECT when no handler is wired) — and the * collected decisions are sent back via `streamWithEventsResume` as a LangChain HITL * resume (`{ decisions }`). Because a resumed run can suspend again on the next gated * tool call, this loops until the graph completes with no pending interrupts. * * No-ops (yields nothing) when the agent does not support interrupts * (`getPendingToolInterrupts`/`streamWithEventsResume` absent) — that is the only exemption. * As of EXT-52 the shipped agent gates `run_shell_command` and exposes the interrupt surface, so * the lean agent is exactly the agent this loop serves; only an agent implementation without * those methods (e.g. a test double) skips it. Aborts (`signal`) propagate through the * resumed stream. */ private resolveToolInterruptsWithEvents; getAgent(): GthAgentInterface | null; /** * GS2-16 — reset the current turn's analytics tally on both the live agent and the runner's * cached snapshot, so a new turn starts clean. Fail-soft (an agent without stats support is a * no-op). Called at the top of each `processMessages` / `processMessagesWithEvents`. */ private resetRunStats; /** * [[EXT-159]] — forget the previous turn's termination reason, on both this runner and the live * agent, so a new turn starts with none. Called at the top of each `processMessages` / * `processMessagesWithEvents`, alongside {@link resetRunStats}. Fail-soft. */ private resetTerminationReason; /** * [[EXT-159]] — record why the turn ended, **first-write-wins**. * * The runner's two exception wrappers are NESTED, not alternatives: a stream fault is classified * at the inner one, re-thrown, and caught again by the outer one. Under last-write-wins the outer * site would overwrite the inner classification on every streamed failure — the funnel this * taxonomy replaces, rebuilt one level up. */ private noteTermination; /** * [[EXT-159]] — classify a thrown value at a runner site: record it here AND attach it to the * error, then hand the error back so the throw reads as one expression. * * Both carriers matter. The runner's own field serves a caller holding the runner; the attached * value serves every layer above that only ever sees the error — and neither is the message, so * no user-facing string is the only carrier of the classification. */ private classifyThrownAt; /** * [[EXT-159]] — classify an approvals stop, which the generic classifier cannot see. * * The gate's errors are typed by their own subclass names, and their prose is the explanation * rather than a diagnosis, so nothing in the exception classifier's grammar recognises one. It * does not need to: this site reaches an `instanceof ApprovalStopError` branch, so it *knows* * what ended the run, and stating the category is more honest than pattern-matching for it. */ private noteApprovalStop; /** * [[EXT-159]] — record that the turn ended because the model finished. * * An ordinary completion is a termination too, and recording it is what makes "no reason" mean * *a site nobody classified* rather than *nothing went wrong*. First-write-wins keeps a deeper * site's answer — a refusal or a truncation is what really ended a turn that also returned text. */ private noteCompleted; /** * [[EXT-159]] — why the just-finished turn ended, or `null` when nothing classified it. * * The agent's answer wins when it has one: its sites (the metadata reader, the cancellation and * suspend paths, the run-ending middlewares) sit INSIDE the runner's catches, so the innermost * classification is the true one. Never throws. */ getTerminationReason(): GthTerminationReason | null; /** * [[EXT-158]] — the checklist work this turn left outstanding, or `null` when it left none. * * Read live from the agent while one is present and falling back to the {@link cleanup} snapshot * afterwards, exactly as {@link getFinishReasonObservations} does and for the same reason: the * non-interactive verbs ask once the agent has been dropped. Never throws. */ getOutstandingWork(): GthOutstandingWork | null; /** * [[EXT-178]] — the recap inputs for the turn that just ended, read live from the agent while one * is present and falling back to the {@link cleanup} snapshot afterwards, exactly as * {@link getOutstandingWork} does and for the same reason. Never throws. */ private captureRunRecapSource; /** * [[EXT-178]] — ask the configured model to recap the turn that just ended, or answer `null`. * * **The runner owns this call because the runner is what holds the config.** A surface that made * it itself would have to be handed a `GthConfig`, and the Ink TUI deliberately has none — the * App is given a `TuiAgent` and a summary, never the live model. Putting it here keeps every * surface asking the same question the same way, which is the same argument [[EXT-158]] makes for * taking its repeat decision in the agent rather than at six render sites. * * **CALLING THIS IS THE SURFACE OPT-IN.** There is no per-surface flag to read: a surface that * does not want to spend a model call simply does not call it, which is how `batch`, `eval`, * `workflow`, `review`, `pr`, ACP and AG-UI are excluded. The config rung then decides whether a * surface that *did* ask actually gets one, so a user's `off` beats every surface's opt-in. * * **It never continues the run.** `requestRunRecap` is a single non-agentic structured call; the * value comes back here and goes to a renderer. Nothing in this path can re-enter the graph. * * Never throws: explaining a finished run must never become a second failure. */ requestRunRecap(reason: GthTerminationReason | null | undefined): Promise; /** [[EXT-159]] — read the live agent's reason into the snapshot (fail-soft). */ private captureAgentTerminationReason; /** * [[EXT-159]] — what the provider said about why each model message stopped, this turn. * * Reads live from the agent while one is present and falls back to the {@link cleanup} snapshot * afterwards, the same way {@link getRunStats} does — `/debug-dump` on the readline surface and * the non-interactive verbs both ask once the agent has been dropped. */ getFinishReasonObservations(): readonly GthFinishReasonObservation[]; /** [[EXT-159]] — read the live agent's finish-reason observations into the snapshot (fail-soft). */ private captureFinishReasonObservations; /** GS2-16 — read the live agent's run stats (fail-soft; empty tally if unavailable). */ private captureRunStats; /** * GS2-16 — the analytics harvested from the just-finished turn (token usage + invoked tools), * to thread into the local history recorder. Reads live from the agent when one is present, * otherwise the snapshot captured at {@link cleanup} (the single-shot path reads post-cleanup). * Never throws. */ getRunStats(): GthRunStats; /** BATCH-32 — read the live agent's advertised-tool inventory (fail-soft). */ private captureAdvertisedTools; /** * BATCH-32 — what the agent advertised to the model at `init` (the `gth eval` coverage * denominator). Reads live from the agent when one is present, otherwise the snapshot captured * at {@link cleanup} — the same discipline {@link getRunStats} uses, because the non-interactive * verbs read it after cleanup. Never throws. * * A live read that yields nothing does NOT erase an existing snapshot: an agent replaced * mid-session (or one that never implemented the accessor) would otherwise turn an observed * inventory back into "never observed", and a consumer reads that as a target that cannot supply * a denominator at all. */ getAdvertisedTools(): GthAdvertisedTools | undefined; /** * Rotate the thread the runner drives by minting a fresh `runConfig` (new `thread_id`), so * subsequent turns start from an empty checkpointer thread rather than retrieving the prior * conversation. Without it the model would still see the full history persisted under the old * thread. * * **This is the PER-TURN primitive, not the user's `/clear`.** `runtime/conversation.ts` calls it * before every turn, so the ACP and AG-UI surfaces reach it once per message: anything dropped * here is dropped on every turn of those surfaces. A person asking for the conversation to be * forgotten is {@link clearConversation}, which is a different event that happens to share this * rotation. * * Rotating the thread_id (rather than deleting from the checkpointer) keeps this independent * of any checkpointer-specific delete API, mirroring how `init()` mints the initial config. */ resetThread(): void; /** * [[EXT-109]] — **the user's own `/clear`**: the gesture that means *drop that*. * * Rotates the thread exactly as {@link resetThread} does, and additionally empties the approvals * gate's capture log. Both places that log reaches a person read it live from this runner — the * `/debug-dump` archive's `approvals.json` and the Auto-mode debug tab — so without this a dump * taken after a `/clear` hands back the conversation the user has just asked to be rid of: a * record carries the command, the rating, and (through the alignment check's `user` role, fed * from the retained provenance window) the user's own recent messages verbatim. The dump is the * artifact people attach to bug reports, so the one command whose whole purpose is "drop that" * was leaving it in the file most likely to be sent to someone else. Redaction still runs over * the archive, but it matches secret SHAPES; what a user asked to be forgotten matches no * pattern, so retention is the control here and redaction is not. * * **Why a separate method rather than a flag on the rotation.** The clear and the per-turn reset * only look alike. Clearing the log inside {@link resetThread} — or inside `rotateThread` — would * empty it once per turn on the ACP and AG-UI surfaces, destroying the facility exactly where an * incident is most likely to be reported from, and doing it silently, because an empty section * reads as *no ratings happened*. As two methods, a future per-turn caller cannot reach this by * forgetting an argument. * * **A resume is not this gesture.** {@link resumeConversation} rotates too, but moving onto * another conversation is navigating away rather than asking for something to be gone, so it * leaves the log alone. * * **What this deliberately does NOT do:** it does not delete the checkpoints written before the * clear. They stay on disk, and the conversation row still names the pre-clear thread, so they * stay resumable by an id a person could type. Whether `/clear` should also make a conversation * unresumable is a product decision — it trades "cleared means gone" against never being able to * resume a cleared conversation — and it is tracked on [[EXT-109]] rather than settled here. */ clearConversation(): void; /** * Move the runner onto another thread: a fresh one (`/clear`) or a stored one (a resume — see * {@link resumeConversation}). Everything that is context of the thread being left goes with it. */ private rotateThread; /** * GS2-23 — **fold the older conversation into a summary, in the live graph.** The idle, * user-invoked seam: what `/compact` calls between turns. * * It is not the seam for the involuntary paths. EXT-160's compact-and-retry runs inside the * driver's `try`, where a turn is in flight and this method refuses; that path composes * `compactMessages` with `replaceGraphMessages` (or the agent's `replaceConversationMessages`) * from inside the turn, or adds a guard-free internal when it needs one, rather than calling this. * * Reads the thread's messages from the graph, runs the shared `compactMessages` with the * summariser bound to the session model, and writes the replacement back through the graph's own * state update — so the compacted history is checkpointed and a later resume loads it compacted. * `after` is read back from the graph rather than computed, so the report describes what the * graph actually holds. * * Refuses while a turn is running (the two drivers count themselves in and out). Refuses a graph * suspended on a pending tool approval — idle, but not between turns: the state write lands on * such a graph and erases the interrupt payload (measured: one pending interrupt becomes none, * `next` preserved), so the approval could never be answered. A graph a THROWN turn left behind * is not refused: its checkpoint ends on the pending human turn with no interrupt, the write * lands, and the next turn runs from the compacted state. Invariant (c) is relative there — the * mechanism never creates a trailing assistant turn and the pending human stays last; having the * next human turn present before the model is invoked again is the caller's job. Does nothing on * a conversation no longer than the kept tail: `changed: false`, nothing written. */ /** * EXT-161 — the preventive compaction threshold in force, with its provenance, or `undefined` * when this session has no resolved model to derive one from. * * Read by `/status` and by the bare `/autocompact`. Asynchronous because resolving a context * window can reach the models.dev cache (and, on a cold cache, the network) — memoised behind * the agent's one window resolution, so calling it per command costs nothing after the first. * * **Never throws.** A status line that could take a session down would be worse than a missing * one, and every source under it already degrades to "unknown" rather than failing. */ getAutocompactStatus(): Promise; /** * EXT-161 — move the preventive compaction threshold for the rest of this session. * * Returns the status that LANDED, so the surface's notice describes what is actually in force * rather than what was asked for — the same discipline `/approvals` and `/compact` follow. * `undefined` when there is no resolved model, in which case nothing was changed. * * Takes an already-parsed budget: the grammar is the shared parser's, and a second entry point * accepting text would be a second place it could drift. */ setAutocompactThreshold(budget: TokenBudget): Promise; compactConversation(options?: CompactConversationOptions): Promise; /** * [[EXT-160]] — the read-compact-write itself, with **no turn-state guards**: the shared * {@link applyConversationCompaction} behind both the idle `/compact` above and the involuntary * compact-and-retry inside a turn. The guards live on {@link compactConversation}, the one caller * that needs them; the module's docblock says why the involuntary path must not have them. */ private applyCompaction; /** * [[EXT-160]] — **decide what a thrown turn's context overflow means, and act on it once**: the * shared {@link handleContextOverflow}, with this runner as its host. The decision — the * taxonomy's predicate, one retry, the reasons a compaction can decline, the two-carrier override * — is documented on the module, because it is the module's; this method only hands over the * turn. Returns `null` on an uninitialised runner as it does for every non-overflow, so the * caller's own error path surfaces the original failure rather than a second one raised from * inside its `catch`. */ private handleContextOverflow; /** * CFG-84 — **the quota cooldown seam: catch, read retry hint, sleep, retry turn.** * * When a Gemini call fails with a 429 whose quota is per-minute, and the provider names a * short wait (90s at most), wait that long, tell the user we are waiting, and re-send instead * of ending the run. * * **Retry shape: the whole turn is re-run with an empty message list (the EXT-160 shape).** * Measured in `quotaCooldownRunner.spec.ts`: a scripted model asks for a tool on its first call * and throws the recorded 429 on its second; re-running the turn resumes from the checkpoint the * tool step committed, so the model's third call already sees the tool's result and the tool * runs exactly once, on the string path (streaming and not) and on the typed-event path alike. * A wrapper around the model call alone was therefore not needed. * * On the streaming string path this runs on the `Stream processing failed` wrapper, not the * original error. The wrapper keeps the original as its `cause`, which is what lets * {@link readQuotaRetryHint} still see the status and the response body. * * Covers the Gemini API client only; every other provider keeps today's behaviour. The AI * rater's calls on the same key are out of scope for this version and escalate as today, and so * is the AG-UI server's own turn loop, which does not go through this runner. */ private handleQuotaCooldown; /** * [[EXT-174]] — this runner as the overflow seam's host: its agent, its thread and its model, the * status callback, and the two hooks for the reason state this class keeps beside the agent's. * * `onTerminationOverridden` writes the runner's own field after the seam has stamped the error, * so the two carriers cannot disagree; `onRetry` is the FULL reset, so the failed attempt's * provider finish reasons go with it rather than being read later as the retry's. */ private contextOverflowSeamHost; cleanup(): Promise; }