Skip to content

Workspace history tracking

This page describes the Herdr TUI plugin's lifecycle hooks and subscriber. The Herdr app mods use separate history and a layout block instead. See app last-space tracking.

The last-workspace-tracking implementation keeps herdr-sesh last and the picker's previous-workspace marker accurate when focus changes outside the plugin. Herdr lifecycle hooks elect one long-lived subscriber per socket, and that subscriber serializes focus and close events into plugin-owned history.1

Protocol 20 replay is reconciled against current session state

The manifest requires Herdr 0.8.2 or newer, whose stable socket protocol is 20. Protocol 20 replays retained lifecycle events without a completion marker, so the watcher reconciles every available event batch with a fresh session snapshot instead of inferring a boundary from timing. Protocol 21 and newer start lifecycle subscriptions at the current event sequence and skip that replay step; see the subscriber implementation and protocol regression tests.

Why a resident subscriber exists

Herdr launches startup and event hooks as separate processes. Those processes can overlap, so letting every workspace.focused hook write history would make the final file order depend on process scheduling instead of focus order.

The plugin manifest routes three lifecycle triggers to the same hidden command:

Trigger Hook behavior Subscriber behavior
Startup Attempts watcher election; does not mutate history directly. Establishes the event stream and records the session snapshot.
workspace.focused Attempts watcher election; does not mutate history directly. Records the focused workspace in stream order.
workspace.closed Attempts watcher election, then removes the ID from data.workspace_id once. Removes the same ID if the stream also reports it; removal is idempotent.

TryHistoryWatcherLock uses a socket-derived flock file named history-watch-<sha256(socket)>.lock. Election happens before resolving the session history directory, so non-winning startup and focus hooks cannot trigger legacy-history migration. A non-winning close hook still prunes its payload ID. The winner holds the lock while WatchWorkspaceEvents runs; a later lifecycle hook can elect a replacement if the subscriber stops. A non-winning startup hook retries election for up to five seconds before exiting, covering the handoff from a restarted server's previous subscriber; focus and close hooks never wait. Herdr runs startup hooks only when a server starts or imports a live handoff, so a lock held at startup belongs to the previous server's subscriber. If that subscriber still holds the lock after the window, as a subscriber from a release that reconnected forever would, the startup hook sends SIGTERM to the lock holders, then retries for one more window. Linux finds the exclusive flock owner in /proc/locks by the lock file's device and inode; macOS uses its bundled /usr/sbin/lsof. No additional runtime tool needs to be installed, and a restricted hook PATH does not affect replacement.

The following diagram maps the production control flow in internal/app while keeping the hook and subscriber responsibilities separate.

flowchart TD
    Hook["startup / focused / closed hook"] --> Elect{"TryHistoryWatcherLock"}
    Elect -->|not acquired startup| Wait["Retry election<br/>up to 5 s"]
    Wait -->|acquired| Resolve
    Wait -->|timed out| Terminate["SIGTERM lock holders<br/>retry up to 5 s"]
    Terminate -->|acquired| Resolve
    Terminate -->|timed out| Exit
    Elect -->|not acquired focused| Exit["Exit hook process"]
    Elect -->|not acquired closed| Resolve["Resolve session history"]
    Elect -->|acquired| Resolve
    Resolve --> Apply["applyHistoryHook"]
    Apply -->|startup or focused| NoWrite["No direct focus-history write"]
    Apply -->|closed| RemoveOnce["Remove payload workspace once"]
    NoWrite -->|winner| Watch["WatchWorkspaceEvents"]
    RemoveOnce -->|winner| Watch
    RemoveOnce -->|non-winner| Exit
    Watch -->|workspace_focused| Record["Record"]
    Watch -->|workspace_closed| Remove["RemoveWorkspace"]
    Record --> Retry["Retry only ErrHistoryLockTimeout in place"]
    Remove --> Retry

Subscriber bootstrap and event ordering

The subscriber connects once and follows this order:

  1. Open the event connection and subscribe to workspace.focused and workspace.closed.
  2. Start decoding events as soon as the subscription acknowledgement arrives.
  3. Call ping on a second connection and reject servers below protocol 20.
  4. Use another socket connection to request the initial session.snapshot.2
  5. On protocol 20, drain each currently available event batch and request a fresh snapshot. Apply focus events only for workspaces that still exist, close events only for workspaces that no longer exist, then record the snapshot's authoritative focused_workspace_id.
  6. On protocol 21 and newer, apply the already-buffered and future live events directly in decoder order.

Starting the decoder before the protocol probe and initial snapshot request closes both bootstrap windows. The protocol 20 path has no timer or fixed replay cutoff: a delayed retained event becomes a later batch and is re-anchored to current session state. The buffer holds 1,024 events. If the stream ends, already-buffered events are applied on a best-effort basis, then the subscriber returns and releases its election lock. Protocol 20 events still need a reconciliation snapshot, so they are lost if the closing server no longer answers session.snapshot. A close between messages is a normal shutdown and exits successfully; a close mid-message, a read error, or a history write failure exits with an error.

