abeam
One window for an AI coding session.
Your agent runs in the left pane — hosted in a pty, parsed, and drawn by abeam, not passed through to your terminal. The right pane shows the state of the git worktree, the document the agent just wrote, or a shell to run things in. A file watcher drives the first two, so neither has to be asked.
It replaces a three-window setup: the agent in one terminal, git in another, and an editor open purely to read the markdown it produced.
Two agents are known by name today — Claude Code and GitHub Copilot CLI
— and any other program on PATH can be hosted as abeam +<name>. Everything
else you type goes to the agent, so abeam <claude args> is claude <claude args> with two panes beside it; "Running it" is the whole rule. Which one you got
is not a thing to remember: the left border says. Read the Copilot half of this
document knowing that abeam has never been run with Copilot CLI, not once:
the selection, the launcher and the failure message are tested, a session is
not, and "Not done, and known" says exactly why and what that leaves unproven.
┌ claude ──────────────────────────────┐┌ git · main ↑2 · 12 changed ──────┐
│ ││ Staged (1) +40 -6 │
│ > implement the parser ││ M crates/abeam/src/app.rs +40-6│
│ ││ Changed (1) +7 -0 │
│ ● Writing crates/abeam/src/parse.rs ││ M docs/design.md +7-0 │
│ ││ Untracked (1) │
│ ││ ? notes/ │
│ ││ Recent │
│ ││ a1b2c3d 2m parser skeleton │
└──────────────────────────────────────┘└──────────────────────────────────┘
That title is the name you chose and never the path it resolved to, which is
why an npm-installed agent started on Windows by way of cmd.exe says its own
name rather than the interpreter's. abeam +copilot should therefore draw
┌ copilot ─… in the same place — "should" because that follows from the code
and from a test of the naming, and nobody has watched it happen. The + is how
abeam was told which agent to host and never part of what it was told, so it is
not on the border either.
Why this rather than wezterm + lazygit + glow
Because the right pane knows what the agent just did. A watcher on the repository root feeds both views: markdown the agent writes becomes the document on screen, and any file it touches refreshes git within one debounce interval. That is the only thing here you cannot assemble out of existing tools, and it is the reason abeam exists.
One watcher over one root turned out not to be one workspace, and that is the whole of "Worktrees" further down. Claude Code makes git worktrees inside the directory abeam is watching and runs other agents in them, so "what the agent just did" and "a file changed under this root" stopped being the same sentence the day somebody ran two agents on one project. The pane still knows what the agent just did. What it costs to keep that true is a routing rule with an argument in it.
Everything else is a consequence of that. The panes are read-only, they never take focus from the agent, and they never switch themselves — a pane that yanks itself into view while you are reading is delightful twice and infuriating thereafter.
Installing
uvx abeam # run it without installing anything
uv tool install abeam # or keep it on PATH
There is no Python in abeam. PyPI is the delivery van: the wheel's whole payload
is a compiled binary, so uvx fetches a few hundred kilobytes and runs it — no
Rust toolchain, no build step, and nothing to compile on your machine. Two
wheels are published, win_amd64 and x86-64 manylinux; the section after next
says what that list leaves out, and what it does not prove about the half of it
that is new.
Requirements
- Windows or Linux, x86-64, which is not the same sentence as "tested on both" — see Platforms.
gitonPATH, for the git pane. Its absence is reported in the pane, not fatal.- An agent to host, which abeam does not install for you — see below for what each one wants.
- Rust 1.95+ — only to build from source. On Windows that means the MSVC
toolchain and plain
cargo build, with no vcvars wrapper; on Linux it means whatever your distribution calls a C toolchain, because that is what supplies the linker. Neither build looks for a system library.
Platforms
Windows x86-64 and Linux x86-64 are what ships, and the two are not equally proven.
abeam was Windows-only by construction until this version — uvx abeam on Linux
found no wheel because there was none to find — and the fix was a real port
rather than a build flag. crates/abeam/src/launch/ is one shared question —
where a program may be found — in front of two answers to what may then be
started; crates/abeam-pty/src/tree/ is one type whose Drop is a kill, with a
job object behind it on Windows and a process group on Unix; the shell pane's
candidate list is per-platform; and four places were folding case or reading \
as a path separator, which is right on Windows and a correctness bug anywhere
else. CI builds, tests and lints both targets on
every push, which is the only reason the second one is a claim at all: a suite
that names a platform is only ever run by that platform, and a cfg that
excludes one is invisible from the other.
Nobody has driven abeam on Linux by hand. That is the sentence to read
before installing it there. The six manual pass criteria in
docs/conpty-findings.md — renders, one character per keystroke, editing keys,
live resize, paste, /exit — have been confirmed on Windows only. They
generalise to a Unix pty without a word changing, which is why re-running them
there is the right acceptance gate rather than a second checklist, and nobody
has run them. Two further things are worse on Linux than on Windows and both are
about killing children rather than starting them; "Not done, and known" says
what they are.
macOS and both ARM targets do not ship. The Unix half compiles for macOS and
would probably work, and aarch64-pc-windows-msvc and
aarch64-unknown-linux-gnu are one more matrix entry each. None of the three
has ever been built or run by anyone on this project, and uvx picks a wheel
automatically — so shipping one would mean the first person to find out it does
not work is a user. That is the rule that already kept ARM Windows out, applied
unchanged: uvx reporting no matching wheel is one line, true and immediately
actionable. Add each target when someone can run it.
Running it
Everything on the command line belongs to the hosted agent, except a single
leading token beginning +, which is abeam's. That is the whole rule, and
what it buys is that uvx abeam <anything> is the session claude <anything>
would have started, with two panes around it.
abeam # the default agent, in the current directory
abeam --resume # ...which is `claude --resume`
abeam -p "fix the tests" # ...and `claude -p "fix the tests"`
abeam agent # ...and `claude agent`, subcommands included
abeam +copilot --resume # GitHub Copilot CLI, with its own --resume
abeam +bash # anything else on PATH
abeam +help # abeam's own help; `--help` is the agent's
abeam -- +1 more thing # `--` stops abeam reading, and goes to the agent too
If you used to write abeam <program>, write abeam +<program> now.
abeam bash, abeam powershell, abeam nu — anything this document used to
describe as "anything else on PATH" — needs the sigil in front of it, and the
next section says what those lines do without one.
A + token is read only in the first position and there is at most one: a
prompt may begin with a +, and abeam config set +x is a real command line.
+name is resolved exactly as the old positional was — if it names an agent
abeam knows, claude or copilot, or a preset out of your config file, matched
without regard to case, abeam looks that entry's executables up on PATH;
anything else is a program name and means what abeam bash used to mean. Spaces
around the name are trimmed, so abeam "+claude " is abeam +claude rather
than a hunt for a program with a space on the end of it. Two words behind the
sigil are reserved and only two, +help and +version, and like every other
name behind a + they are matched without regard to case: +HELP and
+Version are abeam's too. There are no +h/+V short forms, deliberately: a
short form is one more word that can never be a program name, and -h, -V,
--help and --version all go to the agent now, which is the help you wanted.
-- is not a second exception, and it used to be one. A leading -- stops
abeam reading the line — so a first argument beginning with + is safe behind
it — and then goes to the agent along with everything after it, exactly as you
typed it. That is a fix rather than a detail: abeam used to swallow the token,
so abeam -- --resume started claude --resume and resumed the session,
where claude -- --resume hands the literal string --resume over as a prompt.
One command line meant two things depending on whether abeam was in front of
it, which is the whole of what the rule above exists to prevent. So
abeam -- claude agent is claude -- claude agent today, and whether that is a
prompt or a complaint is Claude's business — the point is that it is the same
answer either way.
ABEAM_AGENT names what to host when no + token did — an agent name, a preset
name, or any program — and this change is what made it worth setting: ABEAM_AGENT=copilot abeam --resume resumes Copilot, where before the variable stopped applying the
moment you had arguments to pass. An empty value counts as unset, because
PowerShell leaves one behind — and so does a profile that exports a variable it
then fails to fill — and '' was not found on PATH names nothing anyone can act
on. A + token overrides it.
Its reach grew with that, and that is a cost as well as the feature. The old
code read the variable only when you gave abeam no arguments at all, because
anything else was a positional that selected. It is read on every command line
that does not lead with a + now — so one exported in a dotfile three years ago
used to touch nothing but bare abeam, and today silently redirects abeam -p "commit my changes" into a different agent. There is no version of this that is
only the good half. What stands against it is that a + overrides it for one
run, that the left border says which agent is taking the typing, and that when
what it names cannot be found abeam's message says the variable is why.
It holds a name, not a command line, so ABEAM_AGENT=+copilot — the
spelling every other line here teaches — is refused with the correction printed
rather than accepted with the + quietly dropped. The sigil says which token on
a command line is abeam's, and there is no token to mark inside a variable.
abeam claude and abeam copilot are refused rather than reinterpreted,
with exit code 2 and a message naming both readings and both ways out. They
hosted those agents for the whole of abeam's life before this, they are written
that way in every older copy of this document, and what you would otherwise get
is claude claude — the agent's own complaint about an argument it does not
have, on a screen that never mentions abeam. That refusal is permanent, not a
migration aid, and it is a fixed lookup in abeam's own table rather than a PATH
probe: a refusal that depended on what happened to be installed would accept a
command line on your machine and reject it on a build server.
The message rewrites the first token and leaves the rest of your line
described rather than quoted, which is a correction: it used to print your whole
command line back inside Write `abeam +…`, joined with single spaces,
having never seen your shell's quotes. For abeam claude -p "fix the tests & ship it" that produced an instruction which in bash runs two commands. Only
the first token ever changes, so that is the only part worth spelling out
exactly.
There is no --agent flag, and this document used to give a different reason
for that than it gives now. The old one was that the positional already
selected, so a flag beside it would make abeam --agent copilot powershell
expressible with no honest meaning. Nothing positional selects any more, so that
objection has gone with its premise — but the property it was defending is
exactly what + keeps: there is one place a selection can be written, and it is
impossible to write two.
What the positional cost was the point of abeam. abeam agent looked for a
program called agent, abeam mcp list looked for one called mcp, and
abeam --help was abeam's help rather than Claude's — every argument that
happened not to start with a dash was a trap, and the traps were whatever
subcommands the hosted agent grows next, which is not a list any README can
hold. The change also deleted a class of bug rather than guarding against it:
abeam used to refuse leading flags it did not recognise, because before that
check existed abeam --help reached CreateProcessW as a program named
--help. What the rule deletes is the route — a dashed token becoming a
program name with nobody having named one — so there is nothing left to check.
This paragraph used to claim something stronger and it was false, which is worth
correcting rather than quietly softening, since its own point is that "we added
a check for it" and "it stopped being expressible" are different guarantees. It
said a dashed token could never be a program name at all. It can:
abeam +--help names one, ABEAM_AGENT=--help names one with no + on the
line, and abeam +./-weird reaches a dash-named file by path. All three are you
asking for a dash-named program, which abeam allows on purpose. What keeps such
a name off the operating system's own spawn is launch, which answers only with
a path it has actually located and otherwise says `--help` was not found on PATH in abeam's own voice — and that predates this change entirely.
An agent abeam cannot find is a sentence and never a download. Modern gh copilot is a launcher rather than the retired suggest/explain extension: it
will fetch the Copilot CLI if it is missing, and abeam had that fallback for a
day before it was taken out on purpose. Typing abeam +copilot is a request to
run something, not consent for a network install, and a terminal border cannot
close that gap after the fact. So what you get instead is four things: the
names that were looked for, what the operating system said about the last of
them, then — when another agent abeam knows is already sitting on PATH — the
line that saves the ten minutes, `claude` is installed; `abeam +claude` would host it., and last one sentence on installing the one you asked for, gh copilot included, as a command you run yourself. The third is the best line in
the message and the one most often needed: the default agent missing on a
machine where the other one is right there is a session one word away from
starting, and nothing else on that screen would say so.
Under all of that there is one more line, naming abeam +help, and it is there
because this message is also what abeam --help prints on a machine with no
agent installed. Trace it: no +, so --help belongs to the agent; the agent
is not there; and what comes back is a page about installing Claude to somebody
who was asking what abeam is. F1 is not a route out of that — it needs a
running agent — so without the line the only way to find abeam's own command
line is this document.
What each agent wants:
- Claude Code — the native installer, or
npm i -g @anthropic-ai/claude-code. On Windows those writeclaude.exeand aclaude.cmdshim respectively; both are hostable, the shim was not until recently, and the story is under "Not done, and known". On Linux both write one extensionless file with a#!line on it, which abeam starts directly and which has no story. - GitHub Copilot CLI —
winget install GitHub.Copiloton Windows,npm i -g @github/copiloton Linux, orgh copilotonce on either to fetch it. The npm package wants Node 22 or newer, which is the reasongh copilotis worth naming: it is the route that works where the other two do not.
The current directory is both the child's working directory and the root that
the git pane and the watcher use — resolved once, at startup, before any of
the three is handed it. GetCurrentDirectoryW reports the path the process
was given and resolves neither a junction, nor a subst drive, nor an 8.3 short
name; git worktree list resolves all three. So a session started through any
of them stands in …\link while every root git names is …/real, and those are
two different directories to every comparison abeam makes about a path — which
would leave the routing rule under "Worktrees" wrong for the whole session
rather than wrong once. The child is given the resolved spelling too, and that
is not tidiness: the child writes its own working directory into the session
record abeam reads back out. The visible cost is one surprise, and it is worth
naming rather than describing the resolution as free — somebody who works on a
subst drive X: gets an agent whose pwd says C:\src\forge. Between a name
that surprises you once and a window that cannot tell your worktrees apart, the
name is the cheaper of the two.
The right pane can later be pointed at another worktree of the same repository. The left one cannot, ever, and "Worktrees" is where that asymmetry is argued.
From a checkout, cargo run -p abeam and cargo run -p abeam -- +copilot do
the same. Cargo's own -- and abeam's are two different fences that happen to
be spelled alike: the first ends Cargo's arguments, and everything past it is
the line abeam reads under the rule above.
Configuration
There is one file, it is optional, and most machines will not have one.
Windows %APPDATA%\abeam\abeam.toml
Linux $XDG_CONFIG_HOME/abeam/abeam.toml, or ~/.config/abeam/abeam.toml
Your profile, and never the repository, which is a security decision rather
than a filing one. The repository on screen is the one directory in this whole
program that somebody else gets to write to — it is a clone, it is somebody's
pull request, it is whatever git checkout just put there — and launch spends
four hundred lines making sure a claude.exe committed to it can never be what
starts. A .abeam.toml read out of that directory would undo all of that in six
lines of TOML: [preset.claude], host = "./tools/claude", with abeam's own
border obligingly printing the word claude over the top. So there is no
repo-local config and there is not going to be one. The usual mitigation is a
trust prompt, and a prompt that appears whenever a repository is fresh is a
prompt that gets answered yes.
No file is not an error; it is the ordinary state. A file that is there and does not parse is one: abeam prints the path and the parser's own line and column and exits 2, before it touches the terminal. This file names programs to start, so half-reading it is the one outcome worse than refusing it.
[defaults]
view = "git" # git | files | shell | queue — which right-hand view opens
focus = "left" # left | right — which pane has the keyboard
zoom = false
theme = "dark" # light | dark — the reader's page
[preset.fleet]
host = "claude" # an agent abeam knows, or any program on PATH
args = ["agent"]
view = "queue"
theme = "dark"
[defaults] is how every session on the machine opens. A preset is a name
behind the sigil that behaves exactly like a built-in agent: abeam +fleet --resume starts claude agent --resume with the queue showing,
ABEAM_AGENT=fleet abeam does the same, and +help lists fleet beside
claude and copilot. A preset's own args go in front of what you typed,
because a subcommand is the first word of the line it belongs to — behind them,
abeam +fleet --resume would be claude --resume agent, which is a different
command in every agent abeam hosts. Its four opening keys override [defaults]
field by field, so the preset above moves the view and leaves the rest where the
defaults put them.
Three rules, each of them a refusal you will see rather than a surprise you will not:
- A preset's
hostis looked up in abeam's built-in table and onPATH, never among the other presets. There is no preset chaining, deliberately. Without the rule,[preset.claude] host = "claude"is a row pointing at itself; with it, there is no edge from a preset to a preset to recurse along. What it costs is naming one preset from another, which is one saved line in a config file — bought, otherwise, with a cycle check on the path that decides which program starts. - A preset may not take a name abeam already answers —
claude,copilot,helporversion. It would be a name with two meanings and one of them unreachable, with nothing on screen saying which of the two ran. Two presets whose names differ only in case are refused for the same reason, since every name behind a+is matched without regard to case. - A preset name is refused in front of the sigil too.
abeam fleetgets the same both-readings refusalabeam claudegets, because it is the same mistake — made, this time, by the one person on the machine most likely to believe the word means their preset. The sentence differs in one place and had to:abeam claudereally did host Claude for years, andabeam fleethas never hosted anybody's preset, so it says what that line did mean instead — aPATHlookup for a program calledfleet.
A key abeam does not recognise is an error rather than a shrug. [presets.fleet]
with the plural spelling, or a them = "dark", is a line you wrote and expected
to work, and a config file that quietly ignored it would behave exactly like a
config file abeam never found. The cost is forward compatibility — a file
written for a later abeam is refused by an earlier one rather than partly
honoured — and that is the right way round for a file that names programs to
start.
Keys
Everything abeam binds lives under Alt and the F-keys, with one exception:
Ctrl+\, the escape hatch at the foot of the table, which has to be reachable
on the day an Alt key stops being safe and so cannot live in the namespace it
exists to rescue. (F12 is its alias, for layouts that put backslash behind
AltGr.) Nothing abeam claims is a key the hosted agent can act on — the
audit behind that is docs/keymap.md, and crates/abeam/src/keys.rs has tests
pinning it. Everything else you type goes to the agent untouched.
| Key | |
|---|---|
Alt+G |
right pane → git |
Alt+E |
right pane → files / markdown (again for the file list) |
Alt+S |
right pane → a shell, and focus it (again to hand focus back) |
F2 |
right pane → pty diagnostics, and back to what it displaced |
F3 |
file reader → light / dark page |
F4 / F5 |
move focus left / right |
Alt+J / Alt+K |
scroll the right pane a line — without focusing it |
Alt+PgDn / Alt+PgUp |
scroll the right pane a page — without focusing it |
Alt+Z |
zoom: hide / show the right pane |
Alt+Q |
quit (twice while a child is live) |
F1 |
key help overlay |
Ctrl+\ or F12 |
send the next key to the agent verbatim |
Focus was Alt+←/Alt+→ until abeam gained a second agent, and it is F4/F5
now because GitHub's own command reference declares that pair as word-motion in
Copilot CLI on Windows and Linux — a vendor-declared collision, not a suspicion,
and inside abeam it would have left a Copilot user with no way to move by a word
at all. The arrows go to the agent now. docs/keymap.md has the evidence, and
the argument about which side should yield.
Reading the right pane costs nothing: switching views and scrolling both work
while the agent still has focus. You only need focus to drive a selection — or
to type, which is what Alt+S is for and the only reason a view key moves
focus.
Once the right pane is focused, plain keys work, deliberately the same
vocabulary as Claude's own transcript view — which Copilot's diff mode shares
most of, on GitHub's published tables rather than on anything read out of a
binary, and differs from in two places worth naming: space pages nothing
there, and b toggles the diff rather than paging back. Neither reaches inside
abeam, where these keys are the pane's; one scroll language is close to serving
both rather than exactly serving both. The vocabulary itself: j/k and
arrows for a line, space/b and PgDn/PgUp for a page,
Ctrl+D/Ctrl+U for a half page, g/G and Home/End for the ends,
Tab/Shift+Tab for the selection, Enter to open, r to refresh. t swaps
rendered markdown for its source, and Backspace climbs a directory in the file
list. Three keys search, and they ask three different questions rather than one
question three ways: / in the file list is which file is called this, / in
the document is where is it on this page — n and N walk the matches — and
f, from either of them, is which files say this. Only the third reads the
disk, and it is the only one of the three whose box waits for Enter rather
than narrowing as you type. Esc or q hands focus back to the agent.
w is the one key in that vocabulary that is not about reading: in the git view
it opens the repository's worktrees, Enter there points the right pane at the
selected one, and Esc gives you the status list back instead of the agent —
which is why the border says esc→git while the list is up. It is pane-local
rather than an Alt key, and the table above is right to leave it out.
crates/abeam/src/keys.rs's HELP lists it below the split that separates
abeam's global bindings from the keys that only mean anything while the right
pane has focus and one particular view is showing, and it is exempt from that
file's invariant for exactly that reason: no agent can be listening for a key
that is only ever delivered to a focused pane. A global spelling was available
and refused twice over — Alt+W is Claude's, and a view key spelled F6 would
be a key nobody groups with Alt+G and Alt+E. The list is not a peer of those
views anyway; it is how you point one of them somewhere else.
The shell view is the exception, and it has to be: Esc and q belong to
whatever is running in it. Alt+S or F4 is the way out, and its border says
so rather than leaving you to find out.
Ctrl+\ exists so abeam can never permanently shadow a binding of the agent you
are typing at. If a future Claude or Copilot release binds Alt+G, Ctrl+\
then Alt+G still reaches it.
The panes
git — read-only. Branch, ahead/behind, staged / unstaged / untracked files
with per-file line counts, and recent commits. Every git call is a read: it
stages nothing and commits nothing, which matters because it refreshes itself
unasked. Refreshes when the watcher sees a write, and on a two-second safety
timer for changes the watcher cannot see (.git is deliberately not watched, so
a commit made in another terminal arrives on the timer). Enter opens the
selected file in the viewer, when the row names one to open: git collapses an
untracked tree to a single dir/ entry and there is no directory view to open
one in, and a deletion is a path git has just finished saying is gone. Enter
does nothing on those, rather than trading the view you are reading for a
viewer saying "no such file". w leaves the status list for the repository's
other worktrees, which is how the right pane is pointed at one; the section
after this is the whole of that.
files — read-only markdown and source, and a way to reach any of it.
Markdown is rendered, not shown as source: headings, lists, tables, quotes, GFM
alerts, footnotes, syntax-highlighted fenced code, and mermaid diagrams. t
swaps that for the source it was rendered from, highlighted and numbered like
any other file, and back.
A ```mermaid fence is the one of those that is redrawn rather than
styled, and it is here because it was the conspicuous hole: syntect has no
grammar for mermaid, so the block came out verbatim and uncoloured, and nobody
writes A --> B to be read as A --> B. Two families are drawn, in
box-drawing characters, for the width the pane happens to be —
graph/flowchart in all four directions, and sequenceDiagram. A pane too
narrow for boxes gets the same diagram as an indented outline, or a sequence
diagram as a numbered list of its messages — the same trade a table too wide for
the pane already makes when it gives up its grid and goes out one field per
line: at forty columns a four-cell-wide box is not a diagram, it is a puzzle. Anything else — every other diagram type, a subgraph,
a node spelled in syntax this does not know — keeps the code block it has always
had, and t still reaches the source, which is the only way to read a diagram
abeam declined to draw. What never happens is the fourth thing: a drawing with
an edge missing from it. The source is always true, and a diagram that has
quietly lost a node is worse than one nobody drew. Source files get highlighting and a line-number gutter. Everything is
pre-wrapped to the pane's exact width and scrolled by physical row, so
jump-to-end lands where you asked. On startup it opens the newest markdown under
the root; after that it follows what gets written. If something arrives while
another view is showing it waits, and the border says ◆ Alt+E rather than
switching under you.
A second Alt+E opens the file list: a gitignore-aware directory browser
starting wherever the open file lives. Enter descends or opens, Backspace
climbs — and lands on the directory you just left, so walking back up is a place
rather than a reset. / finds a file by name anywhere under the root, matched
as a subsequence over its path and ranked so a hit in the file name beats one in
a directory name; Esc cancels the find and stays put, because being thrown out
of the pane by the key that means "never mind" is the worst thing a filter box
can do. The list is the second half of the never-switch-under-you rule: a
document arriving from the watcher waits while you are walking a tree, exactly
as it waits behind the git view.
/ in the document is the second question — where is it on this page — and it
is a different matcher from the one in the list, deliberately. A path is matched
as a subsequence, because typing capv to reach
crates/abeam/src/panes/viewer.rs is how anyone who has used a fuzzy finder
expects to get there. Prose is not: a subsequence over a paragraph matches
nearly every paragraph, so the answer is every row and the reader has been
handed noise dressed as results. Plain substring here, and smart case —
case-insensitive until the query contains a capital, which needs no key to turn
on and no explaining when it fires. n and N walk the matches and wrap round.
The query sits in the title beside the position rather than in a row of its own,
so the layout and the scroll arithmetic the pane already depends on do not move
to make room for it. Enter shuts the box and keeps the marks, which is the
state somebody reading is in; Esc does the same, a second Esc clears them,
and only a third hands focus back. A search with nothing marked skips the middle
press, so the border names what this press does rather than where the sequence
ends.
f — from the document or from the file list — is the third question, which
files say this, and the only one of the three that reads the disk to answer. Its
box does nothing at all while you type and runs on Enter. That is the one
place this pane behaves unlike itself and it is deliberate: the other two boxes
answer from something already in memory, where this one reads every file under
the root, so a box that ran per keystroke would sweep the repository for n,
ne and nee before you had finished typing needle and throw all three
answers away. The empty list says so in words, because a box that visibly does
nothing is otherwise indistinguishable from one that is broken. What comes back
is a selectable list of path, line number and the line the match is on, filling
as the sweep goes rather than arriving all at once at the end; Enter opens
that file with the document search already looking for the same phrase, at the
match that row counted, which is what makes the two one feature rather than two
that happen to rhyme. It does not walk the tree again — the startup walk's list
is shared, since a second gitignore walk would double the cost of startup to
re-derive what the first one had in its hand — so it inherits that walk's caps,
reads at most the first half-megabyte of any file, keeps at most a screenful of
matches from each, and stops at a total. Every one of those is visible when it
is hit: a count that is a prefix of the truth is written 137+ rather than
137, and the per-file cap is named outright as · 18 files cut, because it is
the only one of the four whose remedy is a key already on screen — what it left
out is in files the list did reach, and Enter on any row of one of them shows
all of them.
The two searches do not agree about what a match is, and the pane says so
rather than hiding it. f matches the lines in a file; / matches the rows
the pane drew, which is what lets one search serve rendered prose, its source
and a highlighted .rs without the markdown renderer having to grow a second
output — rendering reflows and drops syntax, so no offset into the file means
the same thing on both sides of t, and a mapping is what searching the source
would have needed. Those are the same text for most files and not for all. A
line too wide for the pane is wrapped, so a match that straddles the break is
one the drawn page does not have; that is not a markdown problem, and dragging
the pane narrower is enough to reach it in a plain source file. In rendered
markdown the source syntax is not on the page at any width, so ** cannot be
found there at all. So a page with no match for the phrase names the way out
that is true for the body in front of you — · t for source on rendered
markdown, · widen if a wrap split it on anything else — and a result that
lands on a different match from the one you chose says · not the 3rd rather
than a confident 2/2 about a question nobody asked. Retiring the gap needs
rows grouped by the source line they came from: source_lines knows that
grouping and markdown::render does not, so it is either a second output from
the renderer or a rule true of one body form and not the other. It is written
down in crates/abeam/src/panes/viewer/search.rs rather than half-done, because
half of it is the inconsistency and not the fix.
shell — Alt+S, and the reason it is here rather than in another window:
git branch, uv run ruff format, cargo test, run in the directory abeam was
pointed at, next to the session that is about to be told what they printed. It
is a real pty — on Windows pwsh, falling back to powershell then cmd; on
Linux $SHELL when it is set to anything, then bash, then sh; or whatever
ABEAM_SHELL names, which replaces the list rather than heading it, because a
program you typed is a choice and falling back from it would hide the typo —
started the first time the view is drawn and never before, so a session that
never asks for one never pays for it. Alt+J/Alt+K scroll its history without
focusing it, which is why they are not the arrow keys the shell would read as
history. A child that exits leaves its last screen up with
Enter to start another, and while it is dead Esc means what it means
everywhere else.
Nor will abeam close out from under it: the agent exiting holds the door rather
than killing whatever is running, and says shell open · Alt+Q to quit in the
left title. That is "open", not "busy" — ConPTY cannot be asked whether a
command is running at all, and on Unix the question has an answer abeam does not
go and get (tcgetpgrp on the master says which process group holds the
terminal in the foreground) — so on both a shell sitting at a prompt holds the
door exactly as a build does. Type exit in it, or Alt+Q twice.
pty diagnostics (F2) — what the emulation layer is doing: alt-screen,
application cursor, bracketed paste, mouse mode, byte counts, resize count, and
the pty and parser sizes side by side, to be compared with the pane you can see
rather than with each other. DSR answered is the one that matters on
Windows: ConPTY blocks until its opening cursor query is answered, so a red zero
there means the session is hung rather than slow. Nothing opens a Unix pty with
a cursor query, so there the counter reads zero for the whole of a healthy
session and the pane neither reddens it nor says anything beside it — a
permanent alarm would cost this view the only thing it has, which is that a red
row means something. docs/conpty-findings.md explains each field and why it is
on screen.
It also reports the frame clock — frames drawn, the rate over the last second, and the worst single frame in it rather than an average, because a stutter is one slow frame in a hundred and a mean hides exactly that. While the agent is producing output the rate should sit at the frame floor; a worst frame approaching the gap between frames means the renderer is what is setting the pace, and no amount of pacing will help.
Worktrees, and whose change is whose
This section exists because of a bug, and the bug is the fastest way to explain
the feature. Claude Code does not stay in one directory: it makes git worktrees
— the usual place is <root>/.claude/worktrees/<name> — and runs agents in
them, so a machine with two agents on one project has two working trees inside
one watched directory. abeam runs a single recursive watch of the repository
root and is right to. What that watch could not do on its own was say whose
change it had just seen. So another agent, working in
<root>/.claude/worktrees/other, refreshed your git pane on every file it wrote
and pulled its scratch markdown into your reader, with nothing on screen
admitting where any of it came from. A pane that reports somebody else's work as
yours is worse than a pane that reports nothing, because it is not obviously
broken.
One line in the watcher's noise list would have stopped those events at the door
and closed that bug in an afternoon. It would also have blinded abeam inside its
own worktrees for ever, which is the half of the feature you can actually press
a key on: the right pane is pointed into those directories now, and a noise
entry means the watcher never wakes it. Noise is for directories nobody wants to
read. .claude/worktrees is where the work is.
The obvious fix is not the fix, and that is the part worth reading. Route by
path prefix — take the event if it starts with the workspace root. It does not
work. <root>/.claude/worktrees/other/NOTES.md has <root> as a prefix; it
genuinely is inside the repository, which is exactly what makes the worktree
layout convenient, so a prefix test hands it straight back to the workspace
rooted at <root>, which is the case being complained about. The naive fix is a
no-op dressed as a rule. What works is innermost ownership: a path belongs
to the longest known root that contains it, and a pane takes an event only
when that longest root is its own. That is not a convention invented here to
make a bug go away — it is git's own model. git status in the main worktree
does not report a nested worktree's modifications, because the nested tree has
its own index and its own HEAD, and a pane that mirrors git status should
agree with git status about whose changes those are.
Ownership alone was half a rule, and the missing half is not a corner case — it
is the ordinary shape of the very event the rule exists to route. Writing one
file inside a nested worktree makes the watcher report the directories above it
in the same debounced batch. The file is owned by the worktree and dropped
correctly; <root>/.claude and <root>/.claude/worktrees are owned by the
enclosing workspace, because nothing nested is an ancestor of them. 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 the
second half is that a directory containing another workspace's root is not
evidence about its own. The only way such a directory can have changed is that
something inside the nested workspace did, and that something has already been
reported under its own name and routed to whoever owns it. A path that is a
root stays evidence about itself, and that arm is load-bearing rather than tidy:
every root contains a nested one the moment a worktree exists, so without it the
agent's own workspace would go silent the first time anybody added one — the
routing bug arriving through the back door, in the one workspace that is always
on the list.
With that settled, the worktrees are worth showing. w in the git view — plain
w, with the right pane focused — lists every worktree git knows about: the
branch name, or the directory's own name where a detached or bare worktree has
no branch to use; agent against the one the hosted agent is running in; who is
working there, in Claude's own words rather than a vocabulary abeam invented;
and unwatched on anything the single watcher cannot see. A ▸ marks where the
right pane is standing. Enter moves the right pane to the selected row and
puts the status list back, because looking at that worktree's git is what the
switch was for. Esc — or w again — leaves the list without moving
anything, and the border says esc→git rather than esc→agent so you are not
left guessing which of the two it means. The workspace you are in and the
agent's own always have a row, discovered or not — the list is how the right
pane is switched, so a workspace with no row on it is a workspace nobody can get
back to, and neither absence is exotic: git worktree list names the repository
rather than the subdirectory you started abeam in.
The left pane never moves, and that asymmetry is the design rather than an
unfinished half of it. A live child's working directory belongs to the child;
there is no call that moves a running process to another directory, so the agent
stays where it was started for as long as it runs. The window therefore
deliberately disagrees with itself about where it is, and the border is what
keeps that honest — it names the workspace the right pane is on, and only
when that is not the agent's own. That suppression is not tidiness either. The
pane is 46 columns; a label on every title spends three or four of them saying
the one thing that is true by default, and pushes the branch name and change
count the git title exists for off the end of the border. Shown only when it is
news, it costs nothing and says everything. The list marks agent separately
from ▸ for the same reason: the point of opening it is to look at a workspace
the agent is not in.
Three views read a directory, and the switch reaches all three. The git pane
re-roots and says "reading the repository…" until the first refresh lands, in
preference to drawing the other repository's branch and change count under the
new workspace's name for most of a second. The reader rebuilds its whole index
and opens the newest markdown of the worktree it has moved to, exactly as it
does at startup. The command view is per workspace, and that is the one place
the asymmetry above reaches the right-hand side as well: a shell cannot be
re-rooted any more than the agent can, so switching with the shell up starts a
second child, in the new worktree, the first time it is drawn there. Every
one of them is ticked whether or not it is on screen, so a hidden workspace's
child can still exit, and Alt+Q consults all of them — otherwise quitting
would kill somebody's build in a workspace they were not looking at.
What deliberately does not follow is everything belonging to the agent: the idle/busy probe, the queue and the background dispatcher stay where the agent is. They are the session's, not the view's. Re-rooting any of them would mean a prompt queued for the agent being aimed at a directory the agent is not in, which is the one mistake in this program nobody would see happen.
Drawing
Two intervals, with different jobs.
The agent's output does not wait to be noticed: the pty reader rings the draw loop the moment bytes land, so nothing sits in a buffer waiting for a poll to come round. What the loop does not do is draw every time it is rung — an agent produces output far faster than a screen can show it, and drawing on every chunk spends the whole budget on frames nobody sees while the console write queue backs up. Frames are held to one every 8 ms, start to start, always showing the newest state. A burst becomes one frame, and the frames that go out land evenly, which is what reads as smooth; uneven frames read as jitter at any rate.
The other interval is the 10 ms tick, which is now only what polls the things without a doorbell of their own: the git worker's channel, a viewer walk finishing, a shell child's exit.
Each frame is written between BeginSynchronizedUpdate and End (DEC 2026), so
a terminal that understands them composites the whole frame or none of it rather
than showing a seam partway down a repaint. A terminal that does not know the
sequence ignores it.
Layout
60/40 split, and below 60 columns the right pane collapses entirely rather than squeezing the agent into 36. The pty is sized from exactly the rect that was drawn, once per frame, which is also what coalesces a window drag into a single pty resize.
Repository
crates/abeam-pty/ the pty host layer: sessions, input encoding, DSR
crates/abeam-pty/src/tree/ killing a child's children, per platform
crates/abeam/ the binary: shell, layout, focus, panes
crates/abeam/src/agent.rs the agents abeam knows, and how one is chosen
crates/abeam/src/config.rs the one file abeam reads: presets, and how a
session opens. The profile, never the repo.
crates/abeam/src/launch/ where a program may be found, and what may
then be started: one shared half, two others
crates/abeam/src/workspace.rs the worktrees of the repository, and which of
them owns a watched path. The routing rule
and the argument for it live here.
crates/abeam/src/paths.rs when two spellings are one directory, and the
one spelling everything starts from
crates/abeam/src/panes/viewer/mermaid/ a diagram on a character grid, or an
honest refusal to draw one. `mod.rs` holds
the rule both families answer to.
crates/abeam/tests/end_to_end.rs abeam itself, hosted in a pty and typed at
docs/conpty-findings.md what the spike learned. Read before touching the pty.
docs/keymap.md the keybinding collision audit
pyproject.toml maturin's instructions for wrapping the binary
.github/workflows/ ci on every push; release on a v* tag
Releasing
Tag it and push the tag:
git tag v0.0.1 && git push origin v0.0.1
release.yml builds two wheels — win_amd64 on a Windows runner, and the
x86-64 manylinux one inside a manylinux container on a Linux runner, because
the container is what fixes the wheel's glibc floor at something old rather than
at whatever the runner image happened to ship this month — and uploads both to
PyPI through trusted publishing — an OIDC exchange between GitHub and PyPI,
so there is no API token in this repository to leak or rotate. PyPI is
configured to trust one repository, one workflow file and one environment
(pypi); renaming any of the three breaks the publish, deliberately.
The version lives in [workspace.package] in Cargo.toml and nowhere else. The
workflow refuses to run if the tag disagrees with it, because a release where
those two differ is discovered by whoever installs the wrong thing.
cargo run -p abeam-pty --example host is a complete pty host in one file, kept
as the manual regression harness and as proof that PtySession is sufficient
without abeam. The one thing that reproduces the ConPTY stall itself is the
ignore-DSR arm of conpty_stalls_until_the_dsr_query_is_answered in
crates/abeam-pty/tests/conpty.rs, which pins it in both directions.
Build and test with --all-targets, or the examples bit-rot:
cargo build --workspace --all-targets
cargo test --workspace
cargo clippy --workspace --all-targets
Status
Working, and used. Not finished.
Done. The pty host layer, proven by a spike that ran a real Claude session
against it on 2026-08-01. All four right-hand views, the file list, the
rendered/source toggle, the watcher driving what it should, and the three
searches under the reader — a file by its name, a phrase on the page, a phrase
in every file under the root. Focus, zoom, help, the diagnostics view, and the
literal-next escape hatch. Agent selection and the launcher underneath it. The
Unix port, in the sense that the whole workspace builds, tests and lints clean
for both x86_64-pc-windows-msvc and x86_64-unknown-linux-gnu — see
"Platforms" for the sense in which it is not done. Mermaid flowcharts and
sequence diagrams, drawn rather than shown as source. 648 tests on Windows, and
clippy --all-targets clean on both.
Two of those changed Windows behaviour on the way past, and both are worth seeing before you upgrade rather than after.
An earlier PATH entry holding something abeam cannot start no longer hides
the program behind it. A claude.ps1 with no .cmd beside it, or the
extensionless npm shim on its own, used to end the search where it sat and
produce the error about that file — with a perfectly good claude.exe one
PATH entry further along. The walk now takes two passes, the first for a file
this platform can actually start and the second only to name something in the
error if nothing can be, and it is the same code on both platforms because the
question is the same one. It arrived as a Unix change: there a directory holds
one candidate, so the preference PATHEXT expresses inside a directory on
Windows had to be lifted to the walk. It has a test of its own on this platform,
a_file_windows_cannot_start_does_not_shadow_the_program_further_along_path,
because without one the whole two-pass could be deleted and every Windows test
would still pass.
launch/unix.rs asks the kernel whether this process may execute the file
— access(X_OK) — rather than reading the mode bits, which answer "may
anybody". A --x------ owned by somebody else passed the old check and then
failed at the spawn with a raw EACCES; it is now refused by abeam, with
abeam's own sentence.
Verified how. The whole path — pty → parser → pane, git worker → real git
→ rendered rows, watcher → both panes, Enter in git → open in files — is
covered by tests that spawn a real pty child and draw real frames, on both
platforms.
On top of that, crates/abeam/tests/end_to_end.rs does to abeam what abeam does
to an agent: it spawns the built binary in a pty, types at it as bytes,
and reads the screen that comes back. That is what proves the parts no in-process
test can reach — that abeam starts at all, that raw mode and the alternate
screen survive being someone else's child, that Alt+S written as ESC s
becomes the binding it should, and that a command typed into the shell view runs
in the right directory and puts its answer on screen. Three paths are pinned that
way today: type a command in the shell and read its output; reach a file nothing
pointed the pane at, by Alt+E Alt+E /; and a copy of a real shell planted
in the repository under the name abeam is about to look for, which abeam must
refuse to run. That last one is a test about an attack rather than a feature, and
the two platforms arrive at it down different roads, which is worth stating twice
rather than generalising once. Windows resolves a bare program name against the
calling process's directory before it consults PATH, so a pwsh.exe
committed to a cloned repo was one keystroke from executing and nothing the user
did was wrong. execvp has no rule of that kind, and hands the same hole back
through PATH instead: a ., a .., any relative entry, and the empty string
that a leading, trailing or doubled : leaves behind all name the directory the
process is standing in, which under abeam is the repository on screen — the one
directory in the whole question somebody else gets to write to. PATH=:$PATH is
one typo, and an empty entry is what a shell profile appending to an unset PATH
produces on its own, so it is not an exotic road. On both, the answer is the same
one thing: the PATH walk drops every entry that is not absolute, and nothing
leaves launch that is not an absolute path.
The second line of defence is not symmetric, and this document used to imply
it was. abeam stands in %SystemRoot% on Windows and in / on Unix for the
whole session, and only the first of those buys anything against this hazard. On
Windows it is real: CreateProcessW consults the calling process's directory
before PATH, so where abeam is standing is part of the answer, and standing in
%SystemRoot% is what makes it a harmless one. On Unix it buys nothing, because
abeam never reaches execvp's own PATH walk holding this process's directory —
every spawn goes through portable_pty::CommandBuilder::search_path, which
resolves a bare name against PtyConfig.cwd, and every pty abeam opens is given
the repository on screen as its cwd. So a future call site that built a
PtyConfig with a bare name would resolve it against the repository however far
this process had walked from it, and launch::resolve returning absolute paths
is the whole of what stands behind that. The residual value is smaller again than
/ suggests: uvx abeam inside a container commonly runs as root, where / is
writable like anywhere else. The chdir stays on both because it costs nothing and
still covers anything that does consult the process's own directory — but a Linux
reader should not read it as a spare defence they have.
What none of it covers is a human at a terminal. The six pass criteria in
docs/conpty-findings.md (rendering, one character per keystroke, editing keys,
live resize, paste, /exit) were confirmed against the spike's host and have
not been re-run against abeam itself since the panes landed — and have never
been run on Linux at all, against anything. Do that before trusting it with real
work, on either platform.
Not done, and known.
-
Most of mermaid, by diagram type, is still shown as source. Two families are drawn —
graph/flowchartandsequenceDiagram— andstateDiagram,classDiagram,erDiagram,gantt,pie,mindmap,journey,quadrantChart,gitGraphand the rest are not. Nothing is lost when one arrives: it renders exactly as every mermaid fence did before this existed, which is why the two were worth doing without the other nine.stateDiagramis the obvious next one, because it is a labelled graph and would reuse the flowchart's layout wholesale rather than needing its own. -
A
subgraphdeclines the whole diagram, and so doesclick. Both carry text — a group's title, a URL — and there is no drawing of them here that keeps it, so under the rule the module is built around the fence keeps its source rather than showing a flattened graph with the grouping silently gone. This is the gap most likely to be met in practice, because asubgraphis how anyone draws a diagram with two halves. Also declined: mermaid 11'sA@{ shape: … }node syntax and its@{…}edge form, which is newly written mermaid and so the second most likely to be met. -
actoris drawn as a box, exactly likeparticipant. Mermaid draws a stick figure; a terminal has one cell to do it in and no glyph whose width every terminal agrees on. The name in the box already says which one it is, and nothing else in mermaid depends on the distinction, so it is accepted and drawn rather than declined. -
Two edges that cross between the same pair of ranks cannot be told apart.
A --> D,B --> C,A --> C,B --> Ddraws two identical horizontal rules whose corners merge into the risers already in those columns, and a reader cannot recover which run belongs to which target. The ordering pass removes the simple crossing, so it takes that four-edge shape to see it. Fixing it means reserving a column of the drawing per crossing rather than a row of the band — a different layout, not a tidier version of this one — and the diagram is not wrong meanwhile, only unrecoverable at that one join. -
A flowchart declines below the width of its own longest word. The pane can be narrow enough that a diagram which parses draws nothing, and the fence falls back to source. That floor is a property of the document rather than of the pane:
Choiceis six cells, so no amount of arranging fits it into four, and breaking it in half to make it fit would be the one thing this is not allowed to do. It is why the outline exists and where even the outline stops. -
abeam bashis a prompt now, and nothing says so. This document used to advertiseabeam bash,abeam powershelland "anything else onPATH", and under the rule above every one of those is a word handed to Claude, which will start answering it. That is strictly worse than whatabeam claudegets: the refusal catches the two agent names and every preset name, so those at least produce a complaint with abeam's name on it. It cannot catch a bare program name, and deliberately: the only way to know thatbashis a program on this machine is to probePATH, and a refusal that depended on what happens to be installed would accept a command line on your laptop and reject it on a build server. So the cost is paid in documentation instead, which is what the migration line under "Running it" is. The same shape, reversed, is the other live cost of the flip: a prompt that genuinely begins with a+is read as a program name, because first position is exactly where a prompt lands.abeam "+1 to shipping this"looks for a program called1 to shipping this— the message names the--escape, and it is still a line somebody will type once before they learn it. -
Worktree discovery polls, so the routing rule is allowed to be ten seconds out of date.
git worktree listruns immediately at startup and every ten seconds after it, and both directions of that lag are worth naming rather than leaving to be found. A worktree somebody has just added is not on the list yet, so for up to ten seconds its whole checkout has no innermost root of its own, every path in it is handed to the enclosing workspace, and all of it counts as evidence — which is the original routing bug, for ten seconds, in a worktree that has existed for ten seconds. A worktree somebody has just removed is still on the list, so its former parent directories go on being suppressed for the same window: a real edit at<root>/.claudein that window is dropped rather than misrouted. Neither is fixable by being cleverer about the rule, because both are the list being out of date rather than the rule being wrong, and the rule has nothing but the list. The git pane's own two-second poll makes both of them cost one wrong refresh rather than a screen that stays wrong; the reader has no such net, so in the first window a neighbour's document can still be pulled in front of you and in the second one of your own is quietly dropped. Shortening the window would mean a second watch, on.git/worktrees, and one recursive watch is a decision the watcher makes on purpose. -
A worktree outside the repository root is listed, switchable, and not watched.
git worktree add ../elsewhereis ordinary, and abeam shows it and will point the right pane at it — but the single recursive watch covers the directory abeam was started in, so nothing written in that worktree ever reaches the router. The git pane falls back to its own two-second poll, which is the same net a commit made in another terminal lands in, and the reader does not follow anything at all: it opens the newest markdown once, on the switch, and then sits there. The row in thewlist readsunwatched, and the reader says as much on the empty screen it shows when there is nothing open — because a pane that is merely slow looks exactly like a pane that is broken unless something admits which it is. -
The occupancy column shows an id where it should show a name. Claude's session records and roster entries both carry a
name—"forge-c5", or a dispatched task's own title — and it is the field that column wants;crates/abeam/src/agentstate.rs'sWiredoes not parse it, so what is rendered is the roster's shortidbeside the status word. That is worse than it sounds, because theidis a background agent's and an interactive session does not have one: the common case, somebody typing in a worktree, renders as a bareworkingwith nothing saying who. It is one field onWire, one onSession, and one line of a struct literal in the queue pane's tests. -
A session that had already moved into a worktree before the probe first read a record is never discovered. The probe identifies the hosted session by an exact match on the agent's own root, and the worktrees are consulted nowhere in that search — which is a correction rather than an omission, and the reason is worth having. Claude's neighbouring agents run at the worktree roots git names, so a set consulted during discovery does not merely blur the question, it admits exactly the sessions it was meant to exclude: a recycled pid landing on a neighbour's record, a few milliseconds of clock skew sending the search down a fallback that takes the newest agent in the repository, or a second abeam window during the second before its own Claude writes anything. Each of those answered
Idle, andIdleis the one answer that types a queued prompt into a mid-turn agent. Only revalidation of a record already established as ours is widened, and it is tied to thesessionIdthat was ours — without that the remembered path is a pid, and a pid comes round again. What the strictness costs is the case in the heading: readiness answersUnknownfor the whole session, the automatic send never fires, the queue pane goes on saying it is waiting for the agent to be idle, and the queue drains by hand. That is the direction this module fails in on purpose. -
pathscompares spellings and does not normalise them, and two things follow that somebody will otherwise reach for a fix to...is left where it is, sounder(C:\a, C:\a\..\b\x.md)is true and a containment rule can in principle be walked out of; nothing abeam has emits a..—notifyreports resolved absolute paths,git worktree listprints absolute ones, and the one path a person types is flattened at startup — which makes it unreachable today and not unreachable by construction, and that is the difference worth writing down. And\\?\C:\repocompares unequal toC:\repo, because the verbatim prefix is a different first component to the platform's own parser. Both are the same refusal: canonicalising per comparison touches the disk, andunderis asked once per watched path per known workspace — thousands of stat calls under agit checkoutto answer a question about two strings. What pays for that is the single resolution at startup described under "Running it", which is also the only reason a session reached through a junction routes correctly at all;resolve_rootstrips the verbatim prefix back off rather than handing a third spelling to everything downstream. The fix for a.., if a source ever emits one, is to resolve it at that source — resolving it textually is wrong wherever there is a symlink, sincea/link/..is nota. -
Each workspace you visit gets its own shell, and any of them can hold the door. A shell cannot be re-rooted for the same reason the agent cannot, so switching workspaces with the command view up starts a second child rather than moving the first; the number of shell processes grows with the number of worktrees somebody has typed in, and
Alt+Qasks about every one of them, so a build left running in a workspace nobody is looking at still makes quitting ask twice. The related cost is a workspacegit worktree removehas deleted while a child of yours is still running in it: abeam keeps it rather than killing the build, it drops off the list because the list is built from what git said, and switching away from it is a one-way trip until that child finishes. Unlisted-and-still-running is the smaller of the two failures, and it is the one deliberately chosen. -
The shell view has never been driven by a human. A test types
set /a 123*456into the real binary and reads56088back off the screen, which is more than a smoke test and still less than use: the six pass criteria above were confirmed against Claude in the left pane, and a shell in a 46-column right pane is not the same thing. Expect the first realcargo testrun in there to find something about width, wrapping or the scrollback that no test thought to ask about. -
abeam has never been run with Copilot CLI. Not once, not for a minute. It is not installed on the machine abeam is developed on and cannot easily be: the npm package wants Node 22 and this box has v20.14.0, and neither
winget install GitHub.Copilotnorgh copilothas been run to get around that. What is exercised is the selection and the failure message — against agent tables injected into the tests, because a real table exercises whichever branch the machine it runs on happens to make reachable, which on a build server is the other one — and the launcher, which is the same code path Claude and the command view's shells already take every day. None of that is a session. The first real one will find something. -
The Copilot keymap audit is documentation- and source-derived, and that is weaker evidence than the Claude half of
docs/keymap.mdrests on. Those bindings came out of strings extracted from the installed binary. Copilot's come from GitHub's published shortcut tables, about 150 changelog entries and Ink's source — which is exactly the kind of audit that would have clearedAlt+Fin Claude, andAlt+Fwas bound. The document says so at length, and lists the two steps that would upgrade it. -
Nine of abeam's
Altbindings are probable no-ops in Copilot, not verified ones, and six of the nine are worse than that. The nine are everyAltkey abeam claims:Alt+G,Alt+E,Alt+S,Alt+Q,Alt+Z,Alt+J,Alt+K,Alt+PageUpandAlt+PageDown. Each was looked for in GitHub's tables and in about 150 changelog entries and found nowhere, which is the best a documentation-derived audit can do and is exactly the evidence that would have clearedAlt+Fin Claude.Six of them carry a second problem on top. Ink parses
Alt+letter into the bare letter plus ametaflag, and reportsAlt+PageUpas PageUp withmetaset — either way the handler is handed the unmodified key together with a flag it is free to ignore, soAlt+Jreaches a handler asinput === "j"withkey.metatrue, and a handler writtenif (input === "j")fires on both. Copilot bindsg,s,j,k, PageUp and PageDown bare somewhere — diff mode, the session picker, the timeline — soAlt+G,Alt+S,Alt+J,Alt+K,Alt+PageUpandAlt+PageDowneach shadow a key it really does act on in some view. GitHub has shipped fixes for precisely this class of bug: 1.0.71 stopped modified vim keys moving the selection in tool-permission prompts. Nothing a user can reach is shadowed, because the bare keys still pass through untouched — but the strict form of abeam's invariant, that an intercepted key is one the agent could not have acted on, is unproven for those six, and only the binary can settle it.docs/keymap.mdgrades every one of the nine and says the same thing twice. -
Alt+Jis on borrowed time. Claude has a liveapp:toggleTerminalaction with no default key, and its footer already printsmeta + jas a fallback — so Claude's own UI advertises a key abeam has claimed. Nothing is bound today and the invariant holds; the day it is bound, abeam has to move.docs/keymap.mdcarries the details. -
An npm-installed agent is hosted now, and this README used to say it could not be. All of what follows is about Windows, and a Linux reader can stop at the end of this sentence: the same install there is one file that already runs, and the paragraph after this one says why that is the whole of it.
npm i -gputs three files in%APPDATA%\npm:claude(a POSIX shell script, no extension),claude.cmdandclaude.ps1. Windows starts.exeand.comand nothing else, so plainabeamdid fail at startup for those users. The entry that said so also calledcmd.exe /cone of "two obvious fixes [that] are both wrong", on the grounds that it hands abeam's own argv to a command-line re-parser treating&,|and^as syntax. That half was itself wrong, and deleting it quietly would be the worse correction.crates/abeam/src/launch/windows.rsroutes a.cmdor a.batby namingcmd.exein front of it, and going through the re-parser is safe because of the argument quoting rather than despite it: the command line is built the way Rust's ownmake_bat_command_linebuilds one — the fix for CVE-2024-24576, "BatBadBut" — quoting eagerly rather than trying to enumeratecmd's syntax and coming up one character short, doubling an embedded quote as""because that is the one spelling bothcmdand every CRT since Visual C++ 2008 read, and refusing outright the three characters nothing escapes — a NUL, a carriage return and a newline, the last two because one truncates the command and the other ends it outright, and silently dropping the tail of an argument is what an injection reads like from the outside. Two more things are refused with a sentence rather than escaped. The script's own path may not contain a"or end with a\, either of which would close the quote around it and turn the rest of the line into syntax; Windows file names can hold neither, so nothing legitimate is turned away. And the finished line may not exceed 8124 characters, which iscmd's limit rather than abeam's and was measured rather than read off the documented 8191 — because 8191 is not where the behaviour changes, and the difference is the dangerous part: past itcmdstarts nothing, prints nothing and exits 0, so an over-long argument drew an empty pane and reported success. A natively installed agent is an.exeabeam starts directly and takes roughly four times as much. The line reachescmdin%ABEAM_LAUNCH%rather than on the wire, because portable-pty applies MSVCRT argv quoting to everything it is handed andcmdhas no backslash escape with which to read what that produces. A real shim in a real pty, in a directory with a space in it and with an&in one of its arguments, is what pins the whole of it.Two things are still refused, and both deliberately. An extensionless POSIX shim on its own cannot be started, because there is no shell on Windows that is the right one to hand it to. A
.ps1is refused rather than routed:powershellandpwshload different profiles under different execution policies, so guessing is guessing which of your profiles runs. Neither is normally reached — npm writes a.cmdbeside both,PATHEXTis probed ahead of the bare file name, and.PS1is not onPATHEXTat all — so arriving at either message means the sibling that would have worked is missing.On Linux the whole of that inverts, and
launch/unix.rsis three functions. The samenpm i -gwrites one file, extensionless, with#!/usr/bin/env nodeon its first line and the execute bit set — the very shape Windows cannot start at all — and the kernel reads that line:execvefinds the interpreter, rewrites the argument vector and starts it before any of abeam's code would have had a chance to. So there is nothing to route, and because there is nothing to route there is no second parser between abeam and the child: nothing to quote against, no character that has to be refused because it cannot be escaped, and no command-line length belonging to a shell that is not running. What the platform does ask is one question — whether the file may be executed at all — and a file that may not gets its own sentence namingchmod +x, rather than the search's "was not found on PATH", which would be a lie about a file the user can see withls. Puttingsh -cin front of things is the obvious improvement to a module that short, and it is refused for the reason the paragraph above exists: it would invent on Linux, deliberately, the entire problemwindows.rsspends four hundred lines solving, in exchange for nothing. -
Nobody has driven abeam on Linux by hand. CI compiles it, lints it and runs the suite there on every push; the six pass criteria above have been confirmed on Windows only. "Platforms" says this where somebody deciding whether to install it will read it, which is the more important of the two places.
-
On Linux, killing abeam itself leaves the process tree behind. A Windows job object dies with its last handle and the handle goes when the process does, so even
taskkill /ftakes the hosted shell'scargo buildwith it. A process group is nobody's handle:SIGKILLto abeam runs no destructor, and what is left is the kernel's ownSIGHUPto the foreground process group when the pty master closes. That reaches a shell sitting at a prompt. It does not reach a build that ignores it. Quit withAlt+Q, which is the path that runs the destructor. -
A second, narrower hole in the same place.
killpgsignals the group the child leads, and job control gives every job an interactive shell starts a group of its own — so acargo build &backgrounded underbashin the shell view is outside it even when abeam exits normally. What covers the foreground case is theSIGHUPthat goes first, which a job-control shell answers by hanging up its own jobs.sh -c, direct spawns and non-interactive shells are fully covered. Windows has no equivalent gap: everything the child starts joins the job with it. -
On Unix, abeam signals a bare pid in two places, and a pid comes round again.
tree::unix'skillpgandPtySession::drop'schild.kill()both take the number portable-pty handed back at spawn, and abeam's owntry_wait— called every frame — has usually already reaped it, which is what releases the number to be handed out again. Windows has neither problem: a job object and a process handle each name one process for as long as they are open, and cannot come to mean somebody else's. What makes this reachable rather than academic is that abeam deliberately outlives the hosted agent for as long as a shell pane is open — so the window is however long the user leaves that pane up, and it is the pane people run builds in, spawning thousands of pids against a defaultpid_maxof 32768. To be wrong the group has to have emptied and the pid space to have wrapped inside that window. Nobody has hit it, a distribution that has raisedpid_maxinto the millions puts it back out of reach, and nothing in this repository can close it: the number is the only handle Unix hands back. The fix is apidfdtaken at spawn time, it is Linux-only, and it belongs in portable-pty.crates/abeam-pty/src/tree/unix.rsis the long version and is worth reading before anyone tries to fix it here. -
A marked-executable file with no
#!line runs on glibc and not on musl. This is libc's doing rather than the kernel's — the kernel refuses the file withENOEXEC, and glibc'sexecvpthen retries it against/bin/sh, in__execvpe'smaybe_script_execute. musl declines to implement that retry at all, as a long-standing upstream decision. So on a musl system the same file is anExec format error, and what the user sees is portable-pty's raw spawn failure rather than one of abeam's own sentences. It has no bearing on either wheel today, because the Linux wheel ismanylinuxand Alpine would find no matching wheel at all; it becomes live the day amusllinuxwheel is published or somebody builds from source there.launch/unix.rsrecords it because that is where the next person will look. -
A dispatched background task does not survive closing the terminal window on Windows. A task sent off to run beside the session — rather than typed into it — is now detached into a session of its own on Linux, with
setsidin the child betweenforkandexec, so akill %1, a shell hangup or a dropped ssh leaves it running. Windows has no counterpart yet: the child inherits abeam's console, and closing the window deliversCTRL_CLOSE_EVENTto everything attached to that console and kills it after the usual grace period. Raw mode does not suppress that — it clearsENABLE_PROCESSED_INPUT, which suppressesCTRL_C_EVENTand nothing else. The fix is aDETACHED_PROCESScreation flag, giving the child no console at all, and specifically notCREATE_NEW_PROCESS_GROUP, because a console control event reaches every process attached to the console whatever group it is in. That is a feature with its own questions rather than a line to add, so for now the two platforms differ and this says which way. -
Ctrl+Enteris unreachable on most Unix terminals. Telling it from a bareEnterneeds the Kitty keyboard protocol, and abeam asks for raw mode, the alternate screen, mouse capture and bracketed paste and nothing else. The one place abeam distinguishes the two is the composer in the queue view, whereCtrl+Enterputs a newline in the item being written instead of committing it; on Linux that binding is mostly dead and a multi-line item has to be pasted.Alt+Enterdoes the same job and probably does arrive there — which is exactly the next bullet's point. -
The
Alttable is unverified on Linux. Every key in it was checked against what a Windows console delivers, andcrates/abeam/examples/keyprobe.rsexists to answer precisely that question and has been run against Windows consoles only. Whether anAltcombination arrives as a modifier, as anEscprefix, or not at all because the desktop took it first is a question about that terminal and that desktop, and no amount of reading an agent's source answers it. Run the probe in the terminal you launch abeam from before assuming the table holds;docs/keymap.mdhas the procedure. -
Keybindings are not configurable, and neither are the split ratio or either drawing interval: those are constants in the source. There is a config file now — see "Configuration" — and it holds presets and the four things a session opens with, which is where the reader's light/dark choice went. It says nothing about keys. Claude's own bindings are user-configurable and abeam's should be too before anyone else uses it. Copilot's are not, which makes that the mirror image of the same gap rather than a reason to be relaxed about it. The two environment variables are unchanged beside it:
ABEAM_AGENTnames the agent, preset or program to host when no+token did, andABEAM_SHELLnames what the command view starts. -
A scrolling pane is a full repaint. ratatui diffs by cell and has no notion of a scroll region, so when the agent's output scrolls, every row has changed and the whole pane is rewritten — about 10 KB of escape sequences, measured at a 118×45 pane on Windows Terminal, which keeps up with it comfortably. There is no Linux counterpart to that second half and this bullet does not claim one: the byte count is the renderer's and does not change, but what a terminal does with 10 KB many times a second is the terminal's business and no Linux terminal has been measured. Either way it is the structural ceiling on how cheap a frame can get, and the thing to attack next if the F2 worst-frame figure ever says the renderer is the limit.
-
No diff view. The git pane shows which files changed and by how many lines, not what changed in them.
-
The first source file with code in it costs a visible hitch of 100–200 ms, while syntect deserialises its syntax and theme dumps. A session that only reads prose never pays it.
-
Laying a document out happens on the frame path, and stalling a frame stalls the agent's keystrokes. Reading is the cheap half; parsing, highlighting and wrapping is the expensive one, and it runs again at every new width, so dragging the window border with a large document open pays it per frame. The only bound on it is the 512 KiB read cap — about 210 ms of layout in a release build, measured — and going over that is visible: the pane says where it stopped. Highlighting gives up above 64 KiB and shows plain text. A mermaid fence is laid out on that same path and has caps of its own — 32 KiB of source, 128 nodes, 256 edges, and a row count past which the drawing stops being a drawing — and those are set by legibility rather than by the clock: a graph at exactly the caps lays out in 4.7 ms, against the 170 ms that constant above it is a budget for, and draws 668 rows, which is fourteen screens. Past any of them the fence stays source. A slow network share can still stall the frame that opens a file.
-
The document search cannot always reach what the repository search found.
fmatches the lines in a file and/matches the rows the pane drew, and those are the same text for most files and not for all: a line too wide for the pane is wrapped, and a match straddling the break is not on the drawn page at all. It is reachable in a plain source file by dragging the pane narrower, and no width fixes it in rendered markdown, where the source syntax is not on the page at all. A drawn mermaid diagram is the sharpest case of that same rule: a node label wrapped inside its own box is not one run of text on any row, and the mermaid sourcefmatched is not on the page in any form, sotrather than the width is the whole remedy there. Neither is silent — the page names the remedy its own body form has,tor the width, and a result that lands on a neighbouring match says· not the 3rdrather than a confident2/2— but naming a cost is not the same as paying it. Retiring it means grouping rows by the source line they came from, whichsource_linesknows andmarkdown::renderdoes not, so it is either a second output from the renderer or a rule true of one body form and not the other, and an inconsistency between the two forms is worse than one honest rule that costs something.crates/abeam/src/panes/viewer/search.rshas the argument, written down rather than half-done. -
The reader is the only pane that paints its own background.
F3gives it a light or a dark page — with a matching syntax theme, since base16-ocean.dark on a white page is washed out — and that is what makes one key enough in a bright room. Everything else still draws in named ANSI colours on the terminal's own background, so the git and diagnostics views follow whatever profile the terminal has and the two pty views show whatever their child sent. A TUI still cannot ask the terminal for its palette; the reader sidesteps the question by owning every colour inside its own rect. The choice is still per session —F3flips it and the flip is not written back — but where it starts is now[defaults] themein the config file rather than always dark. -
UTF-16 files are reported as binary. The sniff is a NUL byte in the first 8 KiB, which is what git does.
-
A path that is not UTF-8 breaks
launch's chain of custody.PtyConfig::programis aString, so aPathBufthatlaunchprobed, checked and found absolute is converted lossily on the way into the pty — and aPATHentry that is not valid UTF-8 can therefore hand the spawn a path that was never the one probed. What it produces in practice is a confusing "does not exist" rather than anything worse, because a lossy rendering names a different file rather than a hostile one. The fix is anOsStringon that field, and it is worth doing precisely because the module's whole value is that nothing leaves it unchecked. -
A non-UTF-8 command-line argument aborts abeam before anything runs.
std::env::args()panics on one. That is unreachable on Windows and entirely reachable on Unix, where an argument is bytes;args_osis the fix. Its exposure grew when the command line was handed to the agent, which is the reason it is worth doing rather than noting: abeam's arguments used to be a program name and a handful of flags abeam had written down itself, and they are now the prompt — arbitrary text a user pasted, from an editor, an issue tracker or another program's output, which is where a stray byte comes from. -
The reader's title and the find list spell the same path differently on Windows. The file index rewrites
\to/so that typingsrc/panesfinds something; the reader's own label does not, so a title can readsrc\panes\git.rswhere the list beside it readsrc/panes/git.rs. It is cosmetic, it predates the port, and it is Windows-only — on Unix both are the walk's own/, and rewriting there would be a bug rather than a tidy-up. -
A Claude session record is matched by a case-insensitive
.json. Right on Windows, wrong on Unix, where46256.JSONis a different file that would be read as a record anyway. Nothing writes that spelling, so it is listed for completeness rather than because anyone has met it. -
Two Claude features are unreachable inside abeam, and both will be reported as abeam bugs:
Ctrl+Shift+BandCtrl+Shift+Care indistinguishable fromCtrl+B/Ctrl+Cin legacy terminal encoding, and hold-to-talk voice needs key release events, which abeam drops for a load-bearing reason (docs/conpty-findings.md, constraint 3). -
EnableMouseCapturedisables your terminal's native text selection. Copying out of abeam needs Shift+drag, and which terminals honour that varies. -
A routed script agent sees an abeam variable in its environment, on Windows. The command line
cmd.exeis asked to run travels in%ABEAM_LAUNCH%rather than on the wire, for the quoting reason above, andcmdis handed it as an environment variable — so an npm-installed agent, and every process it goes on to spawn, can read a variable abeam set, containing the full path abeam resolved and the arguments it was given. Nothing is known to care, and it is occasionally the fastest way to see what abeam actually ran; it is listed here because a program's environment is not abeam's to write in silently, and because an agent that reports its own environment somewhere would report this too. Only a routed.cmdor.batis affected — a nativeclaude.exeis started directly and gets no such variable, and on Linux nothing is ever routed, so nothing is ever set. -
The
Altkeys abeam claims are free — verified against one audited Claude build (2026-07-25), and merely not refuted by Copilot's published tables, which is one claim resting on two quite different strengths of evidence. TheAltnamespace is not free on Copilot's side, and saying so is the whole finding this change rests on: those tables declareAlt+←/Alt+→as word-motion andAlt+Enteras newline, and the changelog adds an undeclaredAlt+D. abeam binds none of the three, so what is true is the narrower thing — free where abeam is standing, not free. Neither audit is a promise about the next release of either agent, and gaining an agent can retire a key that was safe while there was only one, asAlt+←/Alt+→has already demonstrated.Ctrl+\is the mitigation.
A warning for anyone changing the pty layer
docs/conpty-findings.md lists five constraints that look like things to tidy
up and are not. The shortest version: ConPTY hangs forever if you do not answer
its opening ESC [ 6 n, wait() never returns so only try_wait() is safe,
Windows sends a Release event for every key and forwarding it double-types
everything, mouse reports must be gated on what the hosted app enabled, and
Screen::contents() tells you nothing about layout. Three of those five are
facts about Windows rather than about ptys, which matters now that there is a
Unix build; the document marks which, and none of the three stops being a rule
the code has to keep.
There are tests pinning all five. Read the document first.
License
MIT. See LICENSE.
Download files
Download the file for your platform. If you're not sure which to choose, learn more about installing packages.
Source Distributions
Built Distributions
Filter files by name, interpreter, ABI, and platform.
If you're not sure about the file name format, learn more about wheel file names.
Copy a direct link to the current filters
File details
Details for the file abeam-0.1.0-py3-none-win_amd64.whl.
File metadata
- Download URL: abeam-0.1.0-py3-none-win_amd64.whl
- Upload date:
- Size: 2.6 MB
- Tags: Python 3, Windows x86-64
- Uploaded using Trusted Publishing? Yes
- Uploaded via: twine/7.0.0 CPython/3.13.14
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
2bae01cd4d88e71c88f883db1379b8da1d23bd933e6094b932e3f20732802f3d
|
|
| MD5 |
31813e49feca92b784bebbc43468af24
|
|
| BLAKE2b-256 |
9c3b61c04ed9315066d0ea62aee7ded9eab70b1e032b72aa26aaa410fc3612d2
|
Provenance
The following attestation bundles were made for abeam-0.1.0-py3-none-win_amd64.whl:
Publisher:
release.yml on OpenAfterHours/abeam
-
Statement:
-
Statement type:
https://in-toto.io/Statement/v1 -
Predicate type:
https://docs.pypi.org/attestations/publish/v1 -
Subject name:
abeam-0.1.0-py3-none-win_amd64.whl -
Subject digest:
2bae01cd4d88e71c88f883db1379b8da1d23bd933e6094b932e3f20732802f3d - Sigstore transparency entry: 2341405511
- Sigstore integration time:
-
Permalink:
OpenAfterHours/abeam@f6e7b07cd26cb0510bf26f0826610e569382bb09 -
Branch / Tag:
refs/tags/v0.1.0 - Owner: https://github.com/OpenAfterHours
-
Access:
public
-
Token Issuer:
https://token.actions.githubusercontent.com -
Runner Environment:
github-hosted -
Publication workflow:
release.yml@f6e7b07cd26cb0510bf26f0826610e569382bb09 -
Trigger Event:
push
-
Statement type:
File details
Details for the file abeam-0.1.0-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl.
File metadata
- Download URL: abeam-0.1.0-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl
- Upload date:
- Size: 2.7 MB
- Tags: Python 3, manylinux: glibc 2.17+ x86-64
- Uploaded using Trusted Publishing? Yes
- Uploaded via: twine/7.0.0 CPython/3.13.14
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
401111ce0835c706e032e3c70cfcbe94c8f598f7c7925d465c0a5c4f93ec49b8
|
|
| MD5 |
bed0059bb99ffd8c3563f477374a6da9
|
|
| BLAKE2b-256 |
11308114f169d085384150b26730ee795e533bb721f3306cb1caa64eba9d50ac
|
Provenance
The following attestation bundles were made for abeam-0.1.0-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl:
Publisher:
release.yml on OpenAfterHours/abeam
-
Statement:
-
Statement type:
https://in-toto.io/Statement/v1 -
Predicate type:
https://docs.pypi.org/attestations/publish/v1 -
Subject name:
abeam-0.1.0-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl -
Subject digest:
401111ce0835c706e032e3c70cfcbe94c8f598f7c7925d465c0a5c4f93ec49b8 - Sigstore transparency entry: 2341405503
- Sigstore integration time:
-
Permalink:
OpenAfterHours/abeam@f6e7b07cd26cb0510bf26f0826610e569382bb09 -
Branch / Tag:
refs/tags/v0.1.0 - Owner: https://github.com/OpenAfterHours
-
Access:
public
-
Token Issuer:
https://token.actions.githubusercontent.com -
Runner Environment:
github-hosted -
Publication workflow:
release.yml@f6e7b07cd26cb0510bf26f0826610e569382bb09 -
Trigger Event:
push
-
Statement type: