Skip to content

Workspace history 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.

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/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

Each connection attempt 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, queued protocol 20 events go through the same snapshot reconciliation before the watcher reconnects after 100 ms. Herdr subscriptions do not expose a replay cursor, so events that never reached the client before an unexpected disconnect can still be lost; the reconnect snapshot restores current focus but cannot reconstruct that transient order.3

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: unexpected EOF
    W->>H: Drain buffered events
    W->>E: Reconnect after 100 ms

Regression tests define four 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, and reconnect after unexpected EOF.

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) so both sides of the switch survive even before a Herdr event is observed.6 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.

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.
  • 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