The subscriber never reconnects. A failed initial connection, such as the socket of a deleted named session, returns immediately, and an established stream that closes ends the process even if a new server later listens on the same path. Reconnecting would keep the previous plugin binary running after a server restart and would retry forever once a session is gone. The next startup, focus, or close hook elects a fresh subscriber that initializes from the current session snapshot.

Subscription request names use dotted event types. Incoming event envelopes use workspace_focused and workspace_closed; malformed, unrelated, and ID-less envelopes are ignored.

sequenceDiagram
    participant W as Watcher
    participant P as Protocol connection
    participant E as Event connection
    participant S as Snapshot connection
    participant H as History
    W->>E: events.subscribe
    E-->>W: subscription_started
    Note over W,E: Decoder starts buffering events
    W->>P: ping
    P-->>W: protocol
    W->>S: session.snapshot
    S-->>W: focused_workspace_id + workspace IDs
    W->>H: Record initial snapshot focus
    opt Protocol 20
        W->>W: Drain available event batch
        W->>S: session.snapshot
        S-->>W: Current focus + workspace IDs
        W->>H: Reconcile batch, then record snapshot focus
    end
    loop Ordered live stream
        E-->>W: workspace event
        W->>H: Reconcile protocol 20 or apply protocol 21
    end
    E--xW: Stream closes
    W->>H: Drain buffered events
    W->>W: Return and release election lock

Regression tests define these boundaries for this flow: reconcile delayed protocol 20 replay without a timer, preserve queued replay across stream failure, preserve an event from the snapshot window, return after a closed stream or unavailable socket, release the election across a server restart on the same path, bound the startup handoff wait, and replace a lock holder that never exits.

Session-scoped persistence

SessionHistoryDir cleans and hashes HERDR_SOCKET_PATH with SHA-256. Each Herdr session therefore writes to an independent path:

${HERDR_PLUGIN_STATE_DIR}/history/<sha256-clean-socket-path>/history.json

Existing unscoped history.json belongs to the default Herdr session. It is copied into that session's scoped directory on first use while holding both the legacy and destination history locks. Named sessions never inherit the old file.

History mutation has a separate stable lock, history.lock. Writers attempt LOCK_EX | LOCK_NB every 10 ms for up to 250 ms. A timeout returns the typed ErrHistoryLockTimeout error.4

  • short-lived close hooks make one bounded mutation attempt;
  • the resident subscriber retries only this typed timeout in place, preserving event order;
  • other errors stop the watcher instead of spinning.

Record, RecordSwitch, RemoveWorkspace, migration, and direct saves all share the same lock boundary. JSON writes go to a temporary file, set mode 0600, and atomically rename over history.json; state directories use 0700.5

flowchart TD
    Producers["Subscriber / CLI / close hook"] --> Mutation["Record / RecordSwitch / RemoveWorkspace"]
    Mutation --> Lock["Acquire history.lock<br/>250 ms maximum"]
    Lock --> Load["Load history<br/>recover malformed JSON for writes"]
    Load --> Normalize["Newest first / deduplicate / cap at 50"]
    Normalize --> Temp["Write temporary file<br/>mode 0600"]
    Temp --> Rename["Atomic rename to history.json"]

The history list is newest first, deduplicated, and capped at 50 workspace IDs. Record ignores an already-current head. Explicit CLI and picker transitions use RecordSwitch(from, to, revision) so both sides of the switch survive even before a Herdr event is observed.6

Those processes write after their focus request, so a write can land after the subscriber has already recorded that focus and later ones, including a return to from. Every locked mutation increments the history revision. Callers capture it before requesting focus, and RecordSwitch applies only if history is still at that revision and to is not already the head. Otherwise the subscriber's ordered events win and the late switch is dropped. Comparing revisions instead of workspace IDs keeps valid switches when the head is stale or from is unknown. An applied switch precedes any write for to, so if the subscriber receives its focus event for to, that event follows the switch and resynchronizes the sidebar marker. A subscriber that is down, or a focus event Herdr 0.9.0 suppresses, leaves the marker stale until the next event the subscriber does receive.

Record does not rewrite history for an already-current head, so an unchanged revision cannot prove the subscriber has not seen focus on to. A switch to the current head is therefore dropped. Revision capture uses the same malformed-JSON recovery as locked writes, so corrupt history is repaired by the next switch.

When the subscriber has already missed a focus change (it is down, or Herdr 0.9.0 suppressed a sidebar move), the origin of a plugin switch can still be lost. This happens if the subscriber records the plugin's own focus event first, or if the switch returns to the stale head. Closed workspaces are pruned, and malformed JSON is recovered only at a locked write boundary.

