Skip to Content
InternalsEvent bus

Event bus

Nothing inside core talks to a frontend directly. Modules publish events to a bus; a bridge maps them to StreamEvents and drops internal-only ones; a single subscribeAll egress writes them to stdout (terminal) and SSE (web). Two-way events — questions and permission asks — park a promise keyed by request id until the answer comes back.

The bus itself

apps/core/src/bus/index.ts is a thin wrapper around Node’s EventEmitter, exposed as a process-wide singleton:

class FreeCodeBus extends EventEmitter { publish(event: BusEvent): void { this.emit(event.type, event); this.emit("*", event); // wildcard for all-events subscriber } subscribe<T>(eventType: T, handler): () => void { ... } subscribeAll(handler: (event: BusEvent) => void): () => void { ... } } export const bus = FreeCodeBus.getInstance();

Publishing an event fires it twice: once under its own type, for a consumer that only cares about session.diff, say, and once under the wildcard "*" for the one consumer that forwards everything to the frontend. Any module in core can bus.publish(...); no module needs to know who, if anyone, is listening.

Event families

BusEvent is a big discriminated union, grouped by concern:

FamilyExamplesPurpose
streamStreamRelayEventwraps a StreamEvent (text delta, tool_start, …) with a sessionId, so multiplexed sessions can be told apart
session.*session.created, session.updated, session.error, session.diffsession lifecycle and file-diff summaries
tool.* / tools.changed—tool lifecycle, mostly superseded by stream events (see below)
mcp.*mcp.server.started/stopped/error, mcp.tools.changedMCP connection state
memory.saved—a memory write completed
subagent.*started/completeddelegated task lifecycle, see Sub-agent runtime
question.*asked/answered/rejectedthe question tool’s interactive round trip
permission.*asked/answered/rejectedthe permission engine’s interactive round trip, see Permission engine

Why there is exactly one egress

bridge.ts’s busEventToClientEvent() is a pure function: bus event in, StreamEvent | undefined out. A handful of event types are declared INTERNAL_ONLY and mapped to undefined — tools.changed, mcp.tools.changed, and, deliberately, tool.called / tool.completed:

const INTERNAL_ONLY = new Set([ "tools.changed", "mcp.tools.changed", // Redundant with the stream tool_start/tool_complete events (the loop's // authoritative tool lifecycle); dropped so tools are never double-emitted. "tool.called", "tool.completed", ]);

The comment is the point: the agent loop already emits tool_start / tool_complete as part of the stream family, which is the authoritative record of what a tool did. The bus’s own tool.* events predate that and would double-report the same call if they reached the wire. Filtering them here, in one place, is what keeps “one tool call → one frontend event” true without every event producer having to remember not to emit twice.

Everything that survives the filter goes out through a single subscribeAll handler in server.ts / web-server.ts. There is no second listener anywhere that also writes to stdout or the SSE stream — if there were, every event would print twice. That invariant is worth protecting: a new output channel (a log file, a websocket for a second UI) should still attach to the same subscribeAll call, not add a parallel one.

Ask/answer round trips

askQuestion() and the permission equivalent (bus.ts’s permission.asked flow) both follow the same shape: publish an event, then return a Promise that only resolves when a matching *.answered or *.rejected event comes back in:

export async function askQuestion(requestId, questions, sessionId?) { return new Promise((resolve, reject) => { pendingQuestions.set(requestId, { resolve, reject }); bus.publish({ type: "question.asked", requestId, sessionId, questions }); const timer = setTimeout(() => { if (pendingQuestions.has(requestId)) { pendingQuestions.delete(requestId); bus.publish({ type: "question.rejected", requestId }); reject(new Error("Question timed out")); } }, PROMPT_TIMEOUT_MS); }); }

The requestId is what ties the eventual answer back to this specific promise — a frontend answers by publishing question.answered with the same id, and whichever await askQuestion(...) call is holding that id resolves. This is why a headless session (no frontend attached) can’t hang forever: if zero listeners are registered for permission.asked, the permission helper rejects immediately instead of waiting on a prompt nobody can ever answer.

PROMPT_TIMEOUT_MS (30 minutes) bounds the wait either way. A timeout is treated as deny for permission prompts and as a rejection for questions — never a silent retry. The 30-minute value is deliberately generous: it assumes a remote user who may need to unlock a phone or notice a notification, not someone sitting at the keyboard. The cost is that an unattended local run can now hang up to 30 minutes before unwedging itself — accepted because a hung, visible loop beats a silent wrong answer.

Ordering

Node’s EventEmitter calls its listeners synchronously, in subscription order, for a given emit(). Because publish() only ever calls emit twice per event (specific type, then wildcard) and core has exactly one wildcard subscriber, events reach the frontend in the same order they were published — there is no queue or batching in between to reorder them. This matters for the stream family especially: text_delta events for one message must arrive in the order the provider produced them, and they do, because nothing reshuffles them on the way out. See apps/core/src/bus/ordering.test.ts for the test that pins this down.