End-to-end: what happens when a Claude Code hook fires, when a transcript file gets a new line, or when you toggle a tray menu item.
Input sources and data flow
flowchart LR
CC["Claude Code<br/>hook forwards raw payload"]
TR["transcript<br/><session>.jsonl"]
UI["Svelte UI"]
CFG["config.json"]
PEER["peer dashboard<br/>(another device)"]
AX["axum (Rust) :9077"]
SYNC["axum (Rust) :9078<br/>bearer-gated sync"]
CMD["commands.rs"]
CW["config_watcher"]
AS[("AppState<br/>sessions: Mutex<Vec<AgentSession>><br/>remote: Mutex<BTreeMap<device, RemoteDevice>>")]
NATIVE["Window / TrayIcon<br/>native APIs"]
SV["Svelte (listen)"]
TT["terminal tab title<br/>(Win32 console / tty)"]
CC -->|"POST /api/event"| AX
AX -->|"adapters::dispatch -> apply_set / apply_clear"| AS
TR -->|"notify::Event"| AS
PEER -->|"POST /api/sync"| SYNC
SYNC -->|"sync::ingest -> remote map"| AS
AS -->|"pusher: local sessions"| PEER
UI -->|"#[tauri::command]"| CMD
CMD -->|"apply_clear"| AS
CMD --> NATIVE
CFG -->|"file change event"| CW
CW -->|"emit(config_updated)"| SV
AS -->|"app.emit(sessions_updated)"| SV
AS -->|"terminal_title::sync (local only)"| TT
Every mutation to local session state funnels through state::apply_set or state::apply_clear so the sticky-label rules, working-time accumulator, and upgrade-only merge policy are enforced in one place regardless of origin. Remote sessions arrive pre-enriched from the device that ran them (its own apply_set already applied those rules) and live in the separate remote map — commands::resolved_snapshot is the single point where the two sets combine on the way to the frontend.
Path 1 — Hook POSTs event
- Claude Code fires a lifecycle event (
UserPromptSubmit,Stop, etc.). The hook command spawnspython claude_hook.pyand pipes the event payload to stdin. claude_hook.pyreads the payload, extractshook_event_name, and POSTs{client: "claude", event: <name>, payload: <verbatim>, console_pids: [...]}to$TAURI_DASHBOARD_URL/api/event(defaulthttp://127.0.0.1:9077/api/event). The hook does no classification or config reading —console_pidsis pure environment gathering: the processes attached to its console plus its ancestor pid chain (Windows) or the ancestor chain alone (macOS), used later for terminal tab titles.POST /api/eventhits the axum handler. Origin guard rejects non-null cross-origin requests.adapters::dispatchroutes byclient;adapters::claude::dispatchmatches oneventand produces anAdapterOutput::Set { input, transcript_path } | Clear { id } | Ignore. All chat-id derivation, prompt cleaning, and transcript question-detection happen here.- For
Set,label_policy::selectdecides the(label, original_prompt)pair. - Session-boundary marking. Claude
/clearfiresSessionEnd→SessionStart; the chat_id (derived from cwd) is unchanged but the JSONL is a new file. The handler covers this in two places: (a) onClear,AppState::mark_session_boundaryappends aSeparatorto the in-memory dialog andPromptHistoryStorepersists it beforeapply_cleardestroys the session — the followingSessionStartthen takes the “new” branch inapply_setand restores a dialog that already ends with the separator, so the upcoming user entry lands after it. (b) OnSet, a defensivetranscript_path-rotation check still callsmark_session_boundaryif the new path differs from whatWatcherRegistryis already watching — covers any rotation that happens without a precedingSessionEnd. AppState::apply_setruns: if status transitions out ofworking, it accumulates elapsed time intoworking_accumulated_ms; if the transition is a task boundary (done/idle→working), it zeroes the accumulator; otherwise existing timers are preserved.- If
transcript_pathis present,WatcherRegistry::startspawns a per-session tokio task with anotify::RecommendedWatcheron the transcript’s parent directory. emit_sessions_updatedbroadcasts the fresh snapshot on thesessions_updatedevent. The same emit reconciles terminal tab titles (terminal_title::sync): every session whose status or display name changed gets its circle re-pushed onto the console the hook’sconsole_pidsidentified.- The Svelte frontend’s
listencallback replaces its$statesessions array, Svelte’s reactivity re-renders the list, the row updates within a frame.
Path 2 — Transcript-driven updates
- The watcher task from Path 1 is listening to filesystem events on the transcript’s parent directory.
- Claude Code writes a new JSONL line to the transcript.
notifyfires aModifyevent; the watcher filters to events matching the exact transcript path. - The task sends a drain signal over an mpsc channel to itself. A 150ms debouncer collapses bursts (editors / streaming writes often produce several events per logical change).
drainreads the new bytes from the tracked byte offset, joins with leftover content from the previous drain, and splits into complete JSONL lines + a new leftover for the next call.infer_statewalks the new lines newest-first, skipping non-conversational entries (metadata, sidechains, synthetic errors). Returns the currentstate, latestmodel, latest summed input-side token count, and the latest assistant text block.apply_watcher_updatemerges the metric inference into the session: watcher can set status toworking, updatemodel, updateinput_tokens, but cannot roll a session back todone,idle, orerror— hook events stay authoritative for terminal states. This avoids the race where the watcher reads a trailing assistant text as “done” while a fresh turn is already in flight. The exception is a pair ofStop-misread corrections.Stopfires before the final assistant turn flushes to JSONL (below), so it classifies the question-vs-done verdict from the prior turn’s text and can be wrong in either direction. Once the real text flushes,flushed_turn_verdict(reusing the adapter’sis_a_question) re-judges it and routes to one of twoAppStatecorrections: a turn that’s really a question butStopsettled asdone→promote_done_to_awaiting(done → awaiting); a turn that’s really a statement butStop(reading a prior question) settled asawaiting "has a question"→demote_scanned_awaiting_to_done(awaiting → done). The demote is the delicate one: a genuine tool-gatedawaiting(AskUserQuestion,PermissionRequest) shares the"has a question"label, so it’s gated onAgentSession::status_from_transcript_scan— a flag the adapter sets only for theStop/idle_prompttranscript-scan states (andpromotesets on its result), so only a scan-derivedawaitingis ever demoted; a tool gate is never touched. Both corrections are restricted to their one transition, so a transient mid-turn assistant text (row stillworking) can’t trigger either. If the chunk produced alatest_assistant_text,AppState::apply_text_entriesreplaces the latest Assistant entry within the current turn (appends if none exists yet) andPromptHistoryStorepersists the change. The watcher owns dialog text because Claude Code’sStophook fires before the final assistant turn is flushed to JSONL — reading from the hook records the prior turn’s text.- If anything changed, the session’s
updatedtimestamp refreshes andemit_sessions_updatedfires exactly as in Path 1.
The initial drain on watcher startup suppresses the inferred state AND the latest assistant text — a resume would otherwise snap to a stale “done” from the prior turn and duplicate the last assistant entry already in the restored dialog. Model and token counts still surface.
Tauri commands target native window/tray APIs (hide_window, show_window, toggle_window, quit_app); session state is only read from the frontend (get_sessions) — every local-session mutation arrives through the HTTP event path above, and remote sessions only through the sync path below.
Path 3 — Tray toggles
- User clicks “Always on top” in the tray menu.
mudafires aMenuEventwith the item’s id. - The tray handler calls
window.set_always_on_top(new_state)directly on the native window — no IPC round-trip. ConfigState::with_mutflipsalways_on_topin the managed config.ConfigState::save_to_diskwritesconfig.json.- The tray’s
CheckMenuItem::set_checkedsyncs the visual checkmark. emit_config_updatedbroadcasts the new config. The frontend picks up the updated color thresholds, token-window lookup, and (future-proof) any UI-driving fields.
Path 4 — External config edits
- User edits
config.jsondirectly (via the “Open config/logs location” tray shortcut or any editor). config_watcher— anotify::RecommendedWatcheron the config directory — receives aModifyevent.- The 150ms debouncer waits for any rename-based atomic writes to settle.
Config::load_or_defaultre-reads the file. Serde serializes both the new and current in-memory configs to JSON strings; if they’re byte-identical, the reload is skipped — this is how our own tray writes avoid re-triggering the reload path.apply_config_to_windowapplies runtime-safe changes (always-on-top, saved window position). Port changes are intentionally ignored on hot-reload and require a restart.config_updatedis emitted and the tray check marks re-sync.
Path 5 — Peer sync (multi-device)
Dashboards on other devices push their sessions here, and this dashboard pushes its own to them — see Features → multi-device sync for the user-facing behavior and HTTP API → sync API for the wire contract.
Outbound: every emit_sessions_updated pokes the SyncDirty notify; the pusher task debounces 300ms (a 30s heartbeat fires regardless) and POSTs to each config.sync.peers entry: a full metadata snapshot of the local sessions plus a bounded chunk of dialog backlog — the oldest ~256KB of entries above that peer’s watermark, in timestamp order. Each acknowledged chunk advances the watermark and the next chunk follows immediately, so a peer that was offline drains the whole backlog within one cycle — but only after proving reachable, one bounded POST at a time; a down peer costs each failed cycle one bounded build + connect attempt instead of an ever-growing full-backlog serialization. The watermark advances only on a 2xx, so a failed push (peer offline) re-sends the missed entries next time. Received remote sessions are never re-broadcast — and remote-driven changes deliberately skip the poke (see inbound below): content can’t echo, but the poke itself would, ping-ponging pushes between two devices at the debounce period.
Inbound: POST /api/sync (bearer-gated, port sync.listen_port) namespaces incoming ids to {device}/{raw_id}, stamps origin, wholesale-replaces that device’s metadata (absence = removal), and merges dialog deltas via state::merge_dialog_entries — the same turn-aware, replay-safe merge the transcript watcher uses. Deltas are merged only when contiguous with what’s held (the push’s delta_from watermark overlaps the newest held entry, or is 0 = a complete dialog); a floating fragment — newer entries with a hole below them, e.g. a fresh install receiving deltas from a long-running origin — is discarded so held dialogs are gap-free by construction, and the on-open catch-up fills in the rest. Then emit_sessions_updated_remote re-emits the merged snapshot — UI-only, without the SyncDirty poke or the terminal-title pass of Path 1’s emit, so a received push can never trigger a push back. A reaper drops devices silent for 90s (also emitting the remote variant).
Accumulated remote dialogs are persisted one file per device (remote_history/<device>.json in the app data dir, mirroring prompt_history.json for local sessions) and re-seeded at ingest, so a dashboard restart keeps them. When the history window opens a remote session, open_history additionally fires a catch-up GET /api/sync/dialog?id= against the origin device for its full dialog and merges the response — the completeness guarantee for whatever disk can’t cover, e.g. a fresh install while the origin’s pusher watermark is already advanced (the dedup merge makes the overlap free; no held timestamp can serve as a since watermark, because the missing entries sit below the newest held one). The fetch is bracketed in history_loading events, which the history window renders as a loading hint until the catch-up lands or fails. Notifications, persistence, terminal titles, and the watcher never see remote sessions — they read AppState::sessions directly, and remote rows live only in the remote map.
Sticky-label state machine
The (label, original_prompt) decision rules and the UI display rule live in Sticky labels. Every apply_set call funnels through src-tauri/src/label_policy.rs::select, so the rules are enforced in one place regardless of which path fired the event.