Skip to content

feat(doctor): check integrations with seats doctor, set up the herdr plugin in one step and tidy shared hooks - #22

Merged
gelzinn merged 5 commits into
mainfrom
feat/setup-doctor
Oct 28, 2026
Merged

gelzinn merged 5 commits into
mainfrom
feat/setup-doctor

Conversation

@gelzinn

@gelzinn gelzinn commented Oct 28, 2026 •

Copy link
Copy Markdown
Owner

Description

On a real machine the shared Claude settings.json ran herdr's hook three times (~/.config/seats/claude/1/hooks/…, ~/.claude/hooks/…, ~/.config/seats/claude/4/hooks/…), because herdr integration install claude writes $CLAUDE_CONFIG_DIR/hooks/… and was run from inside seats. Every session reported three times, and renaming or removing seat 1 or 4 broke that entry for every seat. Setting up the herdr plugin also meant knowing herdr plugin link <path>. Nothing told you whether the session hook, the plugin or herdr's integrations were in place. And seats hook herdr searched every seat's session folders (recursively for Codex) on every pane status change.

  • Hook paths in the shared settings (src/hook-paths.ts, sessionHook.tidy). On sync, hook and status line commands in the shared settings that reach a shared item by way of one seat get one path per item, and hooks that became the same are dropped. Rewritten: a seat's path to the item when it's linked to the shared copy (or is a real copy sync is about to fold in), the shared folder's path, and a path into a store seat folder that no longer exists. A seat's own copy of an item it keeps out is left alone. --dry-run lists the file.
    • Why the live path (~/.claude/hooks/…) and not the shared folder's path. In herdr's source (src/integration/claude_settings.rs, command.rs, env.rs), integration install claude recognises its hook only by the exact string bash '<dir>/hooks/herdr-agent-state.sh' session, with <dir> from CLAUDE_CONFIG_DIR or ~/.claude, no symlinks resolved. It removes matching entries and keeps one canonical entry (its matcher, timeout: 10). integration status only reads the hook script's version, never settings.json. A shared-folder path is a form herdr never writes, so its next install from a plain shell would add a second entry. The live path is exactly what herdr writes from a plain shell, and it leads to the shared copy whichever seat is active. When the active seat keeps its own copy of the item, the shared folder's path is used instead.
    • Duplicates. Same event, same type and command. When matchers differ, an entry matching everything (*, "" or none) wins. Otherwise the first gets a|b, but only when that entry runs nothing else; if not, both stay. herdr's next install swaps a * entry for its own canonical entry, without adding a copy.
    • The settings shape lives in src/providers/claude/session-hook.ts; the generic part only builds the path rewrite. sharedSettingsFile was pulled out of addSessionHook in src/trust.ts so both use it.
  • seats install plugin herdr / seats uninstall plugin herdr. This keeps the install|uninstall <kind> shape of mcp and skill. From a checkout (plugins/herdr/herdr-plugin.toml next to the code) it runs herdr plugin link <checkout>/plugins/herdr. From the compiled binary or dist/, where that folder isn't there, it runs herdr plugin install gelzinn/seats/plugins/herdr --ref v<version> --yes. It first checks that herdr and seats are on PATH (herdr runs seats hook herdr from its server's PATH), leaves a plugin herdr already has, and prints the command with --dry-run. Uninstall runs unlink for a linked plugin and uninstall for one from GitHub (source.kind in plugin list --json), as herdr requires. seats install plugin paseo only prints the ~/.paseo/config.json entry and never edits it.
  • seats doctor. Read-only checks, printed as ✓ / ! / - (skipped), with a fix under each problem, exit 1 when anything needs fixing:
    • seats on PATH, and at this same version (new: an older seats there lacks hook session / hook herdr);
    • the session hook in the shared Claude settings, naming the seats that keep their own settings.json;
    • hook paths sync would tidy;
    • herdr's version against the plugin's min_herdr_version;
    • the plugin present, enabled, and, when linked, its folder still existing (new: linking from a worktree breaks once the worktree is gone);
    • herdr's claude / codex integrations, for tools that have seats. CLAUDE_CONFIG_DIR and CODEX_HOME are removed first, so herdr looks where it would from a plain shell;
    • a Paseo plugins.<name>.path still ending in integrations/paseo, with the new path.
  • Faster seats hook herdr. State per pane is now { session, command }; the old string state still reads. A status change on the session already settled for the pane only costs herdr pane get, since herdr's agent_status_changed payload carries no session id. The seats are searched when the pane's session changes, and on pane.agent_detected. A detection always reports, as before, and leaves the session unsettled, so the next status change searches once more. Right after an auto-switch both seats hold the same copy, mtime included (copyTranscript keeps timestamps), and the search on detection could still pick the old seat. herdr fires pane.agent_detected when the pane's agent label changes or the agent is released (app/api.rs, emit_pane_state_update), so a new claude process after an auto-switch is covered.
  • Verified
    • bun run check: 281 tests. New: test/hook-paths.test.ts, test/commands/doctor.test.ts (including that it writes nothing), test/commands/plugin.test.ts, more in test/herdr.test.ts. They use tempHome() and a fake herdr on PATH (test/helpers/fake-herdr.ts).
    • Real herdr 0.9.5, fully isolated (env -i, throwaway HOME and XDG dirs, its own server). Reproduced the triple entry by running herdr integration install claude from seat 1, plain, and seat 4. Then: sync, herdr install from a plain shell (no change), sync (no change), herdr install from seat 4 (adds its path), sync (collapses it), herdr install from a plain shell (no change). One round converges.
    • seats install plugin herdr linked and seats uninstall plugin herdr unlinked against that server. herdr plugin install gelzinn/seats/plugins/herdr --ref feat/session-hook --yes installed, and uninstall removed it. seats doctor showed all ✓ afterwards.
    • Ran sync on a copy of the real settings' hooks and statusLine only (no env), with the same seat layout, in a throwaway HOME. The three herdr entries became one * entry on ~/.claude/hooks/…, the status line moved from seat 1's path to ~/.claude/statusline-command.sh, and a second sync was a no-op.
    • The user's herdr was only read (session list, plugin list), and both were unchanged before and after.
  • Limits
    • --ref v0.4.0 fails today: that tag has no plugins/herdr. The GitHub install works from the next release, and a checkout links instead.
    • On a first sync, before settings.json is shared, --dry-run doesn't list the tidy-up yet.
    • The live path is chosen at sync time. Switching to a seat that keeps its own hooks/ changes what ~/.claude/hooks/… reaches until the next sync.
    • herdr integration install run inside a seat still adds that seat's path; the next sync collapses it.
    • herdr's integration status checks the hook script, not the settings entry.

