Hand the command line to the agent, and let the right pane follow it into a worktree - #3
Merged
Merged
Conversation
`abeam agent` now means `claude agent`. Everything typed after the program name belongs to the hosted agent, and abeam's own namespace is a single leading token beginning `+` — so `uvx abeam <claude args>` is the same session as `claude <claude args>`, which is the whole reason anyone would put abeam in front of it. The positional selector had to go for that, and with it the argument `crate::agent` used to make against a `--agent` flag. That argument was that `abeam --agent copilot powershell` had no honest meaning, which was true only while the positional *also* selected; nothing selects positionally now, so `+copilot` is the one spelling and there is nothing for it to disagree with. Both module sections are rewritten to say so rather than deleted, because the reversal is the interesting part. What it removes is better than what it adds. `main` records a papercut — `abeam --help` once reached `CreateProcessW` as a program called `--help` — and the guard written for it is gone, because a program name has to arrive behind a `+` today and a dashed token can no longer become one however it is spelled. That is a hazard that stopped being expressible rather than one that is now checked for. `abeam claude` and `abeam copilot` are refused outright instead of quietly becoming `claude claude`. Permanently, not for a release: the old spelling is in a README that is already cloned and in shell histories that will outlive the change. It is a fixed lookup in abeam's own table and never a `PATH` probe — a refusal that consulted the filesystem would accept a command line on one machine and refuse it on the next, for a reason living in a directory nobody mentioned. `end_to_end.rs` names the program it hosts with the sigil at the two call sites rather than in the constant. The border still has to read `cmd.exe` with the `+` stripped, and one constant meaning "the program" keeps that from being written as a string that is right for neither. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Thirteen comments across four files still showed the positional form — `abeam claude`, `abeam bash`, `abeam abeam-agent` — which today is either refused with exit 2 or, worse, quietly handed to the agent as a prompt. In a repository where the prose is the specification, a comment that shows a reader an invalid command line is a defect rather than a nit. `dispatch.rs` needed its argument repaired and not just its spelling. The passage established that the two case spellings of a name are one request, which stopped being true of the bare form the moment the bare form became an error, so it now says out loud what it was relying on: the `+` is the only part of a command line that is abeam's, and a bare `abeam claude` is `crate::agent`'s refusal rather than a quieter way of arriving here. Two of them are past-tense narratives about historical bugs, where the sigil is a mild anachronism because it did not exist when the bug did. Spelled the modern way regardless, on the same grounds `agent.rs` already uses: showing a reader a line that exits 2 today is worse than an anachronism in a story about last year. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
abeam's configuration has been two environment variables for its whole life. This adds one optional TOML file under the user's profile, and the two things worth spending it on: `[preset.<name>]`, which puts a whole command line behind a word, and `[defaults]`, which is somewhere for the opening view and the reader's light/dark page to live — that last one started dark every session because there was nowhere to record an answer. A preset behaves exactly like a built-in agent, because it is one: the table `+` is matched against stops being a `const` and is built once at startup from the built-ins plus whatever the file adds. So `abeam +fleet` works, `ABEAM_AGENT=fleet` works, `+help` lists it beside `claude` and `copilot`, and the refusal that catches the retired positional spelling grows with it — all without a second lookup path to keep in step. The file comes from the profile and never from the repository, which is a security decision rather than a filing one. The repository on screen is the one directory in this program somebody else writes to, `launch` spends four hundred lines making sure a `claude.exe` committed to it cannot be what starts, and a `.abeam.toml` read out of that directory would undo all of it in six lines of TOML with the border printing `claude` over the top. The usual mitigation is a trust prompt; a prompt raised whenever a repository is fresh is a prompt that gets answered yes. Two rules keep a preset honest. Its `host` resolves against the built-in table and PATH and never against another preset — structurally, since the preset rows do not exist yet when that lookup runs, so there is no edge to recurse along rather than a cycle check guarding one. And it may not take a name abeam already answers, which would be a name with two meanings and one of them unreachable. A preset's own `args` go in front of what was typed, because a subcommand is the first word of the line it belongs to. A missing file is silence and the ordinary state. A file that is there and does not parse is fatal, named, with the parser's line and column, before `term::setup` — it names programs to start, so half-reading it is the one outcome worse than refusing it. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Two adversarial reads of the flip found thirteen things. The three that changed behaviour are the ones worth the commit message. `--` is forwarded to the agent now rather than eaten. It was a second exception to a rule stated as having one, and it silently removed a token: `abeam -- --resume` resumed the session where `claude -- --resume` sends the literal word as a prompt. Forwarding it deletes the exception instead of documenting it, and the fence is untouched — a leading `--` still means abeam reads nothing, because fencing was never the same act as consuming. `ABEAM_AGENT=+copilot` is refused with the correction rather than failing as a missing program. The variable holds a name and the sigil has no job in it, so the mistake is deterministic and worth naming; `abeam ++copilot` still reaches a program literally called `+copilot`. The refusal no longer tells preset users something false. `used to host` is true of `claude` and `copilot` and false of a preset somebody wrote yesterday, which has only ever been selectable behind the sigil. The rest is this commit paying for a claim it should not have made. The flip said a dashed token could no longer become a program name however it is spelled, and built its argument on it — "we added a check for it" and "it stopped being expressible" being different guarantees. It is false three ways: `abeam +--help`, `ABEAM_AGENT=--help`, and `abeam +./-weird` through the relative-path branch. All three are somebody asking for a dash-named program, which abeam allows on purpose. What keeps one that nobody asked for off `CreateProcessW` is `launch::find`, which answers only with a path it located — and that predates all of this. Every one of the three passages now credits it. Also: `+help` is named in the not-found message, because a machine without Claude could otherwise not reach abeam's own help at all; a `+` token that resolves to nothing names the `--` escape, since a prompt beginning `+` lands in exactly that position; and the refusal quotes only the first word, having previously told people to type back a line whose quoting abeam never saw. Three tests were weaker than their comments. The worst asserted that `help()` lists every agent in the table while `help()` builds that line from the table, so it could not fail; it now renders a different table and checks that one appears and `claude` does not, which is the only shape that tells listing from coincidence. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude Code makes git worktrees, usually at `.claude/worktrees/<name>`, and runs agents in them. `.claude` is not in the watcher's noise list — deliberately, because abeam is going to want to look *inside* those worktrees — so today an agent working in one fires your git refresh on every file it writes and pulls its markdown into your reader, with nothing on screen saying where any of it came from. The obvious fix does not work, and why is the whole of this commit. A path under `.claude/worktrees/other/` **has the repository root as a prefix**, so routing by prefix hands it straight to the workspace rooted there, which is the case being complained about. The rule has to be innermost ownership: a path belongs to the *longest* root that contains it, and a pane takes it only if that root is its own. That is also what git itself does — `git status` in the main worktree does not report a nested worktree's changes. `paths.rs` is the prerequisite, because three sources of paths meet here and none of them agree how to spell a directory. `current_dir` and `notify` give the platform's own spelling; `git worktree list --porcelain` prints forward slashes on Windows. Both questions — same place, inside that place — are answered by one function over a path's components. That is not tidiness: a string rule and a component rule disagree about a root with a redundant `.` in it, and `abeam .` produces exactly that. The failure is not a wrong pane but `owner` finding an owner for every path and matching none of them, so the watcher goes quiet and looks deleted. `workspace.rs` parses the porcelain and joins it against the agent roster. The `-z` form separates records with an *empty* record, and this file already had an idiom — `filter(|r| !r.is_empty())` — that would have merged every worktree into one; there is a test named after that mistake. Discovery runs on the app's worker channel beside the roster, since the occupancy half of a row comes from there. The roster keeps its gate: a session that never asks for this still never starts a `claude agents` process. Nothing is re-rooted yet and no pane can be switched. What is observable is exactly the bug fix, proved three ways rather than asserted — real `git worktree add`s parsed by the real discovery, a real watcher over a real nested worktree showing a neighbour's document reaching nobody while the root's own still arrives, and both ownership predicates forced to a constant to confirm each test fails when it should. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The right pane can now be moved into a worktree: `w` in the git view lists them with their branch and who is working there, `Enter` switches, and the git pane and the reader both follow. The left pane never moves, because you cannot chdir another process's pty — that asymmetry is the design and the border says so by naming the workspace only when it is not the agent's. **The previous commit's routing was half a rule, and its test said so on every machine but the one it was written on.** Ownership decides which workspace a path belongs to, and that is not enough: writing one file in a nested worktree makes the watcher report the directories above it in the same batch, and `<root>/.claude` and `<root>/.claude/worktrees` are owned by the *enclosing* workspace. Routed on ownership alone they are indistinguishable from somebody editing in the root by hand, so a neighbour's write still bought this window a frame and a `git status` — the whole thing the rule was built to stop, arriving one directory up. So a path that contains another workspace's root is no longer evidence about its own. A root stays evidence about itself, and that arm is load-bearing rather than tidy: every root contains a nested one once a worktree exists, so without it the agent's own workspace goes silent the moment somebody creates one. The rest is state that had to be discarded rather than carried. A git report in flight when the root changes is dropped by generation — a counter and not the root, because switching A→B→A inside one refresh would let a pre-switch A report be believed. The stamp rides on the envelope so `Snapshot`'s equality, which keeps an idle repo at zero frames, is untouched. `set_root` clears the pending `Enter` too, since a switch and an `Enter` can arrive in the same drained batch, and it shows `Pending` rather than the other repository's branch for the second it takes to answer. The reader rebuilds its whole `Browser` instead of setting a field, because `aligned` short-circuits alignment and a stale one sends the first `Alt+E` into the previous tree. Shells are per workspace and cannot be re-rooted at all: a live child's cwd belongs to the child. Every one of them is ticked so a hidden workspace's child can still exit, and `Alt+Q` consults all of them, or it would kill somebody's build in a workspace they were not looking at. Last, the probe. A hosted session that moved into a worktree used to fall out of its own record — `is_here` was an exact match on the agent's root — and the queue's auto-send then stalled silently and for ever, with the pane saying it was waiting and nothing saying why. It now matches against the known set of worktrees. A *set*, never a prefix: this is the one comparison in abeam whose failure types somebody's prompt into a session in another checkout. `w` is pane-local and reaches the agent from everywhere else, so the global table in `keys.rs` is untouched and its invariant is not in play. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
An adversarial pass over the two workspace commits wrote failing tests for four things. Two of them were the feature quietly not working. **A junction, a `subst` drive or an 8.3 short name defeated the routing rule outright on Windows.** `GetCurrentDirectoryW` does not resolve a junction and `git worktree list` does, so a session started through one had an agent root of `…\link` while every root git named was `…/real`. `notify` reports under the spelling that was watched, so the only root that contained any watched path was the agent's own — including paths inside a neighbour's worktree. Ownership handed them to the workspace on screen, `is_evidence` had nothing to suppress, and the bug both previous commits exist to close was silently back. The agent's own workspace was also listed twice and no row was marked as where you are. The root is now resolved once, at startup, to the spelling git will use, and the verbatim prefix stripped back through `Prefix::VerbatimDisk` and `VerbatimUNC` rather than by string surgery. Once at startup is a different proposition from the per-comparison canonicalisation `paths` refuses, which was a cost argument about a rule asked thousands of times; both are written down together so they do not read as contradictory. The pty's own `cwd` gets the resolved spelling too, and that is not tidiness: the child writes its cwd into the session record the probe compares. **The probe's widened set admitted exactly the agents it claimed to exclude.** Claude's neighbours run *at* the worktree roots git lists, so a neighbour's `cwd` was an exact member. A recycled pid, a second window, or a few milliseconds of clock skew — which sends the search down a fallback that ignores `spawned_at` and takes the newest agent in the repository — all ended with abeam adopting somebody else's record and memoising it. Each answered `Idle`, the one answer that types a queued prompt into a mid-turn agent, where the code before the widening answered `Unknown` or `Busy`. Discovery is strict again: an exact match on the agent's own root, the worktrees consulted nowhere in the search. Only revalidation of a record already established as ours is widened, and it is tied to the `sessionId` that was ours — without that, a recycled pid walks back in through the revalidation door, because the remembered path *is* a pid. A session that had already moved before the probe found it is not discovered at all: `Unknown`, and the queue drains by hand. That is the direction this module fails in on purpose. Also: the workspace you are in and the agent's own always have a row now, so a session started in a subdirectory can get back to where it is; and a switch with no worker behind it says so instead of reading the repository for ever. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The worktree work landed across four commits with nothing in the README about it, because the file was being edited by somebody else throughout. This is that section, written the way the rest of the document is: the bug first, then the rule, then the limits. The limits are the half worth having. Discovery polls every ten seconds and the lag goes wrong in both directions — a worktree somebody has just added is owned by the workspace enclosing it, so its whole checkout counts as your news, while one just removed keeps its former parents suppressed, so a real edit there is dropped. A worktree outside the repository is listed and switchable but outside the one watcher, so it falls back to the git pane's own two-second poll. A session that had already moved before the probe found it is never discovered. And `paths` does not normalise `..`, which is unreachable from any source abeam reads today but is not unreachable by construction. One correction to what I had been saying about this feature: the shell does *not* follow a switch. It cannot — a live child's cwd belongs to the child, which is the same fact that keeps the left pane still — so switching starts a second shell in the new worktree the first time that view is drawn, and the process count grows with the workspaces you have actually typed in. The README says that rather than the tidier untruth. Two gaps found while writing it are deliberately left alone, both older than this branch: the Keys table has no `Alt+A` row and "The panes" has no queue entry, and "Drawing" does not mention the worker channel the roster and the worktree discovery now come back on. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
CI found two things and only one of them was a test. **The Linux failure is a product bug, and it predates this branch.** `notify`'s inotify backend registers `IN_OPEN` alongside the write masks, so every `open(2)` inside the watched tree arrives as an event carrying that path — a grep, a build reading a source file, the reader opening a document, `git status` re-hashing a file whose timestamp is too close to the index to be trusted. `Watch` took the paths out of every event regardless of kind, which is right on the three backends that construct no `Access` event at all and, on the fourth, cannot tell a file being read from a file being written. That is worse than a stray refresh, because two of the readers are abeam itself and both loops feed themselves. The git pane runs `git status` on every change it is told about; on Linux that status opens working-tree files, which is reported as a change, which asks for another status. The viewer opens the document it is about to show; that open is reported, routed, and handed back to the pane that just displayed it. Neither loop shows up as anything on screen — the window is simply busy, re-rendering the agent's whole screen for news nobody generated. The rule is the one `AccessKind` already implies: an access is somebody reading, unless it is the close of a write. It only surfaced now because the routing test is the first test in this repository that asserts the watcher stays *quiet*. **The Windows failures were the tests, not the product.** The runner's user is `runneradmin`, so `temp_dir()` hands back `C:\Users\RUNNER~1\…` and both `canonicalize` and git expand it. The fixtures took that path straight to `App::new`, which is something `main` never does — it resolves first — so they built a root in a shape production cannot be given and then disagreed with git about its spelling. `TempDir` now resolves, which is one line and makes every test root in the crate agree with production. This machine's user is five characters, so long and short forms coincide and none of it could ever reproduce here; it was reproduced by giving a directory a short name by hand and pointing `TMP` at it. Two `paths` tests were asserting the opposite of what production wants — that `resolve_root` hands back the path it was given, on a machine where resolving it is the entire point. One is now about the fallback alone, and the other about the property that matters: the resolved form names the same directory, is absolute, is idempotent, and carries no verbatim prefix. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The shell draws the right pane's border — and asks the pane for its title — before it renders the pane. So the frame on which the reader first takes up a pending document carries the title of the pane that had none: `files`, over a page with a document on it. Every later frame is right, and nothing whatsoever promises a later frame, because a pane that changed its own state inside a render has asked nobody to draw again. It has presumably been there as long as the pending queue has, and it took this branch to make it visible. On Windows the hosted `cmd.exe` produces enough output to keep the loop drawing. On Linux nothing does, once `crate::watch` stopped reporting abeam's own reads as writes: until that fix, opening the document generated the events that redrew the title, so the pane was rescued by the noise it made. Take the noise away and a `sh` idle at a prompt leaves the wrong title on screen for the rest of the session. It reached CI as a twenty-second timeout waiting for a word that was never going to arrive — which is the useful shape of this failure, because the screen it printed showed the document present and the title absent, and those two cannot come from one state. Answered on the next `tick`, which is what "this pane wants a frame" already means everywhere else, rather than by moving the take into `tick`: being drawn is the only signal this pane gets that it is the view on screen, and that is the entire reason the take is where it is. One frame, not a stream — asserted, so an idle reader cannot start redrawing for ever. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
abeam agentnow meansclaude agent. Everything typed after the programname belongs to the hosted agent, and abeam's own namespace is a single
leading token beginning
+— souvx abeam <claude args>is the samesession as
claude <claude args>, which is the whole reason to put abeam infront of it.
That change needed two others to be useful.
+is worth little withoutsomewhere to write a command line down, so there is a config file and
presets. And once
abeam agentis the ordinary invocation, Claude spawningagents into
.claude/worktrees/stops being an edge case — which surfaced abug where a neighbouring agent's writes were being reported as yours.
The command line
abeam agentclaude agentabeam --resumeclaude --resumeabeam +copilot --resumeabeam +fleetabeam +help--helpis the agent'sabeam -- +1 more--fences abeam off and is forwardedabeam claudeandabeam copilotare refused with exit 2 rather thanquietly becoming
claude claude. Permanently, and by a fixed table lookuprather than a
PATHprobe — a refusal that consulted the filesystem wouldaccept a command line on one machine and refuse it on the next.
This is a breaking change.
abeam <program>no longer hosts thatprogram; write
abeam +<program>. The refusal catches the two names abeamknows, but it cannot catch
abeam bash, which now reaches Claude as aprompt. That loss is recorded in "Not done, and known" rather than papered
over.
Presets and config
One optional TOML file under the user's profile —
%APPDATA%\abeam\abeam.tomlor
$XDG_CONFIG_HOME/abeam/abeam.toml.[preset.<name>]puts a command linebehind a word;
[defaults]gives the opening view and the reader's light/darkpage somewhere to live, which until now started dark every session because
there was nowhere to record an answer.
Presets extend the same table
+is matched against, soabeam +fleet,ABEAM_AGENT=fleetand+helplisting it all work without a second lookuppath. The file comes from the profile and never from the repository: the
repository on screen is the one directory somebody else writes to,
launchspends four hundred lines making sure a
claude.execommitted to it cannot bewhat starts, and a
.abeam.tomlread from there would undo that in six linesof TOML.
Worktrees
Claude Code makes worktrees and runs agents in them. Before this, a
neighbouring agent working in
.claude/worktrees/otherrefreshed your gitpane on every file it wrote and pulled its scratch markdown into your
reader.
Routing by path prefix is a no-op dressed as a rule — a path in a nested
worktree genuinely has the repository root as a prefix. What works is
innermost ownership, plus a second rule that a directory containing another
workspace's root is not evidence about its own, because writing one file in a
worktree makes the watcher report the parent directories in the same batch.
On top of that,
win the git view lists the worktrees with their branch andwho is working there, and
Entermoves the right pane into one. The left panenever moves — you cannot chdir another process's pty — and the shell does not
follow either, for the same reason: switching starts a second shell in the new
workspace.
Review
Four adversarial passes, which changed the result rather than blessing it.
The findings worth knowing about:
a program name" is refuted three ways; what actually keeps one off
CreateProcessWislaunch::find, which predates the change. Corrected inall three places it was written.
clone of the commit that added it. Parent directories of a nested worktree
are owned by the enclosing workspace, so a neighbour's write was dropped by
name and let straight back in one directory up.
neighbours run at the worktree roots git lists, so a recycled pid, a second
window, or a few milliseconds of clock skew had abeam adopt somebody else's
session and read
Idle— the one answer that types a queued prompt into amid-turn agent. Discovery is strict again; only revalidation of an
already-identified session is widened, tied to its
sessionId.substdrive or 8.3 short name defeated the routing ruleentirely on Windows.
GetCurrentDirectoryWdoes not resolve a junction andgit worktree listdoes, so no worktree root matched anything the watcherreported. The root is now resolved once at startup.
Testing
433 unit tests (from 349) and 4 end-to-end, on Windows. The new end-to-end test
drives the real binary in a pty against a real worktree —
F5,w,Tab,Enter,Alt+E— and asserts both right-hand views follow.cargo clippy --all-targets -- -D warningsis clean.Not run on Linux by hand; CI covers it. Formatting churn from a bare
cargo fmtwas kept out deliberately — the committed formatting predatesrustfmt 1.9.0's import sorting, and reformatting 14 untouched files would have
buried this diff.