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>
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>
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>
The graphical install runs install.sh --no-admin, so this branch needs both
PR #763 (the app) and PR #768 (the route that needs no password).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Codeman itself always lived under $HOME; administrator rights were only
spent on what it needs first (system packages for Node.js, tmux and git,
Homebrew on a Mac). An account without those rights could not install it:
the installer died in ensure_sudo, or in Homebrew's own installer.
install.sh now has a no-admin route, taken by itself when the account has
no administrator rights (no sudo binary; outside sudo/wheel/admin and
`sudo -n true` fails; a refused sudo password; a Mac account outside the
admin group with no writable Homebrew) and forced with --no-admin or
CODEMAN_NO_ADMIN=1:
- Node.js: the official tarball from nodejs.org, checked against the
SHASUMS256.txt published beside it, into ~/.codeman/runtime/node.
- tmux: the tmux project's own build (tmux/tmux-builds 3.7c), checked
against a SHA-256 pinned in the installer, into ~/.codeman/runtime/bin.
- Both are linked from ~/.local/bin (never over a file that is not ours),
and the runtime is on PATH and on the LaunchAgent's PATH.
- git is never installed. Without a working git the source comes as an
archive, the install is marked with .install-source, and it updates by
re-running the installer: the new tree is built beside the old one and
swapped in only after the build succeeded. A later re-run on a machine
that has gained git turns it back into a clone.
- Nothing on this route runs sudo, Homebrew or `xcode-select --install`,
and the route is never decided by `sudo -v`.
What it cannot supply is checked before the first download: a C/C++
toolchain on Linux (node-pty 1.1.0 compiles there), musl, and macOS below
15 (the tmux build's deployment target).
Along the way:
- check_git no longer trusts `command -v git`. On a Mac without the Xcode
Command Line Tools /usr/bin/git is a stub that opens Apple's install
dialog; it is judged by `xcode-select -p` and never executed. Any other
git is asked for its version.
- scripts/git-hooks.mjs answers "not a checkout" without running git, for
the same reason: postinstall calls it on every npm install.
- The in-app updater knows the install kind `archive` and points such an
install at the installer instead of `npm i -g aicodeman@latest`.
- download() and download_to_stdout() resolve DOWNLOADER themselves. Only
main() used to set it, so `install.sh cloudflared` died on `set -u` with
the message swallowed by the step log.
- The AI CLI menu leaves out a Homebrew-only install command where there
is no Homebrew.
Verified with the real installer: a bare Ubuntu 24.04 container as an
unprivileged user (install, a PTY round trip through the API, update by
re-run, cloudflared, uninstall), the same without a compiler (stops before
any download), with a broken then working git, with sudo present but not
usable, and on macOS 26 (arm64) in an isolated HOME with sudo, brew, git,
the compilers, launchctl and tailscale replaced by tripwires. Not verified
on a real standard macOS account: that account was an administrator.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Playwright supports Ubuntu 26.04 from 1.61.0, so the browser suite can go
back to ubuntu-latest. Raises the floor in package.json as well, because
the old ^1.58.0 still admitted versions that refuse to install on the
image every workflow now runs on. Bundled Chromium moves from 148 to 156.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
ubuntu-latest moved to 26.04 and the locked Playwright 1.60.0 refuses to
install chromium there (support starts at 1.61.0), so the nightly stopped
before running a test. Pin to the image of the last green run, so a failure
now points at our code rather than at a new OS and Chromium. Unpin when
Playwright is bumped.
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 gate belonged to whichever read arrived first, and sessions load their stores
independently, so under CI timing another session took it, the loop never reached
release() and the test timed out (master, CI run 41930703100). The code cannot block
one session on another: sync() only waits on its own slot.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The release note and the security doc read as if no file view could return it. The
attachment, folder and download routes refuse it; file-raw serves a session's own
workspace without the blocklist, so a session rooted at the home directory can
still read it.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Consolidates the four contributor changesets, and rewords the em-dashes this release
added in comments and docs. The five in test/settings-save-failure-ui.test.ts stay:
they assert existing toast text.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The startup hint is no longer typed, a Discord reply is one of the input paths that
set fromUser, edit-history is a state file, POST /api/filesystem/mkdir is in the API
reference, and the store's header says which routes really refuse it.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Clone ran an unbounded realpath over every Docker case workspace, and the mkdir
route inherited the picker resolver's realpathSync, which freezes the whole server
on a mount that stopped answering. The picker's browse route has the same call and
is left as it was.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The dedicated route got this order at merge and quick-start kept the old one, so
a user could agree to a swap only to get the 400. It also runs on the pure build
now, so a refused id writes no config dir to clean up.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A table costs its widest cell times its rows, so one wide cell in an agent's last
message built a 202 MB string on the event loop although only 8 posts can ever go
out. Only what can be posted is converted now. A file a person sent from Discord
also fired attachment:detected as if the agent had produced it.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The Discord bot token was readable through the attachment routes, because
discord.json was not on the sensitive list, and so were webhook.json,
custom-model-hosts.json and the generated custom-model configs. Config backups
were the wider gap: a dated copy of users.json or settings.json matched none of
the name-anchored patterns. All are blocked now.
`codeman notify --file` relied on folder confinement alone, which holds nothing
back when a tab's folder is the home directory, and it would also post a
workspace .env. It now applies the attachment blocklist to the real path.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Owner decision. Typing the note into a brand-new non-Claude tab spent a model
turn per tab and, for a spawned worker or a scheduled run, landed with the first
real prompt. Claude keeps its hint through the system prompt, and every other CLI
is told nothing. The setting goes with the path it gated: sessionIdle, the fresh
set, isHintableSession and the session:idle wiring. sendInput loses `fromUser`,
which was only ever false for the hint, and the deps test pins that a reply is a
person's own input.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
`cwd` beside the fixed `workingDir` reads ambiguously on a public API surface, and
`paneCwd` says where the value comes from, like `paneExit`. Also pin the field on
the payload the browser really receives (getSessionStateWithRespawn), and give the
pane-directory text its own architecture-invariants paragraph.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A Bash( row holds a command, so its parentheses do not bound one spaced path:
`Bash(/tmp/a.out input.txt)` became a single link to a file that cannot exist and
lost master's `input.txt` link. The path tools keep linking a spaced name whole,
and a hard-wrapped Bash( row gets no spaced lookup either. Test rows from #734.
Co-Authored-By: DDT <141296842+Hugo-DDT@users.noreply.github.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Say that settings.yaml is shared only until the session's first settings write
(dsh saves by rename, which replaces the link), warn when a session's own DSH_HOME
does not answer and the server's home is linked instead, test
guardSessionConfigDirHome, and document the linkFromRealHome exception and what a
custom-model dsh session does not carry over.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The launch-id check is pure, so it now runs before the context warning and the
swap confirmation: nobody agrees to evict a model only to get the 400. Also the
search-box and in-use comments, the model-plain-colon wording, and the wiki's
order of the picker's marks.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A new install has no seen list, so every new OpenCode user was still warned about
an October incident that is long over. The mechanism stays for the next notice;
an empty list is now a valid file, and the validators keep running over whatever
it holds.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Classify OpenSSL and LibreSSL's SSL_connect errors as unreachable again (`_` is a
word character, so \bssl\b missed them and the user read the temp directory as the
reason), point a cases-folder refusal at Clone Repo instead of Create New, and move
the mechanism and history out of CLAUDE.md into architecture-invariants.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
It sat between the "two places" list and the paragraph starting "It browses",
so that "It" read as New folder, and it said the button is not offered inside
the cases folder when the button shows and the route refuses.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
selectSession() toggles .active at once while _syncTabStripCorner() only runs in
the debounced render, so a cap set after measuring left a newly selected first
tab wider than the room and dropped the first row below the toolbar until then.
max-width changes nothing on a narrower tab, so the cap is simply always on, and
the clear-before-measure and finish-the-animation loops go. The no-usable-room
test uses 8 tabs: at 40 the scroll fallback fires first and it passed at any
threshold.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Both _abandonTunnelEnable() returns come after saveAppSettingsToStorage() wrote
the new values to this device but before _unsavedSettingsBaseline was set, so
after cancelling the no-password prompt the next Save sent no
showPlanUsageLimits flip and did not reload for a gesture-control change.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>