Last returns the second entry because the head is the focused workspace. Code that already knows the current workspace can use Previous to select the first non-current entry.

After each subscriber mutation, syncLastWorkspaceMarker selects the workspace last would focus from the subscriber's last focused workspace, not the history head, because a delayed RecordSwitch can reorder the head. It then reads herdr workspace list and uses herdr workspace report-metadata (source fullerzz.sesh) to set sesh_last on that workspace and clear it everywhere else. Reconciling against Herdr's reported tokens makes each sync idempotent: markers left by a replaced subscriber or a failed report are repaired on the next event. Only the elected subscriber reports. Marker calls share a two-second context deadline, with a short limit on draining command output after cancellation. Report failures are warnings: they cost the indicator, never history. Repeated config warnings are emitted only when their text changes; a clean load resets them.

Failure model

Deliberate constraints
  • The subscriber uses one resident plugin command slot per Herdr socket.
  • Herdr does not supervise the command; the next lifecycle hook performs replacement election after a crash or disconnect.
  • The subscriber exits when its server connection fails or closes instead of reconnecting. Until a later hook elects a replacement, focus changes are not recorded and the sidebar marker can be stale. The replacement's snapshot restores current focus but cannot reconstruct navigation missed in between, and Herdr subscriptions do not expose a replay cursor for events lost at disconnect.3
  • A restarted server's startup hook waits up to five seconds for the previous subscriber to drain and exit, then terminates any remaining lock holder and waits up to five more seconds. If native lock-holder lookup fails (for example, /proc/locks is inaccessible), the hook reports the failure; tracking resumes after the old subscriber exits and a later lifecycle hook elects a replacement.
  • Subscribers from releases that reconnected forever cannot be replaced for a deleted session, because that session never runs another startup hook. Stop them once with pkill -f 'herdr-sesh plugin watch-history'; the next lifecycle hook in each running session elects a replacement.
  • A plugin update while the server keeps running does not replace the resident subscriber; restart the server to load the new binary.
  • File locking depends on flock. Unsupported filesystems fail explicitly.
  • The JSON format and 50-entry cap remain intentionally small; there is no database or background compaction layer.

The close-hook payload is authoritative. HERDR_WORKSPACE_ID is contextual and is not used to decide which workspace closed; a missing or malformed HERDR_PLUGIN_EVENT_JSON.data.workspace_id fails safely.

Verify a change

Host focus-event compatibility

Herdr 0.9.0 suppresses API/plugin focus events in its client navigation path. Its sidebar can therefore change the visible workspace without updating this subscriber's history. Herdr 0.9.1 includes upstream #3850, which restores emit_focus_api_events() when the requesting client's selected target changes. Both releases use protocol 22, so the protocol number alone does not establish whether this fix is present. Check the running server version, not just the installed CLI version.

The restored events remain server-scoped and do not identify the client. History therefore tracks the server's focus event order, not independent navigation history for each attached client.

For issue #125, verify the production path from inside a Herdr-managed pane on a fixed host:

  1. Confirm the running server is 0.9.1 or newer and the plugin's history hooks are enabled.
  2. Subscribe to workspace.focused on that session's socket and wait for the subscription_started acknowledgement.
  3. With one client navigating, click three distinct workspaces in the sidebar in order A, B, C. Confirm workspace_focused events arrive in that order and the socket-scoped history starts with C, B, A.
  4. Invoke the plugin's last action. Confirm it focuses B and history starts with B, C, A. Invoke it again and confirm it returns to C.

CLI-driven focus commands alone do not verify the affected sidebar path. The upstream source fix and local unit tests do not replace this live check.

Local checks

Run the focused race-enabled suite first, then the repository and docs gates:

go test -race -count=1 ./internal/state ./internal/herdr ./internal/app
just check
just build-docs

The most useful implementation entry points are:

Area File
Hook routing and watcher ownership herdr-plugin.toml, internal/app/app.go
Herdr protocol and subscription loop internal/herdr/events.go
Session paths, locks, and history semantics internal/state/history.go
Atomic writes internal/state/json.go
Concurrency and protocol regressions internal/app/app_test.go, internal/herdr/events_test.go, internal/state/history_test.go

Watcher orchestration Event subscriber History state


  1. See the lifecycle declarations in herdr-plugin.toml and watcher election in internal/app/app.go. ↩

  2. See loadSessionSnapshot and the buffered decoder in internal/herdr/events.go. ↩

  3. Herdr documents snapshots as reconnect reconciliation, while lifecycle subscriptions explicitly do not replay retained events. See the Herdr socket API. ↩

  4. syscall.Flock provides the process-level advisory lock used by withHistoryLock and TryHistoryWatcherLock. ↩

  5. The lock and state rules are in internal/state/history.go; atomic replacement is in internal/state/json.go. ↩

  6. See RecordSwitch in internal/state/history.go and its focused tests in internal/state/history_test.go. ↩