Navigace
ELOWEN · Dokumentace

Poslední aktualizace: 20. října 2026

Procházet dokumentaci · Conversation activity and events

Tento článek zatím není přeložen. Je dostupný v angličtině.

Developer reference

Conversation activity and events

Owning code

  • src/brain/session/chatEvents.ts: the chat event writer and reader (recordChatEvent, recordColdChatEvent, parseChatEventDetails, renderChatEventForModel, lastTurnErrorText) and the outcome builders shared by the web, CLI and platform doors.
  • src/shared/wireContract.ts: the closed wire types (ChatEventDetails, ChatErrorNotice, DelegationFinish, SwitchableProject).
  • src/brain/service/spawnEventReducer.ts: writes the interrupted and error rows at the end of a run and publishes the live chat-event frame.
  • src/brain/brainService.ts: recordConversationEvent, runCommand, recordLocalShell, switchWorkMode and the project listing and switch.
  • packages/db/src/repositories/{journal,transcript,delegation}.ts: the asynchronous PostgreSQL journal writer, turn-baseline and pending-tail reads, and delivery-change notifications.
  • The live stream for a conversation is the BrainEvent union in src/brain/events.ts. src/api/sse.ts is the instance-level feed (team activity, presence, alerts and content-free invalidations) and carries no chat events.

Conversation delivery failures use declared software problem code api.brain_stream_delivery_failed. brainStream.ts preserves the original error, aborts only that stream and detaches its subscriber once; subsequent publication does not retry the failed delivery. The existing consent-gated problem outbox recognizes that exact code instead of substituting runtime.reporting_invalid. No diagnostic prose, alternate emitter or replay authority is introduced.

Idle CLI metadata invalidation

GET /brain/stream keeps the bound CLI's branch current on the daemon, including when no turn runs. For surface=cli with a stable client identity, the existing stream wait loop calls BrainStatusService.projectBranch every five seconds. This refreshes the same host, managed and paired computer caches used by GET /brain/status, with the same current ownership and Project policy checks; it never starts an environment. The stream remembers only its last observed branch and emits the typed {type:'status-changed'} once when that value changes, including a transition to detached HEAD or no branch. Session-rebind frames move the observation to the stream's new conversation. Disconnect aborts the cadence and releases the attachment. The lifecycle's optional onDetach callback also aborts the route when a newer stream replaces the same client identity or its binding is released; it fires once after listener cleanup, including disposal during snapshot capture. An observation failure is logged and closes the stream.

The CLI's StreamCoordinator handles this outside transcript hydration and runs its existing refreshMeta. No CLI timer, second stream or new status cache is introduced. Other surfaces may ignore the event, and room adapters explicitly classify it as unrendered. With no bound CLI stream there are no background branch reads. Each attached CLI costs one authorized observation per cache interval; actual host, guest and computer branch reads retain their shared cache bounds. Concurrent authorized managed reads join one pending read per project, just as computer reads already share their pending target read. The first caller owns the managed read's cancellation signal; every joining caller still discards the answer if its own signal was cancelled. Settlement removes the pending entry. Keep-alives remain at 30 seconds. Streams without a bound CLI send a keep-alive after each completed 30-second wait, independently of wall-clock changes. The default frame is : ping; heartbeat=1 opts into the named heartbeat event with {} data.

Native model retry projection

BrainEvent publishes { type: 'retry', retry: RetryProgress }; notice.kind is compaction-only. The stateless PI event mapper does not classify provider prose or manufacture native attempt metadata. SpawnEventReducer owns the live projection: it reads sessionRetryPolicy(session) once per native episode and subscribes through onSessionRetryDispatch. PI remains the only model retry owner; observing progress does not introduce another scheduler or request.

auto_retry_start describes the next scheduled retry, with PI's exact delay and wall-clock deadline. Its policy revision and episode survive internal native agent_start events. Only the actual HTTP fetch callback publishes attempting and the actual dispatched retry count. Without that evidence the actual count is null, not zero or the scheduled retry number. Public PI WebSocket dispatch is unobservable through fetch, and native final-HTTP Retry-After scheduling is unsupported; neither onPayload nor diagnostic text repairs these limits. See request policy and dispatch evidence. Typed assistant stop reasons and agent_settled.aborted distinguish cancellation from recovery even when native auto_retry_end reports success.

LiveEventReplay.modelRetry(): RetryProgress | null exposes one current live model lane independently of bounded transcript-tail retention. Same-target retry frames replace earlier replay entries; compaction is independent. Terminal settlement and the next admitted run clear the sidecar, not intermediate native retry runs. The importless src/brain/transcript.ts fold and its browser mirror expose ChatView.modelRetry separately from compaction, reject stale same-target generation/sequence values and clear at terminal, session and discard boundaries. TranscriptRead.modelRetry and TranscriptModel.modelRetry expose the same canonical RetryProgress | null through that pure fold. Retry/compaction metadata updates visit zero turns, preserve revision-change semantics and copy no history; history/session resets clear the lane. The 40,000-turn indexed regression pins this constant-work boundary. These interfaces supply live status/control and transcript consumers without establishing complete caller composition. Without a retry there is no model line or retry presentation work. Projection keeps one sidecar and existing bounded replay storage; only PI dispatch has model request cost.

