codeman@1.29.0
Minor Changes
-
Custom model endpoints, HTTP API first (#393). Any run mode that has a mechanism for it can be pointed at a custom OpenAI-compatible endpoint (a local llama.cpp, llama-swap, Ollama or vLLM, or a cloud gateway) instead of its native backend, per session. Endpoints are stored in
~/.codeman/custom-model-hosts.json(GET/POST/PUT/DELETE /api/model-endpoints, admin-only in multi-user mode), their model lists are discovered from the endpoint's own/v1/models, andPOST /api/sessions/:id/custom-modelapplies one to a session by restarting its CLI in place. The mechanism is per-CLI registry data (capabilities.customModelInjection): env vars for Claude, Gemini, Grok and DeepSeek,OPENCODE_CONFIG_CONTENTfor opencode, an isolated config dir for Codex, Pi and OMP, unsupported for Antigravity. Verified live against a llama-swap server for claude, opencode, pi, grok and omp; gemini and deepseek reach the server and fail for reasons not yet understood, and codex only speaks the Responses API, so a plain chat-completions server cannot serve it. Those three are documented as gaps rather than shipped as working. The toolbar picker is a follow-up; until it lands the feature is HTTP-API only (docs/custom-model-endpoints.md), and thecustomModelEndpointsEnabledsetting is declared but read by nothing yet. Merged with maintainer follow-ups: clearing a selection now actually clears it (the injected vars are delivered bytmux setenv, whichrespawn-paneinherits, so the relaunched CLI came back still pointed at the endpoint; retired keys are nowsetenv -u'd before the respawn), applying a model to a local claude session no longer kills the pane (the relaunch pins--resume <id>with the--session-idfallback, since Claude Code refuses a session id that already has a transcript), pi, omp and grok now select the generated model through a registry-declaredlaunchModel(custom/<id>,-m codeman-custom) instead of writing a config the CLI then ignored, remote and Docker sessions are refused with a clear 400 until those paths are plumbed, the selection survives a Codeman restart, discovery goes through the egress-guardedwebviewFetch(), key-bearing files are written 0600 and the per-session config dir is removed with the session, and the design plan moved from the repo root todocs/custom-model-endpoints-plan.md. Along the way the multi-user clamp learned aboutGOOGLE_GEMINI_BASE_URL,GROK_BASE_URL,CODEX_HOME,PI_CONFIG_DIRandOPENCODE_CONFIG_CONTENT, which were already reachable throughenvOverridesand now count as privileged keys.Single-page apps work as web tabs, and a frame that reloads comes back (#402). A history-routed dashboard (React Router, Vue Router, a Vite dev server) read
/webview/<cap>/as itslocation.pathnameand rendered its own "page not found" the moment its script ran. The proxy's runtime shim now masks the prefix off the document URL before any page script runs, while every URL the page emits still goes through the rewrite layers (now includingWorker,SharedWorker,sendBeaconandwindow.open). A navigation the page starts itself afterwards (a dev server's full reload, a root-absolutelocation.href) used to land on Codeman's root with no capability; it is now recognised by shape, answered with a static recovery page that posts the lost path to the owning tab, and the frame is remounted inside the prefix at that path, bounded to five recoveries a minute per frame. Merged with maintainer follow-ups: the recovery path is sanitised properly (a leading backslash, or a tab/newline the URL parser deletes before parsing, resolved/\evil.comto a foreign origin in a direct-mode tab); a reload on the dashboard's landing page is recovered too, on password-protected and passwordless installs alike (it used to render Codeman's own shell inside the web tab); and the recovery page is written down as the third unauthenticated 200 in the security table anddocs/security-architecture.md, with the route-enumeration property it implies stated rather than left to be discovered.Shift arrows for Codex on the phone keyboard bar (#408). Two keys,
⇧←and⇧→, send the Shift-modified arrows Codex binds to editing the last queued message and walking the prompt stack (verified against Codex 0.154.0's/keymap). Merged with a maintainer follow-up: the keys are shown only on Codex sessions (acodex-enabledclass on the bar, the same shape as the Read My Mind key), because tapping one in any other session did nothing except hand that session to plain PTY echo for the rest of the prompt.Remote (SSH) cases can finally show you their files (#421, fixes #415). File previews, downloads, text reads and the out-of-workspace attachment path resolved every path against the Codeman host's own filesystem, so in a remote case every click ended in "File not found" while the file plainly existed on the other machine. A single new ssh read layer (
src/remote-files.ts, built on the samebuildSshConnectionArgs()the launch uses) probes realpath and stat for the file and the workspace root in one round trip, then streams the body withcat(or atail/headslice for aRange), so the 200/206/416 contract holds and nothing is buffered on the server. Symlinks are resolved on the host that can resolve them, containment is checked against the resolved remote root, the size cap applies to the remote size before a byte is requested, an unreachable host is a 502 rather than a 404, and there is deliberately no local fallback: a same-named file on the Codeman host is never served under a remote name. Writes, Office previews and generated thumbnails answer 400 for a remote case instead of a misleading 404. Merged with maintainer follow-ups: thereadlink -ffallback resolved only the directory chain, so on a host without it a symlink's final component was returned unresolved andws/notes.txt -> ~/.ssh/id_rsapassed containment whilecatserved the key; it now follows the last component with plainreadlinkfor a bounded number of hops and fails closed (404) on a loop or the cap;PUT /api/sessions/:id/file-contentanswers 400 for a remote case as the PR already claimed (it still validated against the local filesystem, so a same-named local directory took the write); ssh children are bounded by a small semaphore (CODEMAN_MAX_REMOTE_FILE_SSH, default 4) covering the attachment-history fan-out, which now probes the whole history in one batched call, and the fire-and-forget magic-link registrations an injected agent could use to fork hundreds ofsshprocesses; probe records are NUL-delimited and index-keyed so a newline in a filename cannot shift one path's result onto the next; and a 502 body never carries the ssh command line.Docker Compose: bind-mount ownership, override files, a
codemanruntime account, and no more stale volumes (#377). A missing bind source (first run, cleared appdata, restored backup) is created root-owned by the daemon, and the unprivileged server crash-looped onEACCESwhen Compose was run directly; the image now starts through an entrypoint that corrects a root-owned bind mount and drops toPUID:PGIDwithsetpriv, and the compose file adds back only the capabilities that needs.Start-Codeman.shhonoursdocker-compose.override.yml(naming a Compose file with-fsilently disables Compose's own discovery of it), pre-creates the cases directory like it already did for appdata, and detects when the checkout's HEAD or lockfile moved under thecodeman-node-modules/codeman-distvolumes and refreshes them, which used to leave adocker compose buildserving stale compiled routes. The default runtime account is namedcodeman(it wasopencode), the four global agent CLIs live in their own/opt/codeman-cliprefix so the runtime account can update them in place without owning/usr/local/bin, andCODEMAN_ALLOWED_HOSTSis documented and forwarded. Merged with maintainer follow-ups:cap_addgainsKILL(withinit: truetini runs as root while the server runs asPUID, and without CAP_KILL its SIGTERM forward failed and the server was SIGKILLed on everycompose down/restart); the CLI prefix is appended toPATHrather than prepended and the root entrypoint pins its ownPATH, since aPUID-writable directory ahead of/usr/binlet the runtime account plant asetprivthat ran as root on the next start; the entrypoint decides with a real writability probe as the runtime identity instead of an owner comparison, so ACLs, group-writable trees and NFS/CIFS mounts work and only a genuinely unwritable directory is refused, by name; the cases directory is created with the runtime owner afterPUID/PGIDare known; the build-source marker is written only when a refresh actually happened, an empty Compose project name falls back todown --volumes, the build runs before thedownso the stack is offline only for the recreate,docker-compose.override.*stays out of the image, andtest/docker-entrypoint.test.tspinscap_addagainst what the entrypoint needs.⚠️ Compose users: runStart-Codeman.shonce for this release rather than a plaindocker compose up, so the rebuilt image, the refreshed volumes and the new entrypoint arrive together.Selected text is visible again on the light skins (#423, part of #360). Every skin palette named its selection layer
selection, the key xterm renamed toselectionBackgroundin v5, so all seven skins had been painting xterm's default white at 30% instead of the colour next to it in the palette. Dark skins hid it; on the four light skins a selection was white on near-white. The key is renamed andtest/skin-themes.test.tspins it. CI additionally exercisesinstall.sh's dsh identity probe withtimeoutmissing under bash 3.2 (#422), the guard #382's fix shipped without.Eight fixes salvaged from #375 (dignfei; landed with the author's commits preserved, the rest of that PR is covered below). Shift+drag starts a text selection in a pane whose mouse reports go to the CLI, and right-click copies the selection. Ctrl- and Alt-modified navigation keys typed through the CJK composer reach the CLI as the modified sequences instead of plain arrows. A browser whose reliable-input sequence counter fell behind the server's watermark (a restored tab, a cleared localStorage) now recovers: the duplicate ACK carries
dup: trueplus the watermark, the client lifts its counter and re-sends, so a session that had silently stopped accepting typed prompts accepts them again. An SSE reconnect that lands on the session you are already looking at keeps its terminal buffer and resyncs instead of resetting the whole terminal. The hidden offline overlay and the file-preview overlay only applybackdrop-filterwhile shown, which removes a stale compositing layer that swallowed clicks. One adopted Docker container can back several cases at different in-container directories, and the adopt panel gains a "copy an existing case" picker. Of the PR's 27 commits, 14 had already shipped through #357, the selection theme key rename shipped as #423, and foreign tmux adoption plus SSH password auth stay with the author.Thanks
- @opticon454 for custom model endpoints (#393), including the part nobody enjoys: working out each CLI's real endpoint mechanism against real binaries and writing down which ones do not work yet instead of claiming they do; and for the Docker Compose deployment fixes (#377), rebased and reworked through three review rounds.
- @shenlvkang-collab for making single-page apps route inside web tabs and recovering a frame that reloads (#402), the best-engineered PR of this batch, and for the Codex Shift arrows on the phone keyboard bar (#408), verified against Codex's own keymap.
- @dignfei for the eight fixes salvaged from #375 (terminal selection and copy, CJK navigation keys, input recovery, SSE reconnect, overlay compositing, multi-case adopted containers), landed under their own name.
- @Randalix for reporting #415 and then fixing it themselves with the whole missing ssh read side for remote cases (#421), with a real-shell test for the probe script and a full route suite.
Patch Changes
-
349a89e: fix(webview): let a proxied single-page app route on its own path, and recover a frame that reloads
A dashboard served through a web tab saw
/webview/<cap>/as itslocation.pathname, and
no app has a route for that: a React Router, Vue Router or Vite dev-server page painted its
HTML and CSS and then replaced them with its own "page not found" the moment its script ran.
The proxy's runtime shim now rewrites the history entry to the path the page would see on its
own origin before any page script runs, while every URL the page emits still goes through
the existing rewrite layers (plusWorker,sendBeaconandwindow.open, which the masked
Referer can no longer rescue). A navigation the page starts itself afterwards — a dev
server's full-reload HMR, a root-absolutelocation.href— lands on Codeman's root with no
capability; it is recognised by shape (an iframe navigation asking for HTML for a path Codeman
does not serve), answered with a static page that tells the owning tab which path was lost,
and the tab remounts the frame inside the prefix at that path. That answer is served before
the credential checks, so it never counts as a failed login. -
013a5d9: File previews, downloads and text reads now work in a remote (SSH) case.
A remote case's working directory is an absolute path on the remote host, but the
file routes resolved it with localfs— so a clicked path (or the File Viewer) always
failed as "File not found" even though the file existed and the session was clearly
working in that directory.GET /api/sessions/:id/file-raw,file-content,
file-previewandfile-thumbnailnow resolve and read through the same
buildSshConnectionArgs()connection the launch uses (src/remote-files.ts, one
realpath+statprobe per request returning both the file and the workspace root).Clicked paths that point OUTSIDE the case directory (a remote
/tmpscratchpad capture,
a screenshot elsewhere in the remote home) go through the attachment routes, which had
the same local-fsassumption: registration, the by-idrawstream, the metadata poll
and the attachment history list now resolve over ssh as well, so the click-path works
whether the file sits inside or outside the case. Which host a record is read from
follows the SESSION, never the path string — the same absolute path means a different
file on each host, and a remote session never falls back to a local file.The guards are unchanged in strength: the workspace boundary is still enforced (now
resolved on the host that can actually resolve it), the sensitive-path blocklist and
the size cap (CODEMAN_MAX_DOWNLOAD_BYTES) still apply before any bytes are read, and
Rangerequests keep working, so remote<video>/<audio>seeking behaves like a
local file. An unreachable host is reported as502with the remote reason instead of
a misleading 404. Nothing is ever copied to the Codeman host.Still not available for remote cases, and now said explicitly instead of 404-ing:
editing a file (edit=1/PUTanswer 400, the viewer hides its Edit affordance),
office-document previews and generated thumbnails (both need the bytes on the server's
disk), the file tree / path picker, andtail-file. Docker cases are unaffected (their
workspace is bind-mounted at the same absolute path). -
b357fe8: Add Shift+Left and Shift+Right buttons to the default and extended mobile agent keyboard bars, shown only on Codex sessions, enabling Codex queued-message editing and prompt-stack navigation. Flush locally buffered drafts before navigation and keep terminal focus after taps.
-
9acc5aa: Fix an invisible terminal text selection on the light skins (#360). Every xterm palette declared its selection colour under the key
selection, which xterm.js renamed toselectionBackgroundin v5. AnIThemeis a plain object, so the unknown key was dropped without an error and every skin fell back to xterm's own default ofrgba(255,255,255,0.3): unnoticeable on the dark skins, which wanted roughly that anyway, and effectively invisible on Paper Gray, Solarized Light, Catppuccin Latte and Rosé Pine Dawn, where white at 30% over a near-white background moves a channel by about 3/255. Selecting text on those skins now highlights it, with desktop drag-select and the mobile long-press both fixed by the same rename.