Skip to main content

Events & Session Lifecycle

Diminuendo’s event system is the central nervous system of the gateway. Every agent action — streaming a text token, invoking a tool, requesting user permission, reporting token usage — flows through a structured pipeline that transforms raw Podium agent events into the gateway’s typed protocol, routes them through specialized handlers, persists the durable ones, and broadcasts them to connected clients. The session state machine ensures that every session progresses through a rigorous, finite-state lifecycle that prevents invalid states and enables crash recovery.

Event Pipeline

  Podium Agent (WebSocket)
         |
         v
  PodiumEventMapper         Transforms 30+ agent events into gateway protocol
         |
         v
  Handler Dispatch           Routes by event type to 7 handler modules
         |
         v
  EventPublisher             Assigns seq, persists durable events
         |
         v
  Broadcaster                Bun native pub/sub to session/tenant topics
         |
         v
  Client WebSockets          SDK consumers, web UI, desktop app

PodiumEventMapper

The PodiumEventMapper is a pure, table-driven function that transforms raw Podium agent events into the gateway’s own event protocol. A single Podium event may map to zero, one, or multiple gateway events.

Mapping Categories

The mapper handles events across six categories:
Maps tool.call_start, tool.call_delta, tool.call, tool.result, and tool.error from Podium into corresponding gateway events. Both messageType and content.event_type are checked, providing resilience against upstream format changes.
Maps tool.question_requested, tool.permission_requested, and tool.approval_resolved. These events drive the session state machine into the waiting state and back.
Maps thinking.start, thinking.progress (including the legacy thinking_update type), and thinking.complete. Empty thinking content is filtered — the mapper returns an empty array rather than emitting a no-op event.
Maps terminal.stream and terminal.complete for command execution output. Maps sandbox.provisioning, sandbox.init, and sandbox.removed for sandbox lifecycle tracking.
Maps plan.created, plan.step_started, plan.step_completed, plan.revised for structured task planning, and memory.extracted for cross-session memory extraction. See Agent Capabilities for details.
Maps the core message flow: created / stream_start become turn_started; update / stream_update become text_delta; complete / stream_end / stream_complete become turn_complete; and error becomes turn_error. A fallback rule treats any unrecognized event with textual content as a text_delta.

Per-Session Sequence Numbers

Every event emitted by the mapper is assigned a monotonically increasing sequence number scoped to its session. Sequence numbers are per-session, not global — this enables clients to request events after a specific seq via the afterSeq parameter on join_session, and to detect gaps if events arrive out of order.

Ephemeral vs. Persistent Events

Not all events need to survive a restart:
Broadcast to connected clients but never written to SQLite. High-frequency, transient signals that would bloat storage without providing replay value: text_delta, thinking.progress, terminal.stream, tool.call_delta, heartbeat, pong, plan.step_started, plan.step_completed, and others.
The distinction between ephemeral and persistent events is central to replay correctness. When a client reconnects and requests events after a given seq, it receives only persistent events. Text deltas are not replayed individually — instead, the turn_complete event carries the finalText field with the complete accumulated response.

Handler Decomposition

After the mapper produces gateway events, each event is routed by type to one of seven specialized handler modules:
ModuleEvents HandledResponsibility
message-completeturn_complete, turn_errorPersists accumulated text, transitions state, settles billing
tool-lifecycletool_call_start, tool_call, tool_result, tool_errorTracks pending/completed tool calls in ConnectionState
interactivequestion_requested, permission_requested, approval_resolvedManages deferred interactive messages, transitions to/from waiting
thinkingthinking_start, thinking_progress, thinking_completeTracks thinking state in ConnectionState refs
terminalterminal_stream, terminal_completeForwards terminal output
sandboxsandbox_provisioning, sandbox_ready, sandbox_removedTracks sandbox lifecycle
inlinetext_delta, session_stateText accumulation and session state transitions handled directly in the dispatcher
Each handler is a pure function with the signature (ctx: EventHandlerContext, event?) => Effect<void>. Handler modules have zero imports from infrastructure layers — they interact exclusively through a context interface, making them trivially testable with stub implementations.

Broadcaster

The Broadcaster wraps Bun’s native WebSocket pub/sub with topic lifecycle tracking:
ScopeTopic PatternSubscribers
Sessionsession:{sessionId}All clients that have joined this session
Tenanttenant:{tenantId}:sessionsAll authenticated clients for this tenant
A 30-second heartbeat timer publishes to every active session topic. On graceful shutdown, broadcastShutdown(reason) iterates all known topics and publishes a server_shutdown event before the server terminates.

State Snapshots for Late Joiners

When a client sends join_session, the gateway constructs a state_snapshot — a point-in-time view of the session including the current turn’s textSoFar (read from the active ConnectionState.fullContent ref), recent message history, sandbox state, and subscriber count. A client joining mid-stream immediately sees all text generated so far, without needing to replay individual deltas.

The Session State Machine

Every agent session passes through a rigorous, finite-state lifecycle. The gateway defines seven states, an explicit transition guard map, and a deterministic mapping from agent-reported status values to state transitions.

The Seven States

          +--------------------------------------------------+
          |                                                  |
          v                                                  |
    +-----------+         +------------+         +---------+ |
    | inactive  | ------> | activating | ------> |  ready  | |
    +-----------+         +------------+         +---------+ |
          ^                  |    |                |   |   |  |
          |                  |    |                |   |   |  |
          |                  v    |                v   |   |  |
          |              +-------+|          +---------+  |  |
          |              | error  |          | running |  |  |
          |              +-------+           +---------+  |  |
          |                  |                 |   |       |  |
          |                  v                 v   |       |  |
          |              +-----------+    +---------+     |  |
          +--------------| deactiv.  |    | waiting |     |  |
                         +-----------+    +---------+     |  |
                              ^               |           |  |
                              +---------------+-----------+--+
  • inactive — no Podium connection. Metadata-only row in the tenant registry.
  • activating — creating a Podium instance and establishing a WebSocket connection. Transient.
  • ready — connection established, agent idle, awaiting user input.
  • running — agent actively processing a turn (streaming text, invoking tools, reasoning).
  • waiting — agent blocked on user interaction (question_requested or permission_requested).
  • deactivating — Podium instance being stopped, WebSocket closing. Resolves to inactive or error.
  • error — unrecoverable failure. Recovery requires passing through inactive or activating first.

Transition Guard Map

export const VALID_TRANSITIONS: Record<SessionState, ReadonlySet<SessionState>> = {
  inactive:     new Set(["activating"]),
  activating:   new Set(["ready", "error", "inactive"]),
  ready:        new Set(["running", "deactivating", "inactive", "error"]),
  running:      new Set(["ready", "waiting", "error", "deactivating"]),
  waiting:      new Set(["running", "error", "deactivating"]),
  deactivating: new Set(["inactive", "error"]),
  error:        new Set(["inactive", "activating"]),
}
There is no transition from error to ready or running. Recovery from an error state always requires passing through inactive or activating first. This prevents the gateway from silently resuming a session whose underlying Podium connection may be in an unknown state.

Agent Status Mapping

The applySessionTransition pure function computes the next state from the current state and an agent-reported status. It returns null for invalid transitions, allowing the caller to log and skip rather than silently apply:
Agent StatusTarget StateNotes
createdactivatingPodium instance created
connectedreadyWebSocket handshake complete
turn_startedrunningAgent began processing
turn_completereadyTurn finished successfully
turn_errorready or errorready if currently running/waiting; error otherwise
question_requestedwaitingAgent needs user input
approval_resolvedrunningUser responded
terminatingdeactivatingGraceful shutdown initiated
terminatedinactiveShutdown complete
errorerrorUnrecoverable failure

Enforcement

The transitionSessionState helper is the single point through which all state transitions flow. It validates against the guard map, updates the ConnectionState ref, persists to the registry database, and broadcasts to all subscribers. Invalid transitions are logged and skipped — never silently applied, and never thrown.

ConnectionState

Each active session maintains a ConnectionState — a struct of 15+ Effect Ref values tracking turn ID, accumulated text, pending tool calls, thinking state, billing reservation, and session state. At the start of each new turn, resetTurnState clears all turn-specific refs, ensuring no state leaks between turns.

Stale Session Recovery

After a gateway restart, no Podium connections survive. reconcileStaleSessions runs on startup for each tenant, querying all sessions not in inactive and resetting them — driven by the error → inactive transition. Runs with concurrency of 5 to avoid overwhelming the SQLite writer.

Legacy Migration

The state machine provides a migration path from the earlier 4-state model (idle, running, awaiting_question, error) to the current 7-state model via migrateLegacyStatus().