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:
- 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,
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:
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.
Sidebar marker¶
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/locksis 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:
- 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. ↩