User-facing changes

BEGIN_COMMIT_OVERRIDE
feat(doctor): seats doctor checks the session hook, seats on PATH, the herdr plugin and integrations and Paseo's plugin path, with a fix for each problem
feat(install): seats install plugin herdr sets up the herdr plugin in one step, and seats uninstall plugin herdr removes it
feat(sync): seats sync points shared Claude hooks that ran through one seat's folder at ~/.claude/... and keeps one of each, so herdr's hook no longer runs once per seat
perf(herdr): the herdr plugin only searches the seats when a pane's conversation changes or a new agent starts in it
END_COMMIT_OVERRIDE

Type of change

  • Bug fix
  • New provider
  • New feature
  • Docs / metadata

Checklist

  • bun run check passes (Biome lint and format, typecheck, tests)
  • bun src/cli.ts help runs without errors
  • Changes to seat folders were tried with --dry-run or a throwaway HOME
  • A new provider (if added) lives in src/providers/<id>/ and is listed in src/providers/index.ts

…d copy

A hook installed from inside a seat, such as herdr's with
CLAUDE_CONFIG_DIR set, wrote that seat's path into the shared Claude
settings.json, so every seat ran it through that seat's folder (breaking
when the seat is renamed or removed) and the same hook could run once
per seat. seats sync now points hook and status line commands that reach
a shared item through a seat folder, the shared folder or a seat that's
gone at the live path (~/.claude/hooks/...), which herdr also writes from
a plain shell and recognises as its own, so its next install adds no
copy. Duplicate hooks in an event become one. Paths to an item a seat
keeps out stay as they are. --dry-run lists the file.
seats hook herdr ran on every status change of every Claude Code and
Codex pane and searched each seat's session folders (Codex's
recursively) before noticing it had already reported that command. It
now remembers the session it reported per pane and only searches again
when the pane's session changes or herdr detects a new agent process in
it, as on a restore or when an auto-switch reopens the conversation on
the next seat. State saved by the previous version still reads.
seats install plugin herdr links plugins/herdr from a checkout, or from
an installed binary runs herdr plugin install gelzinn/seats/plugins/herdr
pinned to the installed version's tag. It stops with a clear message when
herdr or seats isn't on the PATH, leaves a plugin herdr already has, and
prints the herdr command with --dry-run. seats uninstall plugin herdr
unlinks or uninstalls it. seats install plugin paseo prints the Paseo
config to add, without editing it.
seats doctor prints a short list of what the session hook and the herdr
and Paseo plugins need, with the command that fixes each problem, and
exits 1 when something does: seats on the PATH and at this version, the
session hook in the shared Claude settings (and which seats keep their
own), hooks there that run through one seat, herdr's version, the seats
plugin in herdr (enabled, linked from a folder that still exists),
herdr's Claude and Codex integrations, and a Paseo config still loading
the plugin from integrations/paseo. It never writes anything.
… on the new seat

Right after an auto-switch both seats hold the same copy of the
conversation, mtime included, so the search on detection can still pick
the old seat. A detection now reports as before but leaves the pane's
session unsettled, and the next status change searches once more, when
the new seat has written to its copy.
@gelzinn
gelzinn changed the base branch from feat/session-hook to main October 28, 2026 15:48
@gelzinn gelzinn self-assigned this Oct 28, 2026
@gelzinn
gelzinn merged commit d409ef7 into main Oct 28, 2026
19 of 21 checks passed
@gelzinn
gelzinn deleted the feat/setup-doctor branch October 28, 2026 17:24
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant