Support herdr as a terminal workspace manager beside tmux #427
No reviewers
Labels
No labels
bug
contract
documentation
duplicate
enhancement
good first issue
help wanted
invalid
question
wontfix
No milestone
No project
No assignees
1 participant
Notifications
Due date
No due date set.
Dependencies
No dependencies set
Reference
iterative-works/iw-cli!427
Loading…
Reference in a new issue
No description provided.
Delete branch "IW-422"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Closes IW-422.
Adds
herdras a second terminal workspace manager beside tmux, behind a portthat either backend implements. Two phases: phase 1 introduced the port and
moved tmux behind it with no behaviour change; phase 2 adds the herdr backend,
per-process backend selection, and an
iw doctorcheck.What changes for a user
Nothing, unless they opt in. tmux stays the default.
IW_WORKSPACE_BACKENDselects a manager explicitly;
HERDR_ENV=1selects herdr when the variable isunset. An unrecognised value is a hard error, never a silent fallback — a typo
must fail loudly.
iw doctorgains aterminal workspacecheck that reports the manager thecommands would actually drive, and names a bad value when there is one.
Breaking change
SessionContextlosessessionName, and it is plugin-facing, soVERSIONmoves 0.9.0 → 0.10.0. kanon needs a lockstep one-line fix at
hook-test/src/ClaudeSessionTest.scala:20; its hook itself compiles unchanged.Shape
core/model/— the reply decoders andHerdrLookup, which holds the realdecisions: the checkout path outranks the label, a failure on the path route
never falls through to the label, and ambiguity is always a failure rather
than a reason to pick the first workspace.
core/adapters/—HerdrAdapterandTmuxAdapter, each with pure argvbuilders that both the operations and the printed recovery hints render, so
an operation and its hint cannot drift. This fixed a real defect on the tmux
side: the hand-written hint omitted
-L <socket>, so underIW_TMUX_SOCKETa copy-pasted hint attached to the wrong tmux server.
core/commands/— the two port bindings and the per-process selection.dashboard/is deliberately untouched — it has no invoking terminal, soenvironment detection cannot work there and it needs a configured backend
instead. That is IW-423.
What was established by probing rather than reasoning
Four open items were closed against a live herdr server, and three of them
corrected the phase context, which had reached its answers by elimination:
workspace focusanswersworkspace_info, notok.worktree openneeds a source, and--cwdsupplies it.workspace createproduces a workspace carrying no
worktreeobject, which the parent lookupcould never find again, so every run would build another parent.
createmust ask git for the repo root.HERDR_WORKSPACE_IDanswers adifferent question — which repository the caller sits in, not which one the
worktree belongs to. They differ whenever the worktree is of another
repository, and
createfailed until this was found by running it.is correct.
Testing
IW_CONTRACT_HERDR=1and herdr onPATH. Read-only, because herdr has no socket isolation — the one case that
writes server-wide focus carries its own opt-in.
test/*.batssetup()exportsIW_WORKSPACE_BACKEND=tmux, landedin phase 1 before any herdr adapter existed, so a suite run started inside
herdr cannot drive the real server.
core.test186/186,dashboard.test241/241,dashboard.itest240/240.A pre-existing flaky test was fixed on the way:
TmuxAdapterTestfailed abouttwo runs in three because it asserted on
pane_current_path, which is the paneprocess's live cwd and answers with the caller's directory until the login shell
has started.
session_pathis what tmux fixes from-cat creation. Proven bymutation and by eleven consecutive green runs.
Review
Six single-skill reviews per phase. Phase 1: zero Critical over two iterations.
Phase 2: five Critical (three distinct defects), all fixed in
f453abb:isCurrentcompared twoOptions with==, soNone == Noneanswered trueand
iw rmrefused every removal with "you are in its workspace" for a userin no workspace at all. Found independently by five of the six reviewers.
whichwith stdout inherited,printing a stray path above every report — a regression against the baseline.
existsdropped a failure silently, andiw rmis the one caller with nonext effectful call, so an unreachable server orphaned a workspace with no
message.
Two committed fixtures also carried an internal host name and the abbreviated
real capture path; both are redacted and the documented redaction guard is
widened, since a guard built from remembered literals would let the next capture
repeat it.
Cleared by probing rather than assumption: command injection (hostile labels
were sent to the live binary and every one arrived as a value, never a flag),
hostile JSON, environment trust,
-Wunused:all, and a full assertion audit —no assertion was deleted, loosened or downgraded anywhere in the diff.
Review packets:
review-phase-01-20260820-111839.md,review-phase-02-20260821-095022.md.Still open
LESSONS.mdcarries entries awaiting triage with Michal, including severalfollow-ups that should leave this repository as their own issues. That triage
has not happened yet and should land on this branch before merge.
🤖 Generated with Claude Code
https://claude.ai/code/session_01JhUqj7NTLf2ZeY24jFTDeD
Support herdr as a terminal workspace manager beside tmux Artifacts: - analysis.md 🤖 Generated with Claude Code Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>🤖 Generated with Claude Code Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>Closed by inspection and read-only probe: - pane id comes from `herdr pane list --workspace`, not `workspace get` - the IW_* env filter is inert on both backends (os-lib merges env) - no compatibility alias needed; the plugin API never exposes CommandEnv - attach is dead code under environment selection Artifacts: - analysis.md - LESSONS.md 🤖 Generated with Claude Code Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>Resolved: - analysis.md: Port and operation names -> TerminalWorkspaceOps, env.workspace; sessionName dropped from SessionContext (no hook reads it) - analysis.md: herdr attach path -> attach is real code; selection and isInside stay separate concepts so a configured default cannot orphan the branch - analysis.md: worktree open vs workspace create -> always worktree open under a parent workspace, creating the parent when missing - analysis.md: dashboard TmuxAdapter call -> stays tmux-only; IW-423 filed for the configured backend - analysis.md: backend override -> IW_WORKSPACE_BACKEND names the backend and wins over detection Estimates revised 10-15h -> 13-19h to cover the create path and a real attach. 🤖 Generated with Claude Code Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>Support herdr as a terminal workspace manager beside tmux 2 phases, 13-19h: - Phase 1: Workspace port + tmux backend (5-7h) - Phase 2: Herdr backend + selection + doctor (8-12h) Artifacts: - tasks.md - review-state.json (tasks_ready) Also records three review-state CLI defects hit while generating this index, incl. `--needs-attention false` setting the flag to true. 🤖 Generated with Claude Code Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>Issue: IW-422 Phase: 1 - Workspace port + tmux backend This commit marks the checkpoint for phase 1 context generation. Use wf-implement IW-422 to return to this point. 🤖 Generated with Claude CodeAlso records the review-state --checkpoint format defect in LESSONS.md. 🤖 Generated with Claude CodeMichal decided Risk 5 in favour of backend-specific manual-recovery hints over neutral phrasing: a hint exists to be copy-pasted, so it is worthless once it stops naming a runnable command. Hints are pure attachHint/focusHint/leaveHint methods on the TerminalWorkspaceBackend enum, not operations on TerminalWorkspaceOps; the port gains `backend` so commands can reach them. 🤖 Generated with Claude CodeMichal rejected the enum design: a hint and the operation it describes are one piece of knowledge, so the domain layer must not hold a second copy of the command text. Hints become attachHint/focusHint/leaveHint on TerminalWorkspaceOps, each adapter rendering from the same argv its operation runs. This uncovered a live defect - the hand-written hints omit tmux's -L <socket> - now fixed in phase 1 scope and pinned by a pure argv test. Two further copies in the dashboard are recorded in LESSONS.md for IW-423. Herdr keeps the same two-layer adapter shape as tmux, since IW-423 gives the dashboard a herdr caller eventually. 🤖 Generated with Claude CodeWorkspace port + tmux backend Artifacts: - phase-01-tasks.md Task generation checked the context against the source and found the leak-closing test cannot be a harness test: FakeHookOps.runSessionHooks returns a scripted result without entering SessionHooks. The context's step 5 and its acceptance criterion now name a direct SessionHooks unit test instead. 🤖 Generated with Claude CodeIssue: IW-422 Phase: 2 - Herdr backend + selection + doctor This commit marks the checkpoint for phase 2 context generation. Use wf-implement IW-422 to return to this point. Corrects analysis.md against live herdr probes: already_open is a boolean field on the worktree_opened reply, session names come from socket_path in `herdr session list --json`, herdr reports failure as a JSON envelope on stderr, SessionHooks.run takes the runCommand capability, and the CommandEnv member sits at LiveCommandEnv.scala:747. Records the three decisions taken before implementation: Phase 2 stays whole, the pure doctor decision lives in core/model/, and a Left from select is carried by UnavailableWorkspaceOps. Path-based workspace matching lands in this phase, which forces isCurrent to compare ids. 🤖 Generated with Claude CodeHerdr backend + selection + doctor Live-server probes lead the list. Three of the four open items need a mutating probe or a terminal outside herdr, so they block the decoder and adapter groups. Two parts of open item 4 are closed read-only against the running server and recorded in LESSONS.md: herdr rejects a label where it documents an id, and the detach keystroke is Ctrl+B, Q. Artifacts: - phase-02-tasks.md - LESSONS.md 🤖 Generated with Claude CodeRecorded ten reply fixtures from a running server, protocol 19. Captured in a throwaway repo under the scratchpad, so none carries a client name. Every probe workspace was closed again and the focused workspace was left where it was. Two findings contradict phase-02-context.md, which is corrected here: - workspace focus answers workspace_info, not ok. The context reached ok by elimination from the schema and said so; the elimination was wrong, because a reply variant can be reused across commands. close does answer ok. - worktree open needs a source. With neither --workspace nor --cwd, herdr takes the caller's workspace and refuses with linked_worktree_source when that is a linked worktree - the normal case for iw start. The parent-creating call must carry --cwd. Open item 2 is answered: worktree open --cwd builds the parent and it keeps full worktree metadata, so parentOf can find it again. workspace create leaves no worktree object and is dropped from the design. Open item 1 and open item 4 part 2 stay open. Both need Michal. 🤖 Generated with Claude CodeAdds HerdrReply and the four decoded value types. Every entry point answers Either, so a malformed reply is an ordinary failure and never an exception across the port. Tested against the ten recorded replies, not against hand-written JSON. Three shapes the recordings pin and imagination would have missed: - workspace focus answers workspace_info, not ok - WorkspaceInfo.worktree is an ABSENT key, not a null value - session list carries no envelope, and the field is socket_path 🤖 Generated with Claude CodeAdds HerdrLookup. The checkout path is the identity of the directory iw manages; the label is a display name anyone can type. A worktree opened through herdr's own UI carries a foreign label and only the path route reaches it. Ambiguity is a failure everywhere, never a reason to pick the first candidate: byLabel, byCheckoutPath, activePane and bySocket all refuse. parentOf keeps Right(None) - create a parent - apart from Left - several workspaces claim this root. A Left from the path route does not fall through to the label. Answering by label there would name a workspace holding a different directory. The lookups read no filesystem. Both sides of a path comparison must arrive canonicalised, and the scaladoc says so. 🤖 Generated with Claude CodeThe check now reports the manager the commands will actually drive, instead of always reporting tmux. The decision is a pure function in core/model with a unit test over all five outcomes; the scala-cli script keeps only the wiring, so the check gets the test checkTmux never had. A stub records WHICH CLI name each branch probes for. Without that, the herdr branch could silently ask about tmux and report on a manager the commands would not use - the exact failure this check exists to prevent. An unrecognised IW_WORKSPACE_BACKEND is reported by name and probes for nothing, because no manager was picked. Verified live: tmux, herdr and bogus all report correctly, and bogus exits 1 with no stack trace. checkWorkspaceManagerWith takes no ProjectConfiguration. No project setting reaches this decision, and the sibling checks take a config because they use one. Also adds HERDR_SOCKET_PATH to Constants.EnvVars and rewrites the four PURPOSE headers in commands/ that still named tmux. 🤖 Generated with Claude CodeTmuxAdapterTest runs real tmux against the default server, so it reads the developer's live sessions and fails about two runs in three. Both the test and createSession are byte-identical to main, so it predates this branch. Recorded with proposed home "new tracker issue"; not fixed here, because phase-02-context.md forbids touching Tmux.scala. Also marks groups 2, 3, 4, 9 and 10 complete in the phase task list, and records which open items the live probe closed. 🤖 Generated with Claude CodeThe recording stub used a var, which scalafix DisableSyntax.var rejects on push. The behavioural form is better anyway: make only one manager available and assert the verdict. A branch asking about the wrong manager reads the wrong answer and reaches the wrong verdict, so the assertion pins the name with no recording at all. 🤖 Generated with Claude CodeThe first entry blamed missing tmux socket isolation and proposed setting IW_TMUX_SOCKET. Measurement disproved it: with a private socket the test failed three runs out of three, and it fails on its own, so parallel load is not the cause either. The real cause is a race with the shell. pane_current_path is the pane process's real cwd and only becomes the -c directory once the login shell has started: new-session -d -c /home/mph, queried at once -> the caller's cwd the same, queried two seconds later -> /home/mph The wrong fix is recorded beside the right one so nobody spends the hour again. 🤖 Generated with Claude CodecreateSession sets working directory failed about two runs in three. It asked tmux for pane_current_path, which is the pane process's live cwd: it answers with the caller's directory until the login shell has started, and follows the shell afterwards. new-session -d -c /home/mph, queried at once -> the caller's cwd the same, queried three seconds later -> /home/mph then `cd /tmp` inside the pane -> /tmp session_path, all three times -> /home/mph session_path is the session's working directory, fixed at creation and never moved, which is what the test's name claims to check. Verified both ways: six consecutive full core.test runs green, and a mutation test - dropping -c from createSession makes the case fail, so the assertion still catches a real regression. Socket isolation was the first guess and it was wrong; with a private socket it failed three runs out of three. Recorded in LESSONS.md beside the real cause. core/adapters/Tmux.scala is untouched. 🤖 Generated with Claude CodeFocus is server-wide and focus-before-attach works, confirmed twice with different targets. Attaching also switches focus, which is consistent with that order. There was never a second option: attachSession blocks until detach, so focus-after-attach is not expressible. The hint fallbacks are decided. A field labelled "run this to recover" holds a runnable line, never an error message. All four open items are now closed. 🤖 Generated with Claude CodePure argv builders are the single source for both execution and the printed hints, mirroring TmuxAdapter, so an operation and its hint cannot drift. Three shapes the recorded replies forced: - focusWorkspace decodes workspace_info, not ok. close decodes ok. - runInPane decodes nothing: pane run writes no output, so exit 0 is the whole success signal. - parentOpenArgv carries --cwd. Without --workspace and without --cwd herdr takes the caller's workspace as the source and refuses with linked_worktree_source whenever that is a linked worktree, which is the normal case for iw start. workspace create is not used at all. HerdrReply gains errorMessage, so a failed command reports the server's own message and falls back to raw stderr rather than inventing a description of it. Exercised against the live server: every read operation decodes, and getWorkspace("zzzz") returns Left("workspace zzzz not found"). No IW_* environment filter, as the port carries no such contract. 🤖 Generated with Claude CodeLiveHerdrOps implements all twelve members. Every operation that needs an id goes through one resolve step, so path-first addressing is stated once. isCurrent compares ids, never labels: a workspace opened through herdr's own UI carries a foreign label, and a label comparison would let iw rm close the workspace the user sits in. attach focuses then attaches. Focus is server-wide and attaching lands the client in the focused workspace, both verified 2026-08-21. The hints render the argv their operations run. When the id lookup fails they fall back to a line that still works - the workspace picker for focus, session list for attach - because a field labelled "run this to recover" must hold a runnable line. repoRootOf asks git, not the caller's herdr workspace. The context specified herdr first; implemented that way create failed with "worktree path not found", because HERDR_WORKSPACE_ID answers which repository the CALLER sits in, not which one workDir belongs to. The two agree only when workDir is in the caller's own repo. Recorded in LESSONS.md. WorkspaceBackend reads the environment once and serves both consumers. runSessionHooks still receives the function value, so core/adapters imports core/commands nowhere. UnavailableWorkspaceOps carries a bad IW_WORKSPACE_BACKEND through the port instead of throwing, which would kill iw doctor - the command whose job is to report it. Exercised against the live server end to end, in a throwaway repo, and every workspace it made was closed again. 🤖 Generated with Claude CodeThe contract suite is read-only throughout. herdr has no equivalent of tmux's -L socket isolation, so a contract run must never create or close workspaces on a developer's live session. Gated by IW_CONTRACT_HERDR=1 and by herdr being on PATH; both gate conditions verified to skip. Sixteen cases pass against a live server, protocol 19. The two that earn their keep most: - ResponseResult offers ok and offers neither workspace_focused nor workspace_closed, and workspace focus is observed answering workspace_info. That is the pin the schema alone could not give, because a schema says which variants exist, not which one a command sends. - An unknown workspace exits non-zero with its error envelope on stderr, which keeps HerdrAdapter's failure mapping honest. Also adds the start.bats bad-value case and documents the new suite and its gate in docs/testing.md. 🤖 Generated with Claude CodeThe previous form compared against null, which scalafix DisableSyntax rejects. Exhaustiveness over the enum is already a compile error, so the assertion worth making is that no two backends were wired to the same object by a copy-paste slip. 🤖 Generated with Claude CodeCompletion-flow artifacts for the final PR: a full-branch review packet covering both phases, and user-facing release notes in Czech for 0.10.0. 🤖 Generated with Claude CodeBATS `run` merges the command's stderr into `$output`, so every `--json` assertion parsed a stream that was never only stdout. Anything a process writes to stderr broke them — here the JVM's `NOTE: Picked up JDK_JAVA_OPTIONS` reminder, which the java launcher emits on stderr by specification as an anti-misuse warning whenever the variable is set. The five affected tests now use `run --separate-stderr`, which fills `$output` from stdout alone. `bats_require_minimum_version 1.5.0` declares the feature level the flag needs. With `JDK_JAVA_OPTIONS` set, the suite is 190 ok / 0 failures. It no longer needs `env -u JDK_JAVA_OPTIONS`, a workaround that ran the whole suite with the environment's deliberate heap cap discarded. Also corrects a claim in doctor.bats that the JDK notice reaches stdout. The `which` leak that test guards is genuine stdout pollution; the JDK notice is not, so the comparison was false. 🤖 Generated with Claude Code`SessionHooks` took `(String, String) => Either[String, Unit]`. The type kept `adapters/` free of a `commands/` import, but it carried no meaning: both parameters are String, so transposing them compiles and fails only at runtime, and the roles survived in scaladoc alone. `RunInWorkspace` names the capability and names its parameters. It is declared beside its only consumer, so the hook runner states what it needs and `adapters/` still depends on `model/` alone. Callers pass `workspace.runCommand` unchanged. Both parameters stay String, so transposition is still possible. Opaque types for the workspace name and the command close that, and also the herdr label/id split; filed as its own issue. 🤖 Generated with Claude Code`TerminalWorkspaceChecks` held both the pure verdict and the binder that reads `sys.env` and probes PATH. The binder pulled `ProcessAdapter` into `core/model/`, which `core/CLAUDE.md` forbids, and made the file header's "pure decision" claim untrue. The binder moves to `commands/start.hook-doctor.scala`, which is where the check is assembled and which already may import adapters — the same shape `issue.hook-doctor.scala` and `github.hook-doctor.scala` use. `core/model/` now imports nothing. The pure function and its unit tests are unchanged. The two doctor BATS tests exercise the live binding and stay green. Review finding W11 raised this and accepted it on the `GitHubHookDoctor` precedent. That file is the remaining instance; filed separately. 🤖 Generated with Claude Code`CommandRunner.isCommandAvailable` probed with `s"which $command".!`, which inherits the JVM's stdout, so the resolved path printed above whatever the command was writing. It is the default argument of roughly thirty call sites across `GitHubClient`, `GitLabClient` and `GitHubHookDoctor`, so every `gh` and `glab` prerequisite check could put a stray line into a `--json` reply and break a `jq` assertion on it. It now answers through `ProcessAdapter.commandExists`, which pipes both streams and validates the name first. Two probes existed with names giving no hint that one was safe and one was not; now there is one behaviour. The only visible change is that a path-shaped argument is refused instead of executed. No caller passes one. 🤖 Generated with Claude Code