Turn lifecycle and authority
Owning code
src/brain/service/: theBrainServicefacade (src/brain/brainService.ts, classBrainService) delegates toConversationLifecycle(lifecycle.ts),LiveSessionSpawner(spawner.ts),BrainTurnRunner(turnRunner.ts),TurnContextBuilder(turnContextBuilder.ts),BrainResultDelivery(resultDelivery.ts),BrainReadiness(brainReadiness.ts), the delegated-run owners (delegatedSession.ts,delegatedRecovery.ts,delegatedSettlement.ts),ChannelSessionService(src/brain/channels.ts) andBrainStatusService(statusService.ts).src/brain/session/: the per-session PI wiring:factory.ts,capabilities.ts(composeSessionTools,gateSessionTool),turnPrompt.ts,projectExecutionBoundary.tsand the conversation event writerchatEvents.ts(see the events page).src/brain/prompt/: stable persona and system-suffix bytes (sessionPersona.ts).src/store/brainStore.ts(facade) andsrc/store/brainJournalStore.ts: the journal that durable turn state is read from.
Data flow of a turn
The exact surface differs for owner chat, a platform adapter, automation, or a delegated child, but they converge on the same brain execution model:
- An API, CLI, web proxy, platform adapter, scheduler, or parent delegation submits a turn request.
- The relevant service authenticates or receives verified identity metadata, resolves the account contribution, Project policy, conversation, model, and execution reference, and serializes admission for that session.
ConversationLifecycleresolves or creates the durable session andLiveSessionSpawnercreates or rehydrates the in-memory PI session.- The spawner resolves the provider route, working directory and context-file policy, then calls
prompt/sessionPersona.tsfor stable persona and suffix bytes,service/codeModeComposition.tsfor the nested plugin composition and visibility, andsession/liveRecallWiring.tsfor caller-owned recall options.composeSessionTools()remains the single tool visibility and execute-time wrapper boundary. TurnContextBuilderresolves fresh memory, plugin context, permissions, mode directives, working-directory reminders, session notices, and running-child reminders. The resulting wire frames are placed around the clean user text.- The PI agent loop sends the provider request, receives assistant events, and invokes tools. Tool calls run inside the current identity, Project, execution, and permission scope. Code-mode nested calls use the same wrappers.
- The daemon streams bounded events to attached clients and mirrors user, assistant, and tool-result messages to SQLite as they settle.
- Turn settlement records usage, activity, origin attribution, cards, artifacts, memory-curation work, and delegated-result delivery. Background results are delivered to their durable parent path.
- The live session remains reusable or is later evicted and rebuilt from durable state.
The three composition owners are internal functions used by LiveSessionSpawner, not new plugin APIs. renderSessionPersona renders the existing template route with the instance owner's identity and the composing account's style, then applies the existing prompt-capable hook. sessionPersonaAppend keeps model identity, the existing tool catalog, plugin fragments, caller suffix and escaped account instructions in that order. A fork retains owner-chat bytes; a scheduled prompt omits product and room overlay. Without overrides or hooks, the shipped templates stand. Composition runs once per spawn and adds no provider call.
createCodeModeComposition first validates the optional plugin route with empty tool lists, composes only fully gated nested definitions, then validates exposure against the final tool and wrapper names. Without a control or eligible route the session keeps the direct tool surface. Its delayed session, trace and ownership getters follow spawn ordering; principal and notification liveness stay per-call/per-turn. Notifications steer only the captured principal's active streaming turn, and native agent_start resumes that principal's queued notifications. The shared deferred catalog remains in prompt/toolCatalog.ts.
createLiveRecallWiring builds the factory's existing LiveRecallSpec and installs memory-tool delivery accounting after the live session exists. It excludes delegated children and ownerless sessions; owner chat recalls for its owner, while a room recalls only for the verified current writer and global categories. Budget, user toggle and retained delivery tracker are read on each pass. Retrieval resolves Project/category scope before its first await and keeps the existing dedup predicate. Without a memory service no recall extension is installed; an unwired budget disables searches. Retrieval costs remain bounded by the existing pass/count/byte budget.
Policy refusals use the shared result builder in session/capabilities.ts and return isError: true with details.refusedByPolicy: true. PI forwards the error verdict to the model without treating it as a session failure. Nested code-mode calls reject error results and preserve a policy refusal result as the error cause, so the trace sink keeps its policy warning metadata. Trace records read the result's isError flag unless an explicit event verdict is supplied.
PI's prompt preflight callback reports the accepted dispositions handled, queued and started. A rejection throws and rolls back unadmitted state. PI acceptance and provider delivery are distinct. All prompt dispositions accept the operation, but handled owes no PI user entry: its pending journal admission and discard claim are removed. Only started confirms provider-only one-shot notices and memory deliveries. enqueueMirrored stages attachment and echo identity before PI's synchronous queue events, retains queued inputs, removes handled inputs and returns PI's disposition. A delegated echo carries its confirmHandled callback through every enqueue and survivor requeue; the shared queue path invokes it through the existing deliveredDelegatedSteer transition, which prevents duplicate settlement and cannot revive a dropped receipt.
ChannelSessionService.send is admission, the delegated-call bracket and the steering attempt, followed by the session lock. Inside the lock prepareChannelSession readies the live session (history seed, dispose on a model or classification change, spawn, direct and work-dir persistence), saveResumeEnvelope writes the durable resume envelope, runChannelTurn runs the turn from an explicit context object, and finishChannelTurn drops what the turn leaves behind. The order of those effects is load-bearing: the park marker is cleared before the lock, the envelope is written before the turn and cleared in the finally, the curator runs only after the lock, and turnSender and turnWriterUserId are set and reset in the same window.
Prompt ordering is deliberate. Stable session content is composed at spawn time; volatile turn context is placed around the user message. The first line of the spawn-time append region names the model the session runs on (modelIdentityLine in prompt/sessionPersona.ts: You are powered by the model named {name}. The exact model ID is {provider}/{model}.), from the same resolved route the session uses, so it changes only when a respawn changes the model; the reasoning level changes without a respawn and is told by its chat event instead. Turn context is stored as one native custom_message entry per contribution, with customType: 'elowen.turn-context'. Its strict details contain kind, anchorId, placement and order; memory records carry exact memoryBodies pairs of id and body, without a separate id list. The user entry contains the person's words and attachment references. providerText preserves PI's accepted expansion when it differs from display words. Accepted image blocks remain in content as immutable content-addressed references, preserving PI's resized bytes on restore and fork; images retains original attachments for display only.
composeTurnWire returns words for prompt() and a transient TurnContextPlan for the existing admission queue. PI owns entry IDs and parents. The journal append barrier commits the user or hidden host entry and its context records in one transaction. Admissions are consumed in native append order, distinguished by user versus custom-message type, without comparing text. Handled inputs and failures drop unconsumed admission metadata. Queued user echoes retain their plan until PI delivers them.
TurnContextPlan and mid-turn memory attachment carry exact memoryBodies pairs of id and body, never a parallel memoryIds list. BrainSessionFactory appends a memory or step record using the anchor's native journal identity; missing identity is an invariant error. The optional fifth append argument is memoryBodies. Memory ids used for accounting remain separate from durable prompt records. Owner and channel admission share this plan, and live recall uses the same native append. With no memory bodies no memoryBodies property is written. Migration 58 retires historical stored id lists before the strict reader runs; existing historical context kinds remain readable.
turnContextRecords.ts owns CONTEXT_CLEARED_TYPE with the persisted value elowen.context-cleared. BrainStore.clearContextRecords and coldToolResultClearing use it when retiring a context record into an inert native custom entry, preserving its identity and parent link. No clearing means no marker is written. The constant introduces no property-name registry, migration, new storage or additional work; contextCleared properties and SQL JSON paths stay unchanged.
session/turnContextRecords.ts owns the sole wire composition through the existing agent.transformContext hook. Lead blocks, words and trail blocks form separate text blocks inside the same user message, followed by images. The hook and read-only consumers derive every message's identity from SessionManager.buildSessionProjection and its source entries, including freshly projected hidden custom messages. Context handlers receive these identities before extension cloning; no private PI identity map or text matching is used. Live recall and step-context handlers use the same native append path during the context hook, before the final projection; they do not own transcript copies or splice text. Every mid-turn record projects as its own hidden message after its fixed native anchor, so another record at the same anchor extends the existing sequence. A native compaction cut that retains a record beyond its summarized anchor keeps that record at its native position.
Warm requests never rewrite records. The existing cold and quiescent gate retires eligible context records to inert native custom entries, preserving IDs and parents while removing bodies and memory annotations. Mid-turn memory and step records are eligible even after the newest settled turn; skills and permissions remain. Clearing a full mode reminder marks its user or host anchor contextCleared, so the next directive is complete.
Context consumer audit: warm compaction uses the same transform; standalone compaction uses the pure projection over its native span. Token estimates, prefill measurement and context diagnostics use sessionContextMessages. Native rehydration restores words, records and entry provenance; forks copy the journal and use the same projection. The timestamped export omits hidden context and internal metadata. Titles, curator input, search and the web/CLI transcript read clean user words; the shared transcript query excludes typed context records before pagination. Billing and response-status readers use assistant usage and outcomes and need no injected text. Administrative journal diagnostics intentionally retain whole typed entries.
The read-only sessionContextMessages accessor requires only Pick<AgentSession, 'sessionManager'>. Consumers call sessionContextMessages({ sessionManager }) to compose the current native branch through the same source-entry provenance projection used by provider requests. Tests use it after journal rehydration in storedContextMessages and on the actual fake session manager in latestContextText, so lead/trail blocks stay inside their native user or host message and after-message records retain their order. With no context records, it returns the clean native projected messages without internal anchor metadata. It performs an in-memory projection of the current context and branch, with no provider call or durable write; it does not run live extension handlers or discover context outside that branch. The BrainService memory integration tests are a concrete consumer.
Boot migration 57 atomically converts annotated rows on every physical branch and moves historical replay attachment references into user content, preserving user IDs, timestamps, provisional state, origin and usage epochs. It redirects child links and active leaves through deterministic record IDs, invalidates the derived state projection, and logs progress counts every 128 conversations. These are progress checkpoints inside one transaction: a crash rolls back the entire migration and the next boot retries it. Runtime has no old-frame reader or stripping scanner. B2 changes historical block boundaries once on upgrade; subsequent warm requests reproduce the stored blocks exactly.
A message the person sends while a turn is running is steered into it with the steered trail frame (STEERED_MESSAGE_REMINDER in session/turnPrompt.ts, composed through composeTurnWire like every other part): a reminder that the words arrived while the agent worked and must be addressed before it finishes. The owner's steer gets it from TurnContextBuilder.steerWire, a room's same-sender steer from the channel path, and both hand the typed context plan to the queue mirror through the echo (QueuedUserEcho.contextPlan), so the row written at PI's delivery replays the same bytes after a restore. The frame is strippable scaffolding; the row itself keeps only the person's words. A pause checkpoint and an Esc promotion keep the clean words too, because the fresh turn they become composes its own frames. A delegating agent's follow-up (DelegateContinue) is not the user's and carries no such frame.
Every live session carries the spawner's required planModeToolNames set; mode policy reads its native
getAllTools catalog directly. Cache watch requires the native session subscription seam.
Current conversation mode is read only by BrainStore.conversationMode(sessionId) from the native active-branch mode event projection. Untouched conversations start in Build. BrainService.switchWorkMode is the only authorized switch: it rechecks ownership and complete client generation under the existing send and canonical session locks, commits the native event through the live or cold journal writer, and returns BrainWorkModeSwitchResult. After a 1,500 ms wait, a blocked request acknowledges queued: true with current control, never the requested selection. Requests apply in order at the turn boundary through those existing locks; the durable mode event signals completion. Fast unchanged selections write nothing; a queued unchanged request records its completion. Late failures use the existing conversation error seam, not a success notification. A pending switch fences newly sent owner messages into a fresh turn after the switch, with immediate queue acceptance through the existing pending-admission chips; already-admitted PI steering and hidden nudges keep the running turn's scope. Neither CLI nor web delays sends behind the switch HTTP response. Headless startup requests waitForCompletion: true on the same command. The pending counter stores no mode value and is only an admission fence. Status, snapshots, prompt composition and execution policy all use that reader. Owner and channel scopes share applyConversationModePolicy for tool denials and applyConversationModePermissions for the Plan shell clamp, including sessions without account permission settings; captured denies remain last and independent of the selected mode. A live turn retains its admitted scope until it settles. CLI, web and platform controls render the same durable chat-event; there is no mode field in TurnRequest, queue echoes, /brain/send or /v1/agent/runs. Headless mode flags persistently switch before sending. Scheduled sends, cron, goal-loop sends, background-process nudges and API runs follow their target conversation, including visible ordinary Plan write refusals. Queued messages select mode at admission, not enqueue. Delegated children and workflow nodes start with no parent mode selection or owner-chat mode directive. Delegated forks keep the copied transcript prefix but re-establish Build through a hidden native initializer when that prefix selected another mode. Their immutable captured permissions, readOnlyOrigin and tool policy govern execution instead; runWithPolicy carries that provenance to currentAccess() so further delegation cannot drop an imposed read-only boundary. Platform Plan turns use the platform/plan-mode full and sparse templates without owner-only approval tools; owner chat keeps its existing directive. Forks copy active-branch evidence and clear retains the selection with a hidden initializer. Rewind captures the selected mode before removing its checked reply tail and restores changed evidence as a hidden native initializer in that same write transaction. Every history rewrite notification (compacted) carries the authoritative workMode, including cold rewinds, so CLI and web refresh their control mirror immediately rather than keeping a deleted event's mode. Migration 47 converts each selected legacy branch's latest valid mode-event or composed-turn evidence, including pre-compaction ancestry, into one hidden native mode event, or Build when used history has none; unused empty shells remain empty. Unparseable legacy values are ignored as evidence, counted in one migration warning without transcript content, and cannot prevent daemon boot. Initializers use the cold writer's interrupted-tail placement and emit no user notification. Their model-facing text says This conversation is in X mode. rather than attributing a hidden initialization to a user switch; visible switch events keep their user-action wording. store/brainJournalStore.ts owns the existing exact native append/insert implementation shared by the BrainStore facade and migrations, with the same transaction, sequence, usage-epoch and parent validation. Its bounded provisional-tail header walk does not depend on the derived projection being ready; a migration can therefore insert the initializer before an interrupted tool run while keeping the native leaf and call/result chain intact. At runtime its change observer invalidates session-local readers; boot migrations have no live observers. No parallel migration transcript writer exists. Projection readiness is invalidated and rebuilt before core publication. Runtime never infers selection from historical turns or pending plans.
What a turn still has to say is decided from the journal, never from fields on the live session. Every composed turn stores turnState (TurnState in session/turnPrompt.ts: mode, reasoning level, how many turns since the mode's full directive, the permission and skill digests, and the id of the newest compaction it was composed after) on the entry that carried the prompt: the user row, or for a room's hidden host message its details. The next turn reads the newest such state on the active branch with BrainStore.turnBaseline and decides from it whether a mode is being entered, whether the full directive is due, whether an ambient block (session/ambientBlock.ts) is already in context, and whether a compaction since then calls for re-orientation (compactedSince, compared by id because PI can compact at the start of the call that sends a turn). The full directive is also due again when the cold pass stripped the framing of the row that last sent it (TurnBaseline.modeDirectiveStripped), because the sparse line says the full text is earlier in the conversation. The state is inert to provider serializers, rolls back with a refused turn's row, follows branch switches, and reads the same for a live session and a respawned copy, so both send the same next request. Example: two plan-mode turns with a respawn between them; the third turn gets the sparse directive and no repeated permission summary, because the replayed rows already carry both. Rows written before this state existed read as "nothing shown yet", so the first turn after an upgrade restates everything once.
Plan decisions belong to the daemon, not to browser storage or a CLI decided-ID cache. Web and CLI post { sessionId, planId, decision: 'implement' | 'dismiss' } to POST /brain/plan/decision. The ordinary owner-conversation authorization applies before any history read or mutation; strict Zod validation refuses malformed bodies. BrainStatusService.turnControl supplies the same explicit BrainStreamControl as snapshots. The decision must match the current pending tool-call ID and an idle plan-mode conversation, otherwise the route returns 409 and current control. Competing decisions serialize through durable dismissal or ordinary send admission. Dismiss holds the existing send and conversation locks; implement uses BrainService.startSend with refuseIfBusy and the sole approval prompt IMPLEMENT_PLAN_PROMPT in src/shared/planTool.ts. The host-only asynchronous TurnRequest.beforeAdmission guard rechecks control under the send lock, then selects Build through the same switchWorkMode operation before preparing a user row. If admission fails, its awaited rollback selects Plan through that operation; a failed decision waits for rollback settlement before returning. An admitted implementation remains Build even if its provider later fails. Without a guard, ordinary send behavior is unchanged; a thrown guard refuses admission without a prompt. No extra model request, table or client-local decision memory is introduced.
Dismiss records { v: 1, kind: 'plan', planId, decision: 'dismissed' } through the existing chat-event writer, including its model reminder and localized web/CLI event row. pendingSubmittedPlan scans only the newest turn; a matching later dismissal retires that plan, a different ID or earlier dismissal does not, and a later new plan becomes pending normally. A fresh service over the same journal therefore restores dismissed and undecided plans correctly and keeps the stored plan mode. A plan chat event triggers the existing status reconciliation lane on every attached surface. With no pending plan there is no decision card.
The CLI observes ChatState.controlRevision through watchControl; applyControl applies the daemon's complete projection, including native status, foreground/background liveness, mode and pending plan together so status and snapshots expose no intermediate combination. StreamCoordinator reconciles at most one picker for the authoritative plan ID, only after atomic snapshot replay, and closes it on a remote decision, plan change, child navigation or teardown. openPicker returns an idempotent close handle that restores the editor without selecting or cancelling; only user Esc calls optional onCancel. The plan flow treats Cancel and Esc as daemon dismissals, and notifies its owner after request/status reconciliation so failed decisions can restore the same pending picker. Late responses are fenced by the existing control revision on CLI and hydration stamp on web. A stale web decision re-reads daemon status. The CLI applies the control returned with a stale decision under the same revision fence and shows localized feedback without another status request. The bounded picker adds no polling or decision persistence.
BrainResultDelivery in src/brain/service/resultDelivery.ts owns the durable result inbox drain,
per-parent shared promises, steering receipts, bounded retry timers and settled boot outcomes.
BrainTurnRunner constructs it with the existing store, event publisher and hidden custom-message
sender, retaining the parent-turn locks, admission, context restoration and live facade methods.
The drain records an acknowledgement only after the sender proves context delivery. A steered
result remains pending until the settled turn's next drain sees it in context. Boot callers can
require a settled answer; a stronger caller joining an ordinary drain requeues unanswered rows
before the shared promise resolves. Unsafe recovery notices withhold the whole owner batch until
a real user message follows the notice. Turn cleanup clears steering receipts before re-draining;
it never starts a late turn on a terminal delegated child. Retry scheduling retains the five-attempt
limit and existing backoff, with no poller or timer for delegated parents. An empty inbox does no
work. Delegated settlement imports PendingResultDrainOutcome directly from this module; platform
boot continuation imports its byte-preserved subagentResultReminder instead of the turn runner.
BrainReadiness in src/brain/service/brainReadiness.ts owns synchronous default-model resolution
and the connectivity probe exposed by the BrainService facade. Construct it with live config,
runtime, optional session creator and working-directory getters. It requires no conversation store
or live-session registry. The admin provider test uses the same composer-bound PI session path as
chat, with no tools, extensions, skills, context files or disk-backed settings. It makes one request,
caps the wait at 20 seconds, aborts and disposes the throwaway session, and redacts the selected
provider's stored key before truncating public errors. Missing configuration reports unavailable;
normal conversation status/history reads do not create a probe session.
Delegated follow-ups use the existing brain_delegated_steers receipt, brain_subagent_results inbox, and subagent event seam. The PI journal append confirms delivery in the same transaction as the child message. A settled receipt publishes the joined run snapshot locally or through the runner's settlement frame; a late run row republishes its current durable state, and stale queued events cannot downgrade a terminal display. Hook a parent surface into onDelegatedSteerSettled and read getSubagentRuns before publishing; with no listener or tool row, the durable receipt still hydrates later without a phantom event. A delivered confirmation does not wake the parent: on its next ordinary turn the existing runningSubagents after-user frame carries a bounded <delegated-follow-ups> block. Only the sender who can address that child sees it. PI's successful provider preflight marks notice_reported once; a preflight failure leaves it pending, while a later provider error cannot duplicate it. A dropped text follow-up instead enters the existing result inbox atomically and wakes the parent with the warning that it did not reach the child. When a child result is already pending, the drop is appended without triggering a separate turn, so the result turn sees both; platform resume uses the same result reminders. Migration preserves pending drop inbox rows and marks historic acknowledged drops as reported. For example, a delivered DelegateContinue updates its original tool row and informs the parent below the next user's words, never by rewriting the earlier tool result or cached system context.
Delegation admission precedes runner execution. SubagentDispatch passes the same validated delegatedChannelSendOpts to ChannelSessionService.sendRemote, which creates the durable child session and immutable scope through BrainStore.createSession and emits session before calling the pool. The Delegate listener synchronously persists its existing running run row; the workflow listener journals and snapshots its node's sessionId. Thus sparedChildSessionIds also covers background children waiting in the pool, without a separate queued-child registry. Foreground calls still await settlement and Ctrl+B changes the same run row to background.
The pool retains its FairQueue and producer rotation. Enqueue emits session progress with status: 'queued' and detail: 'queued: waiting for a free sub-agent slot'; dispatch emits status: 'running' and clears that detail. The shared progress fold carries these into the existing run/DAG detail, used by DelegateStatus, DelegateList and CLI/web rails. These are execution hints, not new durable lifecycle states. A background Delegate returns its job id and queued receipt without waiting for capacity; the stall watchdog excludes that wait. Stop removes queued work through the existing removeWhere abort path and settles it through ordinary completion. With the pool disabled or unavailable, the same admission feeds in-process execution.
Boot claims queued runs under the existing running/recovering lifecycle. A never-started Delegate has an empty transcript and is terminalized with the existing "had not started" notice in the parent's durable inbox; the clipped display task is not replayed as a new request. A workflow retains its full node task and channel/session pair in the existing recovery journal and resumes an empty child through the stored-scope fallback prompt. Admission adds only bounded session/progress writes and session events, no polling, timers or extra registry. Without a progress listener the child identity still exists, but no run or workflow projection is manufactured.
Delegated orchestration keeps three internal owners. DelegatedSessionService authorizes handles, dispatches turns and holds the parent-child call edge. DelegatedRecovery owns synchronous boot claims, deepest-first recovery waits, claim-preserving progress and atomic completion. DelegatedSettlement owns interrupted transcript classification, final-answer recognition and the open call's follow-up/result waits. The facade constructs them with the existing store/live registry and only the callbacks each needs; recovery identity reads retain live configuration/runtime getters. An absent recovery delivery/publish callback leaves the durable inbox/progress authoritative. Settlement uses PendingResultDrainOutcome directly from BrainResultDelivery; folded recovery results are acknowledged in the same transcript-settlement transaction. No extra worker, queue, timeout budget or acknowledgement path is introduced.
src/store/brainDelegationPayloads.ts owns the pure progress, result and workflow normalizers used by BrainDelegationStore. They retain the existing reject-before-write behavior, field bounds and serialized shapes; relation checks, boot/steer claims and transaction orchestration stay in the store. For example, workflow snapshots validate every node and output artifact before a durable progress update.
plugins/subagent/lib/delegation.mjs owns concrete tool narrowing, principal derivation, dependency context chunks and the background-process collection reminder. Delegate and workflow import the same pure functions; workflow registration receives only the current agent-type catalog and host channel handler, without caller-supplied replacements for permission or context rules. Empty explicit tool lists are refused, chunks keep the existing character/count budgets, and empty dependency input adds no context. DependencyResultReferenceError (plugins/subagent/lib/errors.mjs) identifies failures preparing a host-validated dependency Read reference. An ordinary child error, even with identical wording, remains a node execution error in the workflow summary; a reference preparation failure retains the delivery-failure/WorkflowResume path.
Workflow engine coverage is split by behavior: tests/plugins/workflowEngine/execution.test.ts owns execution, liveness, start limits and stop guards; tests/plugins/workflowEngine/ contains definitions, dependency handovers, expansion, snapshots, background delivery and model selection; tests/plugins/workflowRecovery.test.ts owns restart and resume; tests/api/workflowOwnerRoutes.test.ts owns owner-only stop and result routes. Each file calls createWorkflowFixture() from workflowEngine/fixtures.ts once. The fixture owns a unique scratch directory, guest definition map, transcript results, persisted output and mutable task gate, removes its files and clears its state in afterAll, and preserves the harness gate reset. Recovery engines created within one file share that fixture to simulate a reboot over the same journal and child results. Behavior-specific runners stay local to their tests; the real workflow registration and dependency chunker remain the implementation under test.
A delegated call (Delegate, an idle DelegateContinue, a recovery respawn, a workflow node continuation, including a recovered or continued child that had already answered) is done when the child's turn has ended, the child has no live helpers, and no helper result is waiting in its inbox; nothing else settles it. DelegatedSettlement.settleDelegatedReply, called through the DelegatedSessionService facade, owns that definition in the process that ran the turn: the daemon, or the sub-agent runner for a runner-hosted child. A helper is live while the LiveSessionRegistry holds its edge (in-process or mirrored from a runner), while this boot's recovery claims it (recovering with this boot's owner_boot_id), or while a workflow it started is live by the one rule status reads also use, BrainStatusService.workflowLive: a boot resume still owed for the DAG outranks the engine, otherwise the engine's isWorkflowLive answers, and when the engine cannot be asked this boot's recovery claim on the workflow row does. A running run row nothing holds is not live: a dead helper becomes terminal through its own lifecycle (boot recovery, runner exit), never through the age of its row. There is no time budget; the 30-second keepalive and the 250 ms to 5 s backoff only set the polling cadence. Each helper result wakes the child inside the same call through the ordinary drain, so the turn is tracked by the call's run row, rail and stop. The drain counts an answer with no visible text as processed; only a turn that appended no answer at all, or failed, is an unprocessed result. An empty reply means the result was steered into a running turn only when that send made a new receipt (delegatedSteerForResult returns the newest receipt's id), so an older dropped receipt cannot keep the result pending and send it again. A failed delivery turn fails the call with a message telling the parent to continue the child; the result stays in the inbox for that next call, and TurnRunner arms no retry timer for a sub-agent parent, so no turn ever runs on a child outside a call. A stop that ends a delivery turn (DelegationAbortedError) is not a failed attempt: the drain leaves the row's count alone, stops, and hands the error to the settlement, which ends the call as the stop. A sub-agent whose every call is terminal, finished or stopped by DelegateStop or its parent's stop, is never woken either: the result drain retires its pending results with the boot sweep's rule, discardOrphanedDeliveries scoped to that parent, so a torn-down helper's result runs no turn. Owner conversations have no run row and are unaffected. The settlement can still end in that failure or a stop, never by giving up. Helpers keep the gap closed from their side: the Delegate plugin records a background result (deliverCompletion) before it publishes the terminal progress row that ends the helper's liveness, and a finished background workflow stays live until its summary has been handed to the result sink. Settlement asks the liveness rule only about the workflow rows that can still be live (openWorkflows): a running row, or a background run whose summary_owed flag is still set; a running background snapshot raises the flag and enqueuing the summary into the result inbox clears it. The engine stops reporting a DAG live once it is done handing the summary over, including when the sink refused it, so a row that still owes its summary while the engine says the DAG is gone is a lost result: the settlement fails the call with that, like a failed delivery turn, and writes the debt off (writeOffWorkflowSummary) so the child's next call is not failed for it again.
A follow-up always lands inside a tracked delegated call or starts one. DelegatedSessionService.continueSubagent picks the route from the child's state: no open call means an idle continuation (its own tracked call, {status:'reply'}); a turn streaming in the daemon takes a local steer; otherwise the committed brain_delegated_steers row goes to the runner holding the child, which steers it into its running turn or, when the child is between turns inside a call the runner settles, hands it to DelegatedSessionService.followUpSettlingChild; a call settling in the daemon takes the same hand-off directly. Each of these returns {status:'queued', messageId}, and the answer arrives once, through the original call. The hand-off runs the follow-up as the child's next turn inside the open call, under the caller's per-turn tool denies (ChannelSendOpts.extraDeny; over the runner IPC the steer frame's deny, whose malformed value refuses the frame) and, in the runner, under the open call's host-RPC turn. The receipt id is that turn's admission echoId (ChannelSendOpts.delegatedSteerId), so the PI journal append confirms it like any other follow-up; a turn that fails drops the receipt with the ordinary notice and fails the call. Only the process running the settlement can see that state, which is why the runner, not the daemon, decides. A child that is active but neither steerable nor settling (queued for a runner slot, starting up) refuses with a retry hint; a runner that holds no open call for the child drops the row with the ordinary notice. A channel turn that ends drops only the receipts it queued itself (hidden results, and text rows in its own queue mirror); a row in transit to it over runner IPC or waiting for its lock is resolved by its own transport. Example: a lead sub-agent waits on three workers, and its parent sends "stop waiting and report"; the lead runs that as its next turn, stops the workers, and the original delegation delivers the report. Before 2 Oct 2026 a runner-hosted lead dropped such a follow-up as "turn ended before the runner could queue the follow-up", and a lead whose result turn ended with an empty reply was handed the same helper result up to four times.
Delegated progress carries optional agentType, the chosen type's catalog name, separately from the task's name. The current catalog has no separate display label. Untyped and fork delegations omit it. applySubagentUpdate preserves the recorded type on later continuations; the existing run state JSON validates and stores it without a schema migration. Workflow nodes carry the same optional display field in their durable snapshots. A child's fixed-session stream includes session.delegation: { agentType?, status }, projected from its parent's existing run or workflow rows, so reopened CLI and web views use the shared childReadOnlyText narrative without a catalog fetch or model call. The same projection publishes a display-only delegation frame on the child's existing stream whenever the parent's persisted subagent or workflow progress changes. Only the child's own type and outcome are forwarded, never parent history or sibling data. Runner taps retain the daemon's existing control lane for these frames, and the daemon overlays the same authoritative delegation projection on runner snapshots. These display frames are broadcast, not journaled, because the atomic snapshot already carries their current state. Turn idle, tool activity and provider errors do not decide the delegation outcome: a child can remain running while waiting for nested children or background jobs. Ordinary conversations omit that metadata. This is display-only and changes no permissions or tool execution.
A delegated child has one name for its whole life: the one it was first delegated under (Delegate's name, the name derived from its original task, or its workflow node's name). packages/plugin-shared/subagentName.mjs supplies the pure label derivation to the Subagent plugin and store readers, preserving the five-word and forty-character bounds without changing saved names. originalChildName (src/store/delegatedChildName.ts) is the single durable origin-selection rule, and BrainDelegationStore.upsertSubagentRun stamps its answer onto every call's run row, so a DelegateContinue row never carries a label of its own and every reader of the newest row (agents rail, DelegateList, the running-subagents reminder, cache-drop alerts, the conversation tree) agrees, also after a restart. Version 42 rewrote the labels of rows written before the rule existed.
Conversation display and control
Native ShowView carries model-authored html, optional css/js, a required plain-text summary, and optional waitForAction. The strict boundary caps the complete UTF-8 document at 48 KiB and each turn at three views. Successful results use details.view/viewId; only this core tool produces a view event/segment. Direct events, message hydration and nested ToolTrace records use that same document. CLI, HTML export and all platform delivery paths render only the summary. Waiting calls are refused on non-web turns rather than pretending the user can click. Ordinary native account grants apply; model-only interaction exposure keeps the definition immediate; no grant migration or plugin UI API bump is introduced. Measured on 2026-10-08, the loaded parameter schema serializes to 678 bytes; a single-button sample is 171 UTF-8 bytes and approximately 45 tokens by PI's estimator. Documents ride the existing native call/result history; no prompt block is injected.
Waiting views extend ElicitationRegistry rather than adding another waiter. brain_view_waits stores one pending document per session, its call id, optional code-mode reporting call id and a write-once answer. The schema's existing additive boot path creates the table and index without a numbered data migration. Parking writes the restart marker in the same transaction before publishing. The view has no timer; shutdown leaves it parked and boot recovery skips provider continuation until an answer exists. Atomic snapshots restore the pending view even without a live replay. POST /brain/view-answer validates bounded text and session ownership, persists before settlement, and resolves the original tool Promise or the existing interrupted-turn recovery path. Recovery answers the original call with a real native journal tool result, not a user message, and deletes the wait after journaling. A resident PI session refreshes through the existing journal proxy before continuing. New owner input is refused as busy while a durable view row remains, including after restart when PI is not streaming and after a click until the answer is journaled; admission preserves the original recovery marker and never opens another provider turn over the unresolved call. Native append and late trace-replacement boundaries retire answered wait rows only after their result is durable. Stop also cancels a recovered wait without a live session and settles its outstanding native call before clearing the park marker and paused queue. Stop, replacement and deletion cancel through the existing elicitation lifecycle; moving or detaching a viewer preserves a view wait. Ordinary question/approval behavior is unchanged. View waits mark blocked/question control without exposing an empty AskUserQuestion picker to text-only clients.
ShowView is a model-only interaction boundary, like AskUserQuestion and ExitPlanMode: it stays direct to PI, never enters the script catalog or deferred set, and never counts toward the automatic deferral threshold. Composition appends its definition after the existing core groups without changing any pre-existing definition or its relative order. Code-mode cells therefore cannot open a view wait or bind its result to an already-yielded wrapper. ShowView passes its execution AbortSignal to the same registry; cancelling a native call closes that view and settles the invocation without a click. Parking for a daemon restart does not abort the signal and retains the durable wait. beginDrain explicitly calls ElicitationRegistry.cancelAll(reason, true) to preserve views; cancelAll defaults to cancelling them, independently of the diagnostic reason string. Non-waiting views may run local controls, but their action bridge sends nothing. A sample ShowView({html:'<button id="pick">Choose</button>',js:'document.querySelector("#pick").onclick=()=>elowen.action("chosen")',summary:'Choose a variant',waitForAction:true}) returns the action only to the agent.
The pure browser-safe transcript projection is src/brain/transcript.ts. The architecture guard names the exact pure display dependency graph and rejects daemon runtime or Node builtin dependencies inside it; erased contract imports remain allowed. Web bundles a byte-identical importless mirror pinned in tests/contract/webMirrorContracts.test.ts, with erased wire types only across the source boundary; CLI uses TranscriptModel for targeted indexing, revision journals and terminal painting, not a second semantic reducer. Browser-only canonical exports carry the existing externalConsumer tag because Knip cannot follow byte-pinned mirror consumption; no module-wide dead-code exemption is used. Complete BrainEvent payloads preserve attachment provenance, command/output associations, model-step boundaries and history identity. Prepending replaces synthetic running-work anchors only when every real anchor is present and carries newer progress into those rows. Unrelated token frames retain child-panel projection identity. The shared workflow view preserves the live event's background flag so detached work never becomes foreground work after projection.
BrainStatusService projects pendingAsk and pendingPlan alongside workMode from the sole BrainStore.conversationMode authority. The newest-user-turn journal policy defines pending-plan lifetime; a newer user turn retires the earlier decision. Status and atomic stream snapshots serve that same authority. Clients do not reconstruct it from displayed history, and implementation selects Build through the serialized switch before admission, restoring Plan if admission fails. Control and sidecar revisions fence status requests started before newer streamed state, including cards and queue. Web control reads are ordered by issuance and target session. CLI questions are retained per viewed session; a read-only child cannot answer or dismiss the parent's question, which is restored on return. Plan decisions wait for parent work to settle. Resync reconnects for an atomic snapshot instead of overwriting a running turn with durable-only history.
Authorized status/snapshot composition uses one execution projection for projectRef and project location. A delegated placement overrides the parent/root directory; absent identity stays absent while a child loads. Host paths and branch reads remain constrained by the viewer policy. The server's switchable-target listing supplies exact executionRef, icon and account order to web, CLI and platform pickers. Browser project registrations are not execution authority, and selection revalidates the chosen target.
The three-view budget counts admitted displays in the current policy turn. Without a view elicitor, a waiting call is refused before reservation; a non-waiting call returns its summary and view details. An elicitor refusal with no details.view releases its reservation. Once a waiting view has been displayed, later native-call cancellation still consumes that slot. Owner CLI turns use the same native ShowView tool and refuse waiting through the turn-bound elicitor, so they can recover with a non-waiting view in the same turn. No separate quota store or error-retry layer is introduced.
beginDrain calls ElicitationRegistry.cancelAll('daemon restarting'). Global cancellation rejects ordinary questions and queued approvals but always leaves native view promises and durable wait rows intact, regardless of the diagnostic reason. The method has no view-lifecycle option. Explicit stop, replacement and session deletion use the existing session-specific cancellation path when a view must be settled and removed. With no parked views, global cancellation only affects ordinary elicitation. Preserved views have no timeout; the authenticated web conversation answers them through POST /brain/view-answer, and interrupted-turn recovery reads the view-wait rows once before settling an answered call, refreshing the journal-backed session and continuing.
Schema migration 58 runs once through openDb under the existing IMMEDIATE write transaction. It settles every historical non-null nested view wait into its original wrapper tool result before dropping reporting_call_id. Stored answers and view documents survive; unanswered actions receive an explicit interrupted result. Existing results retain their original content and gain the recovered view traces. A missing wrapper call or invalid document aborts the complete migration. Direct ShowView waits remain durable and recover normally through ElicitationRegistry. Fresh installations have no wrapper column; no operator SQL or separate recovery worker is required.
Fast mode: capability and choice
Two questions, two owners. Whether a route can take Fast at all is resolveFastModeRoute in src/brain/fastMode.ts; it is the only place that classifies a configured provider and its exact request model, and it feeds the fastAvailable flag of the model catalog and of a live conversation. Whether the account wants Fast for a model is fastModels, a per-user list of providerId/model keys in user_settings (UserSettingStore.fastModels / setFastModel; cli-settings field fastModels), the same key convention as autoCompactAtByModel. Fast is in effect for a provider request only when both agree. LiveSessionSpawner composes them per request in fastRouteFor, keyed by the request's own model (a compaction call on another model is judged on that model), for the account driving the request, and hands the result to wrapFastModeRuntime; the account list is read before every call, so a change reaches live conversations without a respawn. BrainStatusView.fast means Fast is active for the conversation's current model (listed and supported) and fastAvailable that it is supported; every statusline reads fast, so an unsupported model never shows FAST. /fast (BrainService.setFast, and setAccountFast for a room) flips only the current model's key and stores nothing for a model without a route.
resolveFastModeRoute splits the decision in two. The configured route and wire API decide the TRANSPORT, in code: the ChatGPT OAuth account (openai-codex-responses, without its Spark and image models), the official OpenAI endpoint over Responses or Completions, an Azure OpenAI Responses endpoint, and the official Anthropic API endpoint with an API key. A relay never qualifies, and neither does a Claude subscription (OAuth), whatever credentials it holds. The MODEL and its wire come from data: fastModelFor (src/brain/fastModelOverlay.ts) answers "does <catalog>/<model> list a Fast mode, and with which request body and headers", and the route carries exactly that body and those headers (applyFastModePayload merges the body into the request; the headers are merged token by token into any header the request already has, which is how Anthropic's beta flag joins the existing one). The data is the experimental.modes.fast block of the models.dev catalog (service_tier: "priority" for OpenAI, speed: "fast" plus the anthropic-beta flag for Anthropic), reduced by reduceFastModels (src/brain/fastModelCatalog.ts, the only place that reads that document) to OpenAI and Anthropic models, the only catalogs a transport above reaches. Prices are not stored: PI prices OpenAI's priority tier itself, and Anthropic's Fast rate stays the fixed 2x of fastModeCostModel (models.dev lists every Anthropic Fast row at that factor). Codex OAuth, official OpenAI and Azure all read the openai rows; Azure has no rows of its own, so a deployment is assumed to be named after the OpenAI model it serves (a -YYYY-MM-DD suffix is ignored). A deployment with a custom name is therefore not offered Fast.
The data has two layers behind that one lookup. The generated baseline MODEL_FAST_CATALOG in modelCapabilityData.ts (refreshed with npm run models:refresh, which reads models.dev with the same reduceFastModels) is what a fresh install and an offline host know. The runtime overlay fast-models.json in the brain directory is written by the background worker in the same pass that refreshes PI's catalog (boot, every four hours, a forced POST /brain/models/refresh), by the same temp-file-and-rename write, under the same 60 s limit and kill on pause; the document is fetched, reduced and dropped inside the worker and only the reduction is stored. When an overlay exists it is the whole answer, so a model models.dev stopped listing stops being offered; the baseline answers only while there is none. A fetch that fails, is not JSON, or lists no Fast model at all keeps the previous overlay and is reported once under the provider name models.dev in the pass's endpointErrors, never as a catalog failure. The daemon re-reads the file after a pass that rewrote it (reloadFastModelOverlay), and every consumer asks fastModelFor per call, so a new model reaches the Account row, the fastAvailable flags and a live conversation's next request without a restart. Whether the provider's backend really accepts a model's Fast body is what models.dev claims, not something Elowen probes.
Version 40 (migrateFastModeToModels in src/store/db.ts, using fastCapableModelKeys from src/store/fastModelKeys.ts) replaced the account-wide fastMode row: an account whose switch was on gets the keys of every model its stored provider configuration and PI's bundled catalog offer on a Fast route, including the connected Codex account's bundled models; a provider whose model list exists only as a live /models snapshot is not seen and is turned on again with /fast or in Account. Restart the daemon right after building: an old daemon that keeps running reads no fastMode and would recreate a row nothing reads. A chat room's /fast keys the model the room really runs (its live session, else the invoking account's own preference through the spawner's selection rule), because the chat plugins' own catalog pick is only the instance default; every /fast toggle bumps the cli settings revision so a stale Account tab conflicts instead of overwriting it.
Account tool authority
users.allowed_tools is the single account grant for both native and plugin tools. Core tool factories consume names from src/shared/coreToolNames.ts; builtinToolMetas() composes their real definitions and a migration test compares both catalogues. Add a core tool there and in its factory together. src/shared/toolGrants.ts owns exact/prefix matching and account-grant validation; only explicit MCP family patterns are accepted, so a newly composed builtin cannot be picked up by a broad saved prefix. With no account grant, no grantable tool is exposed. Infrastructure envelopes (ToolSearch, code-mode exec and wait) are not grants because discovery and nested calls still pass through the same visibility and execute gates. toolAuthorityForUser resolves a fresh account grant, toolPermitted is the one allow/deny predicate, visibleToolNames and ToolSearch filter the model's catalogue, and the universal gateSessionTool execute wrapper rechecks every call including code-mode nested calls. Its plugin ownership/hook wrapper does not repeat the policy check; personal ownership and plugin vetoes narrow the already-permitted call. Administrators use the same account grant. Plugin grants, access.denyTools from platform adapters, delegated captured restrictions and granular permission rules remain independent narrowing. Version 36 resolves old wildcard grants from the enabled manifest catalogue using the same plugin roots as the daemon's runtime, adds existing built-ins to human accounts, adds none to chatbots, subtracts old denials, and drops the deny column atomically. Administrators' stored grants are made finite the same way. Version 37 grants each existing administrator all grantable built-ins and tools from enabled plugin manifests, the explicit mcp__* family for dynamic connected tools, and every enabled user-grantable plugin, using the same bundled-first roots. Missing or unreadable manifests contribute nothing and never abort boot. Model lists were open then, so v37 left allowed_execs alone. Version 52 closes them: src/shared/execs.ts owns the grant: an empty list allows no model, and the single reserved entry ALL_MODELS_GRANT (*), defined in src/shared/modelGrantRule.ts and re-exported by execs.ts, allows every model the installation offers, now and later; it is valid only as the whole list, and grantsAllModels (same file) reads it only from a list that is exactly that entry. The column is CSV, so isStorableModelGrantEntry refuses any entry holding MODEL_GRANT_SEPARATOR: isValidModelGrant applies it at the API, the store writer, the plugin models field and the migrations, and readModelGrantColumn returns the all-models grant only when the whole stored value is *, so no stored entry can read back as a wildcard. Microsoft provisioning grants only the named known models from a template that mixes * with others. The migration gives every account whose stored column holds no entry at all the all-models grant, so nobody's access changed at the cut. It and version 53 judge the raw column through storedModelGrantEntries in src/store/migrations/grants.ts, a frozen CSV or JSON split, never the live parser: a list of only retired specs must not read as empty and gain every model. Version 53 retires the external agent programs. A model identity is brain-only: ExecRef is { provider, model }, parseExecRef reads the canonical <provider>/<model> and the legacy elowen: and elowen| spellings, and anything else, such as sonnet, codex:gpt-5.5 or opencode:vendor/model (every removed <program>: prefix is refused before the slash is read), names no model. isOfferableExec is the one offered check and isExecAllowedForUser the one account check. The migration rewrites each stored list to its canonical brain entries and drops the rest, including an entry holding the separator (never split) and a * that was not the whole list; a list left with nothing stays empty, which now means no model, so a sonnet,opus grant ends with no model access rather than every model. There is no instance-level model allow-list: the Settings catalog shows what providers offer and the personal grant narrows it, while the retired allowedExecs and defaults config keys are dropped on load and refused by PUT /config. fullAdministratorGrant carries the all-models grant for the first administrator; every later account starts with an empty list. The web mirrors the token in web/lib/modelGrant.ts, pinned to the AllModelsGrant wire type. Future built-ins and plugins still need explicit grants; future dynamic MCP tools match the migrated explicit family grant. With no legacy column it does nothing; a manifest it cannot read contributes no tools, which narrows the grant rather than widening it; only an administrator legacy denial aborts, because the admin bypass could not keep it. After migration the old standalone allow-list script is removed. For example, adding a new MemorySearch-like factory leaves it unavailable to all existing accounts, including administrators, until explicitly granted.
The model-reference cascade (src/store/modelReferenceCascade.ts) removes every stored choice that names a model the installation no longer offers. Reading already refuses such a model, so the reference is inert, but an inert entry still breaks writes: the Users modal sends the held grant plus the newly ticked model, and PATCH /users/:id refuses the whole list for the stale entry. Triggers: ConfigStore.update runs it in the same write lock as any patch that carries brain.providers (the patch's own settings blob and the account tables commit or roll back together; the account half is accountModelReferencePruner in src/store/accountModelReferences.ts, injected by brainCore.ts because the account stores sit downstream of modules that import ConfigStore, so a store built without it, as in tests and read-only tools, prunes the settings blob only), and ConfigStore.sweepModelReferences runs it once at daemon boot from brainCore.ts, so data stored before the cascade existed is cleaned on the next update. The boot sweep judges only a provider list it can read: a missing or unparseable settings row is skipped (read() would turn it into defaults with no providers), and a stored entry this build's sanitizer drops, such as a provider type from a newer release after a rollback, keeps its id as a catalog. What counts as offered is durable configuration only (durableModelOffer): every stored provider entry, where an empty models list is a live catalog and keeps everything, plus every OAUTH_BUILTIN id no stored entry claims, also as a catalog; a revoked or disconnected credential is never read as a deletion. Covered: brain.modelOverrides keys and runtime.hostedToolSearch probes are dropped; brain.defaultModel becomes null and is reseeded by initializeBrainDefaultModel (after PUT /config, and at boot when the sweep cleared it); the categorization and dashboard digest roles are cleared to unset; manifest-declared model fields (PluginModelRefFields, image fields excluded) lose the reference in instance and per-account plugin config (pruneModelRefSlice); users.allowed_execs loses the entry (the all-models grant is never touched, and a list pruned to empty grants no model, so it only narrows); UserSettingStore.pruneModelReferences clears chat, vision and compaction pairs whole and drops project pins, per-model compaction thresholds and Fast entries. Each changed account resource advances its revision, so a stale form gets a conflict instead of writing the reference back. Not covered, on purpose: the embedding and voice blocks (validated by their own write paths, not brain choices), live sessions and workflow state (runtime state that falls back on its own), files outside the database (agent definitions, plugin-owned data such as cron jobs) and every history or usage table. With nothing stale a pass writes nothing; with something removed it logs one config info line with the counts. Cost: one scan of users, the model keys of user_settings and the declared plugins' user_plugin_config rows per provider save and per boot. Example: removing mimo-v2.6-pro from a provider's model list removes opencode/mimo-v2.6-pro from every account that held it, so the next save of that account's models succeeds.
composeSessionTools classifies AskUserQuestion and ExitPlanMode as model-only before resolving
deferral. They stay directly callable by the model and do not enter deferral candidates, automatic
thresholds or the code-mode catalog. Script exclusion uses exposure metadata rather than a second
name filter. Without code mode, their ordinary direct interaction behavior is unchanged; classification
adds no timers or inference calls.
Local tool discovery uses PI's public createToolSearchExtension(). src/brain/toolSearch/toolSearchTool.ts restricts its catalog and activation API to the current sender's composed, gated and schema-capped tools. Registration keeps the durable name ToolSearch and passes the native definition through gateSessionTool. PI owns BM25 ranking and schema declarations; only select: is intercepted, for exact loading of permitted names. deferralPolicy.ts remains the single policy owner, prompt-side previews live in src/brain/prompt/toolCatalog.ts, and local search uses no embeddings. initialActiveToolNames restricts the SDK's explicit initial loadout. activation.ts projects successful journal loads across sender changes before the current sender's visibility filter is applied; the pre-upgrade journal regression documents why PI's latest declared set alone cannot replace it.
Automatic turn-start and live recall require MemorySearch in that same account grant; post-turn curation requires MemoryAdd. The stored memory toggles may disable a granted operation but cannot turn an ungranted operation on. With no grant, neither operation runs. For example, a chatbot retaining only ShareImage and ShareFile keeps those tools and receives no automatic memory; an administrator needs the same grant.
Agent access to the daemon API
An interactive Always allow choice runs the consented call even if rule persistence fails. resolveAsk logs that failure at the approval seam and explicitly identifies the approval as call-only; no durable rule is claimed.
One seam: the core ElowenApi tool (src/brain/tools/elowenApiTool.ts). It is how a turn reaches any daemon endpoint without a typed tool, as the account the turn acts for (currentAccountUserId()) and never wider than the turn.
- Request shape.
src/shared/elowenRequest.tsis the one definition of{ method, path, body? }, wherebodyis a JSON object (an untyped body let models send it as a JSON string, which the routes read as no fields); the MCPelowen_requesttool registers the same zod shape and the host RPC parser validates with it. - Authority.
resolveElowenApiAuthority(src/brain/elowenApi.ts) refuses an unlinked sender, a chatbot account, a turn whose owner flag differs from the account's admin flag, and a turn reaching fewer projects than the account's ownresolvePolicyanswer: the HTTP API authorizes per account and cannot express that narrowing. Plan mode and a read-only delegated child (readOnlyOrigin) are expressible and yield a read-only credential. Tool grants, deny lists and permission rules apply because it is an ordinary tool composed bycomposeSessionTools; it is plan-safe and inREAD_ONLY_AGENT_TOOLSbecause its API access is read-only there. - Credential.
AgentCredentials(src/api/agentCredentials.ts) mints oneela_value in daemon memory per request;runElowenApiRequestsends the request to the daemon's own URL throughcallElowenApiand revokes the value infinally. The bearer gate (src/api/auth.ts) resolves it before any public-route or setup exemption, refuses non-GET/HEAD on a read-only grant, and the session-only gate (isSessionOnly,src/api/middleware.ts) refuses it per credential scope: everything a personal API token is refused, plus login, SSO, impersonation, account management under/usersand the account's own permission rules. One table carries both scopes; the catalog reads the same predicate. - Runner. A forked sub-agent runner cannot mint: its
elowenApidependency sendsELOWEN_API_RPCover host RPC.hostRpcRefusalre-checks the tool against the child's policy; the daemon'screateHostRpc(src/daemon/bootstrap.ts) re-checks liveness and the tool against the child's durableDelegatedExecutionScope, and resolves authority from it (delegatedElowenApiBoundary).resolveElowenApiAuthorityreads the account fresh on every call, in-daemon or runner:ElowenApiAccounts.toolAuthority(toolAuthorityForUser) must still grant the tool, and the administrator flag and project reach must still match, so the effective check is the child's durable scope intersected with the account as it is now. - Discovery failures. Non-OK catalog responses preserve the HTTP status in text and
details.statusand setisError: true. Successful catalog bodies remain unchanged; the request action retains its explicit status envelope. - Discovery. The tool's
searchanddescribeactions readGET /api-catalog/searchandGET /api-catalog/describe(src/api/routes/apiCatalog.ts,src/api/routeCatalog.ts). The catalog is derived per request from the Hono router'sapp.routesand the live plugin registry'sapiRoutes/rootApiRoutes, so a new route or a plugin enable/disable changes it with no edit. A core route's body schema is the zod schema it registered withdeclareBody(schema)(src/api/validation.ts);parseBodythrows when a handler parses a schema its route did not declare, so the two cannot drift. Results are filtered to the caller: admin-only plugin routes, ungranted plugins, session-only routes for token callers, and mutating methods for a read-only credential are left out. A core route's admin gate lives inside its handler, so the catalog cannot see it and the route answers 403 when called;describesays so in anotewhenever it returns a core route. GET and HEAD handlers must not change state, because a read-only credential reaches them: the dashboard digest, for example, starts onPOST /dash/recapwhileGET /dash/recaponly reads and reportsrefreshDue. - Agent shells.
PluginContext.agentShellEnvironment()stampsELOWEN_AGENT_SHELLon every agent launch (Sandbox execution service, Terminal fallback), fromunrestrictedShellAccount:account:<id>only for the account's own non-automated chat with an unrestricted, non-Plan boundary.src/cli/agentShell.tsmakes the CLI refuse otherwise, and foraccount:<id>verifies the cached login withGET /auth/mefirst.
Absent wiring: without BrainDeps.elowenApi the tool answers that the API is unreachable; without ServerDeps.agentCredentials no ela_ bearer authenticates. Cost: one loopback HTTP request per call; results are ordinary tool results, so large responses spill like any other.
Prompt cache prefix identity and historical breakpoints
src/brain/session/promptCanonical.ts owns hashCanonical(value) for provider payloads and
segments. It recursively removes cache_control and folds a message's string content into one text
block, preserving the existing JSON field order and SHA-256 digests. cacheBreakpoints uses it for
cumulative prefix identity; cacheWatch uses the same equality for diagnostic snapshots. Hashing
does not depend on enabling the monitor, retains no prompt content and costs one linear pass over
the supplied value. Prefix tests use the module's internal canonicalPayload projection.
anthropicCacheBlocks.ts::isAnthropicCacheableHistoryBlock is the shared Messages block classifier
for trailing breakpoints and hosted-search marker relocation. Text, images, tool results, tool removals
and immediate inline tool_addition definitions can carry a marker; deferred inline definitions
cannot. Hosted search moves their marker to the preceding eligible block with its existing TTL.
After an accepted 2xx response, the session-local trailing breakpoint remembers the marked message's
cumulative system/tools/history prefix and restores its marker on the next append-only request,
including a historical immediate addition or removal beyond Anthropic's 20-block lookback.
It uses the current request's TTL and respects the four-marker limit. No current history marker,
a changed prefix or a failed unconfirmed request supplies no new write to restore; the fork-opening
boundary remains the existing explicit exception. These modules add no provider calls or durable state.
Anthropic hosted replay has three internal owners. anthropicHostedMetadata.ts owns the
version-1 journal sidecar, server-block classification, search-pair validation, signed-thinking
omission rules and usage readers. Replay and capture import it directly; it imports neither.
contextBreakdown reads the captured request input count and loaded tool names from this owner,
and cacheWatch uses its hosted-search classification to avoid treating summed server rounds as
a cache baseline. With no sidecar these readers preserve ordinary PI usage; a hosted sidecar
without a request count returns null rather than using summed billing tokens.
anthropicSseCapture.ts owns the incremental SSE state machine, its existing 16 MiB frame
limit, request-token counting and single-body response tap. It forwards the original chunks and
settles capture on EOF, cancellation or transport error, attaching no replay content when capture
is unsafe. anthropicHostedToolReplay.ts retains route-scoped matching, restore/verification,
refused-turn repair and the session stream wrapper. Matching uses PI's exported
sanitizeSurrogates, while restoration keeps the original signed content, whitespace, field
order and metadata bytes. The session factory installs this wrapper only for its hosted route;
without hosted content the provider history remains PI's native projection. Capture observes
one stream without an extra provider request or transcript store.
Account model authority during compaction and memory
LiveSessionSpawner resolves a distinct compaction model through resolveBrainModelRoute, which checks the account's live selectionAllowed predicate for the explicit personal pick. No provider supplies an implicit compaction model: without a valid, permitted explicit pick, every provider uses the selected chat model, including ChatGPT OAuth. The route carries its configuration provider ID as compactionSelection, separate from the runtime provider name. The session factory passes one live compactionFallbackAllowed callback to both the in-session summarizer and PI-native createCompactionModelRoute; permittedCompactionModel chooses the captured model only while that callback still permits it. With no distinct model or after revocation, both use the session model. Manual and threshold in-session summaries on that model reuse the chat transcript's system/tool prefix, reasoning level, session cache key and per-request Fast options. Overflow recovery uses a standalone request on the same model, because the full live context just exceeded its window. A distinct explicit compaction model instead uses a standalone request and cannot read the chat model's prompt cache. A cross-provider native fallback strips the chat provider's credentials only when actually switching. For example, revoking a chosen cheap model from a running account makes the next summary use its selected chat model without respawning. Memory categorization and icon suggestions pass their account ID into memoryModelInference, which checks the same model-selection authority; a denied workspace model uses the matching active turn's permitted model or skips background inference when there is no such turn. The check is repeated immediately before every request: piInferenceClient re-resolves its route() in decide, and the account-bound route answers null once the grant is gone, so a queued client (for example a post-turn skill review waiting behind the previous one) refuses the request instead of paying for a revoked model.
coordinateNativeCompactionChecks in src/brain/session/compactionCheckCoordinator.ts wraps PI's native pre-prompt, turn-boundary and post-run checks so teardown can observe and cancel them. The session factory installs it through installTurnBoundaryAutoCompaction; hosted Anthropic sessions also size threshold checks from the resident provider count rather than summed server-tool billing usage. Only threshold sizing may clone the assistant message. Explicit overflow and recoverable-length checks pass the canonical assistant object and the native tool-result batch unchanged: PI resolves their journal entries by object identity and appends native context_edit omissions before compacting and continuing the same run. For example, an Anthropic request_too_large response is omitted before the compacted request, without an added user prompt. Ordinary sessions keep PI's usage calculation, and absence of the native check leaves the injected session's behavior unchanged. The wrapper adds classification and cancellation bookkeeping, no provider calls, new retries or transcript authority.