@sema-agent/core
Version:
Stateless, task-oriented AI agent core
439 lines (438 loc) • 37.3 kB
TypeScript
/**
* The run's CONTROL surface: `TaskStream` — the async-iterable handle `runTaskStream` returns, with
* the steer / halt / approve / compact methods a caller drives a live run through. Separate from the
* frames it yields (`task-event.ts`): a change to the handle's methods is not a change to the wire.
* Layer 0 vocabulary; `types.ts` re-exports the name, so no consumer's import changes.
*/
import type { ActorAssertion } from "../internal/llm.js";
import type { CompactOutcome, TaskEvent } from "./task-event.js";
import type { TaskResult } from "./task-result.js";
/** A running task you can iterate for live events and await for the final result. */
export interface TaskStream extends AsyncIterable<TaskEvent> {
result(): Promise<TaskResult>;
/**
* design/100 §E12 — resolves to the post-completion prompt **suggestions** (the shell's follow-up prompts),
* or `[]` when `suggestNextPrompts` was off / the task did not complete / the pass produced nothing / it
* failed. The pass is **fire-and-forget**: `result()` never waits for it, so a
* shell does `await stream.result()` THEN `await stream.suggestions()`. **Never rejects.** The strings are
* UNTRUSTED model output for display ONLY — never re-feed them to a model.
*/
suggestions(): Promise<string[]>;
/**
* Inject a mid-task **steering** message (design/47) that the running task sees at the start of its
* next turn (delivered via the harness steering queue). Resolves once queued; **throws** (code
* `steering.not_running`) once the task has finished (teardown included) — never silently dropped. A
* steer issued BEFORE the run has ISSUED ITS FIRST PROMPT is not refused: it is HELD for the birth
* window and enters the queue as soon as the loop goes live, so the model sees it at the next turn
* boundary like any other steer — it is refused if the loop ends first, or if that BOUNDED wait runs
* out while the run still has not started (ruled 2026-08-05; a retry under the same `inputId` is then
* clean — a refusal reserves nothing). By default the text enters as a normal **user** message (in an orchestration the caller IS
* the task's user). Pass `trusted: true` ONLY for operator/system-level guidance: it is wrapped as a
* `<system-reminder>` (elevated authority) — do NOT use it for caller/third-party content that could
* carry a prompt injection. The seam gives the channel; the caller owns the judgement (design/43 §6).
*
* design/171 §5.2 — `actor` attributes WHO steered (a shared session's second voice): the text gets
* the speaker envelope from the single projection point and the queued message carries the
* metadata seat. Attribution only, never authority; absent = anonymous (bytes unchanged).
*
* design/171 §6.3 (additive) — `inputId` is the caller's correlation/idempotency key, the SAME key
* space as the parked queue's `PendingSteerEntry.inputId` and the `human_input` event's `inputId`
* (an ingress that already minted a message id passes it here, and the emitted frame carries it
* VERBATIM instead of a fresh uuidv7). It exists because the two legs of one steering ingress must be
* equally replay-safe: the parked leg has taken this key since the queue landed, so a retried request
* that arrives while the task is LIVE was the only one that injected twice.
* - **Absent ⇒ nothing changes**: a uuidv7 is minted for the event, nothing is recorded for the call,
* and the delivered bytes are what they always were. Every pre-existing caller is on this arm.
* - **Replay ⇒ idempotent no-op**: re-steering an id this stream already ACCEPTED, with an identical
* payload (same text, same `trusted`, same `actor`), injects nothing and emits no second
* `human_input` frame. What is remembered is exactly what QUEUED: a delivery that was refused
* (not running) can be retried under its own id, and a call whose DELIVERED payload is
* whitespace-only — which the harness discards without minting a frame; note a `trusted` steer is
* wrapped first, so it queues even for blank text — reserves nothing, leaving that id usable.
* - **Same id, DIFFERENT instruction ⇒ typed throw** `steering.duplicate_input_id`: a key is not
* evidence of a replay, and two callers colliding on one id must not silently lose the second
* (the parked leg's `appendPendingSteer` refuses it identically). Re-issue under a fresh id.
* - **Bad value ⇒ typed throw** `steering.invalid_content`, never a silent fallback to "no id": the
* value domain is the parked leg's (a non-empty string of at most `MAX_STEER_INPUT_ID_CHARS`
* characters), so one key is accepted or
* refused the same way on both legs. Validated before the liveness check, like the parked leg's.
* - **Liveness outranks the key**: once the run's loop-liveness latch has flipped (the same signal
* the injection path stops polling on) a replay is refused `steering.not_running` like any other
* steer, never answered "already accepted" — the parked leg's row CAS answers `false` for a
* resolved checkpoint on a replayed id for the same reason. The one asymmetry, stated rather than
* papered over: in the sub-window where the harness has gone idle but the latch has not yet
* flipped, a FRESH steer polls (and is refused when the latch flips) while a replay answers
* immediately with the SAME outcome its original call reported — a key whose answer depended on
* microsecond timing would defeat its own purpose. Note what that outcome has always meant on this
* verb: ACCEPTED INTO THE QUEUE, not consumed by the model. A steer accepted in the last moments of
* a run can be stranded by the run ending before the next boundary drains it (true of every steer,
* keyed or not); a caller that needs delivery evidence reads the run's own events, not this receipt.
* - **The receipt does NOT distinguish the two**: a fresh accept and a replay both resolve `void`,
* exactly as `setPendingSteer` answers `true` for both. The observable difference is on the event
* stream (a fresh accept emits the `human_input` frame; a replay emits none), which is also where
* the parked leg's difference shows (a replay adds no queue entry, so the resume drains one frame).
* - **Honest window** (weaker than the parked leg's, deliberately stated): the live dedup domain is
* THIS stream object — one run leg, in this process. It is not persisted, so it does not span a
* restart, a replica, or a second `runTaskStream`/`resumeStream` call on the same session; and it
* holds only what the LIVE verb accepted, so an id already delivered by the parked leg (drained
* into this run's resume prompt) is NOT in it and WOULD inject again. A deployment that needs
* cross-leg or cross-process idempotency owns that half (its own key ledger), the same division
* of labor `notify`'s park-window dedup states.
*
* design/373 (additive) — `priority` is the steer face's injection tier, default `"next"`
* (= the pre-373 behavior, byte-identical):
* - `"next"` — the running turn's next boundary (unchanged);
* - `"later"` — never folded into work in progress: delivered at the run's natural
* would-otherwise-stop seat (its own closing turn); a run that ends before that seat re-parks
* the input via the existing durable/undrained lanes (receipt semantics unchanged — "queued"
* still means accepted-at-this-moment, never "consumed"); a `"later"` steer rides the followUp
* lane, so its stranded account is `task.user_followup_undrained` (the accounts are LANE-keyed
* — same two-key split the settled frame uses);
* - `"now"` — `"next"`'s delivery guarantee PLUS the boundary is manufactured early: the frame
* takes the queue's class head and, when a turn is in flight, the engine CUTS that turn at a
* reconciled boundary (finished tool calls keep their real results; never-started ones settle
* as paired interrupted results; the CC-verbatim interruption marker lands) and the run
* CONTINUES with this input — never the run-level `interrupt()`. Best-effort accelerator:
* with nothing in flight (idle tail, between turns) delivery is exactly `"next"`'s. Every
* real cut is announced (`task.turn_interrupted` notice) and counts toward `maxTurns` —
* deliberately (the hard cap IS the interrupt storm's bound), which means a `now` issued at
* the run's FINAL allowed turn spends the remaining budget on the cut and the run then ends
* at the limit before the steer's own turn: the input takes the terminal lanes like any
* accepted-but-undelivered steer (durable park when a resource-suspend seat is eligible —
* redeemed on resume; otherwise the loud `task.user_steer_undrained` account). Same for the
* other terminal gates (budget/walltime) firing at the cut's own `turn_end`. The receipt
* never promised consumption; no budget is reserved for the interruptor.
* ⚠️ POWER FACE (§5-4b): passing `priority` through to a third-party caller GRANTS it the power
* to cut this run's in-flight turns (cancelling that turn's in-flight tool work). A deployment
* relaying steer must gate this option at its own face — relay IS authorization.
* An unknown value throws typed `steering.invalid_content` (bad-value loudness). The replay
* identity (`inputId` idempotency) includes the NORMALIZED tier: the same text replayed under
* the same id at a different tier refuses `steering.duplicate_input_id` — an "idempotent
* success" that silently skipped the interrupt would be a disposition lie.
*
* design/373 §4.3 (D2, ruling of 2026-08-24) — a deployment `userPromptSubmit` hook SCREENS this verb:
* the steer face is a SERVICE entrance (third-party callers reach a running run through it), so
* the deployment's prompt filter sits at the entrance, `ctx.source:"steer"` + `ctx.inputId` +
* `ctx.actor` discriminated. Chain position: domain validation → liveness → `inputId` replay
* short-circuit → screen → accept/enqueue → (now) interrupt — an idempotent replay of an ACCEPTED
* id answers success WITHOUT re-running the hook (a re-run could answer differently and
* retro-falsify the standing receipt). Hook `block` ⇒ **typed throw `steering.blocked_by_hook`**
* (message carries the hook's own bounded reason); a hook timeout / cancellation / crash refuses
* with the SAME code, fail-closed (a screen that did not answer has not cleared the input —
* message discriminates the cause; the crash also reaches `onError` phase:"hook"). A blocked call
* was never accepted: no `human_input` frame, no undrained account, and the `inputId` stays
* UNBOOKED (retry freely, changed content included). `additionalContext` ⇒ prepended to the
* delivered frame as the engine's own reminder (never inside the untrusted mid-turn frame). The
* replay identity is over the CALLER's bytes — hook output never enters it. Engine-authored
* frames (task notifications, diagnostics) and external `notify()` text are OUT of the screen's
* domain (notifications are sanitized DATA, not prompts — the notify contract's ruling).
*/
steer(text: string, options?: {
trusted?: boolean;
actor?: ActorAssertion;
inputId?: string;
priority?: import("./task-notification.js").SystemInjectionPriority;
}): Promise<void>;
/**
* design/144 §2 — inject an EXTERNAL structured event into this run's task-notification lane, as a
* `<task-notification type="external">` frame the model sees like any background completion. Core MINTS
* the payload (`task_type` is cast to `"external"` — a caller can never impersonate an internal lane)
* and every field is sanitized as UNTRUSTED data on the model face. Delivery rides the EXISTING
* notification machinery, so the semantics are identical to internal producers:
* - design/373 — the injection LADDER is live on this lane (BREAKING vs the flat era, named):
* `"next"` = the running turn's next boundary (mid-work included — the previous behavior of
* every frame, now this tier's); `"later"` = never folded into work in progress — delivered at
* the run's natural would-otherwise-stop seat (its own closing turn), and a run that ends
* before that seat parks the frame per session for the NEXT run's turn-open (delivery then
* depends on a next run happening — a recorded structural honesty note, this engine owns no
* idle process); `"now"` is REFUSED typed (`notify.invalid_priority`) — turn-interrupt
* authority belongs to the caller-provenance steer face, never to the notification lane
* (callers wanting earliest-boundary delivery say `"next"`). ⚠️ The parameterless DEFAULT is
* `"later"` (the CC pending-notification default): an omitted priority moved from
* next-boundary to the closing seat with this design — callers that need boundary delivery
* must say `"next"` explicitly. NOTE for the flat era's callers: `"later"` was silently inert
* then (delivered at the boundary with no runtime signal saying otherwise) — the version is
* the ONLY discriminator of which behavior a build has. Delivery order within a tier is
* ARRIVAL order; consecutive buffered notifications drain as one boundary batch. `priority`
* rides the park path (pend records carry it; the turn-open batch is priority-major) and the
* uplink path;
* - duplicate events dedup on `task_id:status[:seq]` — pass a fresh `seq` per repeat event. The
* external lane keys in its OWN dedup domain (design/144 X1): an external event can never collide
* with (or pre-occupy) an internal lane's key, and vice versa. The key deliberately excludes
* `priority` (tier = delivery metadata, not event identity): a repeat under an in-flight key
* folds WHATEVER tier it names — the standing entry keeps the FIRST accept's tier — so
* re-tiering requires a fresh `seq` (a new occurrence), never a same-key resend;
* - a notify landing AFTER the run ended PARKS per session and the session's NEXT run delivers it
* (unlike {@link steer}, a finished task does not reject — the park lane is the contract). The park
* leg dedups on the same key (design/144 X4) with an HONEST WINDOW LIMIT: the dedup set spans ONE
* parked batch — it does not reach back across the live-queue/park boundary, and it resets when the
* next run drains the batch (a repeat sent after that drain is deliverable again);
* Throws typed `notify.invalid_payload` on a malformed input, `notify.invalid_priority` on
* `priority: "now"` (see above — a KNOWN value this lane refuses, distinct from an unknown one),
* and `notify.not_running` when the run
* never built its notification lane (prepare failed). Everything upstream of the event reaching this
* process — transport, webhook/daemon wiring, inbound authn, durable parking for idle sessions — is
* the DEPLOYMENT half (design/43: zero daemon semantics in core).
*/
notify(payload: import("./task-notification.js").ExternalNotificationInput, opts?: {
priority?: import("./task-notification.js").SystemInjectionPriority;
}): Promise<void>;
/**
* Request a manual context **compaction** (design/99 MF-18 — the shell `/compact` button). Forces a
* compaction at the NEXT safe turn boundary regardless of the auto-threshold (the only safe point — never
* mid-turn, which would rewrite the session under an in-flight request); a `compacted` event with
* `trigger:"manual"` is emitted when it actually compacts. Resolves once the request has been PROCESSED at
* a boundary, or when the task ends first (whichever comes first — it never hangs). **Throws**
* (`steering.not_running`) if the task hasn't started or has already finished. Idempotent-ish: concurrent
* calls coalesce onto the next boundary (all waiters get the same outcome).
*
* ⚠️ BREAKING (MF-18: "processed" without "compacted"): resolves with a {@link CompactOutcome}
* instead of `void` — "processed" alone told a consumer nothing when the attempt failed or was mooted
* (the 202-then-silence fingerprint). Every outcome also has a stream/trace counterpart:
* `"compacted"` → the `compacted` event; `"failed"` → `compaction.failed` trace (+ `onError(phase:
* "compaction")` when an attempt was burned); `"blocked"` → `compaction.blocked` trace; `"noop"` →
* `compaction.noop` trace; `"disabled"` → `compaction.disabled` trace; `"mooted"` →
* `compaction.mooted` trace — or the task simply settled before any boundary processed the request
* (the run-end backstop resolves "mooted" without a frame; the task's terminal events are the signal).
*
* design/145 §4 (additive): `opts.instructions` — the CC `/compact <instructions>` parity face.
* Injected into the summarization prompt in the CC-verbatim shape (`\n\nAdditional Instructions:`),
* REPLACING the spec-level `compaction.instructions` / default instructions for THIS pass (a
* one-shot directive should out-rank standing configuration — sema judgment inside the superset;
* CC has no spec level). A `preCompact` hook's `additionalInstructions` still APPENDS (CC 207
* :348007 merge shape). Sanitized like every instructions channel (break-out tags defused, fence
* sentinels capped, ≤2048 code points). Concurrent `compact()` calls coalesce onto one boundary:
* the LAST caller's instructions win that pass (all waiters share its outcome — same coalescing
* contract as before).
*
* `opts.signal` (additive) — CANCEL this compact request. Aborting resolves the caller's promise
* `"mooted"` (honest: the request was withdrawn by the caller, no boundary served it):
* - **before the pass starts** (still parked for a boundary): the request is simply un-parked — no
* summary call is made; when this was the only pending caller the whole manual request (and its
* pending instructions override) is withdrawn.
* - **while the summary call is in flight**: the signal is composed into that pass's summary-call
* abort chain, so the in-flight model call is aborted. The task itself is unaffected (compaction
* is best-effort; the run continues on the un-compacted session). A caller-cancel abort is an
* EXTERNAL cause, not summarizer evidence — it is never counted toward the §17.4 consecutive-
* failure breaker and never fired through `onError` (same discrimination posture as the walltime
* soft-deadline abort; the trace counterpart is `compaction.mooted` with `reason:"cancelled"`).
* Coalescing caveat: coalesced callers share ONE pass, so one caller's cancel aborts the shared
* pass — every waiter of that pass then resolves `"mooted"`.
* An already-aborted signal resolves `"mooted"` immediately without arming a request.
*/
compact(opts?: {
instructions?: string;
signal?: AbortSignal;
}): Promise<CompactOutcome>;
/**
* design/116 detach(CC mid-flight ctrl+b)— move a RUNNING tool call to the background.
* Today only a detach-capable Bash honors it (the env adopts the child as a background shell; the tool
* settles early with "moved to background; task_id=b*", and the G2b completion notification takes over).
* Fire-and-forget and race-safe: a request landing before the tool reads its signal still detaches; one
* landing after the tool finished is a no-op. Unknown toolCallIds are a no-op (nothing to detach).
*/
detach(toolCallId: string): void;
/**
* design/383 §2.1 — the MID-SESSION memory-capture opt-out verb ("聊着聊着发现不该记" — the
* design's primary scenario): from this call on, the running session commits NOTHING to the
* long-term memory store, its already-committed contributions leave the consolidation candidate
* set (the retroactive A2 arm), and the session window's residue on the writable memory root is
* boundary-swept into control-plane quarantine (per-path named; a sweep failure REFUSES the call
* loudly — the one-way record stands either way, so the failure mode is "opt-out on, residue
* named", never a silent partial).
*
* ONE-WAY: no reverse verb exists at any layer; a repeat call is an idempotent `"existed"`. The
* way back to capture is a NEW session (a deliberate product cost — "which turns count" has no
* honest answer for a retroactive re-enable).
*
* AUTHORITY (steer-lane law): core does not authenticate — holding this stream IS the capability
* handle, exactly as with {@link steer}; a service exposing this verb over the wire owns the
* verified-principal / session-ownership check at its own face (a bare sessionId relay would let
* anyone switch off anyone's memory: an availability attack). The call re-adjudicates the
* per-principal entitlement AT FLIP TIME (`RuntimeCaps.allowMemoryOptOut` under
* {@link RunnerDeps.memoryCapturePolicy}, fresh resolve — a denied flip refuses typed
* `memory.capture_optout_denied` and leaves ZERO record residue). MODEL-UNREACHABLE by
* construction (§2.6): no tool face, no MCP face, and governed workflow specs cannot spell it —
* a model that could declare an opt-out could silently disable a user's memory; one that could
* revoke it would pierce the privacy control.
*
* Typed refusals (the closed set, one per arm — rescan post-6.0.0-RC completed the roster):
* `memory.capture_optout_denied` (entitlement), `memory.capture_optout_unpersisted` (the record
* could not be durably written — a store-mark failure OR a failing §2.7 consolidation-epoch
* bump, both refused rather than held in-process, which the first resume would silently break),
* `memory.capture_optout_unavailable` (this run mounted no memory session — nothing to opt out
* of), `memory.capture_optout_sweep_failed` (the record STANDS but the §2.3 boundary sweep could
* not contain named residue paths — a repeat call is the retry lane),
* `config.memory_capture_unsupported` (remote/per-run deployment shape with no
* {@link RunnerDeps.memoryCaptureRecordStore} — the record would not survive to a resume
* replica), `steering.invalid_content` (a non-string `options.reason`), plus the steer-family
* `steering.not_running` once the task has finished. Resolves `{ outcome }`: `"created"` = this
* call made the crossing (the `memory.capture_opted_out` notice minted); `"existed"` = already
* recorded (idempotent; no second notice).
*/
optOutMemoryCapture(options?: {
reason?: string;
}): Promise<{
outcome: "created" | "existed";
}>;
/**
* Change the running leg's PERMISSION MODE (`TaskSpec.permissionMode`'s live half — a shell's mode toggle, a serving
* layer's `permission_mode` control frame). The requested word is rewritten on the leg's mode seat; the deployment's
* fold is re-judged (`bypassPermissions` where `RuntimeCaps.bypassPermissions` is `false`, or `auto` with no
* classifier face / a `false` deny bit, runs `default` and is announced `config.permission_mode_unavailable`); a leg
* that became `auto` arms its classifier now — an arming that fails rolls the steer back (the word it replaced stands)
* and the verb rejects with the arming's code; the NEXT tool call reads the new mode at the gate's arbitration point
* (a tightening and a loosening both take effect one call later, on one seat), and a child leg derives its own
* effective mode from this seat on its next read. Resolves the same three-member receipt `wiring_manifest.permissionMode`
* carries — `{ requested, effective, foldedBy? }` — on EVERY call, moved or not, so a caller renders what the engine
* says and never what it sent. Typed refusals: `steering.invalid_content` (a word outside the closed set) and the
* steer-family `steering.not_running` once the task has finished. AUTHORITY (steer-lane law): holding this stream IS
* the capability handle; a service exposing the verb owns the principal check at its own face — and a deployment that
* must never let a caller reach `bypassPermissions` says so with the deny bit, which this verb honours.
*/
setPermissionMode(mode: import("./ask-origin.js").PermissionMode): Promise<import("./mode-policy.js").PermissionModeReceipt>;
/**
* **RE-DIAL one MCP server** on the running task, the action `RefreshMcpTools` deliberately
* is not (its contract is "never dials or re-dials"). Core holds no MCP connection across task
* legs, so once a server's transport closes this leg is stuck with it: every one of its tools fails
* fast and a refresh can only keep reporting `not_connected`. This is the verb a host hangs a
* "Reconnect" control on. ONE NAMED SERVER, required — CC 2.1.259's control-channel request carries
* a required `serverName` and has no all-servers form, and an all-servers form would CLOSE HEALTHY
* connections to re-dial them (the stdio server owning a device or a port — the case this verb
* exists for — is the one that cannot be re-dialed while its predecessor still holds the device).
*
* ⚠️ **A FAILED RE-DIAL LEAVES THE SERVER DOWN.** The old connection is closed BEFORE the new one is
* dialed (no other order works for a device-owning server), so a dial that fails has spent a
* working connection: the server stays disconnected, with its tools WITHDRAWN from the roster,
* until another re-dial or the next leg's materialize — and calls in flight on the replaced
* connection fail with it. Both are inherent to re-dialing; CC behaves the same way.
*
* Resolves {@link import("./mcp.js").McpReconnectResult} — `outcome` is one of three words, so a
* consumer mapping it onto a three-state control answer needs no table: `reconnected` (the only
* success), `refused` (the engine declined, or the dial failed — `reason` says which and
* `status.errorCode` keys the next action), `not_declared` (no server of that name on this leg).
* Throws, never silently no-ops: `mcp.reconnect_unavailable` when this run declared NO MCP servers
* (a host maps that to "unsupported", not "refused"), `steering.invalid_content` on a missing or
* non-string name, and the steer-family `steering.not_running` once the task has finished.
* LEG-LOCAL: the result rides no checkpoint, and a durable resume takes a fresh first listing.
*/
reconnectMcpServer(server: string): Promise<import("./mcp.js").McpReconnectResult>;
/**
* Hard-**interrupt** the running task — a real abort (not a best-effort hint), equivalent to firing the
* task's `signal` (design/47). Refused typed (`steering.not_running`, the steer-family liveness code —
* `cannot interrupt: …`) once the task has finished (the loop's end included: the teardown window
* refuses like `halt`/`steer` do), never silently dropped. **A run that has already given its answer
* is not cancellable**: once its last turn ended `stopReason:"stop"` with no queued steer / follow-up
* left to serve (a continuation the person had already asked for makes that "stop" work in progress,
* not an answer), a stop landing on the run's
* tail (this verb, the task's `signal`, the walltime or turns limit — one law) still aborts what is
* left of that tail (a final-boundary compaction is mooted) but the run ends `completed` with the
* answer as `result`, writes no interruption marker, mints no `limits.*` code, and says so once with
* the `task.interrupt_unconsumed` notice. For a SOFT redirect that lets the
* task keep running and adjust, use {@link steer} instead. For the CC-Esc "stop and wait for my
* input" form — cut the turn, keep the ending clean and resumable — use {@link halt}.
*/
interrupt(): Promise<void>;
/**
* design/373 (#504) — the BARE user interrupt, the CC Esc true form ("cut + stop"): CUT the
* in-flight turn and STOP the run at that manufactured boundary, collecting to a clean,
* resumable ending. The run-model equivalent of CC's session staying alive waiting for the
* user's next input. Three verbs, three powers: {@link interrupt} hard-aborts the run (the
* orphan-reconcile ending), {@link steer} with `priority:"now"` cuts the turn AND CONTINUES the
* run with the steer text, `halt` cuts the turn and lets the run END awaiting the user.
*
* The CUT half is the design/373 S1 turn-scoped settle, verbatim: finished tool calls keep their
* REAL results; never-started ones settle as paired interrupted results
* (`errorKind:"interrupted_never_started"`); the CC-verbatim interruption marker lands in the
* session as an engine-note user frame. Every REAL cut is announced (`task.turn_interrupted`
* notice, `detail.cause:"user_halt"` — the steer-now landing keeps its own message and no
* `cause` key). The STOP half: from acceptance on, NO new model turn starts — the loop ends at
* whichever boundary comes first (both boundary faces consult the latch, the pre-turn one
* included, so a halt landing between turns cannot buy one more turn). With nothing in flight
* the cut half is a no-op (`turnCut:false`) and the stop half alone answers — no false marker.
*
* **Terminal form (#504 detail ①, ruled completed-with-marker over a new status/stop-reason
* member):** the run ends `status:"completed"` with the additive result seat
* {@link TaskResult.haltedByUser} `: true` as the discriminator. CC anchor: 250 has NO typed
* "interrupted" terminal — Esc leaves a clean continuable session with the transcript marker as
* the record, and the SDK result taxonomy (`error_during_execution`/`error_max_turns`/…) gains
* no interrupt member; a new closed-set status/stop-reason row would therefore be a non-anchored
* invention, while completed-plus-marker is also this repo's own precedent seat
* ({@link TaskResult.haltedOnUserRejection} — "the model did not finish; the person stopped it
* and the run awaits their direction"). Consumers discriminating "user stopped it" vs "ran to
* completion" (billing, continuation copy) read the one boolean.
*
* **Queued-but-undelivered frames (#504 detail ②, ruled keep-don't-clear):** a halt clears
* NOTHING. CC anchor: 250's `control_request:"interrupt"` handler keeps the command queue by
* default and answers `still_queued`; clearing is the explicit `cancel_queued:true` opt-in
* (`cleared_on_cancel`) — destruction is never the interrupt's default meaning. The run-model
* translation: queued steer/follow-up frames stay queued through the cut, are NOT drained into a
* turn that will never start, and settle by the run's EXISTING terminal contract for
* accepted-but-undelivered input — durable park when a suspend seat exists, else the loud
* per-run accounts (`task.user_steer_undrained` / `task.user_followup_undrained`). The receipt
* semantics were always "queued = accepted, never consumed": the caller holds the bytes, the
* account tells it what to re-send, and `inputId` idempotency makes host-side re-delivery on the
* next run replay-safe. (A per-session durable park for non-suspend endings would be new storage
* machinery — out of this slice, recorded.)
*
* **Resume hand-off (#504 detail ③, ruled the ordinary user-lane front door):** the next input
* enters as the NEXT RUN's objective on the same session (`runTask`/`runTaskStream` with this
* result's `sessionId`) — no special lane, no checkpoint. The halted transcript ends with the
* interruption marker exactly where CC's session shows it (marker, then the user's next
* message), so the resumed model reads the same shape a CC session shows after Esc. A
* checkpoint-resume is neither minted nor needed: `"completed"` + `sessionId` IS the
* continuation contract, and a marker-less halted transcript (nothing was in flight to cut) is
* an ordinary clean boundary.
*
* Receipt: resolves `{ turnCut }` — `true` iff THIS call cut a live turn (the completion-race
* arm is stated, not hidden: a turn whose work finished as the abort landed ends normally and
* the run still stops at its boundary). Repeat calls are accepted and answer honestly
* (`turnCut:false` once the stop is already latched); the notice is per-cut, the result seat
* per-run. AUTHORITY (steer-lane law): holding this stream IS the capability — a deployment
* relaying this verb owns its own gate (relay IS authorization; it cancels in-flight tool work
* and ends the run's forward progress). No text ⇒ the `userPromptSubmit` screen has no domain
* here (nothing enters the model). The late window, stated: a halt accepted
* in the run's last moments (past the loop's final commit point) finds the run completing
* naturally — nothing is cut and no boundary stops for it, so the result reports the natural
* completion WITHOUT the `haltedByUser` seat and the engine mints `task.halt_unconsumed` (the
* verb receipt and that notice are the halt's whole trace; the seat is signed from consumption,
* never from acceptance — see {@link TaskResult.haltedByUser}). Typed refusals: the
* steer-family `steering.not_running` once the task has finished (teardown included); a halt
* issued BEFORE the run's first prompt polls the same bounded birth window as {@link steer} and
* then stops the run before its first model turn (an empty, cleanly-halted completed run).
*
* **Receipt tension, stated:** `{turnCut:true}` and the
* `task.turn_interrupted` notice assert facts about the CUT — a seat was cut, no new model turn
* starts — and both stay true even when the gate's durable leg still collects to `suspended`:
* an approval park whose store commit was already in flight (or committed) when the cut landed
* WINS the fence race, the row is redeemable, the park's own abort then owns the ending, and the
* result reads `status:"suspended"` WITHOUT {@link TaskResult.haltedByUser} — the halt cut a seat
* but consumed nothing (no interrupt settlement took its cut, no boundary stopped for it), which
* `task.halt_unconsumed` discloses. A cut observed BEFORE the commit makes the park concede
* instead — no row, no card, and the ordinary halted ending. A BOUNDARY park (review / resource
* slice) beside a consumed halt is the other way round: the terminal is the park's, and the seat
* IS signed — a person also stopped it.
*/
halt(): Promise<{
turnCut: boolean;
}>;
/**
* Reap this task's resources — **callable after it finished**, idempotent, never throws (design/50 §3,
* design/51). The fan-out (v2) cancel primitive: an orchestrator's `cancelAll` is the shared
* `AbortController.abort()` then `Promise.all(streams.map((s) => s.destroy()))`.
*
* - **still running** → hard-interrupts it; the run's own `finish()` tears down its execution env.
* - **suspended** (`status:"suspended"`) → `finish()` SKIPPED env teardown (the remote env was
* `suspendVM`-paused for a durable resume) and the checkpoint is committed → `destroy()` reaps BOTH:
* it CAS-**expires** the checkpoint (`CheckpointStore.expire`, fencing any concurrent resume) and —
* **only if it won that CAS** — destroys the paused env. So a cancelled fan-out worker never leaks a
* paused container + orphan checkpoint, and is never resumed onto an env `destroy()` already tore down
* (the expire/resolve CAS on the one `pending` row is mutually exclusive: exactly one of reap/resume
* wins). If `expire` itself fails, the env is left intact (checkpoint + paused env stay a consistent,
* still-resumable pair; the failure surfaces via `RunnerDeps.onError`, not a throw).
* - **already completed / failed / already destroyed** → no-op.
*
* Idempotent at the promise level (repeat/concurrent calls await the same settled work).
*/
destroy(): Promise<void>;
}