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:
- Open the event connection and subscribe to
workspace.focusedandworkspace.closed. - Start decoding events as soon as the subscription acknowledgement arrives.
- Call
pingon a second connection and reject servers below protocol 20. - Use another socket connection to request the initial
session.snapshot.2 - 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. - 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:
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:
- Confirm the running server is 0.9.1 or newer and the plugin's history hooks are enabled.
- Subscribe to
workspace.focusedon that session's socket and wait for thesubscription_startedacknowledgement. - With one client navigating, click three distinct workspaces in the sidebar
in order A, B, C. Confirm
workspace_focusedevents arrive in that order and the socket-scoped history starts with C, B, A. - Invoke the plugin's
lastaction. 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:
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
-
See the lifecycle declarations in
herdr-plugin.tomland watcher election ininternal/app/app.go. ↩ -
See
loadSessionSnapshotand the buffered decoder ininternal/herdr/events.go. ↩ -
Herdr documents snapshots as reconnect reconciliation, while lifecycle subscriptions explicitly do not replay retained events. See the Herdr socket API. ↩
-
syscall.Flockprovides the process-level advisory lock used bywithHistoryLockandTryHistoryWatcherLock. ↩ -
The lock and state rules are in
internal/state/history.go; atomic replacement is ininternal/state/json.go. ↩ -
See
RecordSwitchininternal/state/history.goand its focused tests ininternal/state/history_test.go. ↩