Skip to content

Hand the command line to the agent, and let the right pane follow it into a worktree - #3

Merged
ShipsAfterHours merged 10 commits into
mainfrom
hand-the-command-line-to-the-agent
Aug 3, 2026
Merged

Hand the command line to the agent, and let the right pane follow it into a worktree#3
ShipsAfterHours merged 10 commits into
mainfrom
hand-the-command-line-to-the-agent

Conversation

@ShipsAfterHours

@ShipsAfterHours ShipsAfterHours commented Aug 3, 2026

Copy link
Copy Markdown
Contributor

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 to put abeam in
front of it.

That change needed two others to be useful. + is worth little without
somewhere to write a command line down, so there is a config file and
presets. And once abeam agent is the ordinary invocation, Claude spawning
agents into .claude/worktrees/ stops being an edge case — which surfaced a
bug where a neighbouring agent's writes were being reported as yours.

The command line

abeam agent claude agent
abeam --resume claude --resume
abeam +copilot --resume hosts copilot
abeam +fleet a preset
abeam +help abeam's own; --help is the agent's
abeam -- +1 more -- fences abeam off and is forwarded

abeam claude and abeam copilot are refused with exit 2 rather than
quietly becoming claude claude. Permanently, and by a fixed table lookup
rather than a PATH probe — a refusal that consulted the filesystem would
accept a command line on one machine and refuse it on the next.

This is a breaking change. abeam <program> no longer hosts that
program; write abeam +<program>. The refusal catches the two names abeam
knows, but it cannot catch abeam bash, which now reaches Claude as a
prompt. 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.toml
or $XDG_CONFIG_HOME/abeam/abeam.toml. [preset.<name>] puts a command line
behind a word; [defaults] gives the opening view and the reader's light/dark
page 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, so abeam +fleet,
ABEAM_AGENT=fleet and +help listing it all work without a second lookup
path. The file comes from the profile and never from the repository: the
repository on screen is the one directory 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 from there would undo that in six lines
of TOML.

Worktrees

Claude Code makes worktrees and runs agents in them. Before this, a
neighbouring agent working in .claude/worktrees/other refreshed your git
pane 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, w in the git view lists the worktrees with their branch and
who is working there, and Enter moves the right pane into one. The left pane
never 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:

  • The flip's headline claim was false. "A dashed token can no longer become
    a program name" is refuted three ways; what actually keeps one off
    CreateProcessW is launch::find, which predates the change. Corrected in
    all three places it was written.
  • The first routing fix was half a rule, and its test failed 3/3 on a clean
    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.
  • The probe widening admitted the agents it claimed to exclude. Claude's
    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 a
    mid-turn agent. Discovery is strict again; only revalidation of an
    already-identified session is widened, tied to its sessionId.
  • A junction, subst drive or 8.3 short name defeated the routing rule
    entirely on Windows.
    GetCurrentDirectoryW does not resolve a junction and
    git worktree list does, so no worktree root matched anything the watcher
    reported. 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 warnings is clean.

Not run on Linux by hand; CI covers it. Formatting churn from a bare
cargo fmt was kept out deliberately — the committed formatting predates
rustfmt 1.9.0's import sorting, and reformatting 14 untouched files would have
buried this diff.

ShipsAfterHours and others added 8 commits August 3, 2026 09:15
`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>
@ShipsAfterHours ShipsAfterHours self-assigned this Aug 3, 2026
@ShipsAfterHours ShipsAfterHours added the enhancement New feature or request label Aug 3, 2026
ShipsAfterHours and others added 2 commits August 3, 2026 19:25
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>
@ShipsAfterHours
ShipsAfterHours merged commit d4dd17f into main Aug 3, 2026
2 checks passed
@ShipsAfterHours
ShipsAfterHours deleted the hand-the-command-line-to-the-agent branch August 3, 2026 19:15
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

enhancement New feature or request

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant