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 theinterruptedanderrorrows at the end of a run and publishes the livechat-eventframe.src/brain/brainService.ts:recordConversationEvent,runCommand,recordLocalShell,switchWorkModeand the project listing and switch.src/store/brainStore.ts: the store facade the event code reads turn baselines and the pending tail from.- The live stream for a conversation is the
BrainEventunion insrc/brain/events.ts.src/api/sse.tsis the instance-level feed (team activity, presence, alerts) and carries no chat events.
Chat events: one record for what the user saw
Migration v55 (retireChatErrorNotices in src/store/migrations/journal.ts) retires the image-refused and provider-timeout error notice codes in every journal copy. It removes only details.notice, retaining details.text for the existing generic error reader and preserving the stored model-facing content, parent links and journal metadata. The parser no longer accepts those notice codes after the migration.
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. Because pi-ai drops every aborted or errored assistant from the wire, 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.
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 (BrainStore.pendingTailStart and 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. Every door of the same command passes the invocation (POST /brain/command, and the dedicated /brain/compact, /brain/fast, /brain/goal and /brain/goal/action when the CLI's user typed it), so the web and the CLI record the same row. 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.
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.
Message blocks: structured content under a reply
A plugin tool can attach structured cards to the reply that mentions them (seam row 72 in docs/PLUGIN_DEV.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 (transcriptPageRows, DISPLAY_ROWS in src/store/brainTranscriptReads.ts) projects the entry. The context, recovery and tail reads (TRANSCRIPT_MESSAGE_ROWS, getDisplayMessages, provisionalMessages, getSettledMessages) do not, 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. collectImageFiles reads details.blocks[].image.ref on the tool result, which the "ref" attachment predicate already indexes, 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.