Recipient-scoped computer waiting controls

GET /brain/stream checks Project membership for each recipient's control.executionState.waiting_for_computer at queued delivery, not just when subscribing or capturing the snapshot. This applies to snapshot.control, embedded replay controls and pending/live control frames, so revocation while a frame is queued cannot disclose computer waiting details. Transcript oversight, including administrator oversight, does not grant computer membership.

A restricted recipient receives executionState: null; ordering, replay cursors, modelRetry, workPending and transcript content are unchanged. Control frames never share serialized bytes between subscribers. Ordinary public events retain the existing same-object serialization cache. The existing access helper performs one membership check per waiting control per recipient, with no new polling, permission store or retry owner. Focused route regressions cover a restricted recipient and membership revocation during queued delivery.

Independent computer-wait and model control

BrainStatusService.turnControl reads the targeted journal.computerPrerequisiteForSession(sessionId) in the existing repository snapshot and projects only { phase: 'waiting_for_computer', projectRef: { kind: 'local', projectId }, waitingSince: Date.parse(createdAt) }. Native reference, generation, token/version and boot authority stay private. A current wait is pending work even without a live runtime; program status remains working unless an existing question/permission block owns it. Existing activity/streaming/background rules remain independent: a real retained working activity can still be streaming, while wait alone does not create streaming on an idle activity. Cancellation or the targeted reader's absence clears executionState; stale/dispatch-admitted waits are excluded natively.

modelRetry reads the actual required live.replay.modelRetry() getter, or null without live runtime. It never erases computer waiting or compaction and adds no chat event/persistent retry history. Scoped callers pass viewerUserId and recheck membership after asynchronous root-pool metadata work. The optional viewer is omitted only for generic daemon publication; the stream route must still apply its existing per-recipient control filter at queued live/replay/snapshot delivery. This projection is not a second delivery filter or authorization store.

Real consumers are status, scoped read-only snapshots and live stream snapshots; no consumer means no read or display work. One targeted native wait read plus existing membership/replay projection adds no timer, provider call or execution retry. Focused projection/native regressions do not prove transport admission, complete cross-owner composition, browser flow or physical-device acceptance.

Chat events: one record for what the user saw

A chat event is something a person sees happen in a conversation that is neither their message nor the model's reply: a stopped turn, a failed request, a changed setting, a command they ran themselves. src/brain/session/chatEvents.ts owns it, and there is exactly one record: a PI custom_message journal entry with customType: 'elowen.chat-event'. Its details hold the closed, versioned fact (ChatEventDetails in src/shared/wireContract.ts); its content holds the English text the model receives, rendered once at write time by renderChatEventForModel and stored verbatim. PI's own convertToLlm turns the entry into a user-role message, so the model reads the stored row itself, live and after a restore, and every surface renders its row from the same entry as a ChatEventView { id, details } (shapeBrainMessages projects it as a role: 'event' view; the live stream publishes a chat-event frame from PI's message_end for the appended entry, after the append barrier has committed it).

To record one, call recordChatEvent(session, details) on the live PI session. Its promise reports whether PI accepted the append: a refusal is logged and returns false, so an acknowledgement-owning caller such as plan dismissal awaits it and refuses success rather than losing the decision. BrainService.recordConversationEvent also checks the live persistence-failure latch before and after acceptance: PI accepting an event on an already poisoned session is not a durable acknowledgement. Streaming producers may still enqueue and let the normal journal publication report durable delivery. It goes through PI's sendCustomMessage(..., { triggerTurn: false }): an idle session appends at once; a streaming session defers the append to the end of the current step or to the end of the run, the first point where it cannot land between a tool call and its result. Entries appended at the tail never rewrite an earlier request, so the cached prompt prefix is untouched. Readers validate details with parseChatEventDetails and render nothing, with a logged error, for a shape they do not know. The model-facing text is English, but a surface in another language must not show it: every fact a person reads is typed in details, so a command's outcome carries codes and values (NoopCompactReason, GoalStatusSnapshot), and an error carries its English text plus, when the daemon wrote that text itself (publicProviderError in service/spawnEventReducer.ts, the compaction circuit breaker's CompactionStopReport), a ChatErrorNotice a surface words it from. A provider's or a thrown error's own text has no notice and is shown verbatim. With no producer nothing is stored and nothing is shown; nothing else keeps a copy.

Example: a stopped turn. Every abort passes its cause to abortSessionWork(session, cause). When the run's terminal agent_end ends on an aborted assistant, spawnEventReducer records interrupted ([Request interrupted by user], … for tool use, the step ceiling, or the delegating parent) and holds the terminal idle until agent_settled, so a client sees the row before the turn ends, as a reload orders them. The Esc-before-output discard and the deleted/probe causes record nothing, and so does connection-lost, whose turn fails with the typed LOCAL_EXECUTION_LOST error that the turn's caller records as an error event. A run settled by a provider-preflight refusal (PROVIDER_PREFLIGHT_REFUSAL in session/providerPreflight.ts) also ends aborted with no cause, because nothing stopped it: the preflight operation rethrows the refusal to the caller that admitted the turn, so the reducer records and reports nothing for it. Any other aborted run without a cause is reported as runtime.abort_cause_missing. Because pi-ai drops every aborted or errored assistant from the wire, session/interruptedReplyReplay.ts (installed by the session factory on agent.transformContext, so chat requests and the compaction summarizer both see it) replays such a reply as a text-only assistant message when an interrupted or error event follows it. It is a pure function of the message list, so a live and a restored session send the same bytes. A failed turn records one error event with the cleaned text publicProviderError produces (the raw provider message stays in the daemon log): at the terminal errored agent_end, at a failed overflow recovery, when the compaction circuit breaker stops, and, through BrainService.recordConversationEvent (the cold path writes the same entry at the active leaf under the session lock), for a turn that failed after admission. Rooms (src/brain/channels.ts) and the activity rail (src/brain/service/turnRunner.ts) read that stored text through lastTurnErrorText; /v1/agent/runs streams the details.text of the same stored error chat event (src/api/routes/agentRuns.ts). The only failure left as a live error frame is a store that refuses writes, which cannot store its own refusal. The one deliberate residue is the reasoning of a cut reply: the user saw it, the model does not get it back, because unsigned partial reasoning cannot be replayed safely.

isCompactionRecovery in src/brain/events.ts is the shared classification used by spawnEventReducer and the persistence projector for PI's post-agent_end compact-and-retry. It covers errored context overflow and isRecoverableLength with the selected model's original maxTokens, even when PI reports willRetry: false and the provider request was clamped to one output token. A successful over-window response and a length stop that reaches the desired output limit remain terminal. Intermediate attempts keep the run open, retain provisional journal entries, publish no idle, and stamp no whole-turn duration. Each attempt's request usage is still billed at its own agent_end; classification adds no provider requests or retry mechanism.

A real compaction inside that run defers compacted until the recovered attempt's terminal agent_end, after persistence settles the journal. The CLI's StreamCoordinator and the web's BrainChatProvider reconnect on a live compacted frame through the same atomic snapshot path as resync; snapshot replay does not reopen the stream. A new user turn can begin before a separate history read returns even when compaction was announced at a terminal boundary. History addresses users by PI entry id, while live user.durableId is the admission's echo id, so BrainStatusService.streamSnapshot excludes journaled users by stored echoId before paging and replays their ordering markers once. This also handles idle manual compaction and refreshes context metadata on reconnect. Cold-start passes finish before the owner runner's user echo; native PI pre-prompt compaction runs after that echo but before agent_start. Both can announce compacted before the following provider output, and a separate history response can arrive after that output. Publication at a turn boundary therefore does not make an asynchronous transcript replacement safe. If recovery fails, its compaction_end records one error chat event using PI's errorMessage, or the deferred overflow error when present; it leaves the run open until agent_settled makes the remaining entries durable. That boundary releases any held compacted and publishes exactly one deferred idle. If no compaction_end arrives, agent_settled still ends the run and publishes the deferred idle, recording only an error that PI actually supplied. The terminal attempt or this canonical fallback measures elapsed time from the original agent_start, including compaction and retries.

Setting changes are chat events too, so the model learns a change from the same row the user sees. A model switch, a rename and a working-directory or project move are recorded when they happen through BrainService.recordConversationEvent(sessionId, details, lockHeld, forceRecord): on a live session through recordChatEvent, on a cold one through recordColdChatEvent, which appends the same entry at the active leaf under the conversation's session lock and publishes its row. When the branch ends on a turn the daemon went down in, the cold entry goes before that provisional run instead (TranscriptRepository.pendingTailStart and JournalRepository.insertJournalEntryBefore), because the spawn that settles the run chains the synthetic answers to its unanswered tool calls onto the active leaf, and an event between a call and its result is a request every provider refuses. Pass lockHeld when the caller already holds that lock (the model switch and the channel project switch do), because the lock is not reentrant. POST /brain/model uses this existing model event as the completion signal: it takes the same per-session lock as a turn, but after 1.5 seconds returns { model, queued: true } while the lock wait continues. Every successful queued switch records completion, including an unchanged provider/model pair or a cold session; immediate changes compare both public provider identity and model id. The queued model is not active until completion is observed; later picks supersede earlier waiting picks, and disposing the original live session drops its waiting pick. Passing waitForCompletion: true on the same route waits for application instead of returning an acknowledgement. A superseded completion-wait request rejects with ModelSwitchRefusedError and HTTP 409, so headless CLI startup and /v1/agent/runs cannot submit their prompt after a selection that never applied. A failure after acknowledgement is logged and recorded through the existing conversation error event path with a typed model-switch-failed notice carrying the requested selection; without a queued pick, the ordinary synchronous response and errors remain unchanged. The web picker and CLI observe that event to reconcile their display without a second polling channel. The web tracks each attempt from request dispatch, retains terminal outcomes across delayed acknowledgements, and settles reconnect snapshots from authoritative provider/model identity or replayed errors through the same event handler. Previously observed event ids are excluded on a retry, so an old stored failure cannot fail the new attempt. A setting change (SETTING_EVENT_KINDS) in a conversation nobody has spoken in yet records nothing, except a queued switch's completion, which passes forceRecord through the existing recorder because attached clients need its terminal acknowledgement; a failure, a command or a !cmd always records. Mode selections are recorded immediately by switchWorkMode, including before the first message. Only reasoning is recorded at admission by recordTurnSettingChanges, against the stored TurnState of the previous composed turn, so cycling the level and back before sending records nothing and a respawn does not repeat a change. A reasoning event recorded after that turn state counts as well (TurnBaseline.announced): a turn discarded by Esc before any output loses its user row onward but keeps the events admission recorded above it, and the next admission must not announce the same change twice. For example, renaming a closed conversation appends one rename entry; the next session restored from the journal reads it without any side buffer.

A command the user typed runs through BrainService.runCommand(userId, session, invocation, run, outcomeOf): with an invocation { name, args? } it records one command event with the outcome, or with the exact error text the caller is answered with; without one the operation runs unrecorded, as a programmatic call. run may record earlier through its recordNow argument, which /goal uses so the row sits above the kickoff turn it starts. The web and CLI execute compact, Fast and goal actions through POST /brain/command. POST /brain/goal remains a separate goal-setting adapter with turnBudget and an optional invocation; without that invocation it runs unrecorded. Dedicated compact, Fast and goal-action HTTP adapters are removed. A CLI !cmd is recorded through POST /brain/local-shell (BrainService.recordLocalShell) as a shell event, and the next message is sent as typed. For example, /compact records after PI's compaction entry, so the model reads Conversation compacted. inside the context it keeps. A platform room's own /compact, /fast and /restart go through PlatformControlApi instead: PlatformOrchestrator records the same command event in the room's conversation (recordEvent, wired to recordConversationEvent) and returns it as event, and the shared runControlCommand core in elowen-plugin-shared/chatCommands renders the room's reply from that event, a failure as its exact recorded error text. The outcome builders (compactOutcome, fastOutcome, failedOutcome in src/brain/session/chatEvents.ts) are shared by both doors, and a room /restart holds the restart until its row is written.

A delegated job's finish row rides the result its parent receives. resultDeliveryDetails(store, parent, resultId) (src/brain/subagentRuns.ts) builds the details of the hidden subagent-result custom message every delivery path sends, adding a marker: DelegationFinish when the result is a job finishing: a sub-agent's name, first task line and outcome, or a workflow's title, outcome and node tally. A steer notice, a steered continuation and a call that returns while another call on the same child still runs carry none. spawnEventReducer publishes the row as a chat-event at that entry's message_end, and shapeBrainMessages projects the same stored entry after a reload, so the row sits where the parent received the result. A foreground delegation has no separate row: its result is the tool call itself.

A Project switch the model requests is deferred to a step boundary. The Project tool only records the request in createProjectExecutionBoundary (src/brain/session/projectExecutionBoundary.ts) and answers pending with a plain sentence saying the switch lands when the current tool batch ends and that the turn must not be ended to apply it; the boundary commits it through BrainService.selectProjectExecution at PI's awaited between-step hook, after the whole tool batch has settled and before the next provider request, so a batch never straddles two targets. The commit passes announce: 'return', so selectProjectExecution hands the cwd chat event back instead of recording it, and the boundary returns that event (built by chatEventMessage, marked by: 'agent', worded "Your Project switch to X is now active; tools now run there") in the snapshot's messages. PI emits and journals it before the next provider request, so the model reads the confirmation in the very next step; recording it through sendCustomMessage would have deferred it behind that step's reply. Every other caller (the /project picker, channel moves, cold switches) omits announce and gets the event recorded by selectProjectExecution with the user wording. selectProjectExecution refuses while work launched against the current target still runs: a turn (not for the model path, which commits between two steps of its own turn), a delegated child, or a tracked background job of the conversation (processRegistry.runningJobCountForSession, which counts job and mode-less handles and not service or foreground ones; a code-mode exec cell is not in that registry). It throws ProjectSwitchRefusedError for that and for a target that cannot be entered, always before the selection is stored. The boundary turns exactly that error into a hidden elowen.project-switch-refused custom message appended to the next step (display: false, so it is journaled and read again after a restore but drawn nowhere), which tells the model the switch was not applied, why, and that the conversation is still on the old target; the turn goes on. Any other error from the commit is a failure past the store and still ends the turn.

An automatic move from an offline computer uses the same cwd event with offlineTarget: {targetId, label}. selectProjectExecution records it with forceRecord even before the first message, under the opening or fresh-turn session lock. Its detail names the destination Project, or the host directory when an administrator has no usable Project. The web localizes this typed fact in Czech, Slovak and English; the CLI and model read the same cause and destination. Passive history reads do not invoke the move, and admitted turns retain the LOCAL_EXECUTION_LOST contract.

A person's /project has one listing and one switch on every surface. The listing is BrainService.listSwitchableProjects(userId), built on the same selectableProjectTargets resolver the Project tool lists from; each row is a SwitchableProject (src/shared/wireContract.ts: id, slug, path, optional icon, executionRef). The chat adapters reach it through PlatformControlApi.listProjects and the shared picker core in elowen-plugin-shared/chatCommands; the CLI reaches it at GET /brain/execution. The switch is selectProjectExecution: the adapters through PlatformControlApi.switchProject (switchChannelProject, under the channel lock), the CLI by posting the chosen row's executionRef to POST /brain/execution, the same route the web project picker uses. A typed /project <slug|id> is read by resolveProjectArgument in elowen-plugin-shared/projectChoice (exact slug, then decimal id) on both surfaces. With nothing listed the chooser says so; a refused switch answers with the selection's own error and stores nothing. The CLI never changes its process directory for a switch, since a managed Project has no host path; /cd remains the CLI's local directory move and does not change the execution target.

The model's Project list additionally exposes hostTarget: { kind: 'host' } when its acting identity is an administrator and Policy.canExecuteHost() permits host execution. Plain host execution has no registered project id, so Project switch authorizes it through this live boundary rather than the project-row listing. The same check serves owner chats and authorized Dreaming children; ordinary delegated children still cannot switch. The pending commit rechecks authority before persistence.

Supplied conversation titles

normalizeConversationTitle in src/brain/conversationTitler.ts owns normalization of supplied names: collapse whitespace, then clamp to 120 UTF-16 code units, without generated-title decoration cleanup. Its typed mode is required. { kind: 'manual' } rejects an empty result with title cannot be empty; BrainService.renameSession and first-message TurnAdmission use it. { kind: 'dedicated', target } returns the full, unmodified target when the normalized title is empty, even beyond 120 code units. BrainService.sendOwnedConversation uses that mode for scheduled and plugin-owned conversations, only when mayCreate && dedicated, after making the session live and before its first turn. Later runs never rewrite the title. The helper is pure; ownership checks, transactions, persistence and publication remain with the callers. Automatic model-generated titles retain their separate cleanup.

Queued user delivery

queueMirror.deliverQueuedUserEcho admits PI's delivered message synchronously at message_start. Its replay sink exposes only publish and journal; standalone mirror consumers use the live replay, while spawnEventReducer supplies its existing ordered publication queue. The preceding queue_update therefore removes the chip before the user echo reaches an attached client or replay snapshot. Since message_start precedes PI's append, the echo effect drains persistence again when it executes, so its row is committed before publication. Shared platform messages retain a journal-only marker. Admission and journal ownership stay with the append barrier; this adds no timer, provider request or second publication queue.

Delivery turns and outstanding work

The first displayed assistant step of a native delivery turn carries trigger: 'delivery' in BrainMessageView and the shared ElowenTurn fold. deliveryTriggerOf validates the existing hidden subagent-result or child background-jobs-finished input's non-empty resultId; a visible details.marker is optional, so markerless DelegateContinue results retain this boundary. Background process wake-ups carry host-only processExit: { processId } on the existing hidden prompt or busy system-nudge custom message. The live reducer publishes a display-only turn-boundary frame with the committed journal entry id, after the same append barrier used by chat-event rows. The fold deduplicates that id and consumes its trigger on the first assistant step. Only the run's opening input decides its trigger. Later steers cannot replace it, including a delivery PI polls before the first model call of an independent hidden goal turn. A mid-turn steer does not start a native turn. Visible user messages, scheduled inputs and compaction dividers retain their boundaries; independent hidden goal inputs carry no delivery trigger. No input text is interpreted and no inbox or stored transcript authority is added. Room adapters classify the frame as explicitly unrendered in the shared live-message reducer (packages/plugin-shared/liveMessage.mjs): the trigger exists to open a native turn's first assistant step in the owner transcript fold, and a room holds no turn fold or work-run grouping.

BrainStatusService.pendingWork shares one aggregation with voice-idle and turnControl. BrainStreamControl.workPending counts admitted or queued turns, compaction, parked questions and approvals, durable waiting views, live or recovering delegation, live workflows or an owed summary, every pending result kind, and the conversation's running background processes including service. It also counts a process's exit wake-up promise, registered before the first exit publication and removed when its sender settles. Scheduled jobs, wake-up timers and goal timers do not count. Voice-idle uses the same conversation signals while ignoring processes. Conversation listings use the shared program projection with their already batched durable read; they do not compute stream-only outstanding work or perform per-session store reads during live enrichment.

The existing publishControl sends this field after delegation/workflow, input wait, queue, process and turn changes. DelegationRepository.onDeliveryChanged observes successful acknowledgement and recovery abandonment of all result kinds, including recovery callers that bypass the delivery runner. Abandonment publishes after its durable acknowledgement, so a client attached while recovery exhausts the last result's wake budget sees outstanding work settle. Workflow enqueue publishes through the runner; delegated completion and steer-notice enqueue retain their existing progress publications. Snapshot control and GET /brain/status await the same control projection. Without work the field is false. spawnEventReducer.closeRun remains the only native replay close point, preserving retry and overflow recovery.

Hidden process nudges use the Agent's custom steering queue, not AgentSession.getSteeringMessages, which exposes user text. PI's session wrapper can continue queued delivery after an errored loop; an explicit abort leaves it for the next input. Neither creates a pending user queue on a settled session, and the next provider context receives the original notice.

Terminal display timing

Persistence passes the native completion timestamp into JournalRepository.setTurnDuration. It updates only the terminal assistant row's turn_duration_ms and display created_at; provider-visible entry_json is byte-identical. The PostgreSQL writer (packages/db/src/journal/writer.ts) stores completion as timestamptz; packages/db/src/parsers.ts::timestampIso decodes it with its explicit offset into UTC ISO text. The transcript codec in packages/db/src/transcript/reads.ts passes those ISO values through unchanged, so a hydrated row carries the same ISO completion time as the live frame. Chronological predicates and ordering operate on PostgreSQL timestamps. The live terminal step receives the same completedAt, including when a chat-event row follows the assistant. For ExitPlanMode, this includes the awaited plan read and tool settlement after the assistant call was appended. Older rows retain their stored precision. A work section's end is the completion time of its last native turn, while its start remains its opener's timestamp; durations are not added together.

Read preview publication

toBrainEvent externalizes a successful direct Read image at tool_execution_end, but that native event and message_end both precede PI's tool-result append. spawnEventReducer holds its preview until a subsequent native event finds the matching tool-result entry in both the resident session and TranscriptRepository.journalEntry. The publication queue has awaited the journal persistence drain that stores the reference, so the first authenticated image fetch is authorized even while the turn remains provisional. This uses the existing journal and content-addressed file store; it adds no ownership record, retry, timer or provider request. The small held list is checked only while a preview is waiting and cleared when the run closes. Failed reads and sessions without an image store produce no preview. Reload and reconnect retain the existing journal/history and live replay paths. Accepted latency: PI runs the tool calls of one assistant message in parallel and appends their results only after the slowest one finishes, so a Read preview in a multi-tool step appears when that step settles, not when the Read itself returns.

Attachment delivery order

Successful direct ShareFile, ShareImage and completed ShowView results receive host-only details.deliveredAt in the universal gateDeniedTools wrapper after permitted execution returns, including any awaited plugin observer. Nested calls receive the same Unix-microsecond completion metadata in ToolTraceLog.settle, immediately before emitting their payload. For file/image shares, the trace budget reserves it before ownership commits; completed delivery metadata stays with its payload when display output is reduced. Waiting view events carry no completion time. The shared InteractiveViewDisplay wire type carries deliveredAt on both completed view events and historical segments. toBrainEvent, settleEventsForTrace, shapeBrainMessages, the strict trace parser and the shared transcript fold preserve the value. The importless orderDeliveries in src/shared/chatPresentation.ts, mirrored in web/lib/chatPresentation.ts, orders stamped payloads without moving tool rows; the web's splitRun applies it across all steps, including late results rewriting earlier reporting entries. Unstamped historical payloads keep their recorded positions. This metadata conveys no attachment authority; the existing validated reference and ownership paths remain authoritative. Both direct and nested deliveries retain the ordinary share row and a separate payload. Explicit image deliveries are not deduplicated against model-facing Read previews.

Message blocks: structured content under a reply

A plugin tool can attach structured cards to the reply that mentions them (seam row 70 in docs/plugin-author/seam-catalog.md). It returns candidates in its result details.blocks, which providers never receive (pi-ai sends only content). normalizeToolResultBlocks (src/brain/messageBlocks.ts) runs in gateToolAccess after the tools.call.after observer: it bounds and cleans the blocks, sniffs each image from its bytes and stores it through storeImageByContent, and replaces the raw array with candidates whose picture is a { ref } reference, so base64 never reaches the journal; a failed result loses its blocks. A tool result with no details.blocks is returned untouched.

installMessageBlocks (src/brain/session/messageBlocksExtension.ts), installed by the resource loader on every session, handles PI agent_end. PI runs extension handlers before its public listeners and has already appended every message of the run, so it reads the branch back to the last user turn (or to an earlier blocks entry), takes the final assistant text (nothing on a provider error), keeps the candidates selectMessageBlocks accepts (the core mention rule, first block per key, at most 12) and appends them with pi.appendEntry('elowen.message-blocks', { v: 1, blocks }). The entry's parent is the final assistant message: that link binds it, and rewind, clear and fork move it with its turn. It is a PI custom entry, which convertToLlm never turns into a message, appended at the tail: the prompt and its cache prefix are byte-identical with and without it. The append barrier commits it inside the run; the terminal agent_end settles it with the turn.

Only the paged display read (TranscriptRepository.transcriptPageRows in packages/db/src/transcript/reads.ts) returns this native custom entry for core display shaping. Context and recovery projections (getSettledMessages, getLatestTurn, provisionalMessages) accept message, compaction and custom_message entries through packages/db/src/transcript/projection.ts, so they exclude this display-only custom entry, so a finished turn still reads final after a restart and is never continued. shapeBrainMessages turns the row into a blocks segment on the preceding assistant view, or into a view of its own when a page boundary separates them. The live blocks event comes from PI's entry_appended for the same entry (toBrainEvent), so its id is the history row's id and the shared fold drops the twin. The journal write projects every file key of an entry's JSON into brain_attachment_refs (refreshJournalReferences in packages/db/src/attachments/projection.ts), which picks up details.blocks[].image.ref on the tool result, so the owner-only image route serves the picture and the sweep keeps it. Platform adapters, the CLI and the export draw nothing. With no producer nothing is stored, sent or drawn; the cost is one scan of the run's own tail per run end.

Activity snapshots and bounded labels

The conversation register consumes BrainStatusService.listSessions(userId) and the administrator-only listManagedSessions(userId). Both return projectRef: ProjectExecutionRef | null, interpreted by projectExecutionFromSession from the existing sessionListing batch's raw execution_ref and delegated_access columns. An explicit execution reference takes precedence; otherwise the canonical delegated scope supplies its project reference. Missing stored selection returns null, distinct from an explicit {kind:'host'}; host, managed and local references use the shared wire contract, including projectId for local computer Projects. Corrupt stored authority rejects the listing with ProjectExecutionRefError, never a synthetic missing-project result. Raw authority, work directories, permission rules and artifact grants do not enter either public listing. The projection performs no per-row repository, project-catalog, guest or computer probes and adds no migration. Its one transcript read is TranscriptRepository.latestRunSpans(ids), a single batched walk of each active branch back to its newest visible user message, issued in the same read snapshot only for personal rows that are settled (done/failed) and still need attention (unread, blocked or with background work). It yields SessionListItem.activity.durationMs: the time from that message to the completion of the last assistant step after it, the same span the chat's settled work summary (AssistantRun) shows, or null when that step has no turn duration yet. Every other row, and every managed row, has no such read; activity.at of a working row already is its run's start, because the working transition stamps it.

Personal SessionListItem.platform is required and is 'api' when the existing durable started_via_api_token flag records API-token creation, otherwise null. The personal listing excludes channels, so no other platform is projected there. Mine uses this same origin metadata for its API facet; session ids, models and later token-authenticated continuations never determine creation origin.

Managed rows additionally expose the same bounded activity state/seq/at/detail/automation as personal rows, but omit unread and every account read cursor. The administrator's All view can join its own personal listing for caller-relative unread; a foreign owner's cursor is not the viewer's unread state. Personal unread remains conversationActivityUnread(). Both listings retain native programStatus and independent backgroundWorking; running still means live-session presence, not working. Managed kind projection remains only conversation or channel, with platform attribution separate. The state detail is a status hint, not a latest-message preview; search snippets remain separate.

Both listings carry liveUsage for a row whose conversation is WORKING: the measured effectiveTps of its current turn and its prompt-cache split. The daemon projects it with liveUsageOf from the SAME sessionUsageSnapshot the open chat's status and the step/idle events report, so there is one measurement and one speed definition. It is absent for a settled conversation. The personal listing carries it for the caller's own conversations and the managed listing for every row it shows, exactly like tokens and cost.

A delegated child whose turn runs in the SUB-AGENT RUNNER has no live session in this daemon: the daemon learns its measured speed from the run row the runner's usage was folded into, and that row is the SAME measurement the sub-agent rail renders — no second speed is computed. Such a row therefore carries effectiveTps alone: the prompt-cache split does not cross the runner's IPC boundary, so the row shows no cache share rather than a made-up one. The run row is the durable home of that figure.

The list's live channel is the content-free, owner-scoped conversation event behind GET /brain/conversations. It is published when a conversation's durable activity changes AND when a provider request settled: the reducer calls onLiveUsageChanged at the assistant message_end — the request terminal, after the request recorder stamped the measured turn — and BrainService's liveUsageChanged publishes the same invalidation the activity change publishes. The lists therefore re-read a working row as the turn measures itself, without a second stream, timer or request.

A delegated child whose turn runs in the SUB-AGENT RUNNER measures its turn in another process, so it has no provider request to settle here. The daemon learns its figure from the runner's step/idle usage, which the delegating plugin folds into the run row. The run-row writer (applySubagentUpdate in src/brain/subagentRuns.ts) therefore publishes the SAME owner-scoped invalidation for the CHILD's session, through the same liveUsageChanged seam: the list re-reads the row and learns the new figure. It publishes only when the row's measured speed CHANGED — the progress update repeats the last measurement on every event, so an unchanged figure publishes nothing — and only when the child has NO live session in this daemon: a child that runs HERE publishes its own measurement from its own provider-request seam, so its row is not invalidated twice. Every surface that writes a run row announces the same way: the owner's turn, a platform channel's turn and the boot recovery's respawn each wire the publication to the one liveUsageChanged seam.

That channel reaches the administrator's All list too: an ordinary account receives the invalidations of its OWN conversations only, while an administrator — whose managed list spans every account — also receives the invalidations of the conversations it may show. A foreign update carries NO unread state: the unread flag is the OWNER's read cursor, not the administrator's. BrainStatusService.searchMessages(userId, query) forwards an account id for personal search or null for explicit administrator-authorized instance search. The API owns scope authorization; this service forwards the resolved scope unchanged to the existing search repository.

TranscriptRepository.getSessionActivity(sessionId) projects state, sequence/read sequence, turn id, bounded detail, automation and timestamps with derived unread state; unknown sessions return undefined. It exposes no bootId. Stored activity_boot_id remains the begin/terminal/reset/reconciliation ownership fence, without a migration. Begin and settlement use oneLineText with a 240-code-point detail bound. BrainService status/list readers consume this without transcript loading.

finishLabel bounds workflow titles to 80 Unicode code points including the ellipsis; finishTaskLabel selects the first nonempty trimmed line before applying that limit. Empty tasks produce an empty label. Result delivery persists these DelegationFinish labels for shapeBrainMessages. Message blocks retain their existing bounds: keys/match keys validated at 64 through a 65-point probe, titles 120, subtitles 200 and chips 40. Missing details.blocks leaves a tool result untouched.

Neutral activity reset

resetConversationActivity in src/brain/session/conversationActivity.ts owns the neutral reset shared by interrupted-turn settlement, clear, rewind, Stop and parked-turn admission. Pass the journal and transcript repositories plus the session id; an explicit turn id keeps the caller's CAS fence, otherwise the helper uses the current activity's turn id. It reads activity once, attempts the journal reset, and acknowledges the current sequence only when web participation already exists, even if the reset CAS returns false. CLI-only sessions remain unlinked from web participation.

The helper returns the reset's changed boolean and awaits an optional onChanged(sessionId) only when true. Without a callback it performs no publication. Stop's cancelConversationContinuation and parked-turn admission pass transaction-bound repositories without a callback, then publish from the returned flag after the existing transaction commits; rollback publishes nothing. Clear and rewind keep the reset outside their history-rewrite transactions and pass their existing callback. The helper does not acquire a conversation lock, open a transaction, retry, or change the callers' admission rules.

Turn-scoped card emission

emitCard remains a forgiving turn-scoped callback. Both owner and channel contexts obtain it from cardEmitter, which normalizes and writes through CardRegistry before publishing the card event by conversation ID. Unusable input emits nothing; a usable card with no body or items removes that card and publishes its removal. Existing item and text bounds still apply. Without a caller there is no publication. The todo plugin is a real consumer. The HTTP writePluginCard operation remains strict and throws for an invalid card; it is not merged into the forgiving callback.

Terminal receipts and sub-agent projections

terminalIdleReceipt is the shared post-persistence receipt for an unattended owner turn and a channel turn. Supply the completed live model/session snapshot, store, resolved session ID, settled idle event, start time and optional durable assistant message ID. Existing reducer timing wins, including zero duration; absent timing is filled at receipt publication. The helper takes the same usage snapshot as before and does not persist or publish on its own. BrainService.sendOwnedConversation and ChannelSessionService are its consumers. A missing durable message ID remains absent, and the reducer's ordinary idle event is separate.

runCompaction and sessionUsageSnapshot remain event-layer operations. Manual compactTurn admission and finally-close are owned by src/brain/session/turnSettled.ts; see the turn lifecycle contract.

subagentUpdateOf projects a persisted BrainSubagentRun into SubagentUpdate: toolCallId becomes id and the internal rowid ordering key is omitted. Progress, steer settlement, nested progress and acknowledged result delivery use this same projection. The store retains rowid and uses it for stable call ordering. Result delivery still publishes acknowledged only after the durable acknowledgement succeeds; the public pending/acknowledged union is unchanged. CLI and web subagent sidecars consume the projected event. No stored row is deleted or reordered by projection.