RC3's lid path went dark on the real MacBook without a word: it waited for
CBClamshellState, gave up after two tries, and wrote ClamshellUpdate 1
back on every failed proof, and none of its silent paths left a trace
without a Terminal launch.
Now, while the lid sensor (AppleClamshellState) says closed, the built-in
display is off and the Mac is on its power adapter, the logo is told the
lid is open (and each keyboard that it is closed) at the start of every
cycle, with no tries and no give-up: monotone, so nothing is toggled in
between. With no other display on the display sleeps, which also keeps
the logo dark, so if the first rise does not move it the logo is told the
display woke (DisplayPowerStateEvent 2); DidSleep is written back at the
end only if that was sent. The end of a blink, and the marker recovery,
still put the logo back to dark as a closed lid has it.
What the closed lid did last is a line in the View menu, under Apple Logo
Blink Styles: "Lid Closed: blinking", "blinking (display told awake)",
"stayed dark, macOS keeps the logo off", or why it was not tried (on
battery, no keyboard to keep dark, no lid sensor), with the time. The
child writes it to logo-lid next to desktop.json, with the readings on a
second line; the app reads it every two seconds.
The battery and keyboard gates stay: the lid helper only holds on the
adapter, and a keyboard told the lid is open would light under it.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The proof that the logo moves under the closed lid now needs a fade level
above 0.01, not just above 0, so no drift of a fade held at 0 passes for a
rise; the slowest rise (the breath's) is past it in about 80 ms. The note
on stderr is a writeln! whose error is dropped: eprintln! panics on a
failed write, and the blink child has panic = "abort".
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
With the lid closed, corebrightnessd tells every module so and keeps the
logo dark, so the blink stopped as soon as the lid came down, which is
exactly when the logo on the lid is the one thing in view.
While the Mac stays awake with the lid closed on its power adapter (Keep
This Mac Awake's lid helper, or an external display), the blink child now
does what LogoLight's Lit with Lid Closed measured on the real MacBook:
once the daemon has handled the lid (CBClamshellState), it tells the logo
the lid is open (ClamshellUpdate 0) and each keyboard alone that it is
closed, so the keyboard stays dark and is never relit after a Blank.
The first rise after that has to move the logo within 1.5 s. If it does
not (with no display on, the daemon may still hold the logo dark), the
lid is told closed again, tried once more, then left dark until the lid
opens, a display comes on or the agents' state changes. A logo the daemon
takes back (it resends the lid state when a display or keyboard comes up)
is told again. Ending a blink under the closed lid hands the logo back to
normal and then writes ClamshellUpdate 1, since normal there is dark; the
marker recovery does the same for a killed child. Never on battery (a
dark wake must not light the logo in a bag), never without the keyboard
IDs, and nothing is written when the lid opens. Every step is a line on
stderr so a real-Mac run shows which one stopped it.
Not seen on a real MacBook yet; the README says so.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The 1.50.0 blink switched LEBacklightMode on and off. Every style was a lit
logo with short dark blips that differed only in how many there were, and
Working (Heartbeat) looked busier than Waiting for you (Wink), so urgency
read backwards. Each change was also a write to a setting corebrightnessd
keeps in root's preferences, and new users had to pick a style in three
submenus before anything happened.
Now one rule: the more the logo moves, the more it needs you.
- View > Blink Apple Logo is one switch, off until turned on. It shows
Breathe while agents work, Calm for a yellow tab and Alarm for a red one.
Each state can take another style under View > Apple Logo Blink Styles.
Picking a style turns the switch on, and turning it on with every state
off brings back those three.
- Breathe is new: a slow fade out and back in, every 6.3 s.
- The logo moves on the LED's own hardware fades (LEPWMFactorFadeDuration,
then DisplayLogicalState Blank or Unblank), as LogoLight measured them on
the real logo (MIT, reimplemented in Rust, not copied). A blink writes no
setting and leaves nothing behind a restart. LEBacklightMode is only read,
so a logo the user switched off stays off.
- The direction of a fade is tracked, because the daemon ignores a write in
the direction it already heads and the state can't be read back. When the
direction is unknown, the logo is turned round with a 1e9 s fade first.
- After every Blank the keyboards alone are told Unblank, and only while
the built-in display is awake (CGDisplayIsAsleep). They are told Blank when
it sleeps and Unblank when it wakes. When it sleeps, the fade duration
also goes back to automatic, as LogoLight does.
- A rise from exactly dark waits for a display frame, up to 0.5 s. Short
dark beats (up to 0.35 s) turn round before their fall ends, so they never
reach 0. Long ones time their light from when the rise starts.
- Every end puts the logo back: lit, with the automatic fade duration. A
new marker file (logo-fade) covers a killed child. 1.50.0's marker
(logo-blink) still switches back on a logo that version left off.
- While LogoLight runs, Codeman leaves the logo to it (process-name check),
and the app says so when the switch is turned on.
- Settings from 1.50.0 keep their styles, with the switch on if any was
chosen. All three off was that version's default, so it reads as
untouched.
The tests run the blink against a fake corebrightnessd that moves in
17.5 ms frames on the measured spring curve. It starts each fade 19 ms
after the write, ignores a write in the direction it already heads, and
holds a rise from exactly dark back by a configurable delay. They pin these
cases:
- dips never reach 0
- long dark beats keep their light
- no keyboard is told to light while the display sleeps
- every end leaves the automatic duration and a lit logo
- a logo switched off or driven by LogoLight is left alone
- old settings are migrated
An ignored probe (what_this_mac_answers) reads a Mac's displays,
processes, keyboards and logo without writing anything.
Not measured on the real logo, since there was no MacBook to test on:
- how the 0.12 to 0.36 s fades of the hard styles look
- whether a rise from a dip (about 7%) really skips the frame wait
- the keyboard during Alarm
- the keyboards through a real display sleep
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
5.3 MB for the universal app and 3.5 MB for the DMG, measured on the Mac
mini from this branch; the README still had the first build's 4.6 and 3.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
No behavior change: the connect page renders pixel-identical in eight
states (Chromium, before and after), posts the same messages, and passes
its jsdom and browser tests.
- policy.rs: the connect commands' camelCase fields come from serde's
rename_all_fields instead of three per-field renames; parse_ipc's
length and id checks are match guards like its other arms, with the
message cap named (MAX_MESSAGE_LEN); new_window reads its URL through
external_url, which is the same parse-and-http(s) check.
- logo.rs: the stubs for other platforms are one module, and Logo,
Marker and recover are private to the file.
- connect.html: answers() is the one test for a server that answers
(six copies), the running and needs-login rows of This Mac share their
branch, prepareWizard, needsAgent, newWait and openTerminal each
replace two or three copies, a finished install and a finished agent
share their path, host() is displayAddress(), state.clock is in the
state literal, three CSS rules that matched nothing are gone, and the
state comments say what wait and gui hold now.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
No behavior change.
- ProbeOutcome::version replaces two copies of the same match in setup.rs,
and classify_response reads the status JSON with serde_json's indexing
(same answer for every body, a missing key is Null).
- local_server::is_env_name is the one test for a variable name, used by
the login shell's environment and by ~/.codeman/.env.
- setup.rs has basename() for its two command-line scans; the install
scan no longer formats the script prefix once per word.
- agents.rs: refused() names the statuses that end the polling, and
answer() takes the body it already has instead of a closure.
- Small simplifications in gui_install.rs (Run::matches, follow, the
download error text) and a let-else in local_server::launch.
- Docs that no longer matched the code: probe.rs said the probe never runs
in a loop, but the This Mac tab re-asks while it waits for an install
(every 3 s) or for a starting server (every second); local_server.rs
covered only Start; FIND_TIMEOUT and exec named one caller of several.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
No behavior change. What was written out two to four times now has one
name:
- AppWindow::new and AppWindow::hidden_for_download, window_builder,
placed_position (the corner tao places, which the frame save and the
pop-out cascade both read; cfg! instead of two attribute pairs),
file_name_of and UNNAMED_FILE for the download toast.
- page_sender, which in_background, attach_to_run and both install
starts use; plan_message and remote_install_failed for the connect
page's answers; open_external_raw for the two OpenExternal commands;
choosing() for the four phase checks.
- An accepted probe (Ok, or a password to change) runs one set of steps.
- macos::install runs inside build_webview, so no web view can be built
without the dialog and login handlers.
- ProbeDone no longer carries the Authorization header beside the
username and password it is made from; basic_auth makes it.
- Load::ConnectPage loses its manual flag, which was never true there.
- MIN_SIZE reads config's limits, the default username is
setup::DEFAULT_USERNAME, and the repository and wiki addresses live
in menu.rs.
- The module doc names the modules as they are now, and two doc comments
that had slid onto the wrong item are back on theirs.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Pure moves, no function body changed (git diff --color-moved shows it):
- terminal_install.rs: the Terminal and SSH installs (the scripts, the
ssh arguments, open_in_terminal, copy_to_clipboard) and their tests,
out of setup.rs, which keeps one subject: the Codeman on this Mac.
- login.rs: choose_login, update_keychain and their types and tests,
out of main.rs.
- menu.rs: the menu bar, its ids and AppMenu, out of main.rs.
- policy.rs: loaded_on_commit and its test, a pure Phase decision like
the rest of policy.
What changed besides the moves: module paths, pub on the moved items,
imports, the module docs, and the plutil test's temporary folder name,
which no longer draws on the Terminal scripts' counter. main.rs goes from
2312 to 1881 lines and setup.rs from 2160 to 1625.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The feature table no longer says v0.1, a few paragraphs that had grown
past the wrap width are rewrapped, and the Gatekeeper limit now starts
with the signed and notarized release DMG, so the ad hoc steps read as
what they are: for a build of your own.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
ureq and wry already pull in base64 0.23 and dirs 7, so the app's own
base64 0.22 and dirs 6 were compiled beside them. The app now asks for the
versions already in the tree. base64 is declared as ureq declares it,
without the default SIMD engines, so the feature set compiled is the one
ureq had before. dirs 7 resolves config_dir and home_dir on macOS exactly
as 6 did (both go through dirs-sys 0.5).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The desktop README's Known limits still said an agent is added by hand after the install, and the wiki gave the menu item a shorter name than the one the app shows.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
release.yml and docs/releasing.md take the branch's text, which is master's text plus the macOS app additions (its first commit copied both files from master).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Conflicts resolved as on test/desktop-macbook: install.sh usage() lists CODEMAN_NO_ADMIN=1 or 0 and #773's CODEMAN_AI_CLI and CODEMAN_PROGRESS=1; probe.rs keeps #781's agent() with TIMEOUT, MAX_BODY and probe_within all pub; the desktop README keeps both known-limits entries.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The release DMG is signed with the owner's Developer ID and notarized, so a
downloaded copy opens after macOS's usual confirmation instead of stopping at
Gatekeeper. The work is a script of its own, so CI and a Mac run it the same way:
SIGN_IDENTITY=... NOTARY_PROFILE=<profile> \
packages/desktop/scripts/sign-macos.sh <Codeman.app> <out-dir>
It re-signs the app with the hardened runtime, a secure timestamp and
macos/entitlements.plist, checks the signature is a Developer ID one with the
runtime flag, has notarytool notarize it and staples the ticket, builds the DMG
around the stapled app, signs it, notarizes and staples it too, and has spctl
assess both. Codeman.dmg and Codeman.dmg.sha256 reach <out-dir> only after every
check passed; a failed run removes an older pair there rather than leave one to
upload by mistake. A submission Apple does not accept stops the script with its
id and Apple's log. The notary login is either a notarytool keychain profile
(NOTARY_PROFILE, NOTARY_KEYCHAIN) or APPLE_ID, APPLE_APP_PASSWORD and
APPLE_TEAM_ID; `sign-macos.sh --check` tests that without doing anything.
The DMG layout (the app next to an /Applications link, UDZO, volume "Codeman")
moves to make-dmg.sh, which both scripts call, and gains a retry: hosted macOS
runners now and then fail hdiutil create with "Resource busy". bundle-macos.sh
keeps its SIGN_IDENTITY path as it was; its new --notarize checks the
environment before building, then hands the app to sign-macos.sh.
packages/desktop/README.md says the release DMG is notarized and how to do
the same by hand, and drops the CI build and notarization from its ideas list.
All three scripts are bash 3.2 clean, and shellcheck reports nothing. With the
macOS tools stubbed, sign-macos.sh was run under bash 3.2 through a full
signing, a notarization rejected for the app and for the DMG, two hdiutil
failures then a pass, three failures, a relative out-dir and every missing
setting.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The logo said one thing: agents are working. It now has a style of its own
for each state the tabs show: at work, finished and waiting for you (yellow),
and stopped on a permission or a question (red), set under View > Blink Apple
Logo. The most urgent state with a style wins, red then yellow then work, and
a state set to Off falls through to the next instead of silencing the logo.
Red and yellow come from `GET /api/approvals`, the list the page rebuilds
those tabs from after a reload, read natively for the reason the session list
is: WebKit stalls a hidden page's stream. An idle item stops counting once it
is acknowledged, so the logo stops when the tab's colour goes. The request is
skipped while neither alert has a style, a 404 from an older server reads as
no alerts, and a request that fails keeps the last answer. A dialog on screen
costs the server one tmux capture per poll.
Alarm is new: three sharp flashes and a short rest. It stays under the limit
of three flashes in any second, so its urgency comes from its shape.
A style saved by the earlier version stays as the one for work, and yellow
and red start as Wink and Alarm, since whoever chose one asked for a logo that
talks. `SETTLE` goes from 2 s to 3 s, the time the server takes to judge a new
dialog, so a prompt answered before its hook landed never flashes red.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
On a MacBook Pro with a PROLED XDR display, the lit Apple logo on the lid
can blink while agents work and stop when they finish, so a run can be
read from across the room. View > Blink Apple Logo While Agents Work
picks a style (Calm, Slow, Wink, Heartbeat, Fast). It is off by default,
and the menu exists only on a Mac that has the logo.
Apple documents one control for the logo, `sudo defaults write
com.apple.CoreBrightness LEBacklightMode -int 0`. The same setting is a
property of BrightnessSystemClient in the private CoreBrightness
framework, which a process of the logged-in user can set with no
administrator, so nothing here needs a helper or a password.
The setting is system-wide and survives a reboot, and the release profile
has panic = "abort", so the work runs in a child process (this binary
with --logo-blink) tied to the app by a pipe. When the pipe closes,
however the app ended, the child restores the logo and exits. A marker
file covers power loss and a SIGKILL of the child itself.
The child asks api/sessions itself: WebKit stalls the event stream of a
hidden page, which lost whole turns and ran a blink 39 s past the end.
It sends the session cookie of its first answer with every poll, because
a good password without a cookie creates a login session each time and
Codeman evicts the oldest of 100.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
When a Codeman server of this user ran at an address the app could not open
as its default one, the This Mac tab only named host and port and sent the
user to Another machine to type the address again. A second instance beside
the one that answers (a beta, a `codeman web --port 4000` in a terminal) was
not shown at all.
The look at this Mac now works out an address for every listening socket of
this user's Codeman servers (`found_addresses`, pure): 127.0.0.1 for a bind
to loopback or to every interface, `[::1]` when that is the only loopback
socket, the address itself for a bind to one; `https` and the base path come
from `--https` and `--base-url` on the command line. A server at the default
address with no `--base-url` of its own keeps the default URL, since
install.sh's launchd job has the base path in its environment, where `ps`
does not show it. The default address comes first, then the others by port.
Each address other than the default one is asked once, all at the same time,
with no login. The default probe's answer is reused for its own row. When
nothing answers at the default address, the first found server that answers
is still what Open is for, so each gets the probe's usual 5 s; next to a
Codeman that answers they are only listed and get 2 s. `LocalStatus.found`
replaces `elsewhere`.
The tab lists them: "Codeman is already running on this Mac." with a row
each when none answers at the default address, and "Also running on this
Mac" under Open otherwise. A row has the address, the version and Connect,
or the reason the app cannot open it. Connect fills the address in and runs
the page's one connect(), so the app checks it, uses its saved login and
remembers it like an address typed under Another machine. Nothing connects
by itself, and a refused connect from a row never arms the reopen that Open
has for a starting server.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
A running Codeman remembers a missing CLI for up to 5 minutes, so a status
poll never spawns a login shell, and its Run button reads that same memory.
So "Run fails, add the agent, Run again" kept saying the CLI was not
installed, right after the app had said it was ready.
install.sh now touches `cli-installed` in the data dir once a CLI has landed,
and resolve() leaves the backoff when that file's mtime changes. It is one
stat, inside the backoff branch, and buys one run of the chain, so a resolved
CLI never pays for it and a CLI that is still missing goes back to waiting.
Re-walking PATH during the backoff would have found hand installs too, but it
would put a synchronous stat of every PATH entry on the request path, where
one dead network mount freezes the server (#516), and execute candidates that
were already refused.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
After "Skip for now" there is no other agent, so a failed add hit
install_offered_cli's gate first, and the app showed its first-install text
("re-run the installer") instead of naming the agent and its command. That
is the main path of adding an agent.
run_offered_cli now runs a trusted command and looks again; the gate stays in
install_offered_cli for the install, and `agent` judges its own run. The one
line that executes a trusted command is unchanged.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The agent chooser only appeared during the install, and only on a Mac with
no agent. After "Skip for now", or with Codeman already installed, there was
no screen for it.
The same chooser now works after the install: "Add a coding agent" on the
This Mac tab, "Add a Coding Agent to This Mac..." in the app menu (it also
works from a Codeman page), and after a Skip the app says Codeman has no
agent yet before it opens Codeman.
`install.sh agent <id>` installs one agent and nothing else. It checks that
THAT agent landed: install_offered_cli is fatal only when no CLI is left, so
with another one present a failed install would have reported success.
The app runs it as a run of its own (`gui_install::Run::Agent`), in its own
temporary folder, so `ps` never reads it as "Codeman is being installed". It
may run next to a live server, which an install must refuse, and stops only
for another run. `--no-admin` goes to both `preflight` and `agent`: it decides
what counts as installable, so the two must agree.
CI: the bash 3.2 preflight check never tested anything. Its child run
inherited CODEMAN_INSTALL_SH_LIB and stopped at the sourcing guard, and a
false [[ ]] does not trip set -e on bash 3.2. Both are fixed for that check.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Both branches changed the connect page and three Rust files. How the
conflicts were settled:
- Layout (connect.html): both sides fixed the page being cut off above a
small window. One layout is kept, the one that also holds the header still:
a fixed top margin on `main`. The wizard's own top margin is dropped, since
the page is anchored from the top in every state now and a second margin
would move the header when the wizard opens. `#elsewhere` is a child of the
card since the status line moved above it, so the wizard hides it too.
- State (connect.html): `gui` from the installer next to `askedFrom`,
`reopen` and the starting-server counters from the review.
- setup.rs: `running_install` for the port, `install_in_app` for the flag.
- local_server.rs: `login_environment` and `in_home` both stay.
- README: the installer's This Mac row with the sentence about a starting
server; both new paragraphs.
test/desktop-connect-page.browser.test.ts reaches the Terminal install the
way the page now offers it (Install, a failed preparation, "Install in
Terminal instead"), since Install opens the graphical install here.
Checked on the merged tree: 138 Rust unit tests, clippy and fmt on the Mac
mini; test/desktop-connect-install.test.ts (jsdom) and
test/desktop-connect-page.browser.test.ts (Chromium) both pass.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
main.rs was the only source file without tests, and it held three decisions
with real failure modes:
- Which login a connect goes out with. The choice was a closure body inside
the probe worker; it is now choose_login, tested for every row: typed wins,
then the Keychain, then the local Codeman's own login, which is used only
when no saved password was expected and only for the same username.
- What the Keychain keeps afterwards (update_keychain): saved or deleted by
Remember, never a copy of the local server's login, never a password the
server wants changed, and the old user's item deleted before the new save.
The in-memory Keychain store moves out of credentials' test module so other
test modules can use it.
- When a commit in the main window means the server page is loaded.
The connect page had only substring checks in the repository; the checks that
drove it lived in a scratch script. They are now
test/desktop-connect-page.browser.test.ts: Chromium, the page's real CSP, a
stubbed native side, five ordered walks through every state of both tabs
(first run, logins, failures, redirects, Start, the install wait, the SSH
install, layout, double clicks, keys, a starting server, a WebKit too old).
Listed in BROWSER_TEST_GLOBS, so the nightly browser suite runs it and
`npm test` does not.
Also pins the install one-liner the page shows to the one Copy copies.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Found by the three-part review of this branch. Each was checked against the
source first; every decision that can run without a window has a unit test.
This Mac:
- A Codeman that runs but listens nowhere yet (the first seconds after login,
a restart) was reported as "already running ... it listens at process N,
which this app cannot open", and Start was refused for it. It is now its own
state: the tab says "starting", looks again every second, and opens Codeman
once it is up when a connect to this Mac had just been refused. The state
table is a pure function with a test (setup::local_state).
- A server the app starts runs in the home folder, like one started by launchd
or in a terminal. It ran in the app's own directory (`/` after a Finder
launch), which Codeman then used as the default for sessions.
- Server detection accepts any entry word that `web` follows, so a dev server
under a folder named codeman is seen and never gets a second server beside
it.
- A login shell is polled from 5 ms up instead of every 100 ms, and the port
after a launchd start every 100 ms instead of 500: both were pure waiting.
- The last line of stdout is no longer glued to the first line of stderr.
Addresses and policy:
- A tailnet name typed with a port gets http://, not https://. With a port it
is Codeman's own listener; https there ended in a TLS error with no hint.
- within_base refuses `..%2F` and the other encoded ways out of the base path,
so a sibling app behind a decoding proxy never sees the login.
- A status answer whose body stalls is a timeout, not "something else answers
here".
- The folder pop-out pattern matches the two routes the server has.
- A malformed `window` in desktop.json no longer costs the server list.
Windows:
- Pop-outs step off the window in use. tao centres a window with no position,
so every pop-out opened exactly on top of the one before.
- A window opened by a script no longer brings the app to the front: wry
activated the application for it, whatever the window itself was told.
- The first-run window sits between the menu bar and the Dock. Centred in the
whole display, its bottom was under the Dock on a 900 pt high one.
- The window frame is saved once after a move or resize ends, not once a
second during the drag.
Also makes the connect page independent of TextEncoder and scrollIntoView, so
it runs in jsdom as well as in a web view.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Layout:
- The header no longer moves. The page was centred with align-items, so every
change of the card's height moved everything (up to 150px between states),
and a page taller than its window lost its top above the scroll origin,
where no scrolling reaches it. The top margin is now fixed, the card grows
downward and has a minimum height, and the page scrolls from its top. A
short window gets a compact header.
- The status line sits right under the action it reports on, above the
"not installed there yet?" section, takes no room while empty, and is
scrolled into view in a small window. It was below the fold there.
- Looking at this Mac again keeps what was found on screen (the pane used to
collapse to "Looking..." and grow back).
Feedback and keys:
- Buttons answer hover and press, every control shows keyboard focus, the
primary button and the footer meet WCAG AA contrast, "Opening Codeman" keeps
its spinner while the server page loads.
- Return opens or starts the Codeman on this Mac, Esc cancels a connect.
Install always takes a click.
- The This Mac result is a live region, Forget buttons are named after their
server, a long unbroken token wraps inside the card, the status text can be
selected.
Correctness:
- The second click of a double click acts only on the control that took the
first. It could connect to the server that slid under the pointer after a
Forget, or flip "Connect automatically".
- An address, username or password too long for the app is refused with a
reason. The app drops such a message without an answer, which left the page
on "Connecting..." (the limits are pinned to policy.rs by a test).
- A connect answer goes to the tab that asked, a failed SSH install to its
tab, and the login block no longer claims a saved password for another
server.
- An automatic connect to the Codeman on this Mac no longer looks at this Mac
first; a failed connect, Cancel or an error asks for that look.
- A login over plain http:// to another machine says that it is unencrypted.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
The findings of the 2026-11-05 review of this branch, each with a unit test
where the code can be tested without a window:
- A window whose web content process ended (a WebKit crash, or macOS taking
the process back under memory pressure) stayed blank: wry answers WebKit's
notice itself, which turns WebKit's own reload off. The window now loads its
page again. A second end within 30 seconds sends the main window back to the
connect page and closes a pop-out, so it cannot loop (policy::recovery).
- A JavaScript dialog from a frame nothing names (about:srcdoc, a data: or
blob: URL under an opaque origin) showed the page's text as its bold first
line. The first line is now always the app's own, the "embedded" flag is set
when the origin names no host (it was the wrong way round), and every
sub-frame counts as embedded, a trusted web tab on Codeman's origin included.
- The connect page lost the text of an error that needs the login fields: the
tab switch cleared the status it had just set.
- Start for http://localhost:PORT missed the LaunchAgent (the compare was
exact against 127.0.0.1) and ran a second, unsupervised server on its port.
- The path picker's preview Open link (api/filesystem/preview) did nothing: it
was the one page Codeman opens in a window that the allowlist lacked.
- The port of a running install was lost whenever the Terminal script that
wraps the installer came first in ps.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
The This Mac tab runs install.sh --no-admin itself. The two questions only a
person can answer move into the app: which coding agent to install, shown only
when this Mac has none, and who may reach Codeman. The installer's output goes
to a file, so a running install survives the app quitting and is picked up
again when it reopens.
install.sh gains --ai-cli, --progress and a preflight subcommand, which is what
the app reads its choices from, so it keeps no copy of the CLI catalogue.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Adds packages/desktop, a small native app (Rust: tao, wry/WKWebView, muda,
objc2) that opens Codeman in its own window. The universal app is about 5 MB
(3.2 MB as a DMG) and runs on macOS 11.3 or later.
- Connect screen with two tabs. This Mac finds the local Codeman and offers
Open, Start or Install. Start uses launchd for a LaunchAgent install and
codeman web -d otherwise. It never starts a second server while one runs,
because two servers on one data dir share live sessions. Another machine
connects by address (recent servers, Keychain logins) and can install
Codeman there over SSH.
- Installs run Codeman's install.sh in Terminal from a self-deleting
.command script. The installer needs a terminal for sudo, Homebrew and
ssh. A local install uses --local --service and the first free port from
3000, and the app opens Codeman once it answers.
- The local Codeman's login is read from its LaunchAgent or
~/.codeman/.env. It is sent only to 127.0.0.1 on that port, only while
the user's own Codeman process holds it, and is never saved.
- Native JS dialogs (alert, confirm, prompt) and HTTP Basic challenge
answers, both scoped to Codeman's own pages, so the page keeps working
after a server restart. App cookies are cleared before every connect.
- Pop-outs through window.CodemanHost, with an allowlist (webview/ never
opens a window), external links in the default browser, downloads to
~/Downloads, real full screen and window state that is remembered.
- The connect page refuses a WebKit older than Safari 16.4, which Codeman's
terminal needs.
Codeman side: viewSubagentTranscript shows a toast when the pop-up is
refused instead of failing, with zh-CN strings and a test that pins them.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The local echo overlay rebuilt every character of the prompt on each
keystroke, one absolutely positioned span per character, so a keystroke
cost O(prompt length) in DOM work, style, layout and paint. Every
positioned span was also its own paint layer, so the browser re-layerized
and repainted thousands of them per frame. In headless Chromium with a 6x
CPU slowdown, a ~4,100-character prompt ran at a median 813 ms per frame,
and ~300 characters already missed 60 fps (54 ms).
renderOverlay() now keeps a per-container record of the line elements it
drew and rebuilds only lines whose text, position or composition range
changed. It starts over when the font, colour, cell size or Unicode
version changes, or when something else emptied or edited the container.
makeLine() lays the spans out in a flex row instead of positioning each
one, and sets the shared font styles once on the line. Cell edges are
snapped to a quarter pixel, which Blink, WebKit and Gecko all store
exactly, so the widths add up to the grid without drift and every edge
stays within 1/8 px of col * cellW. Same prompt: 17 ms per frame.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Every keyboard open and close resized the PTY, and Claude Code's inline renderer answers each height change by clearing the screen and reprinting only the transcript tail that fits, so rows above that tail were lost from tmux for good. Measured on Claude Code 2.1.306 with tmux 3.2a: five keyboard cycles (62x38 to 62x15 and back) left 75 of 100 numbered lines in the pane's history; with the rows held the PTY stays 62x38, no SIGWINCH reaches the pane and all 100 remain (#672).
While the keyboard is up the rows captured at the show transition are held through getTerminalDimensions, columns still follow the box, and the screen, now taller than its box, is translated up by --kb-clip so the last content row sits at the bottom of the visible area. This is the one documented exception to the columns-only geometry rule: there the extra rows came from another device and nothing kept the composer in view, here they are this device's own rows held on purpose and the clip keeps the composer visible. The local-echo overlay's setRowLimit narrows the renderer's existing totalRows bound, so one place decides where a long draft stops growing downward.
Only inline renderers hold, through a new registry capability keyboardHold: 'inline' (Claude alone) published to the page as window.__codemanKeyboardHoldModes. Full-height layouts such as codex, Claude's fullscreen mode (mouse tracking on, unmeasured), and remote or docker panes resize as before. The hold is released on keyboard close, a shape change, a tab switch, a session update, a reconnect, or the setting turned off. Per-device setting Keep Size Under Keyboard, default on.
Gesture control now moves tiles: with the tile grid open, pinch anywhere
on a tile and carry it onto another tile (they swap) or an empty cell (it
moves there); a session tab carried onto the grid joins it there. The
overlay asks tile-grid.js what is under the hand (tileAtPoint,
tileDropTargetAt) and drops through the mouse drag's own paths
(dropSessionOnTile / dropSessionOnSlot, so _reorderTiles stays the one
move path).
Detach Tiles (App Settings > Appearance, per device, OFF by default,
desktop only):
- a tile's header let go outside the browser window opens the session as
a pop-out (detachSession, which now takes an optional size and screen
position), the tile's size, near the drop point, after a 120 ms settle;
- dropped on another Codeman window's tiles it moves there: that window
docks it through the target's own drop and posts 'tile-adopted' on the
window channel, and only the window it was dragged from lets go of its
copy (a last tile goes to the welcome screen, never onto that session);
- a pop-out's title drags back onto any grid: the session is taken back,
the pop-out is asked to close and is released (stops answering
roll-calls; one a script cannot close says where the session went), and
its stale 'detached' answers are ignored for 2 s;
- a tile's menu offers "Open in a new window", and a hand that lets a tile
go outside the grid pops it out too.
With the setting off nothing changes. No server change.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
The overlay no longer hides for any scrolled-up viewport, only when the
cursor row leaves the screen. The changeset gains the echo package's patch
bump and the #576 entry.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Anything typed on a phone vanished whenever the terminal viewport was not scrolled fully to the bottom. Codeman parks the viewport a few rows above the bottom on purpose (scrollToLastNonEmptyLine after a tab switch, or after the keyboard drops and the terminal regrows, so trailing blank rows stay off screen), and the overlay hid itself on a bare viewportY !== baseY test even though the prompt and the cursor were on screen. The text was buffered the whole time and Enter still sent it, which is what made the keyboard look dead.
The overlay now gates on whether the cursor row is inside the viewport (promptRowInViewport), in both the render path and the scroll handler, so a deep scroll into history still hides it. A buffer without cursorY keeps the old bottom-only rule.
Two of Codeman's custom prompt finders made the matching mistake of treating cursorY, which xterm reports relative to baseY, as a screen row; with the gate relaxed that would have painted the text on the wrong line. Both go through cursorViewportRow now, and the Claude finder looks for its glyph from the cursor's screen row up to the top of the live screen only, since a parked viewport shows scrolled-off history whose old composer rows used to be unreachable and would anchor the overlay on the wrong line.
Merge-time fixes for the three findings of the third review round of #499.
- minor: a composition on an empty prompt did not follow the prompt after
output or a resize. The post-write re-place in flushPendingWrites and the
resize observer both ran rerender() only when hasPending was true, and
hasPending deliberately excludes the composition, so the first word of a
prompt (an overlay holding only a composition) stayed on the old row over
whatever output moved there. Both sites now call rerender() unconditionally;
it already returns early when there is nothing to draw, so nothing changes
without a composition. New browser case drives the real
batchTerminalWrite/flushPendingWrites path against real xterm 6 and the
overlay built from source, moves the prompt from row 0 to row 3 and checks
the overlay follows (it fails on the old guard, overlay left on row 0), with
a parity case for pending text. The structure test pins the post-write site
through vm and the resize site, which is a closure inside initTerminal(), by
source.
- nit: removeChar() dropped the composition but did not repaint on its false
path, leaving a composition-only overlay on screen showing text the addon no
longer held. It now hides the overlay there when a composition was dropped.
Package tests cover that path and the flushed path repainting without the
tail.
- nit: the package README did not document setComposition() or the
composition getter and described hasPending as "any content". Added both to
the API tables plus a short IME composition section, reworded hasPending
(pending or flushed text, excludes the composition), and made the quick
start re-render unconditionally instead of teaching the hasPending guard.
The hasPending JSDoc says the same.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
With local echo on, committed text sits in the LocalEchoOverlay and does not
reach the PTY before Enter, so the PTY cursor that places the preview span
stays at the prompt start. The span's z-index 6 only counts inside
.xterm-helpers (its own z-index 5 stacking context), and the overlay is a
z-index 7 layer whose first line is opaque from the prompt column, so every
composition after the first one in a prompt was drawn under the overlay.
- xterm-zerolag-input: add setComposition(text) and a composition getter.
The overlay draws the composition as an underlined, aria-hidden tail after
its pending text, through the same wrapping and grow-upward layout. It is
never part of pendingText, hasPending or anything sent; clear() and
removeChar() drop it, and rerender()/refreshFont() keep it.
- terminal-ui.js: while local echo shows typed text (on, and not handed back
to PTY echo by a nav key), render and clear the preview through
setComposition. The helper span stays for local echo off, and as the
fallback when the overlay cannot place the text (no prompt found).
- Browser test against real xterm 6, the overlay bundled from its source
and styles.css: a second composition after pending text is the topmost
element after that text, and the commit lands in the overlay once. Unit
tests for setComposition in the package and for the routing in the
structure test.
- CLAUDE.md and architecture-invariants: state the preview's effective layer.
Typing a prompt long enough to wrap ran the text off the bottom of the screen: the
tail — the part being typed, where the cursor is — sat behind the on-screen
keyboard, so the user was typing blind. Two independent causes.
**The overlay had no bottom bound.** On touch devices keystrokes are buffered in
the local-echo overlay and do not reach the PTY until Enter, so the CLI never
learns the prompt is long and nothing scrolls or reflows to make room. Meanwhile
the renderer lays its wrapped lines out straight DOWNWARD from the prompt row
(`top = promptRow * cellH`, each line at `i * cellH`) with nothing clamping it to
the visible rows — and with the keyboard up there are only a handful of those.
The block now grows UPWARD once it would pass the last visible row: it is lifted
so its final line lands ON that row. Every line div is opaque, so it covers
transcript above rather than vanishing under the keyboard below — the same thing a
real terminal does when a composer expands. A prompt taller than the whole
viewport keeps its TAIL, for the same reason the fix exists: the end is what the
user is looking at. `startCol` indents only the line that starts at the prompt
marker, so it is dropped along with that line when only the tail fits, and the
cursor follows the last VISIBLE line.
`rows` joins the render key: the layout depends on it, so a keyboard opening —
which changes rows without changing the text — must not be skipped as a redundant
render.
**`_shrinkPaddingToFit()` was reclaiming the bars' own space.** On phones the
toolbar and accessory bar are `position: fixed`, so they occupy no layout space
and `main`'s padding-bottom is the ONLY thing reserving room for them. Shrinking
it by the full sub-row slack pulled the terminal's bottom edge down underneath
them, and the row the following re-fit gained was painted behind them — clipping
the last line of a long prompt. The shrink now has a floor: the MEASURED height of
the currently-visible fixed bars, so genuine over-reservation of the hard-coded
84px is still reclaimed while a device that needs those pixels keeps them. The
floor is `Math.min(currentPadding, measured)`, so it can only ever prevent a
shrink, never cause a grow that would resize the terminal as a side effect.
Overlay behaviour lives in `packages/xterm-zerolag-input/` (single-source; the
vendor bundles are generated), so the fix is in the package with the row count
passed in as an optional `totalRows` — absent, the layout is exactly as before.
Tests: 7 cases in the package's `overlay-renderer.test.ts` (upward lift, tail
retention, indent drop, cursor on the last visible line, and the unclamped
fallbacks) and 7 in a new `test/mobile-keyboard-bottom-padding.test.ts` (reclaim,
floor, partial reclaim, no-grow, hidden bars, CJK strip, whole-row slack). 5 and 4
of them respectively fail without the fix. Package suite 238 pass, including the
codex byte-identity and replay tests.
Verified on Android + Chrome against a live instance: a ~460-character prompt
wrapping ~12 rows stays on screen while typing and arrives at the PTY intact.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
With a real codex login now available, record the one shape the fake-key
lab could never produce: a genuine model reply streaming above the pinned
composer, pushing lines into history (baseY grows) while keystrokes land
mid-stream. The recorder gains an opt-in CODEX_RECORD_REAL=1 scenario
using the user's own ~/.codex (fixture secret-scanned for key/JWT
material before writing; scanned clean). The replay test pins: baseY > 0,
mid-stream predictions painted, exact convergence to the typed text.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Independent post-build review found three gaps, all one family: input that
changes the composer without a prediction leaves the DISPLAYED cursor stale
for one RTT, and anchoring a new run on it painted ghosts one cell off
(blank-neutral, so they lived out the full TTL: "tehh" on
backspace-then-retype, exactly on the links the feature targets).
Fix: the addon now HOLDS new predictions after any such edit (backspace with
nothing outstanding = deleting echoed text, clearPredictions, and now also
IME/plain-paste 'text' commits, which the hook clears like 'clear') until
the next PARSED write releases the hold. The inline predictChar reconcile
deliberately does not count: only the emitter pass or the public
reconcile() is the display-caught-up contract. Worst case is exactly one
unpredicted keystroke, whose own echo releases the hold. Also patched the
one bypass path the PR had missed: _handleCjkInput now clears predictions
like insertTerminalText and the other bypass sends.
Package suite 230, vm gating 85, E2E 10/10 all green after the change.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Mosh-style write-through prediction: the consumer sends every keystroke
unchanged; the addon paints predicted glyphs and reconciles against the
parsed buffer. Confirm = cell match + cursor advance (placeholder-safe,
repaint-safe); two-pass mismatch cascade with neutral blanks (measured:
codex clears its placeholder on first echo); TTL bound; baseY-based line
reads; scroll/resize/off-row clears. Zero edits to zerolag-input-addon.ts.
Tests: 30 addon-law specs + renderer geometry (fake performance clock for
TTL/grace), 6 replay suites running the real algorithm through a real
@xterm/headless parser fed by the recorded codex fixtures, and a
500-iteration seeded fuzz with per-op span/record + grid invariants.
227 total, the pre-existing 175 untouched.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>