mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-09-30 20:49:41 +02:00
Compare commits
201
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
83cb0b46b1 | ||
|
|
9055e1d7e5 | ||
|
|
120d780267 | ||
|
|
0af925fe82 | ||
|
|
b404dacfde | ||
|
|
cbd1fa639d | ||
|
|
43d4be8eeb | ||
|
|
f4d1ee8027 | ||
|
|
7a30a31430 | ||
|
|
fdfcc15c10 | ||
|
|
697b05b118 | ||
|
|
0462a5d5a0 | ||
|
|
da6fa663e7 | ||
|
|
10f87428c3 | ||
|
|
13e652e43f | ||
|
|
2afb1c2c2e | ||
|
|
2fb744f865 | ||
|
|
de4b1db490 | ||
|
|
8536aaef7b | ||
|
|
94b093b617 | ||
|
|
6a01412af9 | ||
|
|
bc04b6457e | ||
|
|
5b5e932ec4 | ||
|
|
f1dfbcdd65 | ||
|
|
233af33dac | ||
|
|
993e5e021c | ||
|
|
02e40f506b | ||
|
|
e558264977 | ||
|
|
9a48c43aa1 | ||
|
|
5cf5a45438 | ||
|
|
110c4696ad | ||
|
|
ac6236b268 | ||
|
|
00f022ccf8 | ||
|
|
b13672596f | ||
|
|
8d45b92eba | ||
|
|
05c788ce9d | ||
|
|
9c286eeddf | ||
|
|
e1e7dc5bd8 | ||
|
|
ce80b7a212 | ||
|
|
e587d84590 | ||
|
|
d9fa9ba1eb | ||
|
|
abf1d1f1ca | ||
|
|
90fd0a5a15 | ||
|
|
64c288a683 | ||
|
|
abd39318e6 | ||
|
|
c0422c4e21 | ||
|
|
74884a20eb | ||
|
|
1cb0441bd8 | ||
|
|
3f2cde2db7 | ||
|
|
47e7935274 | ||
|
|
a2dcc91ddf | ||
|
|
c67c130caa | ||
|
|
90a95f562b | ||
|
|
02dc46dcd7 | ||
|
|
5b4878df3b | ||
|
|
3b714446b4 | ||
|
|
9ba90a674a | ||
|
|
d3ee9f23c2 | ||
|
|
9466acfc1a | ||
|
|
e899af4305 | ||
|
|
299a21d5f5 | ||
|
|
d8e85285c9 | ||
|
|
0f955327b2 | ||
|
|
d3f2ec0220 | ||
|
|
6ef71ec3b9 | ||
|
|
d47f93abdb | ||
|
|
dcf9437308 | ||
|
|
aa13af1f7f | ||
|
|
9a2e14a93a | ||
|
|
ecb95b5d67 | ||
|
|
a7452dc046 | ||
|
|
72d437ab63 | ||
|
|
1ba0684438 | ||
|
|
af744bdb54 | ||
|
|
46d8b92049 | ||
|
|
0b3e086334 | ||
|
|
fafef0aa00 | ||
|
|
3152ec801d | ||
|
|
2e3e245cc6 | ||
|
|
165cfb52d6 | ||
|
|
6c8bd6c606 | ||
|
|
8d3bde5469 | ||
|
|
78dcb0aa24 | ||
|
|
7fc66e8161 | ||
|
|
1678386f50 | ||
|
|
3859506f9b | ||
|
|
33b2605815 | ||
|
|
d0a887d98a | ||
|
|
24a92c8f3e | ||
|
|
f7852081b7 | ||
|
|
f0e24d8ce2 | ||
|
|
2724c922ce | ||
|
|
57406f6c14 | ||
|
|
8903a72662 | ||
|
|
ba7b8b7bef | ||
|
|
8c73128cd6 | ||
|
|
2abf328db8 | ||
|
|
fa8bb13a27 | ||
|
|
6f64e557e5 | ||
|
|
97a1238c85 | ||
|
|
aa0521602d | ||
|
|
2bc16d5fd9 | ||
|
|
d60a164025 | ||
|
|
727817410c | ||
|
|
28d3bd7da8 | ||
|
|
d0f9bdd251 | ||
|
|
ff98006471 | ||
|
|
5f1be90ae9 | ||
|
|
3da8bb7046 | ||
|
|
ac574d6c64 | ||
|
|
d9e6ebb20a | ||
|
|
88e5b7b200 | ||
|
|
73607663fd | ||
|
|
458ca578e7 | ||
|
|
884713cca5 | ||
|
|
a220c28a14 | ||
|
|
0761de3dae | ||
|
|
c2eaba990b | ||
|
|
e214691429 | ||
|
|
773b405429 | ||
|
|
2df9355367 | ||
|
|
cd64b0a3f7 | ||
|
|
2d573d8a34 | ||
|
|
51b4a1b758 | ||
|
|
4205f6930f | ||
|
|
12de3c5164 | ||
|
|
9af12afb57 | ||
|
|
fe3bd0074c | ||
|
|
3b55957d79 | ||
|
|
1a99b5836c | ||
|
|
035bfbc2fe | ||
|
|
4c705094f7 | ||
|
|
c376534a50 | ||
|
|
2c3ccdf030 | ||
|
|
475436242c | ||
|
|
613b774bf1 | ||
|
|
60c9af0599 | ||
|
|
2d842ded35 | ||
|
|
5fc391a47c | ||
|
|
5bb489addb | ||
|
|
95dc6fe944 | ||
|
|
383f834704 | ||
|
|
e0d4477edc | ||
|
|
5cfb98fb8b | ||
|
|
3edf9aae2f | ||
|
|
55a80eab86 | ||
|
|
358aef16e3 | ||
|
|
1040f6c489 | ||
|
|
e271a65e79 | ||
|
|
56209e7829 | ||
|
|
afb6754453 | ||
|
|
f9edb33d15 | ||
|
|
9982a1325f | ||
|
|
1f32128ca9 | ||
|
|
e2034177c5 | ||
|
|
8520925e76 | ||
|
|
db9729e1fc | ||
|
|
2d3fc65758 | ||
|
|
5ddc028a2f | ||
|
|
470f75b08c | ||
|
|
211b872335 | ||
|
|
2c89359d42 | ||
|
|
962029bb3d | ||
|
|
b45a96358e | ||
|
|
29984c639d | ||
|
|
acb8d4b0aa | ||
|
|
7b947fa3f1 | ||
|
|
993710263d | ||
|
|
7bbe408e44 | ||
|
|
55dae31530 | ||
|
|
0af233c96c | ||
|
|
a7f74f374f | ||
|
|
0929694012 | ||
|
|
01b32ee6cd | ||
|
|
2936ba6e3d | ||
|
|
b1db5515d7 | ||
|
|
83033b4299 | ||
|
|
f865f74a0f | ||
|
|
fbee1b2d82 | ||
|
|
bcebc81fcd | ||
|
|
25f22b9839 | ||
|
|
97464bfa27 | ||
|
|
0e8b1981af | ||
|
|
5c25a52f95 | ||
|
|
409a6e65f9 | ||
|
|
9a9e542a7d | ||
|
|
5a9ff07f57 | ||
|
|
60e1bd52f7 | ||
|
|
fed6582d3e | ||
|
|
98d26e14d9 | ||
|
|
25fae9ad10 | ||
|
|
4a30f510e6 | ||
|
|
d0a5a583cd | ||
|
|
8dfc965d13 | ||
|
|
1380b023e2 | ||
|
|
e8f7772320 | ||
|
|
2f61be6e74 | ||
|
|
8b5a13435a | ||
|
|
3f0bfde54a | ||
|
|
0f3eea2fb5 | ||
|
|
a81f430e41 |
@@ -1,5 +0,0 @@
|
||||
---
|
||||
"aicodeman": patch
|
||||
---
|
||||
|
||||
fix(terminal): keep the output a pane capture could not contain. Opening a session, a backpressure refresh, a clear-terminal reload and a full-history re-pull all load the screen from a tmux pane capture, and anything the CLI printed between that capture and the end of the load used to be dropped, so its next partial redraw landed on a frame the terminal had never seen: missing or garbled output right after a tab switch or a refresh, plainest in a shell session. Each load now replays exactly the output that arrived after the capture, through one shared rule for all four paths, and a refresh that restores your scroll position no longer snaps back to the bottom afterwards.
|
||||
@@ -1,5 +0,0 @@
|
||||
---
|
||||
"aicodeman": patch
|
||||
---
|
||||
|
||||
fix(input): make sure a prompt sent through the API actually leaves the composer. Claude Code 2.1.277 started ignoring Enter for the first 30 to 50 seconds after the composer paints while still accepting the typed text, so a prompt sent right after a session came up sat unsent in the pane and every waiter (send-and-wait, the agent skill, cron, the maintainer bot) burned its whole timeout on a turn that never started. The server now reads the pane after every programmatic write that carried Enter and presses Enter again, on a 2 to 60 second schedule, only while the composer verifiably still holds the text it sent; an empty composer, other text, or a pane with no composer at all ends it. The agent skill's `sendwait` gets the same loop for servers that predate this, and its preamble version moves to 1.30.1 so an already-seeded agent picks up the fresh copy.
|
||||
@@ -10,7 +10,7 @@
|
||||
"name": "codeman",
|
||||
"source": "./plugins/codeman",
|
||||
"description": "Drive Codeman from inside a Claude Code session: spawn worker sessions, prompt them, wait for them, read their answers, clean up. Acts only inside a Codeman-managed session.",
|
||||
"version": "1.30.0",
|
||||
"version": "1.32.1",
|
||||
"author": {
|
||||
"name": "Ark0N",
|
||||
"url": "https://github.com/Ark0N"
|
||||
|
||||
@@ -100,6 +100,50 @@ jobs:
|
||||
fi
|
||||
echo "bash $BASH_VERSION: dsh identity probe survives a missing timeout"
|
||||
'
|
||||
# Installer v2: the question phase runs before the build, and every decision it
|
||||
# takes is bash logic over stubbed tailscale state. Drive the flags, the launch
|
||||
# default, the occupied-:443 menu and the rename question with canned answers,
|
||||
# so a bash-4 construct or a flipped default in any of them fails here, not on a
|
||||
# Mac. The JSON parsers need node (absent in this image) and are stubbed; their
|
||||
# own coverage is test/install-sh-invariants.test.ts plus the vitest gate.
|
||||
docker run --rm -v "$PWD":/w -w /w -e CODEMAN_INSTALL_SH_LIB=1 -e HOME=/tmp/h bash:3.2 bash -c '
|
||||
set -euo pipefail
|
||||
mkdir -p /tmp/h
|
||||
. /w/install.sh
|
||||
parse_flags --tailscale --service --name Build-Box --port 4000
|
||||
[[ "$CODEMAN_TAILSCALE" == "1" && "$LAUNCH_PRESET" == "2" && "$TS_NAME" == "Build-Box" && "$CODEMAN_PORT" == "4000" ]]
|
||||
[[ "$(ts_sanitize_name "$TS_NAME")" == "build-box" ]]
|
||||
has_tty() { return 0; }
|
||||
ANSWER=""; read_reply() { eval "$1=\"\$ANSWER\""; }
|
||||
systemctl() { return 0; }
|
||||
LAUNCH_PRESET=""; NONINTERACTIVE=0
|
||||
choose_launch_mode linux >/dev/null 2>&1
|
||||
[[ "$LAUNCH_CHOICE" == "2" ]]
|
||||
check_tailscale() { return 0; }
|
||||
ts_status_field() { case "$1" in "s.BackendState") printf Running ;; "s.Self && s.Self.DNSName") printf "box.tail.ts.net." ;; esac; }
|
||||
ts_backend_state() { printf Running; }
|
||||
ts_dns_name() { printf box.tail.ts.net; }
|
||||
ts_serve_443_target_port() { printf 8080; }
|
||||
ts_serve_find_port_mapping() { :; }
|
||||
ts_serve_port_used() { return 1; }
|
||||
detect_tailscale_serve_url() { :; }
|
||||
tailscale_choose_mapping >/dev/null 2>&1
|
||||
[[ "$TS_SERVE_MODE" == "path" && "$BIND_BASE_URL" == "/codeman" ]]
|
||||
RENAMED=""; tailscale_rename_node() { RENAMED="$1"; }
|
||||
TS_NAME=""; tailscale_choose_name >/dev/null 2>&1
|
||||
[[ -z "$RENAMED" ]]
|
||||
# A flag re-run keeps the password the unit already carries (and so
|
||||
# never writes the unauthenticated ack), and the hand-start line the
|
||||
# done screen prints carries every non-default value.
|
||||
read_existing_binding() { EXISTING_FOUND=1; EXISTING_HOST=0.0.0.0; EXISTING_PASSWORD=s3cret; EXISTING_ACK=0; EXISTING_BASE_URL=""; }
|
||||
CODEMAN_HOST=0.0.0.0; CODEMAN_TAILSCALE=0; unset CODEMAN_PASSWORD; BIND_ACK=0
|
||||
choose_network_binding >/dev/null 2>&1
|
||||
[[ "$BIND_PASSWORD" == "s3cret" && "$BIND_ACK" == "0" ]]
|
||||
BIND_HOST=0.0.0.0; BIND_PASSWORD=x; BIND_ACK=0; BIND_BASE_URL=/codeman; CODEMAN_PORT=4000
|
||||
[[ "$(start_command_hint)" == "CODEMAN_HOST=0.0.0.0 CODEMAN_PASSWORD="*" CODEMAN_BASE_URL=/codeman CODEMAN_PORT=4000 codeman web" ]]
|
||||
RECONFIGURE=0; parse_flags --port 4001; [[ "$RECONFIGURE" == "1" ]]
|
||||
echo "bash $BASH_VERSION: question phase (flags, launch default, occupied :443, rename opt-in, kept password, start line) ok"
|
||||
'
|
||||
|
||||
- name: CLI catalogue artifacts are in sync with stock.ts
|
||||
run: npm run generate:cli-catalog -- --check
|
||||
|
||||
+166
@@ -1,5 +1,171 @@
|
||||
# aicodeman
|
||||
|
||||
## 1.32.1
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- 13e652e: Terminal copy: copying text out of a Claude Code or Codex pane no longer puts the pane's two-column transcript gutter on the clipboard, so pasted lines arrive flush instead of indented (#469). The width comes from the CLI registry (`capabilities.transcriptGutter`, 2 for claude and codex, measured on live panes) and is only a ceiling: a selection only ever shifts as a block, so its own indentation survives. Other CLIs and shells are untouched. It works in split panes and detached session windows too, and can be turned off per device in App Settings under Selection & clipboard.
|
||||
- 13e652e: Sessions: recovering a Claude session whose tmux pane had died relaunched `claude --session-id <id>`, which Claude refuses once that id has a transcript, so the pane died again straight away and the conversation was stranded. The relaunch now resumes the conversation (`--resume <id> || --session-id <id>`), including when tmux lost the whole session (#467).
|
||||
- 00f022c: Terminal: when a burst of output overflows the render queue and a frame has to be dropped, the repaint that repairs it is now retried until it actually happens, instead of being scheduled once and silently skipped when another load was in flight (#470).
|
||||
- 13e652e: Mobile: a long press on blank terminal space on Android Chrome no longer opens the keyboard and blanks the terminal (#471, fixes #360). The long-press guards are now armed before the press is checked for selectable text, so a press on empty space is swallowed the same way a press on a word already was.
|
||||
- 13e652e: Sessions: a tab whose agent has exited (the CLI quit, but tmux kept the pane) now says so with a muted dot and an `exited (137)` badge, instead of looking like an idle session (#466, part 1 of #446). The state is published as `paneExit` on the session and survives a restart. Nothing closes such sessions yet; that is part 2.
|
||||
- 13e652e: Docker: optional GitHub CLI and Azure CLI for private repositories (#472). Both are off by default. With `CODEMAN_INSTALL_GH=1` / `CODEMAN_INSTALL_AZ=1` as build args in `docker-compose.override.yml`, the server image gets `gh` and/or `az` (with the `azure-devops` extension) wired in as git credential helpers, so after one `gh auth login` or `az login` from a shell session, Add Case → Clone Repo can clone private GitHub and Azure DevOps repositories. `CODEMAN_AGENT_IMAGE_INSTALL_GH` / `_AZ` do the same for the Docker-case agent image, and only then are the sign-ins copied into new case containers. In multi-user mode a non-admin's clone runs with the credential helpers cleared. This changes `server.Dockerfile`, so Compose deployments need a `Start-Codeman.sh` rebuild rather than an in-app update.
|
||||
- 13e652e: Run menu: the Gemini, Antigravity and OMP run buttons now show their own colours on every skin; they rendered in Claude blue on all skins except OG (#463). The CLI registry's `accent` values were also corrected to the colours the UI really paints, and a test now guards the stylesheet trap that caused it.
|
||||
- 13e652e: Terminal: five ways the browser terminal could silently stop being correct are fixed (#431, #464). The browser terminal and the PTY can no longer disagree about their width, which is what produced doubled lines and half-overwritten text ("text gets muffled sometimes"): there is now one function that sizes the terminal, and every resize is answered with the geometry the PTY really holds. A replay clear goes through the terminal's own queue, so bytes written just before it no longer fuse into the next snapshot. A renderer that stops painting after an iOS PWA is backgrounded heals itself instead of needing a reload. Every terminal capture has a deadline that also covers the response body, and a capture that runs out of time during a tab switch falls back to the bounded tail instead of leaving a blank pane. Output lost to a half-open WebSocket is repainted on the next successful open. The service worker's precache list is now generated by the build and its cache is rotated per build, so old releases' assets no longer pile up.
|
||||
- 13e652e: Docker: new `docker/Update-Codeman.sh` for the major-update path the docs used to describe by hand (#465). It rebuilds the image with `--no-cache` before taking the stack down, clears the build-artefact volumes, refuses to run when another checkout's Compose project already owns the same name, and then hands over to `Start-Codeman.sh`.
|
||||
- 13e652e: Approvals: a session that is idle only because it is waiting on its own background work (Claude Code's `1 monitor` footer chip, or a Codex background terminal) no longer raises the yellow NEEDS YOU alert or a push (#473, fixes #468). Its idle item is opened already acknowledged, and the tab, the home screens and the rail show a small `watching` badge next to the state instead. The item still exists in the Approvals Inbox, and the TUI's pending count now leaves acknowledged items out.
|
||||
- b404dac: Maintainer fixes applied while landing this batch:
|
||||
- Terminal (#431): while another device holds the pane's width, a resize retry no longer re-fits xterm to the container and re-wraps the whole buffer every 30 s, and no longer clears scrollback for a redraw that never comes. The PTY's spawn geometry is now recorded at attach, so `ptyGeometry` never reports a size the PTY never held.
|
||||
- Terminal (#470): the `TERMINAL DROP` crash-trail line is logged once per recovery window instead of once per dropped frame (which wiped the rest of the trail within a second), and a refresh that died at its fetch deadline is no longer retried.
|
||||
- Sessions (#467): the resume pin also covers the branch where tmux lost the whole session, the conversation id Codeman reports follows what the relaunch actually resumed, and the test setup strips `CLAUDE_CONFIG_DIR` so the suite stays green for anyone running a separate Claude config dir.
|
||||
- Sessions (#466): detailed sidebar and rail rows show an `exited` pill instead of `idle`, the exit is announced to screen readers, and the user manual's tab-appearance table lists the new state.
|
||||
- Approvals (#473): a failed pane capture clears the `watching` badge rather than keeping a stale one (a failure now falls toward an alert, not toward silence), and the header bell's count leaves acknowledged items out, matching the TUI.
|
||||
- Run menu (#463): the Gemini and Antigravity run buttons no longer render two-tone on phones, Gemini's registry accent matches its tab badge, and a test now guards the stylesheet trap for every run mode.
|
||||
- Docker (#465): `Update-Codeman.sh` removes exactly the two build-artefact volumes it names instead of every named volume in the project, reports a failing `docker compose` instead of exiting silently, and its docs and comments were corrected. (#472): the multi-user notes say that a non-admin's seeded Docker case also receives the gh/az sign-in when those switches are on.
|
||||
|
||||
### Thanks
|
||||
- @irisitymichaelgrundberg for four PRs in this release: the `watching` badge that stops background work from raising false alerts (#473, from their own report #468), the exited-agent badge (#466) and the dead-pane resume fix (#467), both from their report #446, and the transcript-gutter strip for copied text (#469), a follow-up to their #451.
|
||||
- @rounakdatta for the terminal resilience work (#431) and the dropped-frame recovery (#470), both from their report #464, and for answering four rounds of review in full.
|
||||
- @opticon454 for private-repository support in the Docker images (#472), the `Update-Codeman.sh` script (#465) and the run-button colour fix (#463).
|
||||
- @DodgyBadger for the Android long-press fix (#471), from their own report #360.
|
||||
|
||||
## 1.32.0
|
||||
|
||||
### Minor Changes
|
||||
|
||||
- d47f93a: feat(custom-model): the model picker puts the ready model first
|
||||
|
||||
When a custom endpoint has more than one model, the Run menu's picker now promotes one row to the top instead of showing raw discovery order: the model llama-swap reports loaded and ready right now (tagged "Currently loaded", the one a launch attaches to with zero wait), else the model you last launched on that harness and endpoint (tagged "Last used", remembered per device). The endpoint's default keeps its own pill, nothing is ever auto-chosen, and a plain OpenAI-compatible server or an endpoint that does not answer within a second simply keeps the old order. The probe is bounded on the client too, so a GPU box that is off no longer holds the picker closed for five seconds.
|
||||
|
||||
- d47f93a: feat(split-pane): view two live sessions side by side
|
||||
|
||||
A new Split button in the header (opt-in in App Settings, off by default, desktop only at 1180px and wider) opens a picker and shows a second live session beside the active one: its own terminal, its own WebSocket, and a divider you can drag. When either session ends the view collapses back to one pane, with Pane B promoted to the primary when it is Pane A that ended. Nothing is persisted on purpose in this first cut, so a page reload always returns to a single pane. Pane B is deliberately plainer than the primary pane (no local-echo overlay, CJK input, touch handling or keyboard accessory bar); the design and the v2 boundaries are in discussion #452.
|
||||
|
||||
- 72d437a: Installer v2. `curl -fsSL https://getcodeman.com/install | bash` now looks at the machine first, asks at most three questions up front (how the dashboard is reached, optionally what to call the machine on your tailnet, whether to run Codeman as a background service), does the install unattended behind progress spinners with the output in `~/.codeman/install.log`, and ends on the URL with a QR code to scan. One consent covers every missing package and sudo asks for your password once. Flags pipe through `bash -s --` (`--tailscale | --lan | --local`, `--name <n> | --no-rename`, `--service | --run | --no-start`, `--yes`, `--password`, `--port`), `install.sh status` prints the URL and the QR code again, and the cloudflared question moved out of the main flow into `install.sh cloudflared`. On the Tailscale route, a `:443` that already belongs to another app gets Codeman under `https://<node>/codeman` (or on a second port) instead of a dead end, the node can be renamed opt-in (`--name`, `install.sh name`, undone by uninstall), and the HTTPS-certificates toggle is polled with the admin page opened for you. Also fixed on the way: the installer's own `npm install` no longer lets the postinstall start a stray server on port 3000 (the service crash-looped on EADDRINUSE while the done screen said "running"), the LAN address comes from the default route rather than the first interface, a hand-written LaunchDaemon on a headless Mac is left alone, a flag re-run keeps an existing dashboard password, and the done screen's start command carries the sub-path and port it was installed with.
|
||||
- d47f93a: feat(mobile): a Compose key for writing prompts on a phone
|
||||
|
||||
The agent keyboard bars on phones replace their Paste key with Compose: a real multiline editor with autocorrect and spellcheck, per-session drafts kept in memory only, image attach that never writes into the terminal early, and a Send that delivers the text as one paste followed by Enter, so a long prompt no longer has to be typed blind into the terminal composer. Anything you had already typed into the terminal is picked up into the editor. Shell sessions keep the direct Paste key. This is the manual first slice from #359; the auto-open setting and terminal tap routing are a separate follow-up.
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- d47f93a: refactor(run-menu): one table-driven launcher for every external CLI
|
||||
|
||||
The eight near-identical per-CLI launch functions in the Run menu collapsed into one launcher driven by a table that a CI test keeps in step with the CLI registry, and a second no-id-branching guard now covers the frontend the way the backend guard covers the server. No behaviour change: the refactor was verified byte-identical across 288 launch permutations against the previous code.
|
||||
|
||||
- e899af4: Maintainer fixes applied while landing the above. The model picker's promoted row keeps its Default pill (the promotion tag and the default marker are two pills now, and they render as pills in the picker rather than as plain text). The phone composer keeps its bottom gutter on folding devices (the generic fold rule used to erase it), a whitespace-only draft is no longer sent, and its dialog is translated on a zh-CN UI. A split that collapses mid-drag no longer leaves the page stuck in resize-cursor mode, Pane B refuses a session that has no live process, and a burst of refresh frames replays once instead of twice. The `</head>` script injections on the page render use replacer functions, so a CLI label containing `$'` can no longer splice the document into the inline script, and the frontend no-id-branching guard now catches comparisons on any variable name.
|
||||
- 6ef71ec: ### Thanks
|
||||
- @timkjr for split-pane sessions (#453): five review rounds turned around in two days, and the pointer-capture edge case measured in a real browser rather than reasoned about.
|
||||
- @DodgyBadger for the mobile prompt composer (#444), a first contribution that took the scope back down to one slice when asked, and that verified the delivery path against a live tmux pane and a live Claude Code composer instead of trusting the diff.
|
||||
- @opticon454 for putting the ready model first in the picker (#459) and for collapsing the eight Run-menu launch functions into one (#458), proven byte-identical across 288 launch permutations instead of argued.
|
||||
|
||||
## 1.31.0
|
||||
|
||||
### Minor Changes
|
||||
|
||||
- 035bfbc: feat(remote): wake a sleeping remote host from Codeman
|
||||
|
||||
A remote SSH case pointing at a machine that suspends used to fail the same way every
|
||||
time: the session was there, the host was not, and typing into it went nowhere. A host
|
||||
can now carry a wake target, either a MAC address for Wake-on-LAN (Codeman builds the
|
||||
magic packet itself, so nothing reaches a shell) or a wake command of your own, and
|
||||
Codeman uses it when you ask for the host: when you type into a sleeping session, when
|
||||
you press the wake button on the banner, or when you start or attach a session on that
|
||||
host. Input you type while it wakes is buffered and flushed once it is back, up to 4 KB,
|
||||
and a chunk over that is refused outright rather than delivered as a fragment.
|
||||
|
||||
Waking only ever happens because you asked. No watcher, dropped-session handler or
|
||||
boot-recovery path can reach it, since a machine woken by a reconnect watcher would come
|
||||
back seconds after every suspend.
|
||||
|
||||
- fbee1b2: feat(custom-model): pick a custom endpoint straight from the Run menu
|
||||
|
||||
#393 landed the backend for custom model endpoints and left it reachable only over the
|
||||
HTTP API. This is the rest of it. Turn on Custom model endpoints in App Settings, save
|
||||
an endpoint, and the Run dropdown grows a Custom Endpoints section built live off the
|
||||
CLI registry, one entry per harness that can actually redirect plus each endpoint you
|
||||
saved. Pick one and it launches that harness pointed at your server, asking which model
|
||||
first when the endpoint has more than one. Endpoints re-discover themselves every five
|
||||
minutes, and one unreachable endpoint never blocks the others. App Settings gains full
|
||||
add, edit and delete for endpoints.
|
||||
|
||||
Seven of the harnesses (opencode, Codex, Gemini, Pi, Grok, DeepSeek and OMP) now launch
|
||||
directly onto the endpoint with no restart at all, where before you watched a native
|
||||
boot followed immediately by a second one. Claude still launches and then restarts in
|
||||
place, which its own resume makes far less jarring.
|
||||
|
||||
Most of this release's work went into things that only show up against a real server,
|
||||
and each was found that way rather than in tests: a freshly launched CLI reporting
|
||||
itself busy for its own startup and getting refused; Claude Code assuming a large
|
||||
context window for a model it does not recognise and silently overflowing a small one;
|
||||
a model whose real context is below what Claude Code's own system prompt costs, which
|
||||
no setting can fix and which now warns before launching into a certain failure; and the
|
||||
big one, llama.cpp running exactly one model at a time, so applying a selection can
|
||||
unload the model another session is using. That last case now asks first, tells you
|
||||
which session it affects, and keeps a "loading model" notice on screen for the whole
|
||||
swap window, so a prompt sent mid-swap reads as loading rather than as an answer from
|
||||
whatever was loaded a moment ago. A background sweep also catches the reverse: your
|
||||
session's model being evicted later by somebody else's ordinary use.
|
||||
|
||||
Two things worth knowing if you drive this over the HTTP API or run multi-user. The two
|
||||
questions an apply can ask (the model's context window is too small, and loading it will
|
||||
unload the model another session is using) are now answered by separate
|
||||
`confirmedContext` and `confirmedSwap` fields rather than one `confirmed`. They shared a
|
||||
flag until now, and since the context check runs first, confirming that one silently
|
||||
agreed to evict another session's model as well. The old `confirmed` still means both.
|
||||
And `CLAUDE_CONFIG_DIR` is now admin-only in multi-user mode: it joined claude's
|
||||
privileged env keys, so a non-granted owner can no longer set it through `envOverrides`,
|
||||
and an already-persisted one is dropped on reboot-restore, which returns that session to
|
||||
the default Claude account rather than the per-client one it was pointed at. Single-user
|
||||
installs are unaffected.
|
||||
|
||||
Remote SSH and Docker sessions are refused for now, since their restart reattaches a
|
||||
durable tmux rather than relaunching the agent.
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- c9515b1: fix(terminal): keep the output a pane capture could not contain. Opening a session, a backpressure refresh, a clear-terminal reload and a full-history re-pull all load the screen from a tmux pane capture, and anything the CLI printed between that capture and the end of the load used to be dropped, so its next partial redraw landed on a frame the terminal had never seen: missing or garbled output right after a tab switch or a refresh, plainest in a shell session. Each load now replays exactly the output that arrived after the capture, through one shared rule for all four paths, and a refresh that restores your scroll position no longer snaps back to the bottom afterwards.
|
||||
- 3edf9aa: fix(terminal): replay a pane capture at the geometry it was taken at
|
||||
|
||||
Opening a session could draw a frame built for a pane bigger than your terminal. A
|
||||
taller pane wrote its overflow rows onto the last line and lost the rows underneath
|
||||
(against a 50-row pane, a 30-row terminal rendered 28 of a 45-line command and drew
|
||||
the survivors twice), and a wider one wrapped every row and scrolled the whole frame
|
||||
up by one. The terminal response now reports the geometry the capture was really
|
||||
taken at, so the browser can see the mismatch and replay once at the size that stuck.
|
||||
A pane that cannot be sized to fit is diagnosed once per session instead of on every
|
||||
tab switch.
|
||||
|
||||
- 035bfbc: ### Thanks
|
||||
- @irisitymichaelgrundberg for three terminal fixes in one release: keeping the output a pane capture could not contain (#436), replaying a capture at the geometry it was taken at (#435, five rounds and a Playwright suite that fails against the merge base), and trimming the padding out of a copied selection (#451), where the scan-instead-of-regex call avoided a 2.9s freeze nobody would have traced back to a copy.
|
||||
- @timkjr for a first contribution that found a real silent failure: the Instance count stepper next to the Run button had only ever applied to Claude, so on the other eight run modes it launched one session and said nothing (#454).
|
||||
- @Randalix for Wake-on-LAN on remote hosts (#439), built and live-tested against a real sleeping machine, and for reading the whole diff again between rounds rather than only the parts that were asked about.
|
||||
- @opticon454 for turning #393's backend-only custom model endpoints into the whole feature (#430), and for validating it against a real llama-swap box rather than against the tests: the `/props` versus `/running` context discrepancy and the DeepSeek `/v1` root cause were both tracked down to the SDK source instead of guessed at.
|
||||
|
||||
- c376534: fix(run): make the Instance count stepper work for every non-Claude mode
|
||||
|
||||
The Instance count stepper next to the Run button only ever applied to Claude.
|
||||
Setting it to 3 and launching OpenCode, Codex, Gemini, Antigravity, Pi, OMP, Grok or
|
||||
DeepSeek started exactly one session, with no error and no hint that the control had
|
||||
done nothing. All eight now launch the count you asked for, and the opening banner
|
||||
says how many are starting. The one exception is a launch started from the Custom
|
||||
Endpoints section of the Run menu, which always starts a single session.
|
||||
|
||||
- 19ffe9b: fix(input): make sure a prompt sent through the API actually leaves the composer. Claude Code 2.1.277 started ignoring Enter for the first 30 to 50 seconds after the composer paints while still accepting the typed text, so a prompt sent right after a session came up sat unsent in the pane and every waiter (send-and-wait, the agent skill, cron, the maintainer bot) burned its whole timeout on a turn that never started. The server now reads the pane after every programmatic write that carried Enter and presses Enter again, on a 2 to 60 second schedule, only while the composer verifiably still holds the text it sent; an empty composer, other text, or a pane with no composer at all ends it. The agent skill's `sendwait` gets the same loop for servers that predate this, and its preamble version moves to 1.30.1 so an already-seeded agent picks up the fresh copy.
|
||||
- f9edb33: fix(terminal): trim the padding out of a copied selection
|
||||
|
||||
Copying out of a pane put a wall of spaces on the clipboard. xterm hands back
|
||||
whole screen rows and trims only the cells that were never written to, so the
|
||||
real spaces a full-screen program paints across the unused part of a row count
|
||||
as content: measured against Claude Code in a 282-column pane, single lines
|
||||
arrived carrying 138 trailing spaces. Pasting that into a chat client or an
|
||||
editor meant deleting the whitespace by hand, while Windows Terminal, iTerm2 and
|
||||
GNOME Terminal all trim it for you. A copy now drops the trailing run from every
|
||||
line, on all four paths (the Ctrl+C chord, right-click, the phone selection
|
||||
button and Auto Copy), while leading indentation is left exactly as it is. An
|
||||
Alt+drag rectangular selection is copied verbatim, because its columns lining up
|
||||
is the point of that gesture. A selection holding nothing but padding is refused
|
||||
rather than copied as bare line breaks.
|
||||
|
||||
## 1.30.0
|
||||
|
||||
### Minor Changes
|
||||
|
||||
@@ -61,12 +61,13 @@ The installer asks before every system change, and re-running the same line upda
|
||||
curl -fsSL https://getcodeman.com/install | bash
|
||||
```
|
||||
|
||||
This installs Node.js, tmux and a build toolchain if missing (node-pty ships no Linux prebuilds, so it compiles from source), clones Codeman to `~/.codeman/app`, and builds it. A few things worth knowing:
|
||||
This installs Node.js, tmux and a build toolchain if missing (node-pty ships no Linux prebuilds, so it compiles from source), clones Codeman to `~/.codeman/app`, and builds it. It looks at what is already on the machine, asks at most three questions, then does all the work unattended and ends on the URL with a QR code for your phone. A few things worth knowing:
|
||||
|
||||
- **It asks first.** Every system change (package installs, AI CLI download) is prompted, and a menu at the end lets you choose: run Codeman in this terminal, install it as a background service (systemd/launchd, auto-start on boot), or don't start yet. Nothing runs in the background unless you pick it.
|
||||
- **How it's reachable, your choice.** The installer offers three ways to reach the dashboard: **Tailscale** (loopback bind fronted by `tailscale serve`, so you get `https://<machine>.<tailnet>.ts.net` with a real certificate and your tailnet as the login, no password needed), **any device on your network** (`0.0.0.0`, with a strongly recommended password prompt), or **this machine only** (`127.0.0.1`, safest). Skipping the password on a network bind requires an explicit confirmation and ends with a loud warning. The highlighted default reflects what is already on the machine (Tailscale when it is already in use, your existing binding on a re-run), and a bare Enter never pulls in new software. A bare `codeman web` started by hand still defaults to loopback.
|
||||
- **Re-run to update.** The same one-liner updates a finished install in place: local changes in `~/.codeman/app` are stashed (never discarded), and a running service is restarted and verified. If a first install was interrupted, re-running resumes the full setup instead. `install.sh update` and `install.sh uninstall` also exist.
|
||||
- **CI / headless:** without a terminal attached, steps that would change your system abort with instructions instead of running silently. Set `CODEMAN_NONINTERACTIVE=1` to approve them for automation.
|
||||
- **Three questions, all up front.** How the dashboard is reached, optionally what to call this machine on your tailnet, and whether to run Codeman as a background service (systemd/launchd, auto-start on boot; Enter says yes). Everything that needs you, including one consent for all missing packages, one sudo password, and the Tailscale login, happens before the build, so you can walk away while it compiles.
|
||||
- **How it's reachable, your choice.** **Tailscale** (loopback bind fronted by `tailscale serve`, so you get `https://<machine>.<tailnet>.ts.net` with a real certificate and your tailnet as the login, no password needed), **any device on your network** (`0.0.0.0`, with a strongly recommended password prompt), or **this machine only** (`127.0.0.1`, safest). Skipping the password on a network bind requires an explicit confirmation and ends with a loud warning. The highlighted default reflects what is already on the machine (Tailscale when it is already connected, your existing binding on a re-run), and a bare Enter never pulls in new software. If another app already owns `:443` on your node, Codeman goes under `https://<machine>.<tailnet>.ts.net/codeman` or on a second port instead of replacing it. A bare `codeman web` started by hand still defaults to loopback.
|
||||
- **The name is yours to choose.** By default the URL uses the machine's existing tailnet name. Answering yes to the second question renames the machine to `codeman-<hostname>` (which also renames it for SSH, so the default is no); `install.sh name` does it later.
|
||||
- **Re-run to update.** The same one-liner updates a finished install in place: local changes in `~/.codeman/app` are stashed (never discarded), and a running service is restarted and verified. If a first install was interrupted, re-running resumes the full setup instead. `install.sh status` prints the URLs and the QR code again; `install.sh update`, `install.sh tailscale` and `install.sh uninstall` also exist.
|
||||
- **Flags for the impatient.** `curl -fsSL https://getcodeman.com/install | bash -s -- --tailscale --service` answers the questions from the command line (`--lan`, `--local`, `--run`, `--no-start`, `--name <n>`, `--port <n>`, `--yes` too). **CI / headless:** without a terminal attached, steps that would change your system abort with instructions instead of running silently; set `CODEMAN_NONINTERACTIVE=1` to approve them for automation.
|
||||
|
||||
You'll need at least one AI coding CLI installed — [Claude Code](https://docs.anthropic.com/en/docs/claude-code), [OpenCode](https://opencode.ai), [Codex](https://developers.openai.com/codex/cli), [Antigravity](https://antigravity.google), [Gemini CLI](https://github.com/google-gemini/gemini-cli), [Pi](https://pi.dev), [Grok Build](https://github.com/xai-org/grok-build), [DeepSeek Harness](https://github.com/deepseek-ai/deepseek-harness), or [OMP](https://github.com/can1357/oh-my-pi) (any combination works; Gemini CLI is enterprise-only since Google's consumer cutover, and Antigravity is its successor). The installer detects whichever of the nine is present; if none is found, it offers to install any of them from a menu (DeepSeek excepted, since its npm package installs only a launcher with no runnable profile), or you can skip and install one yourself later. After install:
|
||||
|
||||
@@ -219,7 +220,7 @@ codeman web --https
|
||||
# Open on your phone: https://<your-ip>:3000
|
||||
```
|
||||
|
||||
> `localhost` works over plain HTTP. Use `--https` when accessing from another device, or use [Tailscale](https://tailscale.com/) (recommended): the installer can set it up for you (choose **Tailscale** at the network-access prompt, or run `bash ~/.codeman/app/install.sh tailscale` on an existing install). That gives you `https://<your-machine>.<tailnet>.ts.net` with a real certificate: private to your tailnet, no password required, and PWA install + push notifications work on your phone.
|
||||
> `localhost` works over plain HTTP. Use `--https` when accessing from another device, or use [Tailscale](https://tailscale.com/) (recommended): the installer can set it up for you (choose **Tailscale** at the network-access prompt, or run `bash ~/.codeman/app/install.sh tailscale` on an existing install). That gives you `https://<your-machine>.<tailnet>.ts.net` with a real certificate: private to your tailnet, no password required, and PWA install + push notifications work on your phone. The installer ends on that URL with a QR code to scan, and `bash ~/.codeman/app/install.sh status` prints it again any time.
|
||||
|
||||
### Secure QR Code Authentication
|
||||
|
||||
|
||||
@@ -28,7 +28,11 @@ export const BROWSER_TEST_GLOBS = [
|
||||
'test/terminal-copy-shortcut.test.ts',
|
||||
'test/terminal-keycode229-recovery.browser.test.ts',
|
||||
'test/capture-load-window.browser.test.ts',
|
||||
'test/capture-geometry-retry.browser.test.ts',
|
||||
'test/codex-predictive-echo.test.ts', // also needs a real codex binary
|
||||
'test/split-pane-terminal.browser.test.ts',
|
||||
'test/split-pane-orchestration.browser.test.ts',
|
||||
'test/split-pane-auto-collapse.browser.test.ts',
|
||||
];
|
||||
|
||||
/**
|
||||
|
||||
@@ -50,6 +50,14 @@ CODEMAN_USERNAME=admin
|
||||
# README.md, "Reverse-proxy host allowlist".
|
||||
# CODEMAN_ALLOWED_HOSTS=codeman.example.com,.internal.example.com
|
||||
|
||||
# The GitHub CLI (gh) and the Azure CLI (az, with the azure-devops extension)
|
||||
# can be built into the images as git credential helpers, so Codeman can clone
|
||||
# private GitHub and Azure DevOps repositories. Both are OFF by default and are
|
||||
# NOT set here: turn them on in docker-compose.override.yml with the build args
|
||||
# CODEMAN_INSTALL_GH / CODEMAN_INSTALL_AZ and, for the Docker-case agent image,
|
||||
# the environment variables CODEMAN_AGENT_IMAGE_INSTALL_GH / _AZ. See
|
||||
# README.md, "Private repositories".
|
||||
|
||||
# Optional: authenticate Gemini CLI without an interactive login.
|
||||
GEMINI_API_KEY=
|
||||
|
||||
|
||||
@@ -41,6 +41,94 @@ Releases that change `server.Dockerfile`, `docker-compose.yaml`, or add a key to
|
||||
changed, and asks you to run `Start-Codeman.sh` here on the host instead. Details:
|
||||
[`../docs/docker-self-update.md`](../docs/docker-self-update.md).
|
||||
|
||||
### Major updates
|
||||
|
||||
`Start-Codeman.sh` rebuilds the image on every start, but with the layer cache,
|
||||
and it refreshes the build-artefact volumes selectively: `codeman-dist` when
|
||||
the checkout's HEAD moved, `codeman-node-modules` only when `package-lock.json`
|
||||
changed. That is right for an ordinary `git pull`. It is not enough when a
|
||||
`server.Dockerfile` change bumps the Node base image without touching the
|
||||
lockfile: `node-pty` is compiled from source (there is no Linux prebuild), so
|
||||
the old `codeman-node-modules` volume would keep a build made for the previous
|
||||
Node version. For that case, or whenever you want to be certain of what ships,
|
||||
`docker/Update-Codeman.sh` force-rebuilds the image with no layer cache, stops
|
||||
the stack, removes the `codeman-node-modules` and `codeman-dist` volumes, then
|
||||
hands off to `Start-Codeman.sh` for the usual start:
|
||||
|
||||
```sh
|
||||
bash docker/Update-Codeman.sh
|
||||
```
|
||||
|
||||
Pass `--keep-volumes` to skip clearing them (safe only if you know the
|
||||
rebuilt image's `node_modules`/`dist` did not change). The scripted default
|
||||
is the "Resetting the build artefacts" procedure in
|
||||
[`../docs/docker-self-update.md`](../docs/docker-self-update.md). Only those
|
||||
two volumes are removed, by name within this Compose project; any volume a
|
||||
`docker-compose.override.yml` adds is left alone, and application data and
|
||||
case workspaces are host bind mounts, never touched either way.
|
||||
|
||||
## Private repositories (GitHub and Azure DevOps)
|
||||
|
||||
The images can include the GitHub CLI (`gh`) and the Azure CLI (`az`, with the `azure-devops` extension), wired into the system Git configuration as credential helpers, so Codeman can clone private repositories. Both are **opt-in and off by default**, and are turned on per host in `docker-compose.override.yml`.
|
||||
|
||||
### Turning them on
|
||||
|
||||
Add the build arguments to `docker-compose.override.yml` (see [Local customisation](#local-customisation)), then rebuild with `Start-Codeman.sh`. Set only the one you need:
|
||||
|
||||
```yaml
|
||||
services:
|
||||
codeman:
|
||||
build:
|
||||
args:
|
||||
CODEMAN_INSTALL_GH: '1'
|
||||
CODEMAN_INSTALL_AZ: '1'
|
||||
environment:
|
||||
# The same two switches for the Docker-case agent image Codeman builds.
|
||||
CODEMAN_AGENT_IMAGE_INSTALL_GH: '1'
|
||||
CODEMAN_AGENT_IMAGE_INSTALL_AZ: '1'
|
||||
```
|
||||
|
||||
The `build: args:` pair controls the Codeman server image. The `environment:` pair controls the agent image for [Docker cases](../docs/docker-cases.md), which Codeman builds on the first Docker case; an agent image that already exists is not rebuilt by this, so run `node scripts/build-agent-image.mjs --no-cache` inside the container afterwards. The same variables work in front of that command when building it by hand. Values must be `0` or `1`; anything else stops the build with an error naming the argument.
|
||||
|
||||
They are not `.env` settings: turning a CLI on is a per-host choice, which is what the override file is for, and a new `.env.example` key makes the in-app updater refuse to update every existing installation until its `.env` gains the key.
|
||||
|
||||
The Azure CLI is the large one, about 600 MB of the roughly 670 MB the pair adds. A CLI left off leaves nothing functional behind: no apt repository, no package, no `azure-devops` extension and no credential-helper entry, so git for that host behaves exactly as it does without this feature. With both off the image is functionally unchanged; it still carries the `AZURE_EXTENSION_DIR` variable, an empty extensions directory and one small layer that copies and then removes the helper script.
|
||||
|
||||
### Signing in
|
||||
|
||||
With a CLI on, the system Git configuration routes credentials through it:
|
||||
|
||||
| Host | Credential helper | Sign in with |
|
||||
| ----------------------------------------------------- | ----------------------------------------- | ---------------------------- |
|
||||
| `https://github.com`, `https://gist.github.com` | `gh auth git-credential` | `gh auth login` |
|
||||
| `https://dev.azure.com`, `https://*.visualstudio.com` | `/usr/local/bin/git-credential-azure-cli` | `az login --use-device-code` |
|
||||
|
||||
Codeman itself still collects no Git credentials. Sign the container in once from a **Terminal / Shell** session (Run menu). The session runs as the runtime account, so the sign-in is stored under `CODEMAN_APPDATA_PATH` (`~/.config/gh`, `~/.azure`) and survives rebuilds and container recreation:
|
||||
|
||||
```sh
|
||||
gh auth login # GitHub.com -> HTTPS -> "Login with a web browser" (device code)
|
||||
az login --use-device-code # then: az devops configure --defaults organization=https://dev.azure.com/<org>
|
||||
```
|
||||
|
||||
After that, **Add Case → Clone Repo** accepts private `https://` URLs on those hosts, and `git clone` works from any session. Until a CLI is signed in its helper prints nothing, so a private clone fails immediately with the usual authentication error rather than waiting on a prompt.
|
||||
|
||||
**Multi-user mode:** every Codeman user's git runs as the same server account, so these sign-ins would otherwise be shared. Clone Repo therefore runs a **non-admin**'s clone and preflight with every git credential helper cleared (`git -c credential.helper=`): a non-admin can clone public repositories and anything their own SSH setup allows, but not a private https repository through the admin's `gh`/`az` sign-in. Admins, and single-user mode, keep the helpers. A non-admin's own agent sessions still run as that same account, and with the agent-image `gh`/`az` switches on, a non-admin's Docker case with credential seeding on also receives the server account's `gh`/`az` sign-in, the same as the Claude and Codex credentials; see `docs/security-architecture.md`, multi-user mode.
|
||||
|
||||
Azure DevOps is authenticated with an Entra ID access token that the helper requests from `az` for each Git operation, so nothing is written to disk beyond `az`'s own sign-in. An account that has to use a personal access token can set `AZURE_DEVOPS_EXT_PAT` for the container instead (for example under `environment:` in `docker-compose.override.yml`); the helper prefers it when present. SSH remotes are unaffected by any of this and keep using the account's own keys.
|
||||
|
||||
Docker cases copy these sign-ins into a case container only when the matching agent-image switch is on (`CODEMAN_AGENT_IMAGE_INSTALL_GH=1` for `~/.config/gh/hosts.yml` and `config.yml`, `CODEMAN_AGENT_IMAGE_INSTALL_AZ=1` for the sign-in files from `~/.azure`) and the case has credential seeding on. With a switch off they are never copied, even when the files exist, because a GitHub token or an Azure refresh token is usable by anything in the container. The copies are made when the container is **created**, so an existing case container never picks them up: after turning a switch on, signing in, or rebuilding the agent image, **recreate the case container** (remove it; the next session in that case creates a fresh one).
|
||||
|
||||
The GitHub agent skill for `gh` installs into the runtime account's home in the same session:
|
||||
|
||||
```sh
|
||||
gh skill install cli/cli gh --scope user
|
||||
gh skill update gh # after a later gh release
|
||||
```
|
||||
|
||||
### Versions
|
||||
|
||||
Both CLIs, and the extension, are installed from their vendors' repositories with no version pinned, so they arrive at whatever is current when that build step runs. Docker caches the step, though: `Start-Codeman.sh` rebuilds with the cache, which keeps the versions from the first build until the Dockerfile changes at or above that step or the image is rebuilt with `--no-cache`. They are apt packages owned by root, so they cannot be upgraded from a session; `az extension update --name azure-devops` is the exception and works without a rebuild.
|
||||
|
||||
## Local customisation
|
||||
|
||||
Compose merges `docker-compose.override.yml` on top of `docker-compose.yaml`. Keep host-specific changes there rather than editing `docker-compose.yaml`, so this repository can be updated without losing them. Both `docker-compose.override.yml` and `docker-compose.override.yaml` are ignored by Git.
|
||||
|
||||
@@ -0,0 +1,255 @@
|
||||
#!/usr/bin/env bash
|
||||
#
|
||||
# The scripted major-update path for the Docker Compose deployment.
|
||||
#
|
||||
# docker/README.md and docs/docker-self-update.md both point operators here for
|
||||
# anything the in-app updater itself refuses to apply: a changed
|
||||
# `server.Dockerfile`, a changed `docker-compose.yaml`, or a new required
|
||||
# `.env.example` key. None of those can be applied by a container restarting
|
||||
# itself — a restart reuses the existing image and configuration (see "The
|
||||
# environment gate" in docs/docker-self-update.md) — so this script does the
|
||||
# three things an in-place update cannot: force a real image rebuild with no
|
||||
# layer cache, stop the stack, then hand off to Start-Codeman.sh for the same
|
||||
# careful PUID/PGID, override-file and fingerprint handling every other start
|
||||
# goes through.
|
||||
#
|
||||
# ⚠️ Build BEFORE stopping the stack, deliberately, same reasoning as
|
||||
# Start-Codeman.sh's own build-then-down ordering: the build needs nothing
|
||||
# stopped, so a slow --no-cache rebuild costs no downtime, and a build failure
|
||||
# (a bad Dockerfile edit, a network blip pulling a base image) leaves the
|
||||
# ALREADY-RUNNING stack untouched instead of stopped with nothing to bring it
|
||||
# back.
|
||||
#
|
||||
# ⚠️ Clears the codeman-node-modules/codeman-dist named volumes by DEFAULT.
|
||||
# Docker seeds a named volume from the image only while that volume is EMPTY,
|
||||
# so a rebuilt image's fresh node_modules/dist otherwise sit unused behind a
|
||||
# volume's old content and the container comes back up looking unchanged —
|
||||
# exactly wrong for a script whose whole point is "be certain of what ships".
|
||||
# Start-Codeman.sh clears codeman-dist when the checkout's HEAD moved and
|
||||
# codeman-node-modules only when `package-lock.json` changed. A released
|
||||
# server.Dockerfile change arrives through `git pull`, so HEAD moves and dist
|
||||
# is refreshed, but a Dockerfile change that bumps the Node base image leaves
|
||||
# the lockfile untouched while every native module (node-pty is compiled from
|
||||
# source, there is no Linux prebuild) has to be rebuilt against the new Node
|
||||
# ABI. Start-Codeman.sh would keep the old codeman-node-modules volume, and it
|
||||
# never builds with --no-cache. This script clears BOTH volumes, and ONLY
|
||||
# those two (targeted `docker volume rm` by Compose label, never
|
||||
# `down --volumes`, which would also take any volume an override file adds).
|
||||
# Pass --keep-volumes to opt out and reuse whatever is already in them.
|
||||
#
|
||||
# Usage: docker/Update-Codeman.sh [--keep-volumes]
|
||||
# --keep-volumes Do not clear codeman-node-modules/codeman-dist. Safe to
|
||||
# combine with a source change Start-Codeman.sh's own
|
||||
# detection would have cleared anyway; unsafe if the reason
|
||||
# you are here is a change to server.Dockerfile alone.
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
script_dir=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)
|
||||
env_file="$script_dir/.env"
|
||||
compose_file="$script_dir/docker-compose.yaml"
|
||||
|
||||
keep_volumes=0
|
||||
for arg in "$@"; do
|
||||
case "$arg" in
|
||||
--keep-volumes)
|
||||
keep_volumes=1
|
||||
;;
|
||||
--help | -h)
|
||||
printf 'Usage: bash %s [--keep-volumes]\n' "$0"
|
||||
exit 0
|
||||
;;
|
||||
*)
|
||||
printf 'Error: unrecognised argument: %s\n' "$arg" >&2
|
||||
printf 'Usage: bash %s [--keep-volumes]\n' "$0" >&2
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
done
|
||||
|
||||
if [[ ! -f "$env_file" ]]; then
|
||||
printf 'Error: Docker environment file is missing: %s\n' "$env_file" >&2
|
||||
printf 'Create it from %s/.env.example before running this script.\n' "$script_dir" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Same override-file discovery as Start-Codeman.sh, and deliberately kept in
|
||||
# step with it: a stack built here and started there must resolve to the exact
|
||||
# same Compose files, or this script's build could target a configuration the
|
||||
# handoff's own `up` never actually uses. Compose's own precedence (measured on
|
||||
# v5.5.0 with both present: it uses .yml and ignores .yaml).
|
||||
override_yml="$script_dir/docker-compose.override.yml"
|
||||
override_yaml="$script_dir/docker-compose.override.yaml"
|
||||
if [[ -f "$override_yml" && -f "$override_yaml" ]]; then
|
||||
printf 'Warning: both %s and %s exist; Compose uses .yml and ignores .yaml.\n' \
|
||||
"$override_yml" "$override_yaml" >&2
|
||||
fi
|
||||
compose_files=(-f "$compose_file")
|
||||
for override_file in "$override_yml" "$override_yaml"; do
|
||||
if [[ -f "$override_file" ]]; then
|
||||
compose_files+=(-f "$override_file")
|
||||
printf 'Using Compose override file: %s\n' "$override_file"
|
||||
break
|
||||
fi
|
||||
done
|
||||
compose_command=(docker compose --env-file "$env_file" "${compose_files[@]}")
|
||||
|
||||
# Collision guard. Start-Codeman.sh has no equivalent; this is the only one,
|
||||
# and it has to run before this script's own --no-cache build, `down` and
|
||||
# volume removal below. docker-compose.yaml hard-codes `name: codeman`, so a
|
||||
# second checkout run without COMPOSE_PROJECT_NAME resolves to the SAME Compose
|
||||
# project as any other checkout on the host and would operate on ITS
|
||||
# containers and volumes.
|
||||
#
|
||||
# The project name is read from the resolved config's top-level `name` key
|
||||
# (the first `name` in the output; nested ones come later), the same parse
|
||||
# Start-Codeman.sh uses. `--format json` needs Compose v2.3+. This is the first
|
||||
# `docker` call the script makes, so its failure is reported here rather than
|
||||
# left to `set -e`, which would exit with no output at all.
|
||||
if ! project_config=$("${compose_command[@]}" config --format json); then
|
||||
printf 'Error: `docker compose config --format json` failed (see the message above, if any).\n' >&2
|
||||
printf 'Check that Docker and Compose v2.3+ are installed and on PATH, and that\n' >&2
|
||||
printf '%s and the Compose files in %s are valid.\n' "$env_file" "$script_dir" >&2
|
||||
exit 1
|
||||
fi
|
||||
project_name=$(
|
||||
printf '%s\n' "$project_config" |
|
||||
sed -n 's/^[[:space:]]*"name":[[:space:]]*"\([^"]*\)".*$/\1/p' | head -n1
|
||||
)
|
||||
if [[ -n "$project_name" ]]; then
|
||||
# `|| true` on the pipeline's LAST command: under `set -o pipefail`, `grep -v`
|
||||
# exits 1 when nothing survives the filter — the ordinary, no-collision case,
|
||||
# since `docker ps` finds nothing at all on a first-ever deployment or a
|
||||
# single matching (own) working_dir gets filtered out. Without it, that exit
|
||||
# status propagates through the command substitution and `set -e` aborts the
|
||||
# WHOLE script right here, every time, regardless of whether a collision
|
||||
# actually exists — caught only by actually running this end-to-end (a
|
||||
# static text/regex check on the source cannot see it). The empty-line
|
||||
# filter keeps a container with no working_dir label from winning head -n1
|
||||
# and hiding a real collision behind it.
|
||||
other_working_dir=$(
|
||||
docker ps -a --filter "label=com.docker.compose.project=$project_name" \
|
||||
--format '{{.Label "com.docker.compose.project.working_dir"}}' 2>/dev/null |
|
||||
grep -v -F -x -- "$script_dir" | grep -v '^$' | head -n1 || true
|
||||
)
|
||||
if [[ -n "$other_working_dir" ]]; then
|
||||
printf 'Error: Compose project "%s" is already in use by a DIFFERENT checkout:\n' "$project_name" >&2
|
||||
printf ' %s\n' "$other_working_dir" >&2
|
||||
printf 'This checkout is:\n' >&2
|
||||
printf ' %s\n' "$script_dir" >&2
|
||||
printf '\n' >&2
|
||||
printf 'docker-compose.yaml hard-codes `name: %s`, so two checkouts on the same host\n' "$project_name" >&2
|
||||
printf 'collide unless each one sets a distinct COMPOSE_PROJECT_NAME. Continuing would\n' >&2
|
||||
printf 'rebuild and stop the OTHER checkout'"'"'s running container and, by default,\n' >&2
|
||||
printf 'delete its codeman-node-modules/codeman-dist volumes.\n' >&2
|
||||
printf '\n' >&2
|
||||
printf 'Fix: export COMPOSE_PROJECT_NAME=<something-unique-to-this-checkout> before\n' >&2
|
||||
printf 'running this script, then retry.\n' >&2
|
||||
printf '\n' >&2
|
||||
printf 'If instead THIS checkout was moved or renamed after its container was created,\n' >&2
|
||||
printf 'the path above is its own old location: remove the old container (for example\n' >&2
|
||||
printf '`docker rm -f <container>` for the codeman container) and retry, rather than\n' >&2
|
||||
printf 'setting COMPOSE_PROJECT_NAME, which would start a second project beside it.\n' >&2
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
|
||||
# Same owner-detection Start-Codeman.sh uses to derive PUID/PGID for its own
|
||||
# build — without it, the --no-cache build below gets Compose's untouched
|
||||
# default of 1000:1000, and on any host whose appdata owner differs (99:100 on
|
||||
# Unraid, per docker/README.md's chown example), Start-Codeman.sh's own
|
||||
# correctly-PUID'd build during the handoff then rebuilds those layers with the
|
||||
# right values anyway — so the "no cache, certain of what ships" image this
|
||||
# script produces is not the one that actually ends up running.
|
||||
#
|
||||
# Deliberately NOT the same as Start-Codeman.sh's own handling of a MISSING
|
||||
# appdata directory (which creates it): this script updates an EXISTING
|
||||
# deployment, so a missing appdata path means there is nothing here yet to
|
||||
# update, and creating one would just be this script quietly doing
|
||||
# Start-Codeman.sh's first-run job worse.
|
||||
appdata_path=$(
|
||||
"${compose_command[@]}" config --environment |
|
||||
awk -F= '$1 == "CODEMAN_APPDATA_PATH" { sub(/^[^=]*=/, ""); print; exit }'
|
||||
)
|
||||
if [[ -z "$appdata_path" || ! -d "$appdata_path" ]]; then
|
||||
printf 'Error: CODEMAN_APPDATA_PATH is not set or does not exist: %s\n' "${appdata_path:-<unset>}" >&2
|
||||
printf 'Run docker/Start-Codeman.sh first to set up a new deployment.\n' >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# `stat -c` is GNU, `stat -f` is BSD/macOS; the bind source lives on the Docker
|
||||
# host, so both need to work. Identical to Start-Codeman.sh's own helper.
|
||||
owner_of() {
|
||||
stat -c '%u:%g' -- "$1" 2>/dev/null || stat -f '%u:%g' "$1" 2>/dev/null
|
||||
}
|
||||
|
||||
if ! owner_ids=$(owner_of "$appdata_path"); then
|
||||
printf 'Error: Cannot determine the owner of CODEMAN_APPDATA_PATH: %s\n' "$appdata_path" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
export PUID=${owner_ids%%:*}
|
||||
export PGID=${owner_ids##*:}
|
||||
|
||||
if [[ "$PUID" == '0' ]]; then
|
||||
printf 'Error: CODEMAN_APPDATA_PATH is owned by root: %s\n' "$appdata_path" >&2
|
||||
printf 'Change the directory ownership to the unprivileged account that should run Codeman.\n' >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# --no-cache, always: a plain `build` reuses cached layers (npm install, apt
|
||||
# packages, the CLI installs baked into the image) and can silently keep them
|
||||
# frozen at whatever they were the day the cache was populated — exactly wrong
|
||||
# for a major update, whose whole point is being certain of what actually
|
||||
# ships. `scripts/build-agent-image.mjs` makes the same call for the same
|
||||
# reason (see its entry in CLAUDE.md's Additional Commands table). Runs BEFORE
|
||||
# the stack is stopped — see the header comment for why.
|
||||
printf 'Building a fresh image (--no-cache)...\n'
|
||||
"${compose_command[@]}" build --no-cache
|
||||
|
||||
printf 'Stopping the stack...\n'
|
||||
if [[ "$keep_volumes" == '1' || -n "$project_name" ]]; then
|
||||
"${compose_command[@]}" down
|
||||
else
|
||||
# No resolvable project name means the label filter below could match
|
||||
# nothing, so fall back to Compose's own removal, and say what it really does.
|
||||
printf 'Warning: could not resolve the Compose project name; clearing EVERY named volume\n' >&2
|
||||
printf 'in this Compose project (override file included) with `down --volumes` instead.\n' >&2
|
||||
"${compose_command[@]}" down --volumes
|
||||
fi
|
||||
|
||||
# Targeted removal of exactly the two build-artefact volumes, scoped by label to
|
||||
# THIS project (the volume key alone is shared by any other stack declaring the
|
||||
# same key). Same lookup as Start-Codeman.sh's refresh. A failure is reported,
|
||||
# not fatal: the stack is already down, and the handoff below is what brings
|
||||
# it back up.
|
||||
if [[ "$keep_volumes" != '1' && -n "$project_name" ]]; then
|
||||
printf 'Clearing the codeman-node-modules/codeman-dist volumes (pass --keep-volumes to skip).\n'
|
||||
for key in codeman-node-modules codeman-dist; do
|
||||
volume_name=$(
|
||||
docker volume ls -q \
|
||||
--filter "label=com.docker.compose.volume=$key" \
|
||||
--filter "label=com.docker.compose.project=$project_name" |
|
||||
head -n1
|
||||
) || volume_name=''
|
||||
if [[ -n "$volume_name" ]] && ! docker volume rm -- "$volume_name"; then
|
||||
printf 'Warning: could not remove volume %s; the container may keep serving the\n' "$volume_name" >&2
|
||||
printf 'previous build from it. Remove it by hand and rerun this script.\n' >&2
|
||||
fi
|
||||
done
|
||||
fi
|
||||
|
||||
# Start-Codeman.sh does everything a plain `up -d` does not: re-derives
|
||||
# PUID/PGID, pre-creates CODEMAN_CASES_PATH with the right ownership, resolves
|
||||
# DOCKER_SOCKET_GID, records the server.Dockerfile/docker-compose.yaml
|
||||
# fingerprint the in-app updater's gate reads on every future update, and
|
||||
# starts the (already freshly built) image. Reimplementing any of that here
|
||||
# would only risk drifting out of step with it — hand off instead, exactly as
|
||||
# docs/docker-self-update.md's own reset procedure does.
|
||||
#
|
||||
# ⚠️ `bash`, not a bare exec of the path: Start-Codeman.sh is committed
|
||||
# non-executable (100644), the same as this script, and is documented
|
||||
# everywhere as `bash docker/Start-Codeman.sh` rather than
|
||||
# `./docker/Start-Codeman.sh` — execing the bare path fails with EACCES.
|
||||
printf 'Handing off to Start-Codeman.sh...\n'
|
||||
exec bash "$script_dir/Start-Codeman.sh"
|
||||
@@ -26,6 +26,88 @@ RUN apt-get update \
|
||||
openssh-client \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# GitHub CLI and Azure CLI (+ the azure-devops extension) with the same system
|
||||
# git credential helpers as docker/server.Dockerfile, so an agent in a Docker
|
||||
# case can clone and push to private GitHub / Azure DevOps repositories. The
|
||||
# sign-ins themselves are NOT baked in: `~/.config/gh` and `~/.azure` are seeded
|
||||
# per container at launch like every other CLI's credentials (CRED_STORES in
|
||||
# src/docker-hosts.ts), and a helper whose CLI is not signed in prints nothing,
|
||||
# so git fails fast instead of prompting. See server.Dockerfile for why the
|
||||
# vendor apt repositories are configured here rather than via deb_install.sh.
|
||||
#
|
||||
# Each is OPT-IN and OFF by default, like the server image: CODEMAN_INSTALL_GH=1
|
||||
# / CODEMAN_INSTALL_AZ=1 turn one on; off leaves no repository, package,
|
||||
# extension or helper entry. scripts/build-agent-image.mjs and the in-app
|
||||
# auto-build pass them from CODEMAN_AGENT_IMAGE_INSTALL_GH / _AZ in their own
|
||||
# environment (for the Compose deployment: `environment:` in
|
||||
# docker-compose.override.yml), and pass nothing when those are unset, so
|
||||
# these defaults (off) apply.
|
||||
ARG CODEMAN_INSTALL_GH=0
|
||||
ARG CODEMAN_INSTALL_AZ=0
|
||||
RUN set -eux; \
|
||||
for flag in "CODEMAN_INSTALL_GH=${CODEMAN_INSTALL_GH}" "CODEMAN_INSTALL_AZ=${CODEMAN_INSTALL_AZ}"; do \
|
||||
case "${flag#*=}" in 0|1) ;; *) echo "${flag%%=*} must be 0 or 1, got '${flag#*=}'" >&2; exit 1;; esac; \
|
||||
done; \
|
||||
codename="$(. /etc/os-release && echo "${VERSION_CODENAME}")"; \
|
||||
arch="$(dpkg --print-architecture)"; \
|
||||
pkgs=""; \
|
||||
install -d -m 0755 /etc/apt/keyrings; \
|
||||
if [ "${CODEMAN_INSTALL_GH}" = 1 ]; then \
|
||||
curl -fsSL -o /etc/apt/keyrings/githubcli-archive-keyring.gpg \
|
||||
https://cli.github.com/packages/githubcli-archive-keyring.gpg; \
|
||||
chmod go+r /etc/apt/keyrings/githubcli-archive-keyring.gpg; \
|
||||
echo "deb [arch=${arch} signed-by=/etc/apt/keyrings/githubcli-archive-keyring.gpg] https://cli.github.com/packages stable main" \
|
||||
> /etc/apt/sources.list.d/github-cli.list; \
|
||||
pkgs="${pkgs} gh"; \
|
||||
fi; \
|
||||
if [ "${CODEMAN_INSTALL_AZ}" = 1 ]; then \
|
||||
curl -fsSL -o /etc/apt/keyrings/microsoft.asc \
|
||||
https://packages.microsoft.com/keys/microsoft.asc; \
|
||||
chmod go+r /etc/apt/keyrings/microsoft.asc; \
|
||||
echo "deb [arch=${arch} signed-by=/etc/apt/keyrings/microsoft.asc] https://packages.microsoft.com/repos/azure-cli/ ${codename} main" \
|
||||
> /etc/apt/sources.list.d/azure-cli.list; \
|
||||
pkgs="${pkgs} azure-cli"; \
|
||||
fi; \
|
||||
if [ -n "${pkgs}" ]; then \
|
||||
apt-get update; \
|
||||
apt-get install -y --no-install-recommends ${pkgs}; \
|
||||
rm -rf /var/lib/apt/lists/*; \
|
||||
fi; \
|
||||
if [ "${CODEMAN_INSTALL_GH}" = 1 ]; then gh --version; fi; \
|
||||
if [ "${CODEMAN_INSTALL_AZ}" = 1 ]; then az version --output none; fi
|
||||
|
||||
# Outside HOME so the seeded `~/.azure` (auth files only) never has to carry
|
||||
# extensions. gid 0 + group-writable, the same arbitrary-uid convention as HOME
|
||||
# below, so `az extension update` works as whatever uid the container runs as.
|
||||
# Created even without az; an empty directory costs nothing.
|
||||
ENV AZURE_EXTENSION_DIR=/opt/az-extensions
|
||||
RUN set -eux; \
|
||||
install -d -m 0755 "${AZURE_EXTENSION_DIR}"; \
|
||||
if [ "${CODEMAN_INSTALL_AZ}" = 1 ]; then \
|
||||
az extension add --name azure-devops --only-show-errors; \
|
||||
rm -rf /root/.azure; \
|
||||
fi; \
|
||||
chgrp -R 0 "${AZURE_EXTENSION_DIR}"; \
|
||||
chmod -R g=u "${AZURE_EXTENSION_DIR}"
|
||||
|
||||
# Only an installed CLI gets a helper entry (see server.Dockerfile).
|
||||
COPY docker/git-credential-azure-cli /usr/local/bin/git-credential-azure-cli
|
||||
RUN set -eux; \
|
||||
if [ "${CODEMAN_INSTALL_GH}" = 1 ]; then \
|
||||
for host in https://github.com https://gist.github.com; do \
|
||||
git config --system "credential.${host}.helper" '!/usr/bin/gh auth git-credential'; \
|
||||
done; \
|
||||
fi; \
|
||||
if [ "${CODEMAN_INSTALL_AZ}" = 1 ]; then \
|
||||
chmod 0755 /usr/local/bin/git-credential-azure-cli; \
|
||||
for host in https://dev.azure.com 'https://*.visualstudio.com'; do \
|
||||
git config --system "credential.${host}.helper" /usr/local/bin/git-credential-azure-cli; \
|
||||
git config --system "credential.${host}.useHttpPath" true; \
|
||||
done; \
|
||||
else \
|
||||
rm -f /usr/local/bin/git-credential-azure-cli; \
|
||||
fi
|
||||
|
||||
# The npm-published agent CLIs, supplied by scripts/build-agent-image.mjs from
|
||||
# config/clis.stock.json so a new stock CLI needs no edit here. The default is
|
||||
# today's literal list, so a bare `docker build` still produces the same image.
|
||||
|
||||
Executable
+34
@@ -0,0 +1,34 @@
|
||||
#!/bin/sh
|
||||
# Git credential helper for Azure DevOps, backed by the signed-in Azure CLI.
|
||||
#
|
||||
# Configured in the image's system gitconfig for https://dev.azure.com and
|
||||
# https://*.visualstudio.com (see server.Dockerfile). On `get` it answers with
|
||||
# an Entra ID access token for the Azure DevOps resource as the password, the
|
||||
# same token type Git Credential Manager uses for Azure Repos. It never prompts:
|
||||
# when `az` is not signed in it prints nothing, so git fails fast with its own
|
||||
# authentication error instead of hanging a request that has no terminal.
|
||||
#
|
||||
# AZURE_DEVOPS_EXT_PAT, the azure-devops extension's own PAT variable, is used
|
||||
# instead when it is set, for accounts that authenticate with a PAT.
|
||||
|
||||
# `store` and `erase` are no-ops: the token belongs to az, which refreshes it.
|
||||
[ "$1" = "get" ] || exit 0
|
||||
|
||||
# Drain the request git writes on stdin; the host scoping is in gitconfig.
|
||||
cat >/dev/null
|
||||
|
||||
if [ -n "${AZURE_DEVOPS_EXT_PAT:-}" ]; then
|
||||
printf 'username=pat\npassword=%s\n' "$AZURE_DEVOPS_EXT_PAT"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
command -v az >/dev/null 2>&1 || exit 0
|
||||
|
||||
# 499b84ac-1321-427f-aa17-267ca6975798 is the fixed application ID of Azure
|
||||
# DevOps: https://learn.microsoft.com/azure/devops/integrate/get-started/authentication/service-principal-managed-identity
|
||||
token="$(az account get-access-token \
|
||||
--resource 499b84ac-1321-427f-aa17-267ca6975798 \
|
||||
--query accessToken --output tsv 2>/dev/null)" || exit 0
|
||||
[ -n "$token" ] || exit 0
|
||||
|
||||
printf 'username=azure-cli\npassword=%s\n' "$token"
|
||||
+106
-1
@@ -68,6 +68,111 @@ COPY --from=docker:29-cli \
|
||||
/usr/local/libexec/docker/cli-plugins/docker-buildx \
|
||||
/usr/local/libexec/docker/cli-plugins/docker-buildx
|
||||
|
||||
# GitHub CLI and Azure CLI (with the azure-devops extension), so a user can sign
|
||||
# this container in to GitHub and Azure DevOps from a Codeman shell session and
|
||||
# then clone PRIVATE repositories, both from that session and through Add Case
|
||||
# -> Clone Repo. Codeman still collects no Git credentials itself: the clone
|
||||
# path (src/git-clone.ts) only inherits HOME and git's config, so whatever the
|
||||
# user signs in to here is what authenticates, and nothing when they have not
|
||||
# (the clone then fails fast with AUTH_REQUIRED, exactly as before).
|
||||
#
|
||||
# Each is OPT-IN and OFF by default: the image is functionally unchanged
|
||||
# unless the build gets CODEMAN_INSTALL_GH=1 and/or CODEMAN_INSTALL_AZ=1, which
|
||||
# a deployment sets under `build: args:` in docker-compose.override.yml
|
||||
# (docker/README.md, "Private repositories"). Off installs no apt repository,
|
||||
# package, extension or credential-helper entry; all that remains is the
|
||||
# AZURE_EXTENSION_DIR variable, its empty directory and one layer that copies
|
||||
# and then removes the helper script. The Azure CLI is the heavy one (~600 MB,
|
||||
# mostly its bundled Python). The base docker-compose.yaml
|
||||
# and .env deliberately do not carry them: turning a CLI on is a per-host
|
||||
# choice, which is what the override file is for, and a new .env.example key
|
||||
# would make the self-updater refuse existing installs until their .env gained
|
||||
# it (docs/docker-self-update.md).
|
||||
#
|
||||
# Both come from their vendors' own apt repositories, the same ones the
|
||||
# documented one-liners configure (https://github.com/cli/cli/blob/trunk/docs/install_linux.md
|
||||
# and https://learn.microsoft.com/cli/azure/install-azure-cli-linux?pivots=apt).
|
||||
# Microsoft's `deb_install.sh` is deliberately not piped into the build: it does
|
||||
# exactly this plus a `gnupg` install, and a remote script run at build time is
|
||||
# the one step a reviewer cannot read in this file. apt reads an ASCII-armoured
|
||||
# `.asc` key directly, which is what keeps `gnupg` out of the image.
|
||||
#
|
||||
# Not pinned, unlike the agent CLIs below: nothing in Codeman depends on a
|
||||
# particular gh or az behaviour, so the pinning argument there does not apply.
|
||||
# The layer cache still keeps whatever version the first build fetched until a
|
||||
# --no-cache rebuild.
|
||||
ARG CODEMAN_INSTALL_GH=0
|
||||
ARG CODEMAN_INSTALL_AZ=0
|
||||
RUN set -eux; \
|
||||
for flag in "CODEMAN_INSTALL_GH=${CODEMAN_INSTALL_GH}" "CODEMAN_INSTALL_AZ=${CODEMAN_INSTALL_AZ}"; do \
|
||||
case "${flag#*=}" in 0|1) ;; *) echo "${flag%%=*} must be 0 or 1, got '${flag#*=}'" >&2; exit 1;; esac; \
|
||||
done; \
|
||||
codename="$(. /etc/os-release && echo "${VERSION_CODENAME}")"; \
|
||||
arch="$(dpkg --print-architecture)"; \
|
||||
pkgs=""; \
|
||||
install -d -m 0755 /etc/apt/keyrings; \
|
||||
if [ "${CODEMAN_INSTALL_GH}" = 1 ]; then \
|
||||
curl -fsSL -o /etc/apt/keyrings/githubcli-archive-keyring.gpg \
|
||||
https://cli.github.com/packages/githubcli-archive-keyring.gpg; \
|
||||
chmod go+r /etc/apt/keyrings/githubcli-archive-keyring.gpg; \
|
||||
echo "deb [arch=${arch} signed-by=/etc/apt/keyrings/githubcli-archive-keyring.gpg] https://cli.github.com/packages stable main" \
|
||||
> /etc/apt/sources.list.d/github-cli.list; \
|
||||
pkgs="${pkgs} gh"; \
|
||||
fi; \
|
||||
if [ "${CODEMAN_INSTALL_AZ}" = 1 ]; then \
|
||||
curl -fsSL -o /etc/apt/keyrings/microsoft.asc \
|
||||
https://packages.microsoft.com/keys/microsoft.asc; \
|
||||
chmod go+r /etc/apt/keyrings/microsoft.asc; \
|
||||
echo "deb [arch=${arch} signed-by=/etc/apt/keyrings/microsoft.asc] https://packages.microsoft.com/repos/azure-cli/ ${codename} main" \
|
||||
> /etc/apt/sources.list.d/azure-cli.list; \
|
||||
pkgs="${pkgs} azure-cli"; \
|
||||
fi; \
|
||||
if [ -n "${pkgs}" ]; then \
|
||||
apt-get update; \
|
||||
apt-get install -y --no-install-recommends ${pkgs}; \
|
||||
rm -rf /var/lib/apt/lists/*; \
|
||||
fi
|
||||
|
||||
# The azure-devops extension goes into a SYSTEM directory rather than the
|
||||
# default ~/.azure/cliextensions: HOME is the application-data bind mount, which
|
||||
# hides anything installed there at build time. The directory is handed to the
|
||||
# runtime account below (next to /opt/codeman-cli) so `az extension update`
|
||||
# works from a session. Nothing that runs as root executes from it. It is
|
||||
# created even without az, so the chown below does not have to know.
|
||||
ENV AZURE_EXTENSION_DIR=/opt/codeman-az-extensions
|
||||
RUN set -eux; \
|
||||
install -d -m 0755 "${AZURE_EXTENSION_DIR}"; \
|
||||
if [ "${CODEMAN_INSTALL_AZ}" = 1 ]; then \
|
||||
az extension add --name azure-devops --only-show-errors; \
|
||||
rm -rf /root/.azure; \
|
||||
fi
|
||||
|
||||
# Git credential helpers, in the SYSTEM gitconfig so they apply to every
|
||||
# account and survive a fresh application-data directory. Each one answers only
|
||||
# for its own host and prints nothing when its CLI is not signed in, so git
|
||||
# falls through to its normal non-interactive failure. Only an installed CLI
|
||||
# gets an entry: a helper naming a missing binary would print an error on every
|
||||
# clone from that host.
|
||||
# github.com `gh auth git-credential`, what `gh auth setup-git` configures.
|
||||
# Azure DevOps an Entra ID token from `az login` (git-credential-azure-cli),
|
||||
# for both dev.azure.com and the legacy *.visualstudio.com hosts.
|
||||
COPY docker/git-credential-azure-cli /usr/local/bin/git-credential-azure-cli
|
||||
RUN set -eux; \
|
||||
if [ "${CODEMAN_INSTALL_GH}" = 1 ]; then \
|
||||
for host in https://github.com https://gist.github.com; do \
|
||||
git config --system "credential.${host}.helper" '!/usr/bin/gh auth git-credential'; \
|
||||
done; \
|
||||
fi; \
|
||||
if [ "${CODEMAN_INSTALL_AZ}" = 1 ]; then \
|
||||
chmod 0755 /usr/local/bin/git-credential-azure-cli; \
|
||||
for host in https://dev.azure.com 'https://*.visualstudio.com'; do \
|
||||
git config --system "credential.${host}.helper" /usr/local/bin/git-credential-azure-cli; \
|
||||
git config --system "credential.${host}.useHttpPath" true; \
|
||||
done; \
|
||||
else \
|
||||
rm -f /usr/local/bin/git-credential-azure-cli; \
|
||||
fi
|
||||
|
||||
# Keep credentials out of the image. Users authenticate these CLIs at runtime
|
||||
# through Codeman sessions, and the configured host bind mount retains state.
|
||||
#
|
||||
@@ -153,7 +258,7 @@ RUN set -eux; \
|
||||
--shell /bin/bash \
|
||||
"${CODEMAN_RUNTIME_USER}"; \
|
||||
fi; \
|
||||
chown -R "${PUID}:${PGID}" /opt/codeman-cli
|
||||
chown -R "${PUID}:${PGID}" /opt/codeman-cli /opt/codeman-az-extensions
|
||||
|
||||
WORKDIR /opt/codeman
|
||||
|
||||
|
||||
@@ -324,6 +324,30 @@ from the session's current state rather than requiring a new transition: the
|
||||
original turn may be long over. It comes back as
|
||||
`"delivered": false, "duplicate": true`.
|
||||
|
||||
**Wake-on-LAN hosts** (`docs/remote-sessions.md` §Wake-on-LAN): when the session's
|
||||
remote host has a wake target and is asleep, the non-wait form answers `200` with
|
||||
`{"buffered": true}` — the bytes are held and flushed after the host is back — or
|
||||
`{"buffered": true, "dropped": true}` for a chunk over the 4 KB wake buffer, which
|
||||
is gone (never delivered as a fragment). Both fields are additive to the historical
|
||||
bare `{}`. With `wait`, the route blocks on the wake instead and answers
|
||||
`422 OPERATION_FAILED` ("did not come back after a wake-on-LAN request — nothing was
|
||||
sent") when the host never returns, rather than writing into the stalled pane and
|
||||
reporting `delivered:true` plus a timeout.
|
||||
|
||||
Two endpoints back that flow directly, both scoped to one session's remote host and
|
||||
both refusing a session that is not remote (`400 INVALID_INPUT`):
|
||||
|
||||
| Method | Path | Purpose |
|
||||
| --- | --- | --- |
|
||||
| `GET` | `/api/sessions/:id/reachability` | Whether the session's remote host answers SSH right now, plus whether a wake target is configured. Read-only: it never wakes. `{"reachable": true\|false\|null, "wakeConfigured": "mac"\|"command"\|"none"}`, where `null` means the answer is unknown (a proxied host, where a TCP probe proves nothing). |
|
||||
| `POST` | `/api/sessions/:id/wake` | Wake the host and wait for it to accept SSH again, bounded by the request budget. `422 OPERATION_FAILED` when it does not come back; `400 INVALID_INPUT` with "No wake-on-LAN target configured for this host" when nothing is set. |
|
||||
|
||||
⚠️ Waking is deliberately reachable only from an explicit user action (this route, a
|
||||
session create/attach, or typing into a sleeping session). No watcher, dropped-session
|
||||
handler or boot-recovery path may wake a host, or a suspended machine would be woken
|
||||
again seconds after every suspend; `test/remote-wake.test.ts` pins that as an import
|
||||
fence around `src/remote-wake.ts`.
|
||||
|
||||
### Response
|
||||
|
||||
All three nest the wait result under `data.wait`, so one client helper works against
|
||||
@@ -558,6 +582,115 @@ All four enforce session ownership in multi-user mode; a foreign session id
|
||||
answers `404 NOT_FOUND` (no existence leak), and profiles of two owners of the
|
||||
same directory are distinct by construction.
|
||||
|
||||
## Custom Model Endpoints
|
||||
|
||||
Points a session's harness at a user-configured OpenAI-compatible endpoint —
|
||||
local (llama.cpp, vLLM, DGX Spark) or cloud (Azure AI Foundry, OpenRouter) —
|
||||
instead of its native cloud backend, gated by the opt-in
|
||||
`customModelEndpointsEnabled` setting (default OFF). Endpoints are
|
||||
machine-level infra, like remote/docker hosts: writes are admin-only in
|
||||
multi-user mode. Design: [`custom-model-endpoints-plan.md`](custom-model-endpoints-plan.md);
|
||||
user guide: [`custom-model-endpoints.md`](custom-model-endpoints.md).
|
||||
|
||||
- `GET /api/v1/model-endpoints` -> `CustomModelHost[]`, an unwrapped bare
|
||||
array like every other list route (still riding the standard `{success,
|
||||
data}` envelope on the wire — unwrap it the same way). Answers `[]` for a
|
||||
non-admin in multi-user mode. `apiKey` is never returned; `apiKeySet:
|
||||
boolean` reports whether one is stored, so a client can render "unchanged
|
||||
if left blank" without ever holding the real value.
|
||||
- `POST /api/v1/model-endpoints` with `{ id, label, baseUrl, apiKey?,
|
||||
authStyle?, defaultModelId? }` creates one. `id` must match
|
||||
`^[a-zA-Z0-9_-]+$`; `authStyle` is `bearer` (default) or `api-key`, never
|
||||
both (a real server hung indefinitely when sent both headers on one
|
||||
request); `baseUrl` must be `http(s)`, carry no embedded credentials, and
|
||||
is refused if it points at (or resolves to) a link-local or
|
||||
cloud-metadata address. `409 ALREADY_EXISTS` on a duplicate id.
|
||||
- `PUT /api/v1/model-endpoints/:id` updates one. An **absent** `apiKey`
|
||||
keeps the stored one rather than clearing it — the client never receives
|
||||
the real value to resend deliberately unchanged, so omission is the only
|
||||
way to say "leave it alone"; there is no way to clear a key back to unset
|
||||
this way. `defaultModelId`, when set, must be one of that endpoint's own
|
||||
`models` (`400 INVALID_INPUT` otherwise).
|
||||
- `DELETE /api/v1/model-endpoints/:id` removes one.
|
||||
- `POST /api/v1/model-endpoints/:id/discover-models` fetches the endpoint's
|
||||
own `GET /v1/models` and stores the result as `models`, updating
|
||||
`lastDiscoveredAt`, plus (best-effort, only for a model llama-swap's own
|
||||
response already reports loaded) `modelContextLengths` and `modelSizesGB`.
|
||||
A `defaultModelId` that no longer appears in the fresh list is dropped
|
||||
rather than carried forward invalid. Failures answer `422 OPERATION_FAILED`
|
||||
with the underlying connection error, or a named egress refusal if the
|
||||
resolved address turned out to be blocked. The same refresh also runs
|
||||
automatically for every saved endpoint every 5 minutes in the background
|
||||
(`refreshAllCustomModelHosts()`, `custom-model-routes.ts`, started from
|
||||
`server.ts`), so there is no route for triggering "refresh all" — one
|
||||
endpoint being unreachable on a cycle never blocks the others.
|
||||
- `GET /api/v1/model-endpoints/:id/running-status` -> `{ isLlamaSwap,
|
||||
running: [{model, state}], logLine? }`, read-only, no admin gate
|
||||
(any session owner who could already point a session at this endpoint can
|
||||
equally ask what it currently has loaded). `isLlamaSwap` is
|
||||
feature-detected via the endpoint's own `GET /running` — a plain
|
||||
llama.cpp/OpenAI-compatible server has none and always answers `false`.
|
||||
`logLine`, present only when `isLlamaSwap` is true, is the most recent
|
||||
REAL backend `llama-server` process log line (`load_model: ...`,
|
||||
`llama_server: model loaded`, etc.), sourced from the endpoint's own
|
||||
`GET /api/events` SSE stream and filtered to `source: "upstream"` frames
|
||||
only (never llama-swap's own `source: "proxy"` request-access log) — one
|
||||
connection is held open per endpoint and reused across every poller,
|
||||
idle-closed after 30s of nobody asking. This is what the Run-menu
|
||||
picker's loading banner polls once a second while a model is loading.
|
||||
- `POST /api/v1/sessions/:id/custom-model` with `{ endpointId, modelId,
|
||||
confirmed? } | { clear: true }` applies (or clears) the session's
|
||||
selection and **restarts the session's CLI process in place** — every
|
||||
supported harness reads its endpoint config at process start, never per
|
||||
turn, so there is no live hot-swap. (`POST /api/v1/quick-start`'s own
|
||||
`customModel: { endpointId, modelId, confirmed? }` field is the
|
||||
no-restart equivalent for a session that doesn't exist yet — see below.)
|
||||
A Claude session resumes its existing conversation across the restart;
|
||||
pi/omp/grok additionally get a forced `--model`/`-m` value, since for
|
||||
those three the config file alone does not select it. `400 INVALID_INPUT`
|
||||
for a remote (SSH) or Docker session — both restart their agent
|
||||
differently under the hood, and applying to one would report success
|
||||
while changing nothing. Two more responses replace the normal
|
||||
`{customModel, restarted}` shape, neither an error, and neither restarts
|
||||
or creates anything on the first ask. ⚠️ **Each is answered by its OWN
|
||||
flag on the retry, and answering one is not consent to the other**: they
|
||||
are questions about different people, and while they shared a single flag
|
||||
a caller who confirmed the context warning silently agreed to evict
|
||||
another session's model as well. Send `confirmedContext: true` to proceed
|
||||
past the context warning, `confirmedSwap: true` past the swap conflict,
|
||||
and both when both were asked (they accumulate, so the second retry still
|
||||
carries the first answer). The original `confirmed: true` still means
|
||||
BOTH and is still accepted, because it shipped in this feature's
|
||||
HTTP-API-only cut; new callers should send the specific one:
|
||||
- `{requiresConfirmation: true, currentlyLoadedModel, affectedSessions}` —
|
||||
llama.cpp/llama-swap only runs one model at a time, and switching would
|
||||
unload a model another **live session's own selection** is actively
|
||||
using. Never returned for a plain (non-llama-swap) server, and never
|
||||
just because a swap is needed at all — only when it would disrupt
|
||||
someone else.
|
||||
- `{requiresContextWarning: true, modelId, contextLength,
|
||||
minSafeContextTokens}` — Claude Code's own fixed per-turn overhead
|
||||
(system prompt + tool schemas) can exceed a small model's entire
|
||||
discovered context on its own, before any conversation history exists
|
||||
to compact, guaranteeing the very first message fails regardless of
|
||||
`CLAUDE_CODE_MAX_CONTEXT_TOKENS`. Gated on the CLI registry declaring a
|
||||
`contextLengthVar` (claude only today), so it never fires for another
|
||||
harness.
|
||||
- `POST /api/v1/quick-start`'s `customModel: { endpointId, modelId,
|
||||
confirmed?, confirmedContext?, confirmedSwap? }` field (alongside its
|
||||
normal `caseName`/`mode`/etc. body)
|
||||
computes the same injection **before** the session exists and launches
|
||||
directly on the endpoint — no restart, because there was never a
|
||||
native-backend boot to restart away from. Runs the identical checks as
|
||||
the dedicated route above (`requiresConfirmation`/`requiresContextWarning`,
|
||||
same shapes, same per-question `confirmedContext`/`confirmedSwap` retry),
|
||||
and is refused the same way
|
||||
for a remote or Docker case. This is what the Run-menu picker uses for
|
||||
opencode, Codex, Gemini, Pi, Grok, DeepSeek and OMP; Claude still uses the
|
||||
dedicated restart route above (its `--resume`-based restart is far less
|
||||
jarring than a full relaunch, and folding it into the one-shot path is
|
||||
separate work — see `docs/custom-model-endpoints-plan.md`).
|
||||
|
||||
## Voice dictation
|
||||
|
||||
Browser dictation transcribed through this server's Claude Code login, i.e. the
|
||||
|
||||
File diff suppressed because one or more lines are too long
+42
-3
@@ -36,7 +36,8 @@ interface CliEntry {
|
||||
launch: CliLaunch; // the structured argv template
|
||||
env: CliEnv; // exports, tmux setenv keys, the env-override allowlist
|
||||
capabilities: CliCapabilities; // what every call site reads instead of the id
|
||||
// .workDetect?: { promptGlyph, workingLine } — how this CLI's pane shows work
|
||||
// .workDetect?: { promptGlyph, workingLine, watchingLine?, watchingLines? } — how
|
||||
// this CLI's pane shows work, and how it shows work it started in the background
|
||||
overlays: CliOverlays; // remote-SSH / Docker pane commands, credential store
|
||||
}
|
||||
```
|
||||
@@ -45,10 +46,46 @@ interface CliEntry {
|
||||
|
||||
### Regexes that come from config
|
||||
|
||||
Two capability fields carry a regular expression an override file can set: `discovery.version.regex` and `capabilities.workDetect.workingLine`. Both go through `compileVersionRegex()`, which caps the source at 200 characters, refuses the nested-quantifier shapes that cause catastrophic backtracking, and returns `null` rather than throwing so every caller degrades instead of crashing.
|
||||
Three capability fields carry a regular expression an override file can set: `discovery.version.regex`, `capabilities.workDetect.workingLine` and `capabilities.workDetect.watchingLine`. All three go through `compileVersionRegex()`, which caps the source at 200 characters, refuses the nested-quantifier shapes that cause catastrophic backtracking, and returns `null` rather than throwing so every caller degrades instead of crashing.
|
||||
|
||||
`workingLine` is the one that matters most, because it is compiled once per session and then run against every accumulated PTY chunk and every pane capture. A nested quantifier there is a ReDoS against the event loop for the whole server, not just that session. The guard therefore runs in two places, and neither is redundant: `schema.ts` rejects the entry at LOAD time so a bad pattern never reaches a session, and `_workingLinePattern()` in `session.ts` compiles through the same helper so the runtime cannot end up with a pattern the schema would have refused.
|
||||
|
||||
`watchingLine` reads a different row of the same screen. A CLI draws it while work the agent
|
||||
itself started is still running — Claude prints `⏵⏵ bypass permissions on · 1 monitor · ← for
|
||||
agents` while a monitor, a backgrounded shell or a cloud session is live. Codeman turns that
|
||||
into `Session.watching`, and an idle prompt from such a session opens already acknowledged,
|
||||
so a pane waiting for its own background work never raises an alert a human cannot answer.
|
||||
Group 1 is the label, and a CLI that declares no pattern reports no background work.
|
||||
|
||||
Two CLIs declare such a row today, and they put it in different places. Claude writes its
|
||||
chip on the last row of the screen, so it keeps the default one-row window and anchors on
|
||||
the `·` its footer joins items with. Codex pins
|
||||
`1 background terminal running · /ps to view · /stop to close` ABOVE its composer, which
|
||||
puts the row third from the bottom once the status line and the composer are counted, so its
|
||||
entry declares `watchingLines: 3` and matches that row end to end. Both were measured
|
||||
against live panes rather than read out of a binary, which is the standard for adding a
|
||||
third.
|
||||
|
||||
That label is the one value in the registry that an AGENT can influence, because it comes off
|
||||
the agent's own screen. Two things keep it honest, and both belong to whoever adds a pattern
|
||||
for a new CLI. `watchingLabel()` in `session-activity.ts` searches only the last few
|
||||
non-blank rows, which should be the part of the screen the CLI draws rather than the agent,
|
||||
and the pattern should anchor on chrome only that CLI can produce. Keep the window as small
|
||||
as the layout allows, since every row it adds is another row the agent may be able to write.
|
||||
The label is also ANSI-stripped and length-capped at the source, and every interpolation of
|
||||
it into markup goes through `escapeHtml()`, since it ends up on a badge and in an approval
|
||||
card.
|
||||
|
||||
The two shipped entries do not sit equally well behind that rule, and the difference decides
|
||||
what a pattern is allowed to do. Claude's chip is the last row, so its one-row window holds
|
||||
nothing the agent can write — not even the status line above it, whose command a session
|
||||
running with permissions bypassed can write into its own `.claude/settings.json`. Codex's row
|
||||
shares its slot with the last row of the transcript whenever no terminal is running, so a
|
||||
message ending in that exact line is matched. What keeps that harmless is `hooks: 'none'`: no
|
||||
hook event from a codex session reaches the approvals inbox, so a forged label costs a wrong
|
||||
badge and cannot silence an alert. Before giving a CLI both hook signals and a pattern, make
|
||||
sure its row is one the agent cannot write.
|
||||
|
||||
### Three capabilities that must stay independent
|
||||
|
||||
`external`, `hooks` and `altScreen` describe three different, deliberately unequal sets, and deriving any one from another has already shipped a bug. `shell` has no hooks but is **not** an external CLI, so a hooks predicate written as `!isExternalCliMode()` accepted `until=stop` on a shell session and then blocked the caller for their entire timeout. `deepseek` is the mirror image: it IS external and it DOES have hooks.
|
||||
@@ -102,6 +139,8 @@ It matches four shapes, not one: `mode === '<id>'`, `mode !== '<id>'`, `case '<i
|
||||
|
||||
The allowlist is not a formality. If a branch is about what a CLI can DO it belongs in `CliCapabilities`; the entries that remain are things that are not CLI-behaviour branches at all — chiefly the legacy per-mode `<Mode>Config` objects on `POST /api/sessions`, which are a fact about the public HTTP API rather than about any CLI, plus a few documented cases where `mode === 'claude'` is genuinely the right question (Read My Mind reads Claude's _own_ transcript, so a capability there would be actively wrong).
|
||||
|
||||
`test/frontend-cli-no-id-branching.test.ts` is the same guard for the two frontend files the CLI registry's Run-menu consolidation touches, `session-ui.js` and `mobile-overview.js` — deliberately not the rest of `src/web/public/`, whose per-CLI rules stay out of scope for now (see "Fields declared for later" below). Its allowlist keys on `<file>::<expression>` with no line number, since a single unrelated edit to a contended file would otherwise shift every subsequent line and make every entry go stale at once, and each entry additionally carries the exact number of approved call sites — a bare key would let a brand-new branch reusing an already-approved expression land unreviewed. Its comparison shape differs from the backend guard's in one respect: the left-hand side may be any identifier, not only one named `mode`, `id` or `agentType`, because the review of #458 found `const m = this._runMode; if (m === 'codex')` slipping past the named form while the scanned file already filters with `(m) => m !== 'shell'`.
|
||||
|
||||
## Two namespaces called `param`
|
||||
|
||||
`launch.params` keys, `env.configSetenv[].fromParam` and `capabilities.privilegedParams[].param` all name a **launch param**. The **legacy wire field** a param arrives as is a separate namespace, and `launch.legacyConfigAliases` is the only bridge between the two.
|
||||
@@ -112,7 +151,7 @@ This matters because it is invisible when it is wrong. `capabilities.privilegedP
|
||||
|
||||
`shortBadge`, `accent`, `capabilities.echo`, `capabilities.wheelForward`, `capabilities.keyboardAccessory` and `capabilities.maxFrameBytes` are **declared but not yet read**. They all describe frontend behaviour, and the frontend is deliberately untouched here: `app.js`, `terminal-ui.js` and `styles.css` keep their own hand-authored per-CLI rules, and moving them is its own piece of work verified by a browser/mobile suite the CI gate cannot see.
|
||||
|
||||
Treat those values as **transcribed, not authoritative** — nothing enforces that `echo.policy` matches `_updateLocalEchoState`'s fallthrough, or that `accent` matches the gradient CSS paints, so re-measure before wiring one up. A field that is both wrong and unread is worse than an absent one, because the next reader trusts it; `test/cli-registry-no-id-branching.test.ts` pins the list so it cannot quietly grow, and wiring one up makes its line there fail, which is the direction you want.
|
||||
Treat those values as **transcribed, not authoritative** — nothing enforces that `echo.policy` matches `_updateLocalEchoState`'s fallthrough, so re-measure before wiring one up. `accent` is the one exception: it was measured against styles.css on 2026-09-21 (method in the comment above `CLAUDE` in `stock.ts`), though nothing keeps it in step with the CSS either. A field that is both wrong and unread is worse than an absent one, because the next reader trusts it; `test/cli-registry-no-id-branching.test.ts` pins the list so it cannot quietly grow, and wiring one up makes its line there fail, which is the direction you want.
|
||||
|
||||
`overlays.credStore` is in the same category, for a sharper reason: the Docker credential-seeding path still reads its own `CRED_STORES` table, because this shape allows ONE store per CLI and the live table needs two for gemini (`.gemini` for the CLI's own auth plus `.config/gcloud` for Vertex), while deepseek declares none here even though `.dsh` is seeded. Wiring it means making the field an array and correcting those two entries — a change to credential seeding, which is simultaneously the worst thing here to get wrong and the least covered by tests, since every docker IO path is no-op'd under vitest.
|
||||
|
||||
|
||||
@@ -104,17 +104,17 @@ declared capability, never an `if (mode === 'claude')` branch.
|
||||
|
||||
## Per-CLI injection recipes (confidence-ranked)
|
||||
|
||||
| CLI | Mechanism | Confidence |
|
||||
| ------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `claude` | Env vars: `ANTHROPIC_BASE_URL`, `ANTHROPIC_API_KEY`, `ANTHROPIC_DEFAULT_SONNET_MODEL`/`_HAIKU_MODEL`/`_OPUS_MODEL` (all set to the chosen model/deployment name) | **Verified end-to-end** against a real llama-swap server — a real "hello world" reply came back. ⚠️ Non-interactive (`-p`) invocations also fire an async session-title-generation call that reuses `ANTHROPIC_DEFAULT_HAIKU_MODEL` and validates it against Claude Code's OWN internal recognized-model list, printing `[claude-code:unrecognized_model]` and, in `-p` mode, hanging the whole invocation rather than just warning. `--settings '{"autoTitle":false}'` does NOT stop this (confirmed); `--bare` does (the warning still prints, but the real prompt runs) — but `--bare` ALSO disables hooks, LSP, plugin sync, and CLAUDE.md auto-discovery, so it is only safe for the standalone one-shot test script, NEVER for a real interactive Codeman session (which depends on hooks for idle detection, trust-dialog auto-accept, etc. — see the External CLI modes section of CLAUDE.md). Whether an INTERACTIVE claude session with a custom model hits the same hang (vs. just a background warning) is untested and should be checked before calling chunk 5/6 done for claude |
|
||||
| `opencode` | `OPENCODE_CONFIG_CONTENT` env var (already a registry mechanism, `stock.ts:342`) holding a JSON blob: `{"provider":{"custom":{"options":{"baseURL":...,"apiKey":...},"models":{"<name>":{}}}},"model":"custom/<name>"}` | **Verified by user** |
|
||||
| `codex` | TOML `config.toml`: top-level `model = "<id>"` + `[model_providers.custom]` (`base_url`, `env_key` naming an env var the real API key rides in — never a literal TOML field, since codex's schema has no such field). Written to an isolated dir via `CODEX_HOME` (`stock.ts:405-415`) so the user's own `~/.codex/config.toml` is never touched | **Config STRUCTURE verified** against a real codex binary (an earlier `[model].default` table shape was rejected: "invalid type: map, expected a string" — caught live). **Protocol CONFIRMED BROKEN against llama.cpp/llama-swap**: codex only speaks the Responses API (`wire_api = "responses"`, the only value it accepts since it dropped `"chat"` support in Feb 2026), and a real llama-swap server does not implement `/v1/responses` — a live run against it failed with repeated `Reconnecting...` then `high demand` errors. Codex support therefore needs a Responses-API-compatible endpoint (most local llama.cpp/Ollama/vLLM setups do not qualify); do not present this as working against a generic OpenAI-Chat-Completions box |
|
||||
| `gemini` | Env vars `GOOGLE_GEMINI_BASE_URL` + `GEMINI_API_KEY` + `GEMINI_MODEL`; CLI needs a restart to pick them up | **Confirmed BROKEN against llama.cpp/llama-swap, unresolved after real investigation.** Setting `GOOGLE_GEMINI_BASE_URL` makes gemini-cli internally select an `AuthType.GATEWAY` auth path (undocumented — inferred from behaviour) with validation requirements distinct from every normal auth mode; a real run against llama-swap fails with `Invalid auth method selected` regardless of what key/format is supplied. Tried and all failed: a Google-format dummy API key, `GOOGLE_GENAI_USE_VERTEXAI=false`, a `GEMINI_DEFAULT_AUTH_TYPE` override, and hand-writing `settings.json` directly. `--skip-trust` was a real, separate fix (without it a trust-folder check silently overrides `--approval-mode yolo` back to `default`) but does not touch this auth failure. Documented as an open gap, not shipped as working — the registry entry and injection code exist and are exercised by the test script, but end-to-end gemini support needs upstream investigation of `GATEWAY` AuthType before it can be called done |
|
||||
| `pi` | Config file `~/.pi/agent/models.json` with a custom provider whose `models` is an **array** of `{id}` objects (not an object keyed by id) plus `authHeader: true`. Redirected via the child process's own `HOME` env var, isolated per test/session — **not** `PI_CONFIG_DIR`, which does nothing for pi (grepped pi's entire bundled JS source: the string appears nowhere) | **Verified end-to-end** against a real llama-swap server — real "hello world" reply came back. Two real bugs found and fixed before this worked: (1) `PI_CONFIG_DIR` is not read by pi at all — pi hardcodes `~/.pi/agent/models.json` with no dedicated override, so the actual redirect has to be the child process's `HOME`; (2) `models` must be an array of `{id}` objects per pi's own bundled `docs/models.md`, not an object keyed by model id (silently loaded zero models). Also requires an explicit `--model custom/<id>` on invocation — without it pi falls back to its own default provider and fails with "No API key found for the selected model" |
|
||||
| `grok` | TOML `config.toml`: a fixed `[model.codeman-custom]` block (`base_url`, `env_key` naming an env var the key rides in, never a literal TOML field) written to an isolated dir via `GROK_HOME`. Invoked with `-m codeman-custom` | **Verified end-to-end** against a real llama-swap server — real "hello world" reply came back. The ORIGINAL recipe in this table (env vars `GROK_BASE_URL`/`XAI_API_KEY`/`GROK_MODEL`) was flat-out **wrong**, not just unverified: it produced "Not signed in" against a real binary. Grok's real mechanism, confirmed against xAI's own docs and a live binary, is a `config.toml` with a `[model.<name>]` block, redirected via `GROK_HOME`; the key still rides as an env var (`XAI_API_KEY` via `env_key`), just referenced from the TOML rather than read directly |
|
||||
| `deepseek` | Reuse the **existing** `DEEPSEEK_BASE_URL` + `DEEPSEEK_API_KEY` keys (already declared in `stock.ts`). Only `DEEPSEEK_BASE_URL` is in `privilegedEnvKeys` — `DEEPSEEK_API_KEY` deliberately stays clamp-exempt, since a non-granted owner supplying their OWN key removes privilege rather than granting it (adding it to the clamp list was a real regression, caught by `test/deepseek-mode.test.ts` and fixed before merge). No model-selection var — dsh model is a profile composition entry, not a flag/env var | **Confirmed reaching the server, but failing — unresolved.** A real run against llama-swap returns `dsh: HTTP_404: DeepSeek API error (HTTP 404)` consistently (confirmed the env vars are read: the request reaches the network rather than failing locally). Root cause not identified — plausible explanation by analogy with codex's Responses-API gap is that `dsh --profile headless` expects DeepSeek's official API response shape/path structure rather than a generic OpenAI-compatible `/v1/chat/completions` endpoint, but this was not confirmed by reading dsh's own bundled source (unlike pi/grok, where that grep resolved the question directly). Documented as best-effort/unknown, not shipped as verified working |
|
||||
| `omp` | Config file `~/.omp/agent/models.yml` with the same array-shaped `models` + `authHeader: true` fix as pi. Redirected via `HOME`, same reasoning as pi (`PI_CONFIG_DIR` does not relocate omp's config either, despite an earlier CLAUDE.md note claiming it does) | **Verified end-to-end** against a real llama-swap server — real "hello world" reply came back, after applying the same two fixes as pi (array-shaped `models`, `HOME`-redirect instead of `PI_CONFIG_DIR`) plus an explicit `--model custom/<id>` on invocation. Unverified against omp's own official docs (none are bundled in the install), but empirically confirmed working live |
|
||||
| `antigravity` | No CLI/env/config mechanism found — Antigravity's docs describe only a GUI settings panel, and explicitly say a custom endpoint "cannot currently" become the core reasoning model. **Not implemented**; toolbar entry stays disabled for this mode with an explanatory tooltip | No known mechanism |
|
||||
| CLI | Mechanism | Confidence |
|
||||
| ------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `claude` | Env vars: `ANTHROPIC_BASE_URL`, `ANTHROPIC_API_KEY`, `ANTHROPIC_DEFAULT_SONNET_MODEL`/`_HAIKU_MODEL`/`_OPUS_MODEL` (all set to the chosen model/deployment name) | **Verified end-to-end** against a real llama-swap server — a real "hello world" reply came back. ⚠️ Non-interactive (`-p`) invocations also fire an async session-title-generation call that reuses `ANTHROPIC_DEFAULT_HAIKU_MODEL` and validates it against Claude Code's OWN internal recognized-model list, printing `[claude-code:unrecognized_model]` and, in `-p` mode, hanging the whole invocation rather than just warning. `--settings '{"autoTitle":false}'` does NOT stop this (confirmed); `--bare` does (the warning still prints, but the real prompt runs) — but `--bare` ALSO disables hooks, LSP, plugin sync, and CLAUDE.md auto-discovery, so it is only safe for the standalone one-shot test script, NEVER for a real interactive Codeman session (which depends on hooks for idle detection, trust-dialog auto-accept, etc. — see the External CLI modes section of CLAUDE.md). Whether an INTERACTIVE claude session with a custom model hits the same hang (vs. just a background warning) is untested and should be checked before calling chunk 5/6 done for claude |
|
||||
| `opencode` | `OPENCODE_CONFIG_CONTENT` env var (already a registry mechanism, `stock.ts:342`) holding a JSON blob: `{"provider":{"custom":{"options":{"baseURL":...,"apiKey":...},"models":{"<name>":{}}}},"model":"custom/<name>"}` | **Verified by user** |
|
||||
| `codex` | TOML `config.toml`: top-level `model = "<id>"` + `[model_providers.custom]` (`base_url`, `env_key` naming an env var the real API key rides in — never a literal TOML field, since codex's schema has no such field). Written to an isolated dir via `CODEX_HOME` (`stock.ts:405-415`) so the user's own `~/.codex/config.toml` is never touched | **Config STRUCTURE verified** against a real codex binary (an earlier `[model].default` table shape was rejected: "invalid type: map, expected a string" — caught live). **Protocol picture more nuanced than a flat break, re-verified live twice on 2026-09-17 against a llama-swap deployment that DOES answer `/v1/responses`** (an earlier test's `Reconnecting...`/`high demand` failure does not reproduce against every llama-swap setup): a plain, no-tool-call chat turn (`codex exec 'reply with just OK'`) returned a real reply. But a real tool-call attempt (`run the shell command: echo hello`) came back as an `agent_message` TEXT item — the tool-call JSON printed as the model's answer, not a `function_call` item codex would actually execute (confirmed via `codex exec --json`'s raw event stream: `item.completed`/`agent_message`, never `function_call`). Since tool execution is what makes codex a coding agent at all, this remains **not usable for real work**, just with a different, more specific failure mode than previously documented — still do not present this as working. Separately, EVERY custom-endpoint codex session also prints `warning: Model metadata for '<id>' not found. Defaulting to fallback metadata...` on launch (confirmed harmless — the successful plain-text reply above still had it): codex's per-model metadata (reasoning tiers, system-prompt templates, context-window figures) comes from `models_cache.json`, a LOCAL CACHE of OpenAI's own hosted model catalog that a custom model can never appear in by construction. No config.toml override exists for it, and the isolated `CODEX_HOME` never gets a `models_cache.json` written into it at all (confirmed: inspected a live, actively-used isolated dir — codex evidently can't reach OpenAI's catalog endpoint for this session and just falls back silently every time, with no file left behind to fix or clean up). Fabricating a fake catalog entry to suppress the warning would mean copying the _shape_ of OpenAI's own proprietary schema — including their real per-model system-prompt content, visible in a genuine `models_cache.json` — for a warning confirmed to have no effect on the actual (broken) tool-calling outcome; not worth building |
|
||||
| `gemini` | Env vars `GOOGLE_GEMINI_BASE_URL` + `GEMINI_API_KEY` + `GEMINI_MODEL`; CLI needs a restart to pick them up | **Confirmed BROKEN against llama.cpp/llama-swap, unresolved after real investigation.** Setting `GOOGLE_GEMINI_BASE_URL` makes gemini-cli internally select an `AuthType.GATEWAY` auth path (undocumented — inferred from behaviour) with validation requirements distinct from every normal auth mode; a real run against llama-swap fails with `Invalid auth method selected` regardless of what key/format is supplied. Tried and all failed: a Google-format dummy API key, `GOOGLE_GENAI_USE_VERTEXAI=false`, a `GEMINI_DEFAULT_AUTH_TYPE` override, and hand-writing `settings.json` directly. `--skip-trust` was a real, separate fix (without it a trust-folder check silently overrides `--approval-mode yolo` back to `default`) but does not touch this auth failure. Documented as an open gap, not shipped as working — the registry entry and injection code exist and are exercised by the test script, but end-to-end gemini support needs upstream investigation of `GATEWAY` AuthType before it can be called done |
|
||||
| `pi` | Config file `~/.pi/agent/models.json` with a custom provider whose `models` is an **array** of `{id}` objects (not an object keyed by id) plus `authHeader: true`. Redirected via the child process's own `HOME` env var, isolated per test/session — **not** `PI_CONFIG_DIR`, which does nothing for pi (grepped pi's entire bundled JS source: the string appears nowhere) | **Verified end-to-end** against a real llama-swap server — real "hello world" reply came back. Two real bugs found and fixed before this worked: (1) `PI_CONFIG_DIR` is not read by pi at all — pi hardcodes `~/.pi/agent/models.json` with no dedicated override, so the actual redirect has to be the child process's `HOME`; (2) `models` must be an array of `{id}` objects per pi's own bundled `docs/models.md`, not an object keyed by model id (silently loaded zero models). Also requires an explicit `--model custom/<id>` on invocation — without it pi falls back to its own default provider and fails with "No API key found for the selected model" |
|
||||
| `grok` | TOML `config.toml`: a fixed `[model.codeman-custom]` block (`base_url`, `env_key` naming an env var the key rides in, never a literal TOML field) written to an isolated dir via `GROK_HOME`. Invoked with `-m codeman-custom` | **Verified end-to-end** against a real llama-swap server — real "hello world" reply came back. The ORIGINAL recipe in this table (env vars `GROK_BASE_URL`/`XAI_API_KEY`/`GROK_MODEL`) was flat-out **wrong**, not just unverified: it produced "Not signed in" against a real binary. Grok's real mechanism, confirmed against xAI's own docs and a live binary, is a `config.toml` with a `[model.<name>]` block, redirected via `GROK_HOME`; the key still rides as an env var (`XAI_API_KEY` via `env_key`), just referenced from the TOML rather than read directly |
|
||||
| `deepseek` | Reuse the **existing** `DEEPSEEK_BASE_URL` + `DEEPSEEK_API_KEY` keys (already declared in `stock.ts`), now with `appendV1Suffix: true` (see confidence). Only `DEEPSEEK_BASE_URL` is in `privilegedEnvKeys` — `DEEPSEEK_API_KEY` deliberately stays clamp-exempt, since a non-granted owner supplying their OWN key removes privilege rather than granting it (adding it to the clamp list was a real regression, caught by `test/deepseek-mode.test.ts` and fixed before merge). No model-selection var — dsh model is a profile composition entry, not a flag/env var | **Root cause of the original `HTTP_404` found and fixed, by reading dsh's own bundled source — the same bar pi/grok's fixes were held to.** Installed `@deepseek-ai/dsh` (all its real published dependencies) into a scratch directory purely to read `@deepseek-ai/dsh-llm-deepseek/lib/index.js`: it builds its request as `fetch(\`${connection.baseURL}/chat/completions\`, ...)`with`baseURL`read straight from`DEEPSEEK_BASE_URL`(or defaulting to DeepSeek's real public API root,`https://api.deepseek.com`, which also carries no `/v1`) — no `/v1` insertion of dsh's own, unlike the OpenAI-SDK convention this recipe originally assumed. llama-swap/llama.cpp only ever serves the OpenAI-conventional `/v1/chat/completions`. Confirmed live: `POST <baseUrl>/chat/completions` → `404`, `POST <baseUrl>/v1/chat/completions` → `200`, on the exact same endpoint — and dsh's own error-message template, `DeepSeek API error (HTTP ${status})`, reproduces the originally reported `dsh: HTTP_404: DeepSeek API error (HTTP 404)` precisely. Fixed by adding `appendV1Suffix` (env kind only, deepseek's entry alone — claude/gemini must NOT get it, since claude was already confirmed working against the unmodified `baseUrl`), which runs `endpoint.baseUrl` through the same `withV1Suffix()` helper `configDir`-kind CLIs already use. ⚠️ Not yet re-run end-to-end with a real `dsh` binary — no install available in this environment (no npm-installed CLI binary in `PATH`, and the `codeman-test-picker` container doesn't bundle it either); the fix is source-confirmed and live-verified at the HTTP level, but a genuine "hello world" reply through `dsh` itself is the remaining step before promoting this to **verified** alongside claude/opencode/pi/grok/omp |
|
||||
| `omp` | Config file `~/.omp/agent/models.yml` with the same array-shaped `models` + `authHeader: true` fix as pi. Redirected via `HOME`, same reasoning as pi (`PI_CONFIG_DIR` does not relocate omp's config either, despite an earlier CLAUDE.md note claiming it does) | **Verified end-to-end** against a real llama-swap server — real "hello world" reply came back, after applying the same two fixes as pi (array-shaped `models`, `HOME`-redirect instead of `PI_CONFIG_DIR`) plus an explicit `--model custom/<id>` on invocation. Unverified against omp's own official docs (none are bundled in the install), but empirically confirmed working live |
|
||||
| `antigravity` | No CLI/env/config mechanism found — Antigravity's docs describe only a GUI settings panel, and explicitly say a custom endpoint "cannot currently" become the core reasoning model. **Not implemented**; toolbar entry stays disabled for this mode with an explanatory tooltip | No known mechanism |
|
||||
|
||||
Everything web-researched-but-unverified gets implemented but must be
|
||||
smoke-tested against real installs of those CLIs before being called done —
|
||||
@@ -208,6 +208,14 @@ extra per-model configuration on Codeman's side at all.
|
||||
|
||||
### 4. Toolbar UI
|
||||
|
||||
> **Superseded.** This section describes the toolbar-button design as originally
|
||||
> planned. What actually shipped is a Run-menu picker instead: one generated entry
|
||||
> per (capable harness, saved endpoint) pair directly in the existing `#runModeMenu`
|
||||
> dropdown, rather than a separate `#customModelBtn`/`#customModelMenu` surface. See
|
||||
> [`docs/custom-model-endpoints.md`](custom-model-endpoints.md#the-run-menu-picker)
|
||||
> for the current design; the sections below (session-restart mechanics, security)
|
||||
> remain accurate regardless of which UI calls the underlying route.
|
||||
|
||||
- New header/toolbar button (e.g. `#customModelBtn`, `btn-toolbar
|
||||
btn-custom-model`), marker-hidden by default (`btn-custom-model--hidden`)
|
||||
and revealed by `applyHeaderVisibilitySettings()` only when
|
||||
@@ -344,8 +352,12 @@ pure unit tests and the live manual checks in Verification:
|
||||
up automatically with zero edits to the script). Already run to
|
||||
completion against the author's llama-swap server (a LAN address,
|
||||
inside a `codeman/agent:llm-test` Docker image with all 9 CLI binaries):
|
||||
claude/opencode/pi/grok/omp **PASS**, codex **FAILs as expected**
|
||||
(Responses-API protocol gap, not a bug), gemini/deepseek **UNCONFIRMED**
|
||||
claude/opencode/pi/grok/omp **PASS**, codex **partially works and still
|
||||
isn't usable** (plain chat succeeds against a llama-swap deployment that
|
||||
answers `/v1/responses`, but a real tool-call attempt comes back as
|
||||
inert text rather than an executable `function_call` — see the
|
||||
confidence table row for the full, re-verified picture), gemini/deepseek
|
||||
**UNCONFIRMED**
|
||||
(reach the server, fail for undiagnosed reasons — see their table rows),
|
||||
antigravity **SKIP** (no mechanism). Re-run this against a real cloud
|
||||
endpoint (e.g. an Azure AI Foundry deployment) once one is available, to
|
||||
|
||||
+434
-18
@@ -11,20 +11,22 @@ company gateway) — anything answering `GET /v1/models` and
|
||||
recipe confidence table, and security reasoning:
|
||||
[`custom-model-endpoints-plan.md`](custom-model-endpoints-plan.md).
|
||||
|
||||
> **Status**: backend is implemented and tested (registry capability, the
|
||||
> injection engine, the endpoint store + discovery route, the session
|
||||
> restart route). The toolbar picker / settings UI described below as the
|
||||
> intended surface is **not yet built** — until it lands, use the HTTP API
|
||||
> directly (examples below). Antigravity has no known custom-endpoint
|
||||
> mechanism and is not supported.
|
||||
> **Status**: fully wired end to end — registry capability, the injection
|
||||
> engine, the endpoint store + discovery route, both the restart-in-place
|
||||
> apply route (Claude) and the one-shot quick-start launch path (every
|
||||
> other supported harness), a settings-panel CRUD surface, and the Run-menu
|
||||
> picker described below. Antigravity has no known custom-endpoint
|
||||
> mechanism and is not supported. The HTTP API (examples below) still works
|
||||
> directly and is what the picker itself calls under the hood.
|
||||
|
||||
## Turning it on
|
||||
|
||||
App Settings → Agents & CLIs → **Custom Model Endpoints** (synced setting
|
||||
`customModelEndpointsEnabled`, default **OFF**). Until the toolbar picker
|
||||
lands, nothing reads this setting: the HTTP routes below work whether it is
|
||||
on or off, and it exists now only so the picker has a switch to hang off
|
||||
when it ships. The API equivalent:
|
||||
App Settings → Models → **Custom model endpoints** (synced setting
|
||||
`customModelEndpointsEnabled`, default **OFF**). Turning it on does two
|
||||
things: it reveals the endpoint list/add/edit/discover panel in that same
|
||||
settings section, and it makes the Run menu offer a generated entry per
|
||||
(harness, endpoint) pair — see "The Run-menu picker" below. The API
|
||||
equivalent:
|
||||
|
||||
```bash
|
||||
curl -sk -X PUT https://localhost:3000/api/settings \
|
||||
@@ -34,6 +36,9 @@ curl -sk -X PUT https://localhost:3000/api/settings \
|
||||
|
||||
## Adding an endpoint
|
||||
|
||||
Via App Settings → Models → Custom model endpoints → **+ Add endpoint**, or
|
||||
directly:
|
||||
|
||||
```bash
|
||||
curl -sk -X POST https://localhost:3000/api/model-endpoints \
|
||||
-H 'Content-Type: application/json' \
|
||||
@@ -62,7 +67,201 @@ configured, `PUT`/`DELETE /api/model-endpoints/:id` update or remove one.
|
||||
Endpoint management is admin-only in multi-user mode, same as remote/docker
|
||||
hosts — these are machine-level infra, not per-user settings.
|
||||
|
||||
## Applying a model to a session
|
||||
**Context length is discovered too, opportunistically and safely.** The plain
|
||||
`GET /v1/models` response has no context-window field. Discovery only ever
|
||||
looks for one for a model llama-swap's own response already reports
|
||||
`status.value === "loaded"` for — never for an unloaded one, because
|
||||
llama-swap treats `?model=` as a routing hint and asking about a model that
|
||||
isn't loaded risks triggering an actual (slow, GPU-swapping) load as a side
|
||||
effect of what should be read-only discovery. A server with no `status` field
|
||||
on any entry at all (not llama-swap) gets no context-length enrichment,
|
||||
rather than guessing. A model's previously-learned context length survives a
|
||||
later cycle where it wasn't the loaded one; it's dropped only once the model
|
||||
disappears from the endpoint's list entirely. Stored per model in
|
||||
`modelContextLengths` and applied automatically (see "Applying a model to a
|
||||
session" below) so a CLI that would otherwise assume a large default context
|
||||
window for an unrecognized model id stops silently overflowing a much
|
||||
smaller real one.
|
||||
|
||||
**Where that number actually comes from matters, and got this wrong once
|
||||
already.** The first cut read it from llama.cpp's own
|
||||
`GET /props?model=<id>` (`n_ctx`) — plausible, and it worked in testing, but
|
||||
confirmed live to be actively WRONG for a `--fit-ctx`-launched llama-swap
|
||||
backend: `/props` reported `n_ctx: 154112` for a model llama-swap itself had
|
||||
launched with `--fit-ctx 16384`, and the real server then refused a request
|
||||
right at that real 16384-token limit — `/props`'s `n_ctx` appears to report
|
||||
the model's theoretical/trained maximum there, not the runtime-configured
|
||||
one. Discovery now parses the REAL configured size straight out of
|
||||
llama-swap's own launch command instead (`GET /running`'s `cmd` field —
|
||||
`--fit-ctx <N>` first, then the plain llama.cpp `-c`/`--ctx-size` a
|
||||
hand-written command might use), and only falls back to the `/props` probe
|
||||
when `cmd` states no recognizable flag at all.
|
||||
|
||||
**File size is discovered too, when the server states one.** llama-swap
|
||||
writes a GB figure into an auto-discovered model's own `description`
|
||||
(`"Auto-discovered 16.35 GB - parameters auto-fitted by llama.cpp"`), parsed
|
||||
into `modelSizesGB` — unlike context length, this needs no `/props` probe
|
||||
(the figure is right there in the `/v1/models` response) and so is populated
|
||||
for every model regardless of loaded state. A hand-configured profile's own
|
||||
description has no such figure and correctly gets no entry, never a guess.
|
||||
Used only to label the Run-menu picker's "loading model" banner (e.g.
|
||||
"Loading qwen3.8-27b-ud-q4_k_xl (16.4 GB) on llama-swap..."); never anything
|
||||
a server-side check relies on.
|
||||
|
||||
**The loading banner is unbounded by design, and says so — no countdown, no
|
||||
automatic give-up.** An earlier version scaled an expected-time estimate and
|
||||
a timeout off the model's file size and auto-closed the session once that
|
||||
elapsed, but a real load's actual duration depends on hardware this feature
|
||||
has no way to know (VRAM, storage speed, whatever else is contending for the
|
||||
GPU) — any fixed number was a guess dressed up as a fact, and a model that
|
||||
genuinely takes 10+ minutes on slower hardware would just get killed
|
||||
mid-load by its own display. The banner now says outright that it can take a
|
||||
while depending on hardware and model size, polls
|
||||
`GET /api/model-endpoints/:id/running-status` every second for as long as it
|
||||
takes, and carries a **Cancel** button (rendered on the banner itself) that
|
||||
ends the wait and closes the session the load was for — the user's own call
|
||||
on when it's taking too long, not a fixed number baked into the client.
|
||||
|
||||
**The banner's second line is the real backend log line, not a guess.**
|
||||
llama-swap's `GET /api/events` SSE stream carries the actual `llama-server`
|
||||
process's own stdout — `load_model: loading model '<path>'`,
|
||||
`llama_server: model loaded`, tokenizer warnings, all of it — tagged
|
||||
`source: "upstream"`, distinct from llama-swap's own `source: "proxy"`
|
||||
request-access lines. `running-status`'s response now includes `logLine`
|
||||
(via `getLatestLlamaSwapLogLine`), and the banner shows it on its own line
|
||||
under the disclaimer, e.g. "llama.cpp: load_model: loading model '...'" —
|
||||
confirmed live end-to-end through a real forced swap, sequentially showing
|
||||
the model path, a tokenizer warning, then staying on whatever llama.cpp last
|
||||
printed once the load goes quiet (never cleared back to blank). ⚠️
|
||||
**`GET /logs` — the endpoint this feature's own first cut was built
|
||||
against — turns out to carry ONLY llama-swap's own proxy request-access
|
||||
log.** Confirmed live it never showed a single backend line, even seconds
|
||||
after a real, verified model swap; `/api/events`'s `logData` frames are the
|
||||
only source that actually has it, and its own `source` field (`upstream` vs
|
||||
`proxy`) is what `getLatestLlamaSwapLogLine` filters on. One `/api/events`
|
||||
connection is held open per endpoint and reused across every session
|
||||
watching a load on it (confirmed live to stay open indefinitely, unlike
|
||||
`/logs`, which closes after a fixed ~100KB), idle-closed after 30s of nobody
|
||||
polling it (`pruneIdleLlamaSwapLogTails`, same 20s sweep as the
|
||||
swap-displacement check below).
|
||||
|
||||
`defaultModelId` names which discovered model the picker pre-marks for that
|
||||
endpoint — the settings panel's Edit form exposes it as a select populated
|
||||
from the endpoint's own discovered `models`, and the route refuses a value
|
||||
that isn't one of them. It is applied automatically only when the endpoint
|
||||
has exactly one discovered model (nothing to choose); with two or more it
|
||||
is a pre-selection in the model-picker dialog below, never a silent default.
|
||||
Re-discovering drops a default that no longer appears in the fresh list
|
||||
rather than carrying an invalid one forward.
|
||||
|
||||
**Model lists refresh themselves.** A background sweep (`server.ts`,
|
||||
`CUSTOM_MODEL_REDISCOVER_INTERVAL_MS`, every 5 minutes) re-discovers every
|
||||
saved endpoint the same way the manual `POST .../discover-models` route
|
||||
does, best-effort per endpoint — one being unreachable on a given cycle
|
||||
never blocks the others. Off under `npm test`, same reasoning as the Codex
|
||||
plan-usage poll it sits beside: no real network to hit, no server instance
|
||||
to keep the timer alive for.
|
||||
|
||||
## The Run-menu picker
|
||||
|
||||
With the setting on and at least one endpoint carrying a discovered model,
|
||||
the toolbar's Run dropdown grows a **Custom Endpoints** section: one entry
|
||||
per (harness that can redirect to a custom endpoint, saved endpoint) pair,
|
||||
e.g. "Claude Code (llama.cpp)". The harness list is read off the CLI
|
||||
registry's own `capabilities.customModelInjection` at page render
|
||||
(`window.__codemanCustomModelClis`, `server.ts`) — never a hardcoded id list
|
||||
in the frontend — so a CLI whose injection recipe lands later shows up with
|
||||
no frontend change, and Antigravity (`unsupported`) never does.
|
||||
|
||||
Picking an entry re-fetches the endpoint (`selectCustomModelEntry()`,
|
||||
`session-ui.js`) rather than trusting anything cached from the dropdown's
|
||||
own render — the model list can have changed via the 5-minute sweep above
|
||||
or a settings-panel edit since the menu opened. With exactly one discovered
|
||||
model it runs straight away; with two or more, a small modal
|
||||
(`#customModelPickModal`) lists them and asks which one to use for this
|
||||
launch, with the endpoint's `defaultModelId` marked but not auto-chosen —
|
||||
the point of asking is letting one launch deliberately differ from the
|
||||
saved default, not just confirming it.
|
||||
|
||||
The modal promotes exactly one row to the top of the list rather than
|
||||
always showing raw discovery order, so the zero-wait choice is the one
|
||||
under your thumb:
|
||||
|
||||
- **"Currently loaded"** — a model from this host's own list that
|
||||
llama-swap reports `ready` right now, queried via
|
||||
`GET /api/model-endpoints/:id/running-status`. Bounded client-side to
|
||||
~800ms (`Promise.race`), on top of the route's own 5s server-side
|
||||
timeout, so an endpoint that is asleep or firewalled cannot leave the
|
||||
modal invisible for the full 5s after the Run menu has already closed.
|
||||
- **"Last used"** — shown only when nothing is currently loaded: the model
|
||||
actually launched last for this exact (harness, endpoint) pair, read
|
||||
from the per-device `codeman:customModelLastUsed:<mode>:<endpointId>`
|
||||
localStorage key. Written by `_runCustomModelEntryViaRestart` (claude)
|
||||
and `_quickStartWithCustomModelConfirm` (every one-shot launch; the
|
||||
`runCustomModelEntry` entry point itself only dispatches between the
|
||||
two) only once the model is actually applied, never on the mere click —
|
||||
declining the context-window warning means this exact model cannot work
|
||||
with this CLI at all, so promoting it next time would be actively wrong,
|
||||
not just premature.
|
||||
|
||||
Neither tag reorders anything past that one promoted row. The "Default"
|
||||
pill is a separate span, not a third value of the same slot: a promoted
|
||||
row that is also the endpoint's `defaultModelId` shows both tags (on a
|
||||
single-purpose GPU box that is the common case, and an exclusive slot
|
||||
silently dropped the Default marking for exactly that row), and a row
|
||||
with neither promotion nor default shows no tag at all.
|
||||
|
||||
**How the launch itself applies the endpoint depends on the harness.** For
|
||||
opencode, Codex, Gemini, Pi, Grok, DeepSeek and OMP (`runCustomModelEntry` →
|
||||
`_runCustomModelEntryOneShot`), the endpoint/model is folded into the SAME
|
||||
`POST /api/quick-start` call that creates the session (`customModel` field),
|
||||
so the session launches directly on the endpoint — no restart, no visible
|
||||
relaunch. Claude (`_runCustomModelEntryViaRestart`) still uses the original
|
||||
two-step design: the launch runs a single native session exactly the way its
|
||||
own Run-menu entry would, then **waits for the new session to go idle**
|
||||
(`GET .../wait?until=idle`, bounded at 20s — a normal 200 either way, never
|
||||
an error, per the wait endpoint's own contract) before applying the endpoint
|
||||
via the restart route below. That wait exists because a freshly launched CLI
|
||||
reports itself as `busy` for its own startup (a boot spinner, a
|
||||
workspace-trust check) well before the apply call would otherwise reach it,
|
||||
and the apply route correctly refuses to restart a session mid-turn — a
|
||||
fresh boot looks exactly like one from the outside. A session still busy
|
||||
after the wait reaches the apply call anyway and gets that route's own
|
||||
honest `SESSION_BUSY` error, now visible as a sticky toast with a close
|
||||
button rather than a generic message that vanished in three seconds. Claude
|
||||
stays on this path because its own restart (`--resume`-based, keeping the
|
||||
conversation) is far less jarring than the other seven's, and `runClaude()`'s
|
||||
multi-tab launch and docker-config-drift confirm/retry loop make folding it
|
||||
into the one-shot path separate work. It is a
|
||||
one-off "try this endpoint" action, not a sticky mode: the plain Run button
|
||||
still means "this harness, native cloud" afterward. Entries are hidden
|
||||
entirely for a remote or Docker active case, since the apply route refuses
|
||||
both (see the next section).
|
||||
|
||||
## Launching directly on an endpoint (no restart)
|
||||
|
||||
```bash
|
||||
curl -sk -X POST https://localhost:3000/api/quick-start \
|
||||
-H 'Content-Type: application/json' \
|
||||
-d '{"caseName": "myapp", "mode": "codex", "customModel": {"endpointId": "llama-box", "modelId": "qwen3"}}'
|
||||
```
|
||||
|
||||
`POST /api/quick-start`'s `customModel` field (`{endpointId, modelId,
|
||||
confirmed?}`) computes the same injection the restart route below does, but
|
||||
BEFORE the session exists — the session is minted its own id up front
|
||||
(`crypto.randomUUID()`), the injection (env vars, and for a `configDir`-kind
|
||||
CLI, the written config file) targets that real id, and the session launches
|
||||
already pointed at the endpoint. No restart, because there was never a
|
||||
native-backend launch to restart away from. Runs the same llama-swap
|
||||
conflict check as the restart route (below) — a `409`-shaped
|
||||
`{requiresConfirmation, currentlyLoadedModel, affectedSessions}` response
|
||||
with no session created, resolved by retrying with `confirmedSwap: true` — and
|
||||
is refused the same way for a remote or Docker case. This is what the
|
||||
Run-menu picker uses for opencode, Codex, Gemini, Pi, Grok, DeepSeek and OMP;
|
||||
Claude still uses the restart route below (see "The Run-menu picker" above
|
||||
for why).
|
||||
|
||||
## Applying a model to an ALREADY-RUNNING session
|
||||
|
||||
```bash
|
||||
curl -sk -X POST https://localhost:3000/api/sessions/<sessionId>/custom-model \
|
||||
@@ -84,6 +283,179 @@ since for those three the config file alone does not switch the model.
|
||||
reattaches the durable remote/in-container tmux rather than relaunching the
|
||||
agent, so the selection would report success and change nothing.
|
||||
|
||||
**Claude gets two more env vars when known/applicable, both declared on its
|
||||
registry entry (`contextLengthVar`/`configDirVar`), not hardcoded here:**
|
||||
|
||||
- `CLAUDE_CODE_MAX_CONTEXT_TOKENS` is set to `modelId`'s discovered context
|
||||
length (see the discovery section above) whenever one is known. Without
|
||||
it, Claude Code assumes a large (200k) window for any unrecognized custom
|
||||
model id and never compacts, which reliably overflows a much smaller real
|
||||
local context — confirmed live: a stock ~33.7K-token system prompt against
|
||||
a 16384-token llama-swap model failed with `exceeds the available context
|
||||
size`. No entry for the model in `modelContextLengths` means the var is
|
||||
simply omitted, never a guess. ⚠️ **This var only affects when Claude
|
||||
Code compacts conversation _history_ — it cannot fix a model whose real
|
||||
context is smaller than Claude Code's own fixed per-turn overhead**
|
||||
(system prompt + tool schemas, empirically ~36.4K tokens, confirmed live
|
||||
via an `in:0 out:0` failure on the very first message, before any
|
||||
history exists to compact). No context-length declaration changes that
|
||||
fixed overhead, so a model below the safe floor fails outright on
|
||||
message one regardless of what this var says. See "Context-window floor
|
||||
warning" below for how Codeman catches this case before launching
|
||||
instead of after.
|
||||
- `CLAUDE_CONFIG_DIR` is pointed at the same isolated per-session directory
|
||||
the `configDir`-kind CLIs use (empty, no files written into it), so the
|
||||
injected `ANTHROPIC_API_KEY` never shares a directory with a stored
|
||||
claude.ai OAuth login. Claude Code still prints "Both claude.ai and
|
||||
ANTHROPIC_API_KEY set" when the two coexist in the same config directory —
|
||||
cosmetic (confirmed live: the API key wins for actual requests either way,
|
||||
visible in the terminal's own `API Usage Billing` line) but worth
|
||||
eliminating rather than living with. The directory's `projects`
|
||||
subdirectory is symlinked (a junction on Windows) back to the real
|
||||
`~/.claude/projects` so the response viewer, subagent windows and Read My
|
||||
Mind keep working for that session — the same trade-off and fix documented
|
||||
for a manually-set `CLAUDE_CONFIG_DIR` in
|
||||
[`docs/wiki/Agent-CLIs.md`](wiki/Agent-CLIs.md), just applied
|
||||
automatically here. Best-effort: a platform that refuses the symlink keeps
|
||||
the pre-existing blind-response-viewer side effect rather than failing the
|
||||
whole custom-model apply over it. ⚠️ **This relocates the whole `.claude`
|
||||
tree, not just transcripts**: a custom-model Claude session also loses the
|
||||
user's global `settings.json`, user-level skills (the codeman agent skill
|
||||
included), user-level agents and commands, and the MCP servers configured
|
||||
in `~/.claude.json` — none of those are symlinked back, only `projects` is.
|
||||
A fine trade for "point this session at my local llama.cpp," but worth
|
||||
knowing before it surprises you mid-session.
|
||||
|
||||
**That isolated directory needed one more fix to actually be usable
|
||||
non-interactively.** An otherwise-empty `CLAUDE_CONFIG_DIR` has none of a
|
||||
real profile's prior "Detected a custom API key — use it?" approvals, so
|
||||
without more, Claude Code stops and asks that on _every single launch_ —
|
||||
confirmed live, and with nobody at a TTY to answer, its own default answer
|
||||
("No") silently refuses the very key this feature just injected, which
|
||||
looks like the endpoint being ignored entirely. `customModelInjection`'s
|
||||
`apiKeyTrustFile` (`{ relPath: '.claude.json', shape:
|
||||
'claude-api-key-responses' }` on claude's entry) pre-seeds that exact
|
||||
approval: the apply step merges `customApiKeyResponses.approved: [apiKey]`
|
||||
into `<configDir>/.claude.json`, the same field a real answered prompt
|
||||
itself writes to (confirmed against a real file after answering by hand
|
||||
once) — this answers the prompt in advance rather than bypassing it. The
|
||||
merge preserves whatever else the CLI already wrote into that file on an
|
||||
earlier launch in the same isolated directory (`userID`, `numStartups`,
|
||||
earlier approved keys), and a missing or corrupt file is treated as empty
|
||||
rather than failing the apply.
|
||||
|
||||
**A fresh `CLAUDE_CONFIG_DIR` isn't just missing that one approval — Claude
|
||||
Code treats it as a brand-new profile and replays its ENTIRE first-run
|
||||
sequence on every launch: the theme picker, the security-notes screen, the
|
||||
per-project "trust this folder?" dialog, and (running with
|
||||
`--dangerously-skip-permissions`) a one-time warning about bypassing
|
||||
permissions.** Confirmed live: none of these show up again for a real,
|
||||
already-onboarded profile, but every custom-model session gets a fresh,
|
||||
otherwise-empty isolated directory, so it saw all four every single time.
|
||||
`customModelInjection`'s `skipFirstRunPrompts` (`true` on claude's entry,
|
||||
requires `apiKeyTrustFile` since it reuses the same file) pre-seeds the
|
||||
state a real profile accumulates from answering all of that once:
|
||||
`hasCompletedOnboarding: true` and the launching session's own
|
||||
`projects[workingDir].hasTrustDialogAccepted: true` go into the same
|
||||
`<configDir>/.claude.json` the API-key approval above already merges into
|
||||
(other projects, and other fields on this session's own project entry, are
|
||||
left untouched), and `skipDangerousModePermissionPrompt: true` goes into
|
||||
`<configDir>/settings.json` — a different file, merged the same
|
||||
corrupt-tolerant way. `workingDir` is used exactly as the session was
|
||||
launched with as its cwd, never realpath'd or slash-normalized, since
|
||||
that's the literal string Claude Code itself uses as the project key.
|
||||
|
||||
**llama-swap gets two more fixes on top of the context-length/config-dir
|
||||
ones above, both from watching a real switch live.** llama.cpp only ever
|
||||
runs one model at a time; llama-swap swaps the backing process on demand,
|
||||
which can take anywhere from a few seconds to well over a minute:
|
||||
|
||||
- **The conflict check.** Both apply routes (the restart one here and the
|
||||
one-shot `POST /api/quick-start` above) call llama-swap's own
|
||||
`GET /running` first — feature-detected, so a plain llama.cpp/OpenAI-
|
||||
compatible server (no such endpoint) is simply never checked. If a
|
||||
_different_ model is currently loaded and ready, and another **live
|
||||
session's own selection** is using it, the apply returns
|
||||
`{requiresConfirmation: true, currentlyLoadedModel, affectedSessions}`
|
||||
instead of silently switching — nothing is applied or created yet.
|
||||
Retrying with `confirmedSwap: true` skips the check (the legacy `confirmed: true`
|
||||
still means both questions). Switching with nothing
|
||||
else affected proceeds immediately; this is a warning about disrupting
|
||||
another session, never a gate on the switch itself.
|
||||
- **Actually starting the load.** llama-swap has no "switch model" admin
|
||||
call — the only thing that starts a swap is a real inference request
|
||||
naming the model, and confirmed live: applying a selection alone never
|
||||
reached llama-swap at all (nothing in its own server logs), since nothing
|
||||
had actually asked it to load anything yet. Both apply routes now also
|
||||
send the smallest real request that will —
|
||||
`POST <baseUrl>/v1/chat/completions` with `max_tokens: 1` and one
|
||||
throwaway message — whenever the
|
||||
target model isn't already the one loaded and ready, fire-and-forget (its
|
||||
response is never read; `GET /api/model-endpoints/:id/running-status`,
|
||||
polled client-side, is what actually confirms readiness). The response
|
||||
also carries `modelSwapInProgress: true` in that case, which is what
|
||||
drives the Run-menu picker's own "loading model" status banner.
|
||||
|
||||
## Catching a swap after the fact
|
||||
|
||||
The conflict check above only runs at the moment a session is created or a
|
||||
model is applied — it has no way to catch a swap that happens **later**.
|
||||
Confirmed live: a session created while nothing else conflicted at that
|
||||
exact instant can still get silently displaced afterward, once a
|
||||
_different_ session's own normal use (or its own create-time load trigger)
|
||||
asks llama-swap to load something else. llama-swap has no push
|
||||
notification of its own for this, so a background sweep
|
||||
(`detectCustomModelSwapDisplacements`, `CUSTOM_MODEL_SWAP_CHECK_INTERVAL_MS`
|
||||
= 20s in `server.ts`) polls `GET /running` once per distinct endpoint that
|
||||
has at least one live custom-model session, and compares each such
|
||||
session's own `modelId` against what is actually loaded. A session whose
|
||||
model is no longer in that list gets a `custom-model:swapped-out` SSE event
|
||||
(`{sessionId, sessionName, endpointId, previousModel, currentlyLoadedModel}`),
|
||||
shown as a global toast — global rather than tied to that session's tab,
|
||||
since the whole point is telling the user before they type into it
|
||||
expecting the model they picked. Notifies **once per displacement**: the
|
||||
same de-dupe `Set` clears a session's flag once its own model is loaded and
|
||||
ready again, so a later, genuinely new displacement notifies again rather
|
||||
than the session staying silently un-notified forever after the first one.
|
||||
|
||||
## Context-window floor warning
|
||||
|
||||
Claude Code's own fixed per-turn overhead (system prompt + tool schemas,
|
||||
empirically ~36.4K tokens) can exceed a small local model's _entire_ real
|
||||
context on its own, before any conversation history exists to fill it —
|
||||
confirmed live twice, both as an `in:0 out:0` failure on the very first
|
||||
message sent. `CLAUDE_CODE_MAX_CONTEXT_TOKENS` (above) cannot fix this: it
|
||||
only governs when Claude Code compacts conversation history, and there is
|
||||
no history yet on message one. Applying such a model would look like the
|
||||
endpoint being ignored, or the wrong model being used, when in fact the
|
||||
endpoint applied correctly and the model is simply too small for this CLI.
|
||||
|
||||
Both apply routes (the restart route and the one-shot `POST
|
||||
/api/quick-start`) now check for this **before** launching or restarting
|
||||
anything, gated on the CLI's registry entry declaring a `contextLengthVar`
|
||||
(currently only claude — the check is a no-op for every other CLI by
|
||||
construction, never a hardcoded mode check). If the model's discovered
|
||||
context (`modelContextLengths`, from discovery above) is below
|
||||
`CLAUDE_MIN_SAFE_CONTEXT_TOKENS` (40000, comfortably above the measured
|
||||
~36.4K overhead), the response is `{requiresContextWarning: true, modelId,
|
||||
contextLength, minSafeContextTokens}` instead of applying — nothing is
|
||||
restarted or created yet. A context length that was never discovered at
|
||||
all skips the check entirely (nothing to compare, so it fails open rather
|
||||
than warning on every model an endpoint hasn't reported a size for).
|
||||
Retrying with `confirmedContext: true` launches anyway (the legacy `confirmed: true` still means both questions).
|
||||
|
||||
The Run-menu picker shows this as an in-app modal
|
||||
(`#customModelContextWarningModal`, matching the llama-swap conflict
|
||||
modal's look) naming the model, its discovered context, and the safe
|
||||
floor, and explaining the fix: reconfigure llama-swap to give that model
|
||||
(or a smaller one) an explicit larger context instead of relying on
|
||||
auto-fit (`--fit-ctx`), which optimizes for the biggest _model_ that fits
|
||||
rather than the biggest _context_ — e.g. adding `-c 65536` (or as large a
|
||||
`--ctx-size` as the hardware holds) to that model's llama-swap config
|
||||
entry. A smaller model at a much larger explicit context often fits in
|
||||
the same VRAM a bigger model's auto-fit context gets shrunk to make room
|
||||
for.
|
||||
|
||||
Clear back to the harness's native cloud default with:
|
||||
|
||||
```bash
|
||||
@@ -101,6 +473,14 @@ id, model and injected key NAMES are persisted, the values are re-derived
|
||||
from the endpoint store on recovery, and the pane keeps running against the
|
||||
endpoint in between because tmux retains its environment.
|
||||
|
||||
⚠️ Clearing removes injected keys **by name**, and `CLAUDE_CONFIG_DIR` is one
|
||||
of the names claude's selection injects — so a session that ALSO had
|
||||
`CLAUDE_CONFIG_DIR` set through the generic `envOverrides` field (the
|
||||
per-client-account case) loses that override on clear too, and silently
|
||||
falls back to the server's default Claude account. If you route a session
|
||||
to a specific account this way, re-apply the override after clearing a
|
||||
custom-model selection from it.
|
||||
|
||||
**New sessions always default back to the harness's native backend.** A
|
||||
custom-endpoint selection is a per-session choice, never a sticky global
|
||||
default — starting a fresh session doesn't inherit whatever the last one was
|
||||
@@ -115,17 +495,53 @@ automatically). Results:
|
||||
|
||||
- **Claude, opencode, Pi, Grok, OMP** — verified: a real "hello world" reply
|
||||
came back through the endpoint.
|
||||
- **Codex** — the config is structurally correct, but Codex only speaks the
|
||||
Responses API since Feb 2026, which llama.cpp/llama-swap don't implement.
|
||||
This is a real protocol incompatibility, not a bug here; Codex support
|
||||
needs a Responses-API-compatible endpoint.
|
||||
- **Codex** — the config is structurally correct, and against a llama-swap
|
||||
server that DOES answer `/v1/responses` (confirmed live: a plain,
|
||||
no-tool-call chat turn returned a real reply), the picture is more
|
||||
nuanced than a flat failure. A real tool-call attempt (`run the shell
|
||||
command: echo hello`) came back as `agent_message` TEXT — literally the
|
||||
tool-call JSON printed as the model's answer — instead of a
|
||||
`function_call` item Codex would actually execute (confirmed via `codex
|
||||
exec --json`'s raw event stream). So plain chat can work while the thing
|
||||
that makes Codex a coding agent — actually running commands and editing
|
||||
files — does not; treat Codex as still unreliable for real work against a
|
||||
llama.cpp/llama-swap endpoint, tool-calling gap included, not just the
|
||||
earlier-documented `wire_api` mismatch (which not every deployment hits
|
||||
the same way — some legitimately have no `/v1/responses` route at all).
|
||||
Separately, EVERY custom-endpoint Codex session prints `Model metadata
|
||||
for '<id>' not found. Defaulting to fallback metadata...` on launch —
|
||||
confirmed harmless (the reply above still came back correctly): Codex's
|
||||
model metadata (reasoning-tier options, per-model system-prompt
|
||||
templates, context-window figures) comes from `models_cache.json`, a
|
||||
local cache of OpenAI's own hosted model catalog that a custom local
|
||||
model can never appear in by construction, since it isn't one of
|
||||
OpenAI's models. There's no config.toml override for a model's metadata,
|
||||
and fabricating a fake catalog entry would mean copying the _shape_ of
|
||||
OpenAI's own proprietary schema (their per-model system-prompt content
|
||||
included) for a warning that doesn't otherwise affect behavior — not
|
||||
something to build into discovery.
|
||||
- **Gemini** — fails with `Invalid auth method selected`, traced to an
|
||||
undocumented `GATEWAY` auth path gemini-cli selects once
|
||||
`GOOGLE_GEMINI_BASE_URL` is set. Unresolved after real investigation
|
||||
(several auth workarounds were tried and ruled out); do not rely on
|
||||
Gemini support yet.
|
||||
- **DeepSeek** — the request reaches the server (env vars are read) but
|
||||
gets a consistent `HTTP_404`. Root cause not identified; best-effort only.
|
||||
- **DeepSeek** — root cause of the `HTTP_404` found and fixed. DeepSeek
|
||||
Harness's own bundled provider module (`@deepseek-ai/dsh-llm-deepseek`)
|
||||
builds its request URL as `${DEEPSEEK_BASE_URL}/chat/completions` with no
|
||||
`/v1` insertion of its own (its real public API, `https://api.deepseek.com`,
|
||||
expects the caller's base URL to already carry any needed prefix) —
|
||||
confirmed by reading its own source and, live, that
|
||||
`POST <baseUrl>/chat/completions` 404s against llama-swap while
|
||||
`POST <baseUrl>/v1/chat/completions` succeeds; the harness's own error
|
||||
template (`DeepSeek API error (HTTP ${status})`) matches the originally
|
||||
reported symptom exactly. `customModelInjection`'s new `appendV1Suffix`
|
||||
(deepseek's entry only — claude/gemini must NOT get it, since claude was
|
||||
already confirmed working against the raw `baseUrl`) fixes it by writing
|
||||
`DEEPSEEK_BASE_URL` with `/v1` appended. Not yet re-run end-to-end with a
|
||||
real `dsh` binary (no install available in this environment) — the fix
|
||||
is source-confirmed and live-verified at the HTTP level, but a real
|
||||
"hello world" reply through `dsh` itself is still outstanding before
|
||||
calling this fully verified like the harnesses above.
|
||||
- **Antigravity** — no known custom-endpoint mechanism at all; unsupported.
|
||||
|
||||
See the confidence table in `custom-model-endpoints-plan.md` for the full detail behind
|
||||
|
||||
@@ -81,6 +81,8 @@ Antigravity (`agy`) and Grok (`grok`) are the two CLIs not installed from npm (G
|
||||
|
||||
Pi's credentials are seeded per-FILE rather than as a whole directory (`auth.json`, `settings.json`, `trust.json`, `models.json`, `models-store.json` out of `~/.pi/agent`), because that directory also holds `sessions/`, `extensions/`, `skills/` and the installed package trees — gigabytes on an active host. Consequence: in-container pi sessions are invisible host-side, so `pi -c` inside a Docker case only sees that container's own history. See [`pi-integration.md`](./pi-integration.md). Grok is seeded per-file for the same reason (`auth.json`, `config.toml`, `pager.toml` out of `~/.grok`, which also holds `sessions/`, `memory/` and the ~160MB binary under `downloads/`), with the same consequence for `grok -c`. See [`grok-integration.md`](./grok-integration.md). OMP is the one CLI in this family where `sessions/` is the EXCEPTION rather than the rule: `~/.omp/agent/{config.yml,mcp.json,models.yml,settings.yml}` are seeded per-file (the dir also holds SQLite caches and `terminal-sessions/`), but `~/.omp/agent/sessions/` is shared RW like codex's, not seeded, because Codeman reads it host-side for history recovery and `--resume` pinning. See [`omp-integration.md`](./omp-integration.md).
|
||||
|
||||
The image can also carry the GitHub CLI (`gh`) and the Azure CLI (`az` + the `azure-devops` extension, in `AZURE_EXTENSION_DIR=/opt/az-extensions` so it stays out of the seeded HOME), wired into the system git config as credential helpers for github.com and dev.azure.com / *.visualstudio.com, exactly as in `docker/server.Dockerfile`. Their sign-ins are seeded per-FILE like pi's: `~/.config/gh/{hosts.yml,config.yml}` and `~/.azure/{azureProfile.json,msal_token_cache.json,service_principal_entries.json,clouds.config,config}`, never `~/.azure`'s logs, command index or extensions. A token kept in a desktop keyring, or in the encrypted MSAL cache az uses on Windows/macOS, is not in those files and does not carry. None of the three is version-pinned; the `--no-cache` rebuild recommended above is also what refreshes them. Both CLIs are opt-in and OFF by default: `CODEMAN_AGENT_IMAGE_INSTALL_GH=1` / `CODEMAN_AGENT_IMAGE_INSTALL_AZ=1` in the environment of `scripts/build-agent-image.mjs`, or of the Codeman server for its own auto-build (in the Compose deployment, `environment:` in `docker-compose.override.yml`), become the `CODEMAN_INSTALL_GH` / `CODEMAN_INSTALL_AZ` build args and put that CLI, its extension and its helper entry into the image. Unset passes nothing, so a default build's argv is unchanged and the image has neither. The sign-in seeds follow the same switches, read when a case container is created: `.config/gh` only with `CODEMAN_AGENT_IMAGE_INSTALL_GH=1`, `.azure` only with `CODEMAN_AGENT_IMAGE_INSTALL_AZ=1` (`enabledByEnv` in `CRED_STORES`), never merely because the files exist. Seeds are create-time mounts and deliberately not part of the config hash (hashing them would trip the drift gate for every case), so an existing case container picks them up only when it is recreated.
|
||||
|
||||
## Quickest path: one-click "Run in Docker"
|
||||
|
||||
On the **New case → Create New** tab there's a **🐳 Run in an isolated Docker container** checkbox. Checking it alone is enough: Codeman creates the case folder in `~/codeman-cases/<name>`, spins up a hardened container with sensible defaults (auto-provisioning a shared `default` host), and starts the session inside it. No host/image/network fields to fill in.
|
||||
|
||||
@@ -6,6 +6,8 @@ For the Compose configuration, environment settings, storage migration, and macv
|
||||
|
||||
The image includes Claude Code, Codex, Gemini CLI, and OpenCode. Authenticate a CLI from its Codeman session; credentials are never baked into the image.
|
||||
|
||||
It can also include the GitHub CLI (`gh`) and the Azure CLI (`az`) with the `azure-devops` extension, wired in as Git credential helpers, so Clone Repo and `git clone` reach private GitHub and Azure DevOps repositories once they are signed in. Both are off by default; [Turning them on](../docker/README.md#turning-them-on) shows the `docker-compose.override.yml` settings.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- Docker Engine or Docker Desktop with Docker Compose v2
|
||||
@@ -67,7 +69,7 @@ If that directory was created by an earlier root-running image, change its owner
|
||||
|
||||
Codeman updates itself from **App Settings → Updates**, as it does on a bare host. The checkout mounted at `/opt/codeman` is the same directory Compose builds from, so the update's `git checkout` and rebuild land on the host and survive container recreation; the restart is the server exiting, which `restart: unless-stopped` turns into a relaunch on the new build.
|
||||
|
||||
That applies application code only. A release that changes `docker/server.Dockerfile`, `docker/docker-compose.yaml`, or adds a key to `docker/.env.example` needs the image rebuilt or the container recreated, which a container cannot do to itself. The updater detects each case and refuses with a message naming what changed; run `docker/Start-Codeman.sh` on the host to apply those.
|
||||
That applies application code only. A release that changes `docker/server.Dockerfile`, `docker/docker-compose.yaml`, or adds a key to `docker/.env.example` needs the image rebuilt or the container recreated, which a container cannot do to itself. The updater detects each case and refuses with a message naming what changed; run `docker/Start-Codeman.sh` on the host to apply those. For a major update, or a base-image change `Start-Codeman.sh` does not fully pick up, `docker/Update-Codeman.sh` rebuilds with no layer cache and clears the two build-artefact volumes before handing off to it (see "Major updates" in `docker/README.md`).
|
||||
|
||||
`CODEMAN_REPO_PATH` overrides which checkout is mounted. It defaults to the compose project's parent directory, so it normally needs no setting. Point it at a directory that is not a git checkout and in-app updates are reported as unavailable.
|
||||
|
||||
|
||||
@@ -10,15 +10,18 @@ this file covers only what the container changes.
|
||||
|
||||
## The short version
|
||||
|
||||
| Change in the release | Applied by |
|
||||
| -------------------------------- | ------------------------------------------------ |
|
||||
| Application code | The in-app updater |
|
||||
| `docker/server.Dockerfile` | `docker/Start-Codeman.sh` on the host |
|
||||
| `docker/docker-compose.yaml` | `docker/Start-Codeman.sh` on the host |
|
||||
| New key in `docker/.env.example` | Add it to `docker/.env`, then `Start-Codeman.sh` |
|
||||
| Change in the release | Applied by |
|
||||
| ----------------------------------------- | ------------------------------------------------------------------------------- |
|
||||
| Application code | The in-app updater |
|
||||
| `docker/server.Dockerfile` | `docker/Start-Codeman.sh` on the host |
|
||||
| `docker/docker-compose.yaml` | `docker/Start-Codeman.sh` on the host |
|
||||
| New key in `docker/.env.example` | Add it to `docker/.env`, then `Start-Codeman.sh` |
|
||||
| A major update, or a Node base-image bump | `docker/Update-Codeman.sh` on the host (no-cache rebuild + fresh build volumes) |
|
||||
|
||||
The in-app updater detects all three of the bottom rows itself and refuses with a
|
||||
The in-app updater detects the three middle rows itself and refuses with a
|
||||
message naming what changed, so you never have to work out which case you are in.
|
||||
`Update-Codeman.sh` is the heavier option for when `Start-Codeman.sh` is not
|
||||
enough: see "Major updates" in `docker/README.md`.
|
||||
|
||||
## Why the container needs its own path
|
||||
|
||||
@@ -224,7 +227,10 @@ the host and the in-app path works from then on.
|
||||
|
||||
**Resetting the build artefacts** — `docker compose down -v`, then
|
||||
`Start-Codeman.sh`. This discards the named volumes and re-seeds them from a fresh
|
||||
image.
|
||||
image. `docker/Update-Codeman.sh` scripts the same reset by default for the two
|
||||
build-artefact volumes (`codeman-node-modules`, `codeman-dist`) only, plus an
|
||||
unconditional `--no-cache` rebuild, which a plain `Start-Codeman.sh` run does not
|
||||
force on its own. See "Major updates" in `docker/README.md`.
|
||||
|
||||
## Disabling it
|
||||
|
||||
|
||||
@@ -0,0 +1,380 @@
|
||||
# Installer v2: three questions, then a URL you can open on your phone (Plan)
|
||||
|
||||
Status: **Phase 1 IMPLEMENTED (2026-09-20)**, phases 2 and 3 open. It builds on
|
||||
`docs/tailscale-installer-plan.md` (implemented 2026-08-04), which made Tailscale a
|
||||
guided option; this round makes it the thing the install ENDS on, and makes the whole
|
||||
installer shorter to sit through. Owner decisions taken before implementation: rename
|
||||
is opt-in and **defaults to no everywhere** (the machine name is used for other things);
|
||||
the URL keeps the node name unless asked; `codeman-<hostname>` is the suggested name;
|
||||
sub-path is the default for an occupied `:443`.
|
||||
|
||||
Verification record for phase 1 (all on the maintainer's box, 2026-09-20):
|
||||
|
||||
- `test/install-sh-invariants.test.ts` (28 tests, incl. the new Tailscale safety pins)
|
||||
and the detection-parity test pass; `bash -n` passes.
|
||||
- Every new decision function driven with stubbed tailscale state under **bash 5.2 and
|
||||
bash 3.2** (the `bash:3.2` container CI uses): flags, the launch default, the serve
|
||||
shape for free / ours / occupied `:443` (all four answers plus the non-interactive
|
||||
default), the three serve commands, the rename question (Enter keeps the name; `--yes`
|
||||
and non-interactive never rename; `codeman-*` nodes are skipped; `--name` is
|
||||
sanitized), `run_step` success/failure/stdin, the unit round-trip of
|
||||
`CODEMAN_BASE_URL`/`CODEMAN_PORT`/an escaped password, and the done screen.
|
||||
- A full non-interactive install into a sandboxed `HOME` with `CODEMAN_TAILSCALE=1`:
|
||||
preflight summary, kept the existing prod mapping (no serve mutation), clone 2 s,
|
||||
`npm install` 18 s, build 23 s, symlink, done screen; `install.sh status` on a pty
|
||||
renders the QR code. Nothing on the real system changed.
|
||||
- **Sub-path mode end to end over the real tailnet**: an isolated Codeman
|
||||
(`CODEMAN_INSTANCE`, port 3999, `--base-url /codeman`) behind
|
||||
`tailscale serve --https=8445 --set-path /codeman 3999` answered `/codeman/api/status`,
|
||||
`/codeman/` (with `<base href="/codeman/">` and `__CODEMAN_BASE__="/codeman"`), the
|
||||
hashed CSS/JS, `/codeman` without a slash, and the SSE stream; mapping and server
|
||||
removed afterwards. **Correction to section 2**: serve STRIPS the mount prefix
|
||||
before proxying (a direct `/codeman/api/status` on the server is 404 while the same
|
||||
path through serve is 200). That is fine because Codeman's ingress tolerates
|
||||
unprefixed requests; `--base-url` is needed for the URLs Codeman EMITS, not for
|
||||
what it receives.
|
||||
- Not yet exercised on a fresh machine (unchanged from the previous plan): Tailscale
|
||||
absent / logged out / HTTPS toggle off, the rename against a real node (the
|
||||
off-rename-re-add order is implemented but only unit-driven), macOS, uninstall. The
|
||||
Mac mini and a throwaway VM are the venues; see section 8.
|
||||
- **Review fixes (2026-09-21)**, from the two reviews on PR #460 (DeepSeek Harness, then
|
||||
Claude): the done screen's Start line is composed from every non-default value
|
||||
(`start_command_hint`, shared with the exec branch as `export_bind_env`), so "do not
|
||||
start" under a sub-path or a custom port no longer prints a bare `codeman web`; the
|
||||
`--lan`/`--tailscale`/env preset paths keep an existing password instead of rewriting
|
||||
the unit open; `--password`/`--port` flip `RECONFIGURE` so they reach the unit;
|
||||
`install.sh name` re-syncs the unit's base URL after a rename; the sudo keepalive is
|
||||
ended before the `exec` into the foreground server; Ctrl+C in the HTTPS-toggle poll
|
||||
skips Tailscale instead of killing the run; `uninstall` asks before removing a
|
||||
LaunchDaemon it never wrote; a foreign LaunchDaemon gets a restart hint and the done
|
||||
screen stops claiming the new build is running; the preflight summary reads the
|
||||
Tailscale state without node; the LAN security notice uses the configured port; a
|
||||
bare re-run ends on the done screen; a build failure after a rename names the
|
||||
`install.sh tailscale` recovery; `TS_JOINED_HERE` is gone.
|
||||
|
||||
Goal, in one sentence: a user runs the one-liner, answers at most three questions, walks
|
||||
away during the build, and comes back to `https://<name>.<tailnet>.ts.net` printed with a
|
||||
QR code, already answering, on every device in their tailnet. That is exactly the
|
||||
maintainer's own production setup (`tnode.tailf80371.ts.net` fronting `127.0.0.1:3000`),
|
||||
and the installer should produce it without the user knowing what `tailscale serve` is.
|
||||
|
||||
## 1. Where the installer is today
|
||||
|
||||
Facts from reading `install.sh` (2886 lines, 19 `prompt_yes_no` sites) and the live
|
||||
Tailscale state on the maintainer's box (tailscale 1.102.2, user-owned node, MagicDNS +
|
||||
HTTPS certs on, serve mapping `443 -> https+insecure://localhost:3000`).
|
||||
|
||||
**The order is backwards for a human.** The flow is: detect -> ask about git -> ask about
|
||||
node -> ask about tmux -> ask about build tools -> AI CLI menu -> ask about cloudflared ->
|
||||
clone -> `npm install` -> build (minutes) -> **then** the network-access question -> the
|
||||
Tailscale sub-steps (install? login URL, sudo for operator, admin-console toggle loop) ->
|
||||
the launch menu (no default; a bare Enter re-prompts) -> tunnel-service question. A fresh
|
||||
Ubuntu server taking the Tailscale route answers roughly ten prompts plus two to four sudo
|
||||
password prompts, split around a multi-minute build. The user cannot walk away at any
|
||||
point, and the question that matters most (how do I reach it) comes last.
|
||||
|
||||
**The Tailscale flow works but was never exercised on a fresh machine.** The previous
|
||||
plan's manual matrix still lists items 1-4, 7 and 10-12 (Tailscale absent, logged out,
|
||||
HTTPS toggle off, port 443 occupied, macOS, uninstall, phone PWA) as untested. The
|
||||
maintainer's own verification was the idempotent "kept as-is" path.
|
||||
|
||||
**The URL is the machine's name, full stop.** `setup_tailscale_serve` derives it from
|
||||
`.Self.DNSName`, and nothing lets the user influence it. A second Codeman on the same
|
||||
tailnet is `macminis-mac-mini.tailf80371.ts.net`, which tells you nothing about Codeman.
|
||||
|
||||
**Port 443 taken means give up or clobber.** If another app already owns the root of
|
||||
`:443`, the only offer is "replace it?" (default no), and declining falls back to
|
||||
local-only. Codeman already supports running under a sub-path (`--base-url`), and
|
||||
Tailscale serve supports mounting a path (`--set-path`), so there is a third answer nobody
|
||||
is offered.
|
||||
|
||||
**The result is invisible afterwards.** Once the terminal scrolls away, nothing in the app
|
||||
or the CLI tells the user their Tailscale URL again. `codeman doctor` does not probe
|
||||
Tailscale; App Settings -> Remote access shows only the Cloudflare tunnel.
|
||||
|
||||
**Two service writers exist.** `install.sh` carries its own plist/unit generator (~180
|
||||
lines) next to `codeman service install` (`src/service-installer.ts`). They agree on the
|
||||
job name by design, but the bash copy is the one that writes `CODEMAN_PASSWORD` into the
|
||||
unit, so they cannot simply be merged. Left as-is in this plan (see section 9).
|
||||
|
||||
## 2. What Tailscale makes possible for the name (researched 2026-09-20)
|
||||
|
||||
| Option | Resulting URL | What it needs | Side effects | Verdict |
|
||||
| ------ | ------------- | ------------- | ------------ | ------- |
|
||||
| **A. Node name** (today) | `https://tnode.tailf80371.ts.net` | `tailscale serve --bg 3000` | none | **Default.** Zero admin-console work, matches the maintainer's prod. |
|
||||
| **B. Rename the node** | `https://codeman-tnode.tailf80371.ts.net` | `tailscale set --hostname codeman-<host>` (operator or root) | Renames the machine tailnet-wide: ssh targets, other serve URLs, the admin console entry. Tailscale de-dups a clash as `-1`. The cert follows the new name. | **Opt-in, default NO everywhere** (owner decision 2026-09-20: the machine is used for other things, so a bare Enter never renames it). The proposal was YES when the installer itself had just joined the tailnet; rejected. |
|
||||
| **C. Tailscale Service** | `https://codeman.tailf80371.ts.net` | tailscale >= 1.86 on the host; the host must have a **tag-based identity** ("You cannot use a device authenticated with a user account as a Service host"); the service is defined in the admin console first; the host is then approved there (or via `autoApprovers.services`). Public beta since 2025-10-28, all plans. | Re-authenticating a personal machine as a tagged node changes its identity (SSH ACLs, user attribution). Known daemon quirk: approval is not picked up until `serve clear` + re-advertise (tailscale/tailscale#18821). | **Detect and hint only** in this round. The maintainer's own node has `Self.Tags: null`, so it could not host one without re-tagging. Worth a real flow once someone with a tagged fleet asks. |
|
||||
| **D. Sub-path** | `https://tnode.tailf80371.ts.net/codeman` | `tailscale serve --bg --set-path /codeman 3000` plus `--base-url /codeman` on the server | Codeman runs under a prefix. Hooks are unaffected (they hit the raw port with no prefix, which `rewriteUrl` already tolerates). Serve forwards the prefix unchanged, which is exactly the shape `--base-url` was built for. | **The answer when `:443` root is already taken.** Replaces today's replace-or-nothing prompt. |
|
||||
| **E. Second port** | `https://tnode.tailf80371.ts.net:8443` | `tailscale serve --bg --https=8443 3000` | Port in the URL; the beta-preview recipe already uses this. | Fallback when the user rejects D. |
|
||||
| Funnel (public internet) | `https://tnode.tailf80371.ts.net` from anywhere | `tailscale funnel` | Public exposure; different risk class. | **Out of scope**, as before. Docs only, with the password warning. |
|
||||
|
||||
Sources: Tailscale Services docs (`tailscale.com/docs/features/tailscale-services`), the
|
||||
Services beta announcement (`tailscale.com/blog/services-beta`), machine names
|
||||
(`tailscale.com/kb/1098/machine-names`), the serve CLI reference
|
||||
(`tailscale.com/docs/reference/tailscale-cli/serve`), the macOS variants page
|
||||
(`tailscale.com/docs/concepts/macos-variants`), and `tailscale serve --help` on 1.102.2
|
||||
(which lists `--service`, `--set-path`, `--yes`, `advertise`, `get-config`/`set-config`).
|
||||
|
||||
**Trap for option B (verify on the Mac mini before shipping):** the serve config is keyed
|
||||
by `host:port` using the DNS name at configuration time (`"Web": {"tnode.tailf80371.ts.net:443": ...}`
|
||||
in `serve status --json`). Renaming a node after serve is configured most likely orphans that
|
||||
entry: the handler lookup uses the current name and never matches the old key, and the only
|
||||
tool that removes a stale key is `serve reset`, which this installer must never run. So the
|
||||
order is **rename first, then configure serve** on a fresh install, and on a retrofit
|
||||
(`install.sh name`) **turn our mapping off, rename, wait for `.Self.DNSName` to change,
|
||||
re-add**.
|
||||
|
||||
## 3. Target UX
|
||||
|
||||
### 3.1 Three questions, then walk away
|
||||
|
||||
```
|
||||
Codeman installer
|
||||
|
||||
Found: git, Node 22.14, tmux 3.4, build tools Missing: nothing
|
||||
AI CLIs: Claude Code (~/.local/bin/claude)
|
||||
Tailscale: connected as tnode (tailf80371.ts.net)
|
||||
Existing: none
|
||||
|
||||
1/3 How should the dashboard be reachable?
|
||||
1) Tailscale https://tnode.tailf80371.ts.net (recommended, already connected)
|
||||
2) Any device on your network (0.0.0.0, password required)
|
||||
3) This machine only (127.0.0.1)
|
||||
Choose [1/2/3] (default 1):
|
||||
|
||||
2/3 Name this machine "codeman-tnode" on your tailnet? [y/N]
|
||||
(only shown for option 1; default no, always)
|
||||
|
||||
3/3 Run Codeman as a background service that starts on boot? [Y/n]
|
||||
|
||||
Installing… this takes a few minutes. You can leave this running.
|
||||
✓ dependencies ✓ clone ✓ build (2m 41s) ✓ service ✓ tailscale serve
|
||||
```
|
||||
|
||||
Rules that make this work:
|
||||
|
||||
- **Every step that needs a human runs BEFORE the build.** The dependency consent, the
|
||||
AI CLI menu, the Tailscale install consent, the `tailscale up` login URL, the operator
|
||||
grant, and the tailnet HTTPS toggle all move into the question phase. The build, the
|
||||
service, `tailscale serve` and the verification are unattended.
|
||||
- **One consent for all missing system packages.** "Install git, Node 22 and build tools
|
||||
now? [Y/n]" replaces four separate prompts. Each package still runs its own
|
||||
distro-specific installer.
|
||||
- **One sudo prompt.** When anything needs root (packages, the Tailscale installer,
|
||||
`tailscale up`, the operator grant), the installer says so once, runs `sudo -v`, and keeps
|
||||
the timestamp alive in a background loop until it exits. macOS needs no sudo for the
|
||||
Tailscale GUI-app CLI and the pattern still holds for Homebrew packages.
|
||||
- **Service is the default.** Enter on the last question installs the service; "run in
|
||||
this terminal" and "don't start" stay reachable by answering, and by flag.
|
||||
- **The cloudflared question is gone from the main flow.** It is optional, defaults to
|
||||
no, and has an in-app toggle (App Settings -> Remote access). The done screen mentions it
|
||||
only when `cloudflared` is already installed. The Linux tunnel-service prompt goes with it.
|
||||
- **The HTTPS-certificates toggle no longer asks "re-check now?"** The installer prints the
|
||||
admin URL, opens it in a browser when one is available (`xdg-open` / `open`, never on a
|
||||
headless box), and polls `tailscale status --json` every 5 s for up to 5 minutes. Ctrl+C or
|
||||
the timeout falls back exactly as today.
|
||||
- **Progress, not silence.** `npm install` and `npm run build` run behind one line each
|
||||
with elapsed time; their output goes to `~/.codeman/install.log` and is printed only on
|
||||
failure, with the exact retry command.
|
||||
|
||||
### 3.2 The done screen
|
||||
|
||||
One block, the URL first, a QR code the phone can scan, and nothing the user does not need
|
||||
right now.
|
||||
|
||||
```
|
||||
✓ Codeman 1.31.0 is running
|
||||
|
||||
Your tailnet: https://codeman-tnode.tailf80371.ts.net (HTTPS, any of your devices)
|
||||
This machine: http://localhost:3000
|
||||
|
||||
▄▄▄▄▄▄▄ ▄ ▄▄ ▄▄▄▄▄▄▄
|
||||
█ ▄▄▄ █ ▄▄▀ ▄ █ ▄▄▄ █ scan with your phone
|
||||
█ ███ █ ███▀▀ █ ███ █
|
||||
█▄▄▄▄▄█ █ ▄ █ █▄▄▄▄▄█
|
||||
|
||||
Manage systemctl --user restart codeman-web · journalctl --user -u codeman-web -f
|
||||
Update re-run the install line, or App Settings → System → Updates
|
||||
Docs https://github.com/Ark0N/Codeman/wiki
|
||||
|
||||
Security: Codeman binds 127.0.0.1. Tailscale authenticates every device before a
|
||||
packet reaches it. Details: docs/security-architecture.md
|
||||
```
|
||||
|
||||
The QR comes from the `qrcode` package Codeman already depends on
|
||||
(`node -e "require('qrcode').toString(url, {type:'terminal', small:true}, …)"` from
|
||||
`$INSTALL_DIR`, verified locally: 17 rows by 45 columns). Skipped when the terminal has no
|
||||
color support or fewer than 50 columns. The QR encodes the plain URL, not an auth token:
|
||||
the tailnet is the login.
|
||||
|
||||
### 3.3 Express mode and flags
|
||||
|
||||
Env vars stay (`CODEMAN_TAILSCALE=1`, `CODEMAN_HOST`, `CODEMAN_PASSWORD`,
|
||||
`CODEMAN_NONINTERACTIVE=1`, `CODEMAN_PORT`). Flags are added because they are
|
||||
discoverable from the one-liner and pipe through `bash -s --`:
|
||||
|
||||
```bash
|
||||
curl -fsSL https://getcodeman.com/install | bash -s -- --tailscale --service
|
||||
curl -fsSL https://getcodeman.com/install | bash -s -- --lan --password 'x' --service
|
||||
curl -fsSL https://getcodeman.com/install | bash -s -- --local --run
|
||||
curl -fsSL https://getcodeman.com/install | bash -s -- --tailscale --name codeman-build --yes
|
||||
```
|
||||
|
||||
| Flag | Meaning |
|
||||
| ---- | ------- |
|
||||
| `--tailscale` / `--lan` / `--local` | Answer 1/3 (same semantics as `CODEMAN_TAILSCALE=1`, `CODEMAN_HOST=0.0.0.0`, `CODEMAN_HOST=127.0.0.1`) |
|
||||
| `--name <n>` / `--no-rename` | Answer 2/3: rename the node to `<n>`, or never ask |
|
||||
| `--service` / `--run` / `--no-start` | Answer 3/3 |
|
||||
| `--yes` | Accept every default, still prompt for a login URL (a human must open it) |
|
||||
| `--password <p>` | Same as `CODEMAN_PASSWORD` |
|
||||
| `--port <n>` | Same as `CODEMAN_PORT`; the serve target follows it |
|
||||
|
||||
`--yes` differs from `CODEMAN_NONINTERACTIVE=1`: it is the interactive user saying "I trust
|
||||
the defaults", so it may install software and may wait on a login URL. Non-interactive stays
|
||||
the CI contract and never installs Tailscale.
|
||||
|
||||
## 4. The Tailscale flow, v2
|
||||
|
||||
The state machine from the previous plan stays; these are the changes.
|
||||
|
||||
1. **Preflight, before the build** (`tailscale_preflight`): installed? -> install
|
||||
(Linux: official script; macOS: brew cask, else download link and wait). Logged in? ->
|
||||
`tailscale up` with the URL printed prominently and a 5-minute poll. Operator (Linux):
|
||||
grant once under the single sudo session. HTTPS certs: poll instead of ask (Ctrl+C
|
||||
during the poll skips Tailscale for this run rather than ending the installer). The
|
||||
rename default does not depend on whether this run performed the login (decided NO
|
||||
everywhere), so nothing records it.
|
||||
2. **Name** (`tailscale_choose_name`, question 2/3): shown only on the Tailscale route.
|
||||
Default `codeman-<oshostname>` sanitized to `[a-z0-9-]`, max 63. Applied with
|
||||
`ts_cmd_serve set --hostname`, then poll `.Self.DNSName` until it carries the new name
|
||||
(up to 60 s). Order matters: this runs before any serve mutation (section 2 trap).
|
||||
Declining keeps the node name. On a re-run against a node already named `codeman-*`,
|
||||
the question is skipped.
|
||||
3. **Serve, after the service is up** (`setup_tailscale_serve`): unchanged idempotent
|
||||
"kept as-is" path first. When `:443` root belongs to another target, the new prompt is:
|
||||
|
||||
```
|
||||
tailscale serve already sends https://tnode.tailf80371.ts.net to port 8080.
|
||||
1) Add Codeman under a path: https://tnode.tailf80371.ts.net/codeman (default)
|
||||
2) Use another port: https://tnode.tailf80371.ts.net:8443
|
||||
3) Replace the existing mapping with Codeman
|
||||
4) Skip Tailscale for now
|
||||
```
|
||||
|
||||
Option 1 writes `--base-url /codeman` into the service unit (it is a `WebLaunchOptions`
|
||||
field already, and `buildWebArgs` carries it) and runs
|
||||
`tailscale serve --bg --set-path /codeman <port>`. Option 2 runs `--https=8443`.
|
||||
`detect_tailscale_serve_url` learns to recognize all three shapes (root, path, port) so
|
||||
uninstall, the security notice and the re-run default keep working.
|
||||
4. **Warm the certificate.** Right after serve is configured, fire one background
|
||||
`curl -sk https://<url>/api/status` so Let's Encrypt issuance overlaps the rest of the
|
||||
install instead of adding 30 s to the verify step.
|
||||
5. **Verify** as today (200 or 401 on `/api/status`), with the path-aware URL.
|
||||
6. **Services hint** (option C): when `.Self.Tags` is non-empty and `serve --help`
|
||||
lists `--service`, the done screen adds one line: "This is a tagged node, so it can also
|
||||
host `https://codeman.<tailnet>.ts.net` as a Tailscale Service: see Remote Access in the
|
||||
wiki." No flow, no prompt.
|
||||
7. **macOS**: the App Store and Standalone variants cannot run before login, so a
|
||||
LaunchAgent plus serve only comes back after someone logs in. The done screen says so on
|
||||
macOS. The Mac mini (`arbbot`, headless, system LaunchDaemon) is the reference for the
|
||||
"headless Mac" caveat, and `install.sh` must keep refusing to replace a LaunchDaemon it
|
||||
did not write (today it removes one; that is a bug for the Mac mini and is fixed here:
|
||||
detect `UserName` in the daemon plist and leave it alone with a message).
|
||||
8. **Uninstall** additionally offers to restore the original node name when this installer
|
||||
renamed it (the original is recorded in `~/.codeman/install.json`, the one marker file
|
||||
this feature adds, because tailscaled does not remember previous names).
|
||||
9. **Subcommands**: `install.sh tailscale` (unchanged purpose, now runs the v2 flow),
|
||||
`install.sh name [<n>]` (rename with the off/rename/re-add dance), `install.sh status`
|
||||
(prints the done screen again, URL and QR included, for the "what was my URL" moment).
|
||||
|
||||
## 5. In-app: the URL stays discoverable
|
||||
|
||||
Small, read-only, and the first server-side code this feature has ever needed.
|
||||
|
||||
- **`GET /api/system/remote-access`** returns
|
||||
`{ tailscale: { installed, connected, dnsName, url, mode: 'root'|'path'|'port'|null } }`
|
||||
by running `tailscale status --json` and `tailscale serve status --json` through
|
||||
`execFile` with the existing exec timeout, cached 30 s, resolved through the same
|
||||
`get_tailscale_path` search as the installer (PATH, then the macOS app bundle), and a
|
||||
no-op under `VITEST` like every other IO probe. Never mutates serve config.
|
||||
- **App Settings -> Remote access** gains a **Tailscale** row above the Cloudflare toggle:
|
||||
the URL as a copy chip, a QR button reusing `showTunnelQR`'s modal, and when nothing is
|
||||
configured a one-line hint with `bash ~/.codeman/app/install.sh tailscale`. The welcome
|
||||
screen's "open on your phone" affordance shows the same QR.
|
||||
- **`codeman doctor`** grows a `tailscale` entry under `other` in
|
||||
`config/dependency-registry.ts`: installed, connected, serving Codeman (URL). Pure
|
||||
engine, injectable probe host, like the existing rows.
|
||||
- No new SSE event, no settings key, no state.json change.
|
||||
|
||||
## 6. Security posture
|
||||
|
||||
Nothing widens. The bind stays loopback; the tailnet is the authentication boundary;
|
||||
`.ts.net` is already in `DEFAULT_TRUSTED_HOST_SUFFIXES`. New surfaces are read-only
|
||||
probes. `install.sh` still never runs `tailscale serve reset`, still touches only the
|
||||
mapping it created, and gains one more never: it never advertises a Tailscale Service or
|
||||
runs `tailscale funnel`. The sudo keep-alive loop is killed by the existing `cleanup` trap.
|
||||
The rename records the previous name locally and offers the reversal at uninstall.
|
||||
|
||||
## 7. Implementation inventory
|
||||
|
||||
| File | Change |
|
||||
| ---- | ------ |
|
||||
| `install.sh` | New `parse_flags`, `preflight_summary`, `ask_everything` (the three questions), `sudo_session`, `run_step` (spinner + log), `tailscale_preflight`, `tailscale_choose_name`, `tailscale_rename_node`, `print_done_screen`, `print_qr`, `status` subcommand, `name` subcommand. Modified: `main` (reordered into ask -> work -> done), `choose_network_binding` (question 1/3, same defaults), `setup_tailscale_serve` (path/port options), `detect_tailscale_serve_url` (three shapes), `setup_systemd_service`/`setup_launchd_service` (`--base-url`, LaunchDaemon guard), `uninstall` (rename reversal), header docs (flags). Removed from the main flow: the cloudflared prompt, the tunnel-service prompt. bash 3.2 rules unchanged. |
|
||||
| `src/web/routes/system-routes.ts` | `GET /api/system/remote-access` |
|
||||
| `src/tailscale-status.ts` (new) | Pure parser for the two JSON shapes + the IO wrapper; unit-tested against captured `serve status --json` fixtures (root, path, port, foreign target, none) |
|
||||
| `src/config/dependency-registry.ts`, `src/utils/dependency-checker.ts` | `tailscale` doctor row |
|
||||
| `src/web/public/index.html`, `settings-ui.js`, `panels-ui.js` | Tailscale row + QR, welcome-screen QR |
|
||||
| `test/install-sh-invariants.test.ts` | Extend: flags documented in the header, no `serve reset`, no `funnel`, no `--service` advertise, every serve mutation goes through `ts_cmd_serve`, rename happens before serve in `main` (static order check) |
|
||||
| `.github/workflows/ci.yml` | The bash 3.2 step additionally sources the script with stubbed `ts_cmd`/`ts_cmd_serve`/`read_reply` and drives `ask_everything` through all three answers and the 443-occupied menu |
|
||||
| `test/tailscale-status.test.ts`, `test/routes/system-routes-remote-access.test.ts` | Parser + route |
|
||||
| Docs | README install + remote-access sections, `docs/wiki/Installation.md`, `Remote-Access.md` (naming options table, Services caveat, path/port variants), `Mobile-Guide.md`, `Running-As-A-Service.md` (macOS login caveat), `FAQ.md`, `docs/security-architecture.md` §A, CLAUDE.md Scripts & Tunnel paragraph, `docs/tailscale-installer-plan.md` gets a pointer here. getcodeman.com copy lives outside the repo (maintainer handbook). |
|
||||
|
||||
Changeset: `minor` (new flags, new subcommands, new API route).
|
||||
|
||||
## 8. Test plan
|
||||
|
||||
Automated (the gate): the static invariants above, the bash 3.2 container drive of the
|
||||
question phase, the JSON parser fixtures, the route test.
|
||||
|
||||
Manual matrix, on a fresh Ubuntu 24 VM and on the Mac mini, since the previous plan's
|
||||
items never ran on a fresh machine:
|
||||
|
||||
1. Tailscale absent, declined -> local-only, done screen shows the retrofit command.
|
||||
2. Tailscale absent, accepted -> install, login URL, operator, certs toggle polled, rename
|
||||
question shown (default no), service, serve, URL verified, QR scans on a phone, PWA installs.
|
||||
3. Tailscale present and logged in on a pre-existing node -> rename default NO, URL is the
|
||||
node name, `serve status` gains exactly one entry.
|
||||
4. `:443` root occupied -> path option -> `https://<node>/codeman` answers, hooks still
|
||||
fire (raw port), `install.sh status` prints the path URL.
|
||||
5. Rename on a node that already has our serve mapping (`install.sh name`) -> off, rename,
|
||||
re-add, `serve status` has no stale key.
|
||||
6. Re-run the one-liner -> quiet update, binding and name preserved, no prompts.
|
||||
7. `--yes` end to end; `CODEMAN_NONINTERACTIVE=1` end to end (no software installed).
|
||||
8. Uninstall -> mapping removed, other mappings intact, rename reversal offered.
|
||||
9. Mac mini: LaunchDaemon left alone with the message; done screen carries the login caveat.
|
||||
|
||||
## 9. Phasing and open decisions
|
||||
|
||||
**Phase 1 (this round):** the reorder, the three questions, one consent + one sudo, flags,
|
||||
the done screen with QR, Tailscale preflight-before-build, the path/port answer for an
|
||||
occupied 443, the rename step, `status` and `name` subcommands, docs.
|
||||
|
||||
**Phase 2:** the in-app Tailscale row + QR, `codeman doctor` row, the `remote-access`
|
||||
route. Independent of phase 1 and useful on its own for existing installs.
|
||||
|
||||
**Phase 3 (optional):** replace the bash service writers with `codeman service install`
|
||||
once that command can carry `CODEMAN_PASSWORD` behind an explicit flag; and a Tailscale
|
||||
Services flow if a tagged-fleet user asks for `codeman.<tailnet>.ts.net`.
|
||||
|
||||
Decisions for the maintainer:
|
||||
|
||||
1. **Rename default.** Decided 2026-09-20: always NO; the yes answer, `--name` and
|
||||
`install.sh name` are the ways in. (The proposal was YES only when this run had joined
|
||||
the tailnet, NO otherwise; rejected because the host is used for other things.)
|
||||
2. **Name pattern.** `codeman-<hostname>` (proposed; unique per machine, and two Codemans
|
||||
on one tailnet stay distinguishable) versus plain `codeman` (nicer once, collides on the
|
||||
second install, Tailscale silently appends `-1`).
|
||||
3. **Path versus port** as the default answer for an occupied 443. Proposed: path, because
|
||||
the URL has no port and `--base-url` already exists for exactly this proxy shape.
|
||||
4. **Whether Phase 2 ships in the same release.** It is the part that helps people who
|
||||
installed months ago.
|
||||
@@ -352,6 +352,177 @@ path but the SESSION (`session.remote`): a remote session never falls back to lo
|
||||
`fs`, and a local session never opens an ssh connection — including for attachment
|
||||
records, which are keyed to the session that registered them.
|
||||
|
||||
## Wake-on-LAN from user input
|
||||
|
||||
A durable remote session survives an SSH drop (COD-104/108), but nothing brought the
|
||||
HOST back. When the remote machine suspended, the local pane's `ssh` child **stalled**
|
||||
rather than exited: `tmux send-keys` SUCCEEDS against a stalled pane, so typed input
|
||||
vanished with no error anywhere, and without a keepalive the pane could look alive for
|
||||
the OS TCP timeout. The only recovery was waiting for the reconnect watcher, which
|
||||
gave up after ~13 minutes and, once exhausted, never retried.
|
||||
|
||||
An **optional** `wakeMac` (one or more MAC addresses, comma-separated) or `wakeCommand` on a
|
||||
remote host closes that: on user input, `POST /api/sessions/:id/input` probes the host, and if
|
||||
it is unreachable it wakes it, polls until the host answers, reattaches the pane
|
||||
(`Session.reattachRemote()`, which idempotently attaches the still-running remote tmux — the
|
||||
agent conversation is not restarted), and flushes the input that arrived meanwhile.
|
||||
Implementation: `src/remote-wake.ts`.
|
||||
|
||||
The same wake path also serves **opening** a session, which is where a sleeping host used to
|
||||
be a dead end: pressing Run on a remote case (`POST /api/quick-start`) or Attach on a
|
||||
discovered remote tmux session (`POST /api/sessions` + `attachRemoteSession`) probes the host
|
||||
first, and on a sleeping one wakes it, waits for SSH and only then runs the tmux prereq probe.
|
||||
Without that the run failed with `could not verify tmux on remote host …` — an ssh error that
|
||||
blames tmux for a machine that is merely suspended. The wait is **blocking** (the caller gets
|
||||
the session or the error) but bounded by `REMOTE_WAKE_REQUEST_READY_TIMEOUT_MS` (40 s) rather
|
||||
than the 90 s session default, because the dashboard sits behind a reverse proxy whose default
|
||||
`proxy_read_timeout` is 60 s: a longer wait would be cut off at the proxy while the session was
|
||||
still being created. The budget covers the whole request, not just the wait (40 s wake + 1.5 s
|
||||
probe + the tmux prereq probe's own 15 s timeout = 56.5 s worst case). A host with no wake target is not even probed on this path, so nothing
|
||||
changes for it, and `remote:hostWaking` is broadcast without a `sessionId` (the toast then reads
|
||||
"the session starts when it is back" — there is no session yet, and no input queued behind it).
|
||||
|
||||
Two wake paths, `wakeCommand` first because it is the explicit override:
|
||||
|
||||
- **`wakeMac`** — Codeman builds the magic packet itself (`buildMagicPacket`, six `0xFF`
|
||||
bytes then the MAC repeated 16×; the shape is asserted byte-for-byte) and broadcasts it
|
||||
over UDP port 9 (`sendWakePackets`). This is the normal case: no external script, and one
|
||||
MAC list per host instead of one per consumer.
|
||||
- **`wakeCommand`** — a single executable path, run WITHOUT a shell. For hosts that need a
|
||||
router/another machine to send the packet.
|
||||
|
||||
**UI**: a banner (`#hostWakeBanner`, `host-wake-ui.js`) appears while the ACTIVE remote
|
||||
session's host is unreachable — amber, since the Codeman session is healthy and only the
|
||||
machine is asleep. With a wake target the action is **Wake** (`POST /api/sessions/:id/wake`);
|
||||
with none it is **Configure WoL** and opens `#wakeConfigModal`, a small form for that host's
|
||||
`wakeMac`/`wakeCommand` that saves with `PUT /api/remote-hosts/:id` (in multi-user mode that
|
||||
GET is admin-only, so a non-admin is told the setting is admin-only instead of "host not
|
||||
found"). Reachability for the banner comes from `GET /api/sessions/:id/reachability`: once
|
||||
when the remote tab is activated (a user action), and every 30 s while the tab is visible
|
||||
**only for a host with a wake target** — each poll is a TCP connect to the host, and a timer
|
||||
that connects to a host Codeman could not wake anyway is exactly the timer-driven traffic
|
||||
the keepalive rule below rejects (it cannot wake a host, but it can keep an activity-based
|
||||
suspend timer from firing). A host the probe cannot reach (see the next section) is never
|
||||
polled. ⚠️ The button is pressed from the SAME
|
||||
dashboard as Run/Attach, so it holds its request open under the same proxy and uses the same
|
||||
40 s budget — and it **queues nothing**: browser keystrokes travel over the WebSocket, which
|
||||
deliberately does not pass through the registry (that is the hot path this feature keeps its
|
||||
hands off), so the banner says "waiting for the host to come back" for the button and only
|
||||
claims "input is queued" when the HTTP input path actually buffered bytes
|
||||
(`queuedInput` on the two SSE events).
|
||||
|
||||
**Hosts behind a jump host or SOCKS proxy are reachability-UNKNOWN.** The probe is a bare
|
||||
TCP connect to `host:port`, and a host reached through `jumpHost`, `socksProxy` or a
|
||||
`ProxyCommand`/`ProxyJump` in `extraSshOptions` does not answer that even while ssh works —
|
||||
the direct address may not route at all (the cloudflared case). Acting on the resulting
|
||||
"unreachable" verdict was wrong three times over: a permanent banner over a healthy session,
|
||||
a create-path error that replaced a genuine "needs tmux" with "not reachable", and — with a
|
||||
wake target configured — every HTTP input buffered for the life of the session, because the
|
||||
readiness poll could never succeed. `isProbeable()` (`remote-wake.ts`) decides from the
|
||||
proxy fields, which travel on `WakeableRemote`; for such a host the registry delivers input
|
||||
unchanged, `GET …/reachability` answers `reachable: null, probeable: false` (unknown is not
|
||||
`false`, and only a proven `false` raises the banner), the create/attach path is not gated
|
||||
(`ensureHostAwake` → `'unprobeable'`, handled like `'no-target'`), and the quick-start
|
||||
"not reachable" message is reserved for a **proven** unreachable host (`=== false`). A wake
|
||||
target can still be fired for it through `POST /api/sessions/:id/wake`, blind: the packet or
|
||||
command goes out and the response says only whether it did — no readiness poll, no reattach
|
||||
(the COD-108 watcher owns the pane once ssh works again), no "waking" toast.
|
||||
|
||||
The invariants worth keeping:
|
||||
|
||||
- **Authorization comes before the wake.** In multi-user mode the attach path
|
||||
(`POST /api/sessions` + `attachRemoteSession`) answers `403` to a non-admin BEFORE the
|
||||
host is looked up or probed: remote hosts are admin-only infrastructure everywhere else
|
||||
(the list is `[]` for a non-admin, write and discovery routes are `adminOnly`), and the
|
||||
wake spawns the host's `wakeCommand` or broadcasts a packet — a gate that came after the
|
||||
wake handed an unprivileged account a way to run that executable for any configured
|
||||
`hostId`, hold the request for the wake budget, and only then be refused for the
|
||||
workingDir. The quick-start path resolves its remote case through `canAccessOwned`
|
||||
first. Pinned in `test/routes/session-remote-wake.test.ts` (wake spy stays empty).
|
||||
- **The caller is told what happened to its bytes.** The non-wait input route answers
|
||||
`{buffered:true}` when the registry took the chunk and `{buffered:true, dropped:true}`
|
||||
when it was over the cap and is gone; the send-and-wait route answers `OPERATION_FAILED`
|
||||
when the host never comes back, like the create and attach paths, instead of writing
|
||||
into the stalled pane and reporting `delivered:true` plus a timeout. Flushed chunks are
|
||||
written with `fromUser`, so a first prompt that was buffered through a wake can still
|
||||
name the tab.
|
||||
- **Only an EXPLICIT request may wake a host:** user input on an established session, the wake
|
||||
button, or the user's own session create/attach request (`ensureHostAwake`). Everything that
|
||||
runs on a TIMER must never wake one — the COD-108 watcher, the server's dropped-session
|
||||
handler, boot recovery and session discovery have no access to the wake registry, and neither
|
||||
has the shared session service, because `cron-service.ts` builds sessions there with nobody
|
||||
waiting on the answer; a wake on such a path would re-wake the host seconds after every
|
||||
suspend, so it could never stay asleep (the same failure `hufflepuff-mcp-lazy` exists to
|
||||
prevent for MCP keepalives). A reachability check, a discovery listing and the tmux prereq
|
||||
probe never wake: they are questions, not actions. All of it is enforced by tests in
|
||||
`test/remote-wake.test.ts` (two wiring guards: one pins the importers — the route module and
|
||||
`server.ts`, which holds the registry for its LIFETIME only, `drop()` on session cleanup and
|
||||
`stop()` on shutdown — and one asserts `server.ts` calls nothing but those two, while
|
||||
`ensureHostAwake` has exactly one caller file) and `test/routes/session-remote-wake.test.ts`,
|
||||
not by comments.
|
||||
- **Detection is a bare TCP connect** to the SSH port (then the configured `port`, else 22),
|
||||
throttled per session, and only for wake-enabled hosts. No `ServerAliveInterval` is added to
|
||||
the launch command: keepalives push bytes into an otherwise idle connection every interval,
|
||||
which is exactly what a byte-threshold idle detector must not count as activity. A probe is
|
||||
~200 bytes per 30 s, orders of magnitude below any such threshold, and the SYN alone cannot
|
||||
wake a host.
|
||||
- **Input is buffered while a wake is in flight** (`REMOTE_WAKE_PENDING_MAX_BYTES`,
|
||||
oldest whole chunks dropped, bounded so user input cannot grow memory) and flushed in
|
||||
order after the reattach, with a settle delay so bytes cannot land in a still-connecting
|
||||
pane. ⚠️ A chunk LARGER than the cap (one big paste is one `input` value) is dropped
|
||||
**outright**, never trimmed: it was never typed character by character, so its tail is not
|
||||
"what the user just typed" but a fragment of a command they never sent — the drop is logged
|
||||
instead. ⚠️ Only the HTTP input route reaches the registry; the **WebSocket keystroke path
|
||||
is deliberately NOT wake-aware**, so typing into a sleeping host sends nothing and queues
|
||||
nothing (the banner's Wake button is the recovery for that case, which is why it must not
|
||||
promise queued input). The **send-and-wait** path blocks on the wake instead — its response
|
||||
is open anyway, and buffering would break the wait contract. ⚠️ A flush write that FAILS
|
||||
drops the whole remaining buffer (logged) rather than retaining it: the wake still resolves
|
||||
and marks the host reachable, so the next input takes the deliver path while a retained
|
||||
chunk would wait for the NEXT wake — replayed hours later, after everything typed since,
|
||||
possibly ending in a carriage return. Same policy as the oversized paste.
|
||||
- **The command runs without a shell** (`spawn(path, [], { stdio: 'ignore' })` — `shell`
|
||||
defaults to `false`), the schema
|
||||
requires a single executable path (no arguments, no `$`/backtick), and `wakeMac` is a
|
||||
structural hex-pair allowlist. A broken or missing wake target fails the wake, never the
|
||||
input route.
|
||||
- **`wakeMac`/`wakeCommand` are host-level config, refreshed on recovery AND live**
|
||||
(`rehydrateRemoteHostFields` in `src/remote-hosts.ts` plus `RemoteWakeDeps.resolveRemote`).
|
||||
A session's `remote` block is persisted at launch time, so a field added to
|
||||
`remote-hosts.json` later would otherwise never reach an already-running session — not even
|
||||
across a Codeman restart, and certainly not right after saving the banner's config dialog.
|
||||
Recovery rehydration covers restarts, the (throttled, cache-backed) resolver covers the live
|
||||
session; the host config is authoritative for both (removing the field disables the feature
|
||||
again). Other host-level fields deliberately stay as persisted, so neither path can
|
||||
silently re-point an existing pane's SSH options.
|
||||
- **UI/SSE**: `remote:hostWaking` and `remote:hostWakeFailed` (plus the reused
|
||||
`remote:sessionReconnected`) drive the banner and toasts, all from `host-wake-ui.js` —
|
||||
its handlers are the ONLY definitions, since a second one in another mixin would be
|
||||
silently shadowed by script order. Both carry `queuedInput`, which is true only when the
|
||||
server actually holds bytes for that session — the wording keys off that, not off "a wake
|
||||
is running", so the button path never claims input is queued. In multi-user mode the
|
||||
whole `remote:` family is **session-scoped** (`deriveSseHint`, `server.ts`): an event with
|
||||
a `sessionId` reaches that session's owner, and the create/attach wake — which has no
|
||||
session yet — carries the requesting `username` instead (`ensureHostAwake({ requestedBy })`),
|
||||
since its payload names a `hostId`/`label` that `GET /api/remote-hosts` withholds from
|
||||
non-admins. With neither, it reaches admins only.
|
||||
- **No real IO under vitest.** `probeRemoteHostReachable`, `runRemoteWakeCommand` and the
|
||||
default UDP socket of `sendWakePackets` throw under `VITEST` (as `remote-files.ts` does),
|
||||
so a test that reaches the defaults fails loudly instead of connecting, spawning or
|
||||
broadcasting from CI. Every consumer injects its IO (`RemoteWakeDeps`, the socket
|
||||
factory); `createDefaultRemoteWakeDeps({ probe })` also polls readiness with THAT probe,
|
||||
which is the leak the guard found.
|
||||
|
||||
Tests: `test/remote-wake.test.ts` (decision/throttle table, single-flight registry,
|
||||
buffering + flush order, MAC parsing/magic packet, live host-config resolution, the proxied
|
||||
host, SSE payload routing, the vitest IO guard, and the wiring guard),
|
||||
`test/routes/session-remote-wake.test.ts` (the input route buffers instead of writing into a
|
||||
sleeping host — and writes straight into a proxied one —, the reachability route never wakes
|
||||
and reports a proxied host as unknown, and the wake route reports the no-target case the UI
|
||||
turns into "configure WoL"), `test/sse-routing-remote.test.ts` (multi-user routing of the
|
||||
`remote:` family) and `test/host-wake-banner.test.ts` (banner visibility and when the poller
|
||||
may connect).
|
||||
|
||||
## API
|
||||
|
||||
Routes are registered in `src/web/routes/case-routes.ts`:
|
||||
@@ -365,6 +536,10 @@ Routes are registered in `src/web/routes/case-routes.ts`:
|
||||
| `GET` | `/api/remote-hosts/:hostId/sessions` | Discover `codeman-*` sessions on the host (COD-105; `listRemoteCodemanSessions`, never errors) |
|
||||
| `POST` | `/api/cases/remote-link` | Link a case to a remote host (creates the `RemoteCase`) |
|
||||
|
||||
`RemoteHost` accepts the optional `wakeMac` (magic packet, sent by Codeman) and `wakeCommand`
|
||||
(single executable path, run without a shell, takes precedence) — see **Wake-on-LAN from user
|
||||
input** above.
|
||||
|
||||
Attaching to a discovered session is a **session-create** path, not a host route:
|
||||
`POST /api/sessions` accepts `attachRemoteSession: { hostId, remoteSessionName }`
|
||||
(schema in `schemas.ts`; `remoteSessionName` must match `^codeman-[a-zA-Z0-9._-]+$`),
|
||||
|
||||
@@ -270,9 +270,15 @@ tailscale serve --bg 3000 # HTTPS at https://<node>.<tailnet>.ts.net
|
||||
Only devices on your tailnet can reach it; Tailscale handles identity and
|
||||
terminates TLS with a real Let's Encrypt certificate (so PWA install and web
|
||||
push work). No app password and no `0.0.0.0` bind required. (This is the
|
||||
maintainer's production setup.) `CODEMAN_TAILSCALE=1` presets the choice for
|
||||
automation; the installer never runs `tailscale serve reset` and never touches
|
||||
serve mappings other than `443 -> Codeman's port`.
|
||||
maintainer's production setup.) `CODEMAN_TAILSCALE=1` or `--tailscale` presets
|
||||
the choice for automation. When `:443` on the node already belongs to another
|
||||
app, the installer mounts Codeman under `/codeman` (`tailscale serve --set-path`
|
||||
plus `--base-url`, which keeps the loopback bind and the same host guard) or on a
|
||||
second port rather than replacing it. The installer never runs `tailscale serve
|
||||
reset`, never touches serve mappings other than the one it created, never opens a
|
||||
`tailscale funnel` (public internet, a different risk class) and never advertises
|
||||
a Tailscale Service. Renaming the node (`--name`, `install.sh name`) is opt-in
|
||||
and defaults to no, because the tailnet name is also the machine's SSH identity.
|
||||
|
||||
### B. Authenticated cloudflared tunnel + password
|
||||
|
||||
@@ -491,7 +497,7 @@ production layout (`~/.codeman`, `-L codeman`, port 3000).
|
||||
Docker cases (1.4.0) run a session inside a per‑case container instead of on the host. The security posture:
|
||||
|
||||
- **Hardened create flags, always** — `--cap-drop ALL`, `--security-opt no-new-privileges`, `--pids-limit` (fork‑bomb guard), `--memory` == `--memory-swap` (a real OOM cap), `--init`, and non‑root: `--user <hostUid>:0` on Linux (host uid → workspace files stay host‑owned; GID 0 keeps `$HOME` writable), `--userns=keep-id` on rootless Podman. **Never** `--privileged`, and **never** the docker socket — the pure builder in `docker-hosts.ts` cannot emit them and the schema cannot represent them.
|
||||
- **Credentials never enter an image** — the convenient default bind‑mounts host cred dirs (`~/.claude`, `~/.codex`, `~/.gemini` — which also carries Antigravity's `antigravity-cli/` state — `~/.config/{gcloud,opencode}`, five seeded files from `~/.pi/agent`, and three from `~/.grok`) read‑write. Bind mounts are physically excluded from `docker commit`, so exported images are secret‑free. API‑key CLIs get their key as an exec‑time NAME‑ONLY `--env OPENAI_API_KEY` (no `=value`, no `ps` leak, never committed); a create‑time `-e` for a secret is never used. The **sealed** profile (`mountCredentials:false` + `network:none`) drops the host mounts; full‑image export is then refused (an in‑container login would ride the committed layer) unless a pre‑commit scrub is opted into.
|
||||
- **Credentials never enter an image** — the convenient default bind‑mounts host cred dirs (`~/.claude`, `~/.codex`, `~/.gemini` — which also carries Antigravity's `antigravity-cli/` state — `~/.config/{gcloud,opencode}`, five seeded files from `~/.pi/agent`, three from `~/.grok`, and, only when their opt-in switches `CODEMAN_AGENT_IMAGE_INSTALL_GH` / `_AZ` are `1`, `~/.config/gh/{hosts.yml,config.yml}` and the sign-in files from `~/.azure`) read‑write. Bind mounts are physically excluded from `docker commit`, so exported images are secret‑free. API‑key CLIs get their key as an exec‑time NAME‑ONLY `--env OPENAI_API_KEY` (no `=value`, no `ps` leak, never committed); a create‑time `-e` for a secret is never used. The **sealed** profile (`mountCredentials:false` + `network:none`) drops the host mounts; full‑image export is then refused (an in‑container login would ride the committed layer) unless a pre‑commit scrub is opted into.
|
||||
- **Blast radius — accept it explicitly** — the convenient profile mounts an arbitrary host workspace RW plus the host credential dirs RW into a network‑enabled container, so container‑run agent code can read/modify those host trees and reach the network at once. Still a net improvement over today's on‑host `--dangerously-skip-permissions` execution; use the sealed profile for genuinely untrusted work.
|
||||
- **Import is untrusted‑bundle‑safe** — `/api/docker-cases/import` validates the manifest + per‑member SHA‑256 before extraction, rejects absolute / `..` tar members (traversal guard), and re‑tags the loaded image into a quarantined namespace so it can never overwrite `codeman/agent:base` or a pre‑existing tag.
|
||||
- **Host guard & the bridge‑hooks listener** — in‑container hook callbacks carry `Host: host.docker.internal` / `host.containers.internal`; both are on the always‑on host‑header allowlist (`DOCKER_HOST_GATEWAY_ALIASES`) and resolve to the host only from inside a container netns, so they are not a browser DNS‑rebinding surface. On a loopback‑only server, in‑container hooks are opt‑in via `CODEMAN_DOCKER_BRIDGE_HOOKS=1`, which binds a SECOND listener on the docker bridge gateway serving **only** the hook endpoints (every other path → `403`) into the same hook‑secret‑gated pipeline. The bridge is host‑internal (containers + host), not the LAN, so it does not widen network exposure; the hook secret is bind‑mounted read‑only and referenced by path.
|
||||
@@ -510,6 +516,7 @@ Full feature guide: [`docker-cases.md`](docker-cases.md).
|
||||
- **Auth is a parallel branch** (`middleware/auth.ts`) that leaves the single‑user path untouched: per‑user scrypt verify (`timingSafeEqual`, timing‑equalized against user enumeration), identity‑carrying cookies, a per‑username failure bucket (a botnet can't brute one account across IPs; one NATed user can't lock out the rest), and a `mustChangePassword` lockbox. The hook‑secret loopback bypass, host guard, and Origin/CSRF guard are unchanged (hooks authenticate the INSTANCE, not a user).
|
||||
- **Ownership is enforced server‑side only** and fails closed: `req.authUser` (a synthetic admin in single‑user), `findSessionOrFail` returns NOT_FOUND (never 403) for a foreign session, list/SSE/WS/file‑preview/search all filter by `session.owner`, and SSE routing defaults session‑scoped events to their owner (unresolved owner → withheld). The load‑bearing rule is **non‑admin `workingDir` confinement**: a non‑admin's session/one‑shot working dir must realpath‑resolve inside `~/codeman-users/<name>/cases`, checked BEFORE any disk write.
|
||||
- **Privileged actions are a one‑bit grant** (`canBypassPermissions`, default off): only granted users (and admins) get `--dangerously-skip-permissions` (others are silently downgraded to `--permission-mode auto`), shell‑mode sessions, cron `launchCommand`, and other CLIs' bypass flags. Machine‑level resources (remote/Docker host definitions, tunnel, self‑update, settings writes) are admin‑only.
|
||||
- **Clone Repo does not lend the server's git sign-in to non-admins.** A clone writes only inside the caller's own case space, so it is not admin-gated, but the server account's git credential helpers (the Docker image's opt-in `gh`/`az` helpers, or any `gh auth setup-git`) are shared by every user. A non-admin's clone and preflight therefore run with `git -c credential.helper=`, which empties the helper list including the URL-scoped entries (`cloneWithoutCredentialHelpers` in `case-routes.ts`, argv pinned in `test/git-clone.test.ts`). This closes the Clone Repo path only: the account's SSH keys still apply to an `ssh://` URL, and a non-admin's agent sessions run as the same account, consistent with the first bullet above. Docker cases are a second route to the same sign-in: with `CODEMAN_AGENT_IMAGE_INSTALL_GH`/`_AZ` on, a non-admin's Docker case with credential seeding on (the default) receives a copy of the server account's `gh`/`az` sign-in, exactly as it receives the Claude and Codex credentials.
|
||||
- **Admin actions are audited** append‑only to `~/.codeman/admin-audit.jsonl` (acting admin, action, target, IP). Passwords set by an admin create/reset are one‑time (returned once, force change). Under Basic auth, `logout` only truly ends QR‑issued sessions — to lock someone out, disable the account or reset the password (a proper login form is a deferred Phase 6).
|
||||
|
||||
---
|
||||
|
||||
@@ -0,0 +1,198 @@
|
||||
# Split-Pane Sessions — Design Spec
|
||||
|
||||
**Status**: Implemented (v1)
|
||||
**Author**: Claude (session with Tim), 2026-09-15
|
||||
**Scope**: v1 only. v2 items are named and explicitly deferred, not designed.
|
||||
|
||||
## Problem
|
||||
|
||||
Codeman's terminal area shows exactly one active session (pane) at a time —
|
||||
switching panes re-binds the single xterm instance and the single WebSocket
|
||||
to a different session. Multi-monitor spanning (`scripts/span-codeman.sh` /
|
||||
`span-codeman.ps1`) turned out to solve a different problem: it makes one
|
||||
browser window bigger, but that window still shows one session; floating
|
||||
subagent windows are draggable overlays on top of it, not tiled panes. There
|
||||
is no way today to see two live sessions (e.g. `w1-codeman` and
|
||||
`w1-mcp-memory`) side-by-side in one window, even on a monitor wide enough to
|
||||
fit both.
|
||||
|
||||
## Goal (v1)
|
||||
|
||||
From the active session, open a **second, independent, fully live session**
|
||||
in a pane beside it — draggable divider, side-by-side only. Closing the
|
||||
second pane collapses back to today's normal single-pane view. No
|
||||
persistence: a page reload always returns to single-pane. Floating
|
||||
subagent/Ultracode windows keep their current behavior unchanged (global,
|
||||
unconstrained across the whole viewport, split or not).
|
||||
|
||||
Explicitly out of scope for v1 (v2 candidates, not designed here):
|
||||
- More than 2 panes / grid layouts
|
||||
- Vertical (stacked) splits
|
||||
- Drag-a-tab-to-split as a trigger (v1 trigger is an explicit button + picker)
|
||||
- Persisting the split layout across reload or across devices
|
||||
- Mobile/tablet layouts (viewport is too narrow for this to make sense; gated
|
||||
to desktop widths the same way `home-sessions.js`'s rail is)
|
||||
- Feature parity between the two panes (see "Pane B is deliberately plainer"
|
||||
below)
|
||||
|
||||
## Current architecture (why this isn't a CSS change)
|
||||
|
||||
`terminal-ui.js` is built entirely around **singleton** state: `this.terminal`
|
||||
(one xterm instance), `this._ws`/`this._wsSessionId` (one WebSocket, rebound
|
||||
on every pane switch via `_disconnectWs()` + `_connectWs(newId)`), a
|
||||
`this._xtermSnapshots` map used only to restore scrollback into that one
|
||||
terminal when switching back to a session. Roughly 280 references to this
|
||||
singleton state exist across the file (input handling, resize/fit, sizing-
|
||||
token claims, mobile touch gestures, CJK IME, local-echo overlay wiring,
|
||||
keyboard accessory bar, link providers, etc.).
|
||||
|
||||
Showing two sessions at once therefore requires a second, independently
|
||||
alive xterm + WebSocket pair running concurrently — not a layout change to
|
||||
one shared instance.
|
||||
|
||||
**Related prior art**: `detachSession(id)` (app.js) already opens one session
|
||||
in a genuinely separate browser window (`isSoloWindow` mode) with its own
|
||||
independent WebSocket, and two of those can already be snapped side-by-side
|
||||
today with zero new code. That covers "two sessions visible at once" but not
|
||||
what this spec is for: one Codeman window with two panes and a divider you
|
||||
can drag without leaving your seat, each still a full participant in that
|
||||
window's floating subagent windows, header, and settings. This spec builds
|
||||
past detach, not a duplicate of it.
|
||||
|
||||
**Server-side check (done, not just assumed)**: `MAX_WS_PER_SESSION = 5`
|
||||
(`src/web/routes/ws-routes.ts`), scoped by `clientId:tabNonce`
|
||||
(`ws-connection-registry.ts`). Splitting always opens a *different* session
|
||||
in the second pane (self-splitting is disallowed, see below), so this is two
|
||||
sessions each getting their normal one connection — the existing cap is
|
||||
irrelevant here and needs no server change.
|
||||
|
||||
## Key design decision: Pane B is deliberately plainer than Pane A
|
||||
|
||||
Porting all ~280 singleton behaviors to a second, symmetric pane is not
|
||||
worth it for v1 — most of that code is input-quality-of-life for **mobile/
|
||||
touch** (local-echo overlay, CJK IME textarea, touch gesture handling,
|
||||
keyboard accessory bar), and this feature is desktop-only by nature (a split
|
||||
view needs a wide viewport). So:
|
||||
|
||||
- **Pane A** (the session that was already active when you opened the split)
|
||||
stays exactly what it is today — `this.terminal`, `this._ws`, unchanged
|
||||
code path, zero regression risk.
|
||||
- **Pane B** is a new, smaller `SplitTerminalPane` object: its own xterm
|
||||
instance + fit addon, its own WebSocket to `/ws/sessions/:id/terminal`,
|
||||
resize-on-divider-drag, and plain keyboard input. It does **not** get the
|
||||
local-echo overlay, CJK IME composition, touch/mobile handlers, or the
|
||||
keyboard accessory bar. On a desktop, typing directly into an xterm
|
||||
instance with no overlay is exactly how Codeman behaved before the local-
|
||||
echo overlay existed for touch devices — normal, not degraded, for a
|
||||
keyboard-and-mouse user.
|
||||
|
||||
If this asymmetry actually bothers you in daily use, promoting Pane B to full
|
||||
parity is a scoped v2 (extract the shared logic already once you have two
|
||||
call sites to compare, rather than guessing the right abstraction now).
|
||||
|
||||
One more asymmetry worth naming here rather than discovering by surprise:
|
||||
while both panes accept keyboard input, the global capture-phase shortcut
|
||||
handler (`app.js`) always resolves against Pane A — it has no notion of
|
||||
which pane currently has focus. So Ctrl+L or Ctrl+W typed while Pane B has
|
||||
focus clears or closes Pane A, not the session you were actually typing
|
||||
into. Not fixed for v1, same reasoning as the rest of this section.
|
||||
|
||||
## Components
|
||||
|
||||
### 1. `SplitTerminalPane` (new, `terminal-split.js`)
|
||||
|
||||
A small class, one instance per secondary pane:
|
||||
- `constructor(sessionId, mountEl)`
|
||||
- `connect()` — creates the xterm instance (same theme/font config as the
|
||||
primary, read from the same settings so it doesn't visually clash), opens
|
||||
`/ws/sessions/:id/terminal`, wires input → WS, WS → terminal write
|
||||
- `fit()` — calls the fit addon; called on divider drag (rAF-throttled) and
|
||||
on window resize
|
||||
- `destroy()` — disposes the xterm instance, closes the WS cleanly
|
||||
|
||||
No snapshot/scrollback-restore map is needed the way `_xtermSnapshots` exists
|
||||
for Pane A — Pane B is destroyed on close, not hidden-and-restored, since
|
||||
there's no persistence requirement.
|
||||
|
||||
### 2. Split container (layout)
|
||||
|
||||
```
|
||||
.terminal-split-container (flex row, only rendered when split is active)
|
||||
├── .terminal-wrap (existing element, Pane A — untouched)
|
||||
├── .split-divider (new, draggable seam)
|
||||
└── .terminal-pane-b (new, hosts SplitTerminalPane's xterm + a
|
||||
small header: session name + × close button)
|
||||
```
|
||||
|
||||
When not split, `.terminal-wrap` renders exactly as it does today (no
|
||||
wrapping container at all, to keep the no-split path byte-identical to
|
||||
current behavior). Splitting inserts the container and reparents
|
||||
`.terminal-wrap` into it as the first child — same reparenting pattern
|
||||
already used by `applySessionListLayout()` for `#sessionTabs`, so this isn't
|
||||
a new pattern for the codebase.
|
||||
|
||||
Default split is 50/50 (`flex-basis: 50%` each). Divider drag updates both
|
||||
panes' `flex-basis` live (rAF-throttled) and calls `fit()` on **both**
|
||||
terminals per tick, clamped to 20%/80% so neither pane can be dragged into an
|
||||
unusably thin sliver.
|
||||
|
||||
### 3. Trigger UI
|
||||
|
||||
A **"Split"** button (header, opt-in like the other header buttons —
|
||||
`showSplitButton`, default off, same pattern as `showMultiMonitorButton`)
|
||||
opens a small picker listing your other open sessions (reuses
|
||||
`this.sessions`/`sessionOrder`, filtered to exclude the currently active
|
||||
session — you cannot split a session against itself). Picking one:
|
||||
1. Creates the split container, reparents `.terminal-wrap`
|
||||
2. Instantiates `SplitTerminalPane` for the chosen session in `.terminal-pane-b`
|
||||
3. Button state flips to "close split" (or Pane B's own header × does it)
|
||||
|
||||
Closing (via Pane B's × or the header button toggling off):
|
||||
1. `SplitTerminalPane.destroy()`
|
||||
2. Removes `.terminal-split-container`, reparents `.terminal-wrap` back to
|
||||
its original location at 100% width
|
||||
3. Fires a resize/fit on Pane A (same `ResizeObserver`-driven fit already in
|
||||
place today — no new code needed here, it fires naturally once the
|
||||
container's size changes)
|
||||
|
||||
v2 note (not designed): dragging a session tab onto the active pane as an
|
||||
alternate trigger. You confirmed right-click doesn't work today (Codeman
|
||||
doesn't intercept it) and declined a keybind, so v1 is button+picker only.
|
||||
|
||||
### 4. Failure / edge cases
|
||||
|
||||
- **The Pane B session ends or is deleted while split is active** → treat
|
||||
identically to the user closing Pane B manually: destroy the pane, collapse
|
||||
to Pane A at full width.
|
||||
- **The Pane A session ends while split is active** → Pane B is promoted:
|
||||
it becomes the new single full-width pane (reusing today's normal
|
||||
single-pane code path means Pane B's `SplitTerminalPane` must hand off to
|
||||
a real `this.terminal`/`this._ws` binding — simplest correct approach is
|
||||
to just collapse the split and let normal session-select logic reopen
|
||||
Pane B's session as the new primary, rather than trying to promote the
|
||||
lightweight pane object in place).
|
||||
- **Both end** → falls through to today's normal "no active session" /
|
||||
welcome-screen state.
|
||||
- **Subagent/Ultracode floating windows** → no design work needed; they're
|
||||
already positioned independent of `.terminal-wrap`'s layout, so they
|
||||
continue to float over whichever pane(s) are on screen, unconstrained,
|
||||
exactly as today.
|
||||
|
||||
## Testing
|
||||
|
||||
- Unit: `SplitTerminalPane` connect/fit/destroy lifecycle (mock WS, like
|
||||
existing terminal tests use `TEST_PTY_SCRIPT`).
|
||||
- Route/integration: opening two WS connections to two different sessions
|
||||
from one simulated client concurrently — confirms the existing per-session
|
||||
cap and connection registry need no changes.
|
||||
- Browser (Playwright, `test/browser` since this is desktop-viewport-gated
|
||||
UI): open split via button+picker, verify both panes render live output
|
||||
independently, drag divider and confirm both refit, close Pane B and
|
||||
confirm Pane A returns to full width, kill the Pane B session externally
|
||||
and confirm auto-collapse.
|
||||
|
||||
## Open questions for review
|
||||
|
||||
None blocking — the scope-narrowing decisions above (Pane B feature parity,
|
||||
no persistence, side-by-side only, button+picker trigger) came directly from
|
||||
your answers during brainstorming. Flag anything here you want reconsidered.
|
||||
@@ -1,5 +1,10 @@
|
||||
# Tailscale Setup in the Installer (Plan)
|
||||
|
||||
> Superseded in part by [`installer-v2-plan.md`](installer-v2-plan.md) (2026-09-20), which
|
||||
> moved every human step before the build, added the sub-path / second-port answer for an
|
||||
> occupied `:443`, the opt-in rename, flags, and the done screen with a QR code. The
|
||||
> state machine and safety rules below still hold.
|
||||
|
||||
Goal: make "Codeman over Tailscale, with real HTTPS" a first-class, guided path in
|
||||
`install.sh`, instead of a one-line hint pointing at the docs. Today the safest
|
||||
recommended deployment (loopback bind + `tailscale serve`) is exactly what the
|
||||
|
||||
@@ -273,9 +273,16 @@ into the case's `.claude/settings.local.json` so that `/model` keeps working.
|
||||
- **Shell** for the times you want a terminal on your phone with no agent at all. It is a
|
||||
genuinely useful mode, not a fallback.
|
||||
|
||||
## Pointing one at your own server
|
||||
|
||||
Most of these harnesses can also run against a custom OpenAI-compatible endpoint instead of
|
||||
their native cloud backend, for one session at a time, an opt-in feature covered in full on
|
||||
[Custom Model Endpoints](Custom-Model-Endpoints).
|
||||
|
||||
## Read next
|
||||
|
||||
- [Core Concepts](Core-Concepts) - run modes versus location overlays.
|
||||
- [Custom Model Endpoints](Custom-Model-Endpoints) - run a harness against your own server.
|
||||
- [Settings Reference](Settings-Reference) - model, effort, and permission-mode settings.
|
||||
- [Keeping Agents Running](Keeping-Agents-Running) - what idle detection does per mode.
|
||||
- [Security](Security) - what skipping permission prompts actually means.
|
||||
|
||||
@@ -20,12 +20,19 @@ Three ways to get one, all under **+** next to the case picker:
|
||||
| How | Result |
|
||||
| ----------------- | ------------------------------------------------------------------------------------------------------ |
|
||||
| **Create New** | A fresh `~/codeman-cases/<name>` with a scaffolded `CLAUDE.md`. |
|
||||
| **Clone Repo** | A public repo cloned into `~/codeman-cases/<name>` and registered as a case. |
|
||||
| **Clone Repo** | A repo cloned into `~/codeman-cases/<name>` and registered as a case. Private repos need this machine's own git credentials (see below). |
|
||||
| **Link Existing** | An existing folder anywhere on disk, registered in place. Nothing is copied or moved. |
|
||||
|
||||
Linked cases keep living where they are. Deleting a case in Codeman removes the
|
||||
registration, and for a linked case that is all it removes.
|
||||
|
||||
**Clone Repo never asks for credentials.** It uses whatever the server's own git already has:
|
||||
an ssh key, or a credential helper such as `gh auth setup-git`. The Docker image can include
|
||||
helpers for GitHub (`gh`) and Azure DevOps (`az`), turned on in `docker-compose.override.yml`;
|
||||
then signing those CLIs in once from a shell session is enough. See the private repositories
|
||||
section of `docker/README.md`. Without credentials a private repo fails straight away with an
|
||||
authentication error.
|
||||
|
||||
**Cases created from scratch are the only copy of that code.** Uninstalling Codeman does not
|
||||
delete `~/codeman-cases/`, but treat that directory as real work, not scratch space.
|
||||
|
||||
|
||||
@@ -0,0 +1,189 @@
|
||||
# Custom Model Endpoints
|
||||
|
||||
Point a harness at your own OpenAI-compatible server instead of its native cloud backend, for
|
||||
one session at a time. "Custom endpoint" covers **local** hardware (llama.cpp, Ollama, vLLM,
|
||||
a home GPU rig, DGX Spark, Strix Halo) and **cloud** services (Azure AI Foundry's
|
||||
OpenAI-compatible endpoint, OpenRouter, a company gateway) alike, anything answering
|
||||
`GET /v1/models` and `POST /v1/chat/completions` in the standard shape.
|
||||
|
||||
**Off by default.** Turn it on in App Settings → Models → **Custom model endpoints**.
|
||||
|
||||
## Adding an endpoint
|
||||
|
||||
Still in App Settings → Models → Custom model endpoints:
|
||||
|
||||
1. **+ Add endpoint** — give it an id, a label, and the base URL (`http://192.168.1.50:8080`,
|
||||
say). An API key is optional; most local servers don't check one.
|
||||
2. **Discover** — fetches the endpoint's own model list over `GET /v1/models` and stores it.
|
||||
3. Pick a **default model** from what was discovered. This is the model the Run-menu entry
|
||||
applies directly when only one model is discovered; with two or more, it's just the one
|
||||
pre-marked in the picker dialog described below, not a silent default.
|
||||
|
||||
Endpoint management is admin-only in multi-user mode, the same as remote hosts and Docker
|
||||
hosts — these are machine-level infra, not a per-user setting.
|
||||
|
||||
**Model lists refresh themselves.** Every saved endpoint is re-discovered automatically every
|
||||
5 minutes in the background, so a model the server starts serving later — or stops serving —
|
||||
shows up without another manual click of **Discover**. One endpoint being unreachable on a
|
||||
given cycle (powered off, wrong network) never blocks the others from refreshing.
|
||||
|
||||
**Context length is picked up automatically where it can be, safely.** Against a
|
||||
llama.cpp/llama-swap server, discovery also learns each _currently loaded_ model's real
|
||||
context window and applies it to the launched session (Claude Code today — see below), so
|
||||
the harness stops assuming a large default window for a model name it doesn't recognise and
|
||||
overflowing a much smaller real one. It's deliberately never probed for a model that isn't
|
||||
already loaded, since asking a llama-swap server about an unloaded model can trigger an
|
||||
actual, slow model swap as a side effect — a model just not currently loaded keeps whatever
|
||||
context length an earlier cycle already learned for it instead.
|
||||
|
||||
## Running a session against one
|
||||
|
||||
With the setting on and at least one endpoint carrying a discovered model, the **Run**
|
||||
dropdown grows a **Custom Endpoints** section: one entry per harness that can redirect to a
|
||||
custom endpoint, per saved endpoint, e.g. "Claude Code (llama.cpp)". Picking one starts a
|
||||
session on that harness exactly the way its own entry would. It is a one-off "try this
|
||||
endpoint" action, not a sticky mode — the plain **Run** button still means "this harness,
|
||||
native cloud" afterward, and a fresh session never inherits whatever the last one was
|
||||
pointed at.
|
||||
|
||||
**Which model it uses depends on how many the endpoint has discovered.** With exactly one,
|
||||
the session launches straight away on that model — nothing to choose. With two or more, a
|
||||
small dialog asks which one to use for this launch before starting the session; the
|
||||
endpoint's default model, if set, is marked but not auto-picked, so a launch can deliberately
|
||||
use a different one without changing the saved default. The list is not raw discovery order
|
||||
either: the model llama-swap reports loaded and ready is moved to the top and tagged
|
||||
**Currently loaded**, and when nothing is loaded, the model you last launched on this harness
|
||||
and endpoint pair is moved up instead and tagged **Last used** (a per-device browser value, so
|
||||
another device starts from its own history). The default model keeps its own **Default** pill
|
||||
in both cases, and nothing is ever auto-chosen: the promoted row is simply the one under your
|
||||
thumb.
|
||||
|
||||
**For opencode, Codex, Gemini, Pi, Grok, DeepSeek and OMP, picking an entry launches
|
||||
straight onto the endpoint** — no restart, because the endpoint is applied before the
|
||||
session's process ever starts. **Claude still restarts the harness's process in place** —
|
||||
same tab, same conversation (`--resume`) — after a normal native launch, since that restart
|
||||
is far less jarring for Claude than for the other seven, whose own TUI can fully
|
||||
reinitialize on a restart. Either way, every supported harness reads its endpoint config at
|
||||
process start, never per turn, so there is no live hot-swap while a turn is running.
|
||||
|
||||
Picking an entry that launches a **brand-new** Claude session waits (up to 20 seconds) for it to
|
||||
finish its own startup before applying — a freshly started CLI reports itself as busy for its
|
||||
boot sequence, and applying to a genuinely busy session is refused so a real, in-progress
|
||||
turn is never interrupted out from under you. A session that is still busy after that wait
|
||||
(a very slow-starting CLI, or one you started typing into right away) surfaces that refusal
|
||||
as an ordinary error, which now stays on screen with a close button instead of vanishing
|
||||
after a few seconds — read it, it names the actual reason rather than a generic failure.
|
||||
|
||||
Entries are hidden entirely for a session in a **remote (SSH) or Docker case** — support for
|
||||
redirecting those hasn't landed yet, see below. The picker also only appears in the desktop
|
||||
**Run** dropdown; the phone home screen builds its own run picker separately and does not
|
||||
currently offer these entries.
|
||||
|
||||
**Against llama-swap, applying a selection also starts the actual model load, rather than
|
||||
waiting on your first prompt to do it.** llama-swap has no "switch model" button of its own
|
||||
— the only thing that starts a swap is a real request naming the model, and confirmed live:
|
||||
just applying a selection never reached llama-swap's own logs at all until something asked
|
||||
it to load. Picking an entry now also sends the smallest real request that will trigger
|
||||
that load, in the background, the moment the target model isn't already loaded and ready.
|
||||
|
||||
**The centred loading banner has no countdown and no automatic timeout — it waits as long as
|
||||
it takes, and tells you so.** When it knows the model's discovered file size (its GB figure,
|
||||
when llama-swap states one) it's shown too, e.g. "Loading qwen3.8-27b (16.4 GB) on
|
||||
llama-swap — this can take a while depending on your hardware and the model size." An
|
||||
earlier version tried to estimate and enforce a time limit, but real load time depends on
|
||||
hardware this feature has no way to know, so a fixed number was always a guess — worse, one
|
||||
that could kill a genuinely slow load partway through. If it really is taking too long, a
|
||||
**Cancel** button right on the banner ends the wait and **closes the session that load was
|
||||
for**, on your own call rather than a guessed deadline.
|
||||
|
||||
**The banner also shows a real, live second line of what llama.cpp itself is doing** — not
|
||||
a made-up progress phase, the actual next line the `llama-server` process printed, e.g.
|
||||
"llama.cpp: load_model: loading model '/models/.../Qwen3.8-27B.gguf'" then later
|
||||
"llama.cpp: llama_server: model loaded". It comes straight from llama-swap's own event
|
||||
feed, filtered down to just the backend process's own output (not llama-swap's own request
|
||||
logging), and stays on whatever it last said once the load goes quiet, rather than
|
||||
clearing back to nothing.
|
||||
|
||||
**You'll also be told if a session's model gets swapped out from under it later, not just
|
||||
at launch.** The conflict warning above only fires at the moment you launch or apply a
|
||||
model — llama.cpp only runs one model at a time, so if a DIFFERENT session using the same
|
||||
endpoint later triggers its own load, whatever was loaded before (including a session you
|
||||
already had running) gets silently evicted, with no warning at that instant since nothing
|
||||
conflicted when it was first set up. A background check (every 20 seconds) catches this
|
||||
after the fact and shows a toast naming which session lost its model and what's loaded now
|
||||
— so you know before typing into that session that it's about to reload (and, in turn,
|
||||
evict whatever displaced it).
|
||||
|
||||
**Claude Code specifically gets three extra fixes applied automatically:**
|
||||
|
||||
- Its discovered context length (see above) is passed through as
|
||||
`CLAUDE_CODE_MAX_CONTEXT_TOKENS`, so it doesn't send a full-size prompt against a much
|
||||
smaller real local context and overflow it.
|
||||
- Its session runs with an isolated `CLAUDE_CONFIG_DIR`, so the injected API key never sits
|
||||
in the same directory as a stored claude.ai login — that combination is harmless for actual
|
||||
requests (the API key wins) but the CLI still prints a "both claude.ai and
|
||||
ANTHROPIC_API_KEY set" warning about it, which this avoids entirely. The isolated directory
|
||||
keeps a link back to your real session history so the response viewer and similar features
|
||||
still work for that session. That isolated directory starts with no prior approvals of its
|
||||
own, so Codeman also pre-approves the injected key the same way answering Claude Code's own
|
||||
"Detected a custom API key" prompt once would — without it, that prompt would otherwise
|
||||
reappear on every single launch with nobody there to answer it.
|
||||
- **That same fresh isolated directory also looks like a brand-new Claude Code profile**, so
|
||||
without this fix it replayed the WHOLE first-run sequence every single launch: the theme
|
||||
picker, the security-notes screen, the "trust this folder?" dialog, and a one-time warning
|
||||
about running with permissions bypassed — none of which a real, already-used profile shows
|
||||
again. Codeman now pre-seeds that same "already been through this once" state (onboarding
|
||||
completed, this session's own project marked trusted, the bypass-permissions warning
|
||||
acknowledged) so a custom-model launch reaches the actual conversation exactly as fast as a
|
||||
native cloud one does, instead of stopping at a wizard with nobody there to click through it.
|
||||
|
||||
**If a model's real context is too small for Claude Code to even get started, you get a
|
||||
warning instead of a confusing failure.** Claude Code's own system prompt and tools take up
|
||||
roughly 40K tokens on their own, before you've typed anything — a small local model with a
|
||||
smaller real context than that fails outright on the very first message, no matter what
|
||||
context size Codeman tells it to expect (raising the declared context only changes when
|
||||
Claude Code trims _conversation history_, and there is none yet on message one). Picking
|
||||
such a model now shows an in-app dialog naming the model, its discovered context and what's
|
||||
needed, before anything launches or restarts, with the fix spelled out: reconfigure
|
||||
llama-swap to give that model (or a smaller one) an explicit larger context instead of
|
||||
relying on auto-fit (`--fit-ctx`), which sizes the context around fitting the biggest model
|
||||
rather than the biggest context — for example adding `-c 65536` to that model's llama-swap
|
||||
entry. "Launch anyway" is still there if you want to try regardless.
|
||||
|
||||
## Which harnesses actually work
|
||||
|
||||
| Harness | Status |
|
||||
| ---------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| **Claude Code, opencode, Pi, Grok, OMP** | Verified end-to-end against a real local server. |
|
||||
| **Codex** | Config is correct, and plain chat can work against a server that speaks the Responses API — but a real tool-call attempt comes back as inert text instead of running, so it's still not usable for real coding work. |
|
||||
| **Gemini** | Fails with an auth error gemini-cli raises once redirected. Unresolved; don't rely on it yet. |
|
||||
| **DeepSeek** | The original 404 is root-caused and fixed (DeepSeek Harness's own code was missing a `/v1` most local servers require) — not yet re-run against a real `dsh` install to confirm end-to-end. |
|
||||
| **Antigravity** | No known custom-endpoint mechanism at all. Not offered. |
|
||||
|
||||
Which harnesses show up in the Run-menu picker is read live off Codeman's own CLI registry,
|
||||
not a fixed list here, so this table can go stale before this page does — a greyed-out or
|
||||
missing entry is the more current answer.
|
||||
|
||||
## What it does not do
|
||||
|
||||
- **No remote or Docker sessions yet.** Both restart their agent differently under the hood
|
||||
(reattaching a durable tmux session rather than relaunching the process), so redirecting
|
||||
them needs its own plumbing that hasn't been built.
|
||||
- **No live hot-swap mid-conversation.** Applying a selection always restarts the process.
|
||||
- **No button to un-point a session from the UI yet.** Clearing back to native cloud is an
|
||||
HTTP call (`POST .../custom-model {"clear": true}`) or deleting the session; the settings
|
||||
panel manages saved endpoints, not what a running session is currently pointed at.
|
||||
- **Nothing is shared with your real cloud credentials.** The endpoint's own key, if any,
|
||||
never touches your Anthropic/OpenAI/Google login — a custom endpoint is a separate,
|
||||
explicit choice per session.
|
||||
|
||||
## Security
|
||||
|
||||
An endpoint's base URL can't point at a link-local or cloud-metadata address (both at save
|
||||
time and against the address it actually resolves to), the same guard Web Tabs uses for
|
||||
saved dashboards. Endpoint records and any per-session config files a harness needs are
|
||||
written with owner-only permissions. See
|
||||
[custom-model-endpoints-plan.md](https://github.com/Ark0N/Codeman/blob/master/docs/custom-model-endpoints-plan.md)
|
||||
in the repository for the full design reasoning, including why this feature closed a
|
||||
pre-existing gap in how session environment overrides were guarded rather than opening a new
|
||||
one.
|
||||
@@ -118,6 +118,35 @@ invisible from the host (`pi -c` and `grok -c` inside a docker case see only tha
|
||||
container's history). OMP's `sessions/` is the exception and is shared read-write, because
|
||||
Codeman reads it host-side for history and resume.
|
||||
|
||||
**Git hosts.** The agent image can also include the GitHub CLI (`gh`) and the Azure CLI (`az`,
|
||||
with the `azure-devops` extension), off by default, and its git then uses them as credential
|
||||
helpers for github.com and Azure DevOps. When the matching switch is on, their sign-ins are
|
||||
seeded like everything else, file by file: `~/.config/gh/hosts.yml` and `config.yml`, and the
|
||||
sign-in files from `~/.azure` (not its logs or extensions). With a switch off they are never
|
||||
copied in, even if the files exist. So once a switch is on and `gh auth login` / `az login`
|
||||
have been run where Codeman runs, agents in a Docker case can clone and push private repos on
|
||||
those hosts. Two limits:
|
||||
|
||||
- A token held in a desktop keyring or an encrypted token cache (Windows, macOS) is not
|
||||
inside those files and does not carry in. Sign in inside the container instead. The Docker
|
||||
server image and a headless Linux host keep it in the files, so they carry.
|
||||
- The sign-ins are mounted when a case container is **created**, so an existing container
|
||||
never picks them up. After turning a switch on, signing in, or rebuilding the agent image,
|
||||
**recreate the case container**: remove it, and the next session in that case creates a
|
||||
fresh one. (Or sign in inside the existing container instead.)
|
||||
|
||||
This hands a GitHub token and an Azure sign-in to every agent in a seeded Docker case, the
|
||||
same trust you already give it with Claude, Codex or gcloud. Turn seeding off for a case that
|
||||
should not have them.
|
||||
|
||||
Both CLIs are opt-in. To build the agent image with them, set
|
||||
`CODEMAN_AGENT_IMAGE_INSTALL_GH=1` and/or `CODEMAN_AGENT_IMAGE_INSTALL_AZ=1` where the image
|
||||
is built: in front of `node scripts/build-agent-image.mjs`, or in the Codeman server's
|
||||
environment for the image it builds automatically (in the Docker deployment, `environment:`
|
||||
in `docker-compose.override.yml`), then rebuild the image with `--no-cache`.
|
||||
`docker/README.md` ("Private repositories") has the details and the matching switches for
|
||||
the server image.
|
||||
|
||||
## Isolation
|
||||
|
||||
Every container runs hardened by default:
|
||||
|
||||
+47
-13
@@ -24,16 +24,22 @@ This installs Node.js, tmux and a build toolchain if they are missing (node-pty
|
||||
Linux prebuild, so it compiles from source), clones Codeman into `~/.codeman/app`, and
|
||||
builds it.
|
||||
|
||||
What it asks you:
|
||||
It starts by printing what it found (git, Node, tmux, build tools, agent CLIs, Tailscale,
|
||||
an existing install), then asks everything it needs up front, then does the work
|
||||
unattended. You can leave while it builds. What it asks you:
|
||||
|
||||
1. **Permission for every system change.** Package installs and agent CLI downloads are
|
||||
prompted individually. Nothing is installed silently. If no agent CLI is found, a menu
|
||||
offers to install any of them (DeepSeek excepted: its npm package installs only a
|
||||
launcher with no runnable profile), or you skip and install one yourself later.
|
||||
1. **One consent for the missing packages.** Git, Node.js, tmux and (on Linux) the build
|
||||
toolchain are installed after a single yes, and sudo asks for your password once for
|
||||
the whole run. Nothing is installed silently. If no agent CLI is found, a menu offers
|
||||
to install any of them (DeepSeek excepted: its npm package installs only a launcher
|
||||
with no runnable profile), or you skip and install one yourself later.
|
||||
2. **How the dashboard should be reachable.** Three choices:
|
||||
- **Tailscale** (recommended for phone access): keeps the loopback bind and walks you
|
||||
through `tailscale serve`, including the tailnet HTTPS toggle, then verifies the result
|
||||
end to end.
|
||||
- **Tailscale** (recommended for phone access): keeps the loopback bind, installs
|
||||
Tailscale if needed, logs in, enables the tailnet HTTPS toggle (it opens the admin
|
||||
page for you and waits; Ctrl+C there skips Tailscale for this run), then configures `tailscale serve` after the build and
|
||||
verifies the result end to end. If another app already owns `:443` on your node,
|
||||
you choose between a sub-path (`https://<machine>.<tailnet>.ts.net/codeman`, the
|
||||
default), a second port, replacing the other mapping, or skipping.
|
||||
- **Your local network** (`0.0.0.0`): prompts for a password. Skipping the password takes
|
||||
an explicit confirmation and ends on a loud warning.
|
||||
- **This machine only** (`127.0.0.1`): the safest option, and the default for a bare
|
||||
@@ -44,26 +50,54 @@ What it asks you:
|
||||
Tailscale. An existing loopback install defaults to keeping loopback, or to Tailscale when
|
||||
a serve mapping for Codeman is already there. A bare Enter never pulls in new software,
|
||||
and a non-interactive run always keeps the safe loopback default.
|
||||
3. **What to do when it finishes.** Run in this terminal, install as a background service
|
||||
that starts on boot, or do nothing yet.
|
||||
3. **What to call this machine on your tailnet** (Tailscale route only). By default the URL
|
||||
uses the machine's existing name. Answer yes to rename it `codeman-<hostname>`; the
|
||||
default is no, because the tailnet name is also what SSH and everything else on that
|
||||
machine are reached by.
|
||||
4. **Whether to run Codeman in the background.** Enter installs a systemd user service or a
|
||||
macOS LaunchAgent that starts on boot; answering no offers to start it in this terminal
|
||||
instead, or not at all.
|
||||
|
||||
It ends on a screen with the URL (your tailnet, your network, or this machine), a QR code to
|
||||
scan with your phone, and the two commands you need to manage the service.
|
||||
|
||||
Re-running the same one-liner **updates an existing install in place**. Local changes in
|
||||
`~/.codeman/app` are stashed rather than discarded, a running service is restarted and
|
||||
verified, and your existing network binding is preserved. An interrupted first install
|
||||
resumes instead of restarting.
|
||||
|
||||
Two other entry points exist:
|
||||
Other entry points:
|
||||
|
||||
```bash
|
||||
install.sh status # print the URLs, the QR code and the manage commands again
|
||||
install.sh update # update only
|
||||
install.sh uninstall # remove
|
||||
install.sh uninstall # remove (offers to undo a rename it performed)
|
||||
install.sh tailscale # retrofit Tailscale access onto an existing install
|
||||
install.sh name [<n>] # rename this machine on your tailnet (default codeman-<hostname>)
|
||||
install.sh cloudflared # install cloudflared for the in-app Cloudflare tunnel
|
||||
```
|
||||
|
||||
**Flags** answer the questions from the command line and pipe through `bash -s --`:
|
||||
|
||||
```bash
|
||||
curl -fsSL https://getcodeman.com/install | bash -s -- --tailscale --service
|
||||
curl -fsSL https://getcodeman.com/install | bash -s -- --lan --password 'x' --service
|
||||
curl -fsSL https://getcodeman.com/install | bash -s -- --local --run
|
||||
```
|
||||
|
||||
`--tailscale` / `--lan` / `--local` answer the access question, `--name <n>` / `--no-rename`
|
||||
the name, `--service` / `--run` / `--no-start` the last one. `--yes` takes every default
|
||||
(it still waits on a Tailscale login URL, and a network bind still asks for a password).
|
||||
`--port <n>` moves Codeman off 3000; the service file and the serve mapping follow it. On an
|
||||
existing install, `--port` and `--password` re-run the setup so the service file picks them up,
|
||||
and a re-run with `--lan` or `--tailscale` keeps the password the service already has.
|
||||
|
||||
**Automation and CI**: with no terminal attached, any step that would change the system
|
||||
aborts with instructions instead of running silently. Set `CODEMAN_NONINTERACTIVE=1` to
|
||||
approve those steps. `CODEMAN_TAILSCALE=1` preselects the Tailscale answer, and never
|
||||
installs Tailscale itself non-interactively.
|
||||
installs Tailscale itself non-interactively; a non-interactive run never renames the
|
||||
machine and never starts a service. Everything the unattended steps print goes to
|
||||
`~/.codeman/install.log`, and the last lines of it are shown when a step fails.
|
||||
|
||||
## Route B: npm
|
||||
|
||||
|
||||
@@ -34,6 +34,8 @@ Press `Ctrl+?` in the app for the same list in a floating overlay.
|
||||
| Right-click | Copy the selection. With nothing selected the native menu is left alone. |
|
||||
| `Ctrl+Z` | Swallowed in agent sessions so a running CLI cannot be suspended. Normal job control in a shell. |
|
||||
|
||||
Anything you copy is cleaned on the way to the clipboard: each line loses the padding spaces a full-screen program paints across the rest of the row. Leading indentation is left exactly as it is, so indented code, a `git log` message body and `git diff` context lines paste back the way they looked on screen. An `Alt+drag` rectangular selection is copied exactly as it looks, so its columns stay lined up.
|
||||
|
||||
## Everything else
|
||||
|
||||
| Shortcut | Action |
|
||||
|
||||
@@ -60,14 +60,20 @@ On by default; it can be turned off in settings.
|
||||
|
||||
A row of keys above the virtual keyboard, and what it contains depends on the session.
|
||||
|
||||
**Agent sessions** get quick actions: `/init`, `/clear`, `/compact`, a clipboard key, `Esc`,
|
||||
a path picker, an image key, and 🧠 when Read My Mind is on. Destructive commands need a
|
||||
double press, so you cannot fire `/clear` with a stray thumb. On Codex sessions the bar also
|
||||
shows `⇧←` and `⇧→`, the Shift-modified arrows Codex binds to editing the last queued
|
||||
message and walking the prompt stack.
|
||||
**Agent sessions** get quick actions: `/init`, `/clear`, `/compact`, a Compose key, `Esc`,
|
||||
a path picker, and 🧠 when Read My Mind is on. Compose opens a multiline editor with
|
||||
autocorrect: Enter adds a new line, and only Send delivers the text, as one paste followed
|
||||
by Enter, so your line breaks reach the agent intact. Anything already typed on the terminal
|
||||
prompt moves into the editor when it opens. Drafts are kept per session and in memory only,
|
||||
so switching tabs keeps them and a page reload forgets them; a dot on the key shows a draft
|
||||
is parked. The editor's Image button attaches photos and puts their paths into the draft.
|
||||
Destructive commands need a double press, so you cannot fire `/clear` with a stray thumb. On
|
||||
Codex sessions the bar also shows `⇧←` and `⇧→`, the Shift-modified arrows Codex binds to
|
||||
editing the last queued message and walking the prompt stack.
|
||||
|
||||
**Shell sessions** automatically swap it for terminal controls: `Ctrl`, `Esc`, `Tab`, four
|
||||
arrows, paste, and dismiss. Your normal preference is remembered and restored when you
|
||||
arrows, a direct Paste key (shell input is not an agent prompt, so there is no Compose
|
||||
there), and dismiss. Your normal preference is remembered and restored when you
|
||||
switch back to an agent session, so a settings change during a shell session cannot strip
|
||||
the bar away permanently.
|
||||
|
||||
|
||||
@@ -103,6 +103,30 @@ locked phone and the agent continues.
|
||||
With the inbox off, the buttons are stripped from the notification payload entirely rather
|
||||
than being shown and failing.
|
||||
|
||||
## When a session is watching its own work
|
||||
|
||||
An agent that starts a monitor, puts a shell in the background or hands a task to a cloud
|
||||
session is told by its CLI to end the turn and wait to be notified. The pane then goes
|
||||
quiet, and the CLI's idle notification arrives about a minute later — for a session that
|
||||
wants nothing from you.
|
||||
|
||||
Codeman reads what the CLI prints about its own background work and treats that prompt
|
||||
differently. It raises no tab alert, no desktop notification and no push, the session stays
|
||||
out of NEEDS YOU on every surface, and the row wears a blue **watching** badge instead. Hover
|
||||
it, or read it on a phone through your screen reader, and it says what is running: "1
|
||||
monitor", "2 shells", "1 background terminal".
|
||||
|
||||
The prompt itself is not thrown away. It sits in the Approvals drawer as an ordinary card,
|
||||
still answerable, with a line reading "quiet, watching 1 monitor" where a card you had
|
||||
already looked at would say nothing. The next time that session goes quiet for an ordinary
|
||||
reason, it alerts you exactly as before.
|
||||
|
||||
Two limits are worth knowing. A permission prompt or a question dialog still goes red
|
||||
whatever else the agent started, because that one blocks it outright. A question asked in
|
||||
plain prose is not a dialog, so an agent that starts a monitor and then writes "which branch
|
||||
should I target?" is quiet along with the rest — check a watching session yourself if it has
|
||||
been quiet longer than the work it is waiting for should take.
|
||||
|
||||
## The phone overview
|
||||
|
||||
On phones, tapping the "C" logo gives a session overview with **NEEDS YOU** first, then
|
||||
|
||||
@@ -43,7 +43,7 @@ To make a new one, click **+** next to the picker. The Add Case dialog has three
|
||||
| Tab | Use it when |
|
||||
| ----------------- | ------------------------------------------------------------------------------------------------------------------ |
|
||||
| **Create New** | Starting a fresh project. Creates `~/codeman-cases/<name>` and scaffolds a `CLAUDE.md` into it. |
|
||||
| **Clone Repo** | Working on an existing public repo. Paste the URL; Codeman preflights it as you type, offers the repo's real branches and tags, and fills in the case name. |
|
||||
| **Clone Repo** | Working on an existing repo: public, or private once this machine's git can authenticate (the Docker image can include `gh`/`az` helpers for this). Paste the URL; Codeman preflights it as you type, offers the repo's real branches and tags, and fills in the case name. |
|
||||
| **Link Existing** | The code is already on disk. Point at the folder, with **Browse** if you would rather click than type. |
|
||||
|
||||
The gear next to the picker holds two per-case toggles: **Agent Teams** and
|
||||
|
||||
@@ -33,11 +33,12 @@ Your devices join a private network, and Codeman stays bound to loopback. Nothin
|
||||
published to the internet, and you get real HTTPS with a real certificate.
|
||||
|
||||
The installer sets this up for you, including installing Tailscale, logging in, enabling
|
||||
tailnet HTTPS, and verifying the result end to end. To retrofit it onto an existing
|
||||
install:
|
||||
tailnet HTTPS, and verifying the result end to end. It ends on the URL with a QR code to
|
||||
scan. To retrofit it onto an existing install, or to see the URL and QR code again:
|
||||
|
||||
```bash
|
||||
install.sh tailscale
|
||||
install.sh status
|
||||
```
|
||||
|
||||
By hand:
|
||||
@@ -49,6 +50,22 @@ tailscale serve status
|
||||
|
||||
Then open `https://<machine>.<tailnet>.ts.net` from any device on your tailnet.
|
||||
|
||||
### The name in the URL
|
||||
|
||||
The URL is the machine's MagicDNS name, so on a machine called `tnode` it is
|
||||
`https://tnode.<tailnet>.ts.net`. Three ways to influence that, from least to most work:
|
||||
|
||||
| You want | How |
|
||||
| ------------------------------------------ | ----------------------------------------------------------------------------------------------------- |
|
||||
| The machine's existing name (default) | Nothing. This is what the installer does unless you say otherwise. |
|
||||
| `https://codeman-<hostname>.<tailnet>.ts.net` | Answer yes to the installer's name question, pass `--name codeman-<hostname>`, or run `install.sh name`. This renames the machine tailnet-wide (SSH included), which is why the installer defaults to no. `install.sh uninstall` offers to rename it back. |
|
||||
| `https://codeman.<tailnet>.ts.net` | A [Tailscale Service](https://tailscale.com/docs/features/tailscale-services). Only a **tagged** node can host one (a device signed in with a user account cannot), the service is defined and approved in the admin console, and the feature is in beta. The installer does not set this up; it is a `tailscale serve --service=svc:codeman --https=443 127.0.0.1:3000` on a tagged host once the service exists. |
|
||||
|
||||
If `:443` on your node already belongs to another app, the installer offers Codeman under
|
||||
`https://<machine>.<tailnet>.ts.net/codeman` (the default, via `tailscale serve --set-path`
|
||||
plus Codeman's `--base-url`), on a second port (`https://<machine>.<tailnet>.ts.net:8443`),
|
||||
or replacing the other mapping. It never replaces anything without asking.
|
||||
|
||||
Notes:
|
||||
|
||||
- Keep the loopback bind. `tailscale serve` connects to `127.0.0.1:3000` locally, so
|
||||
@@ -58,7 +75,11 @@ Notes:
|
||||
- Codeman's Host-header allowlist already accepts `.ts.net`, so no extra configuration is
|
||||
needed.
|
||||
- The installer never resets or rewrites `serve` mappings other than the one pointing at
|
||||
Codeman's port, so unrelated serve configuration is left alone.
|
||||
Codeman's port, so unrelated serve configuration is left alone. It also never opens a
|
||||
`tailscale funnel` (that is the public internet) and never advertises a Tailscale Service.
|
||||
- On macOS, the App Store and standalone Tailscale apps only run once someone is logged in,
|
||||
so a headless Mac needs the open-source `tailscaled` for the URL to come back after a
|
||||
reboot on its own.
|
||||
|
||||
## Cloudflare tunnel
|
||||
|
||||
|
||||
@@ -67,6 +67,14 @@ On Linux, if you want the service running while you are not logged in:
|
||||
loginctl enable-linger $USER
|
||||
```
|
||||
|
||||
On macOS, a LaunchAgent starts when you log in, not at boot. A headless Mac (no GUI login)
|
||||
needs a system LaunchDaemon instead, written by hand as root. The installer recognises an
|
||||
existing `/Library/LaunchDaemons/com.codeman.web.plist` and leaves it alone rather than
|
||||
installing a LaunchAgent next to it, since the two would fight over the port; remove the
|
||||
daemon first if you want to switch. The same login caveat applies to the App Store and
|
||||
standalone Tailscale apps, so on a headless Mac the Tailscale URL only comes back after a
|
||||
reboot if the open-source `tailscaled` is used.
|
||||
|
||||
### Writing the unit by hand
|
||||
|
||||
**Linux (systemd user unit):**
|
||||
|
||||
@@ -46,6 +46,7 @@ supervised by systemd or launchd; npm installs report as non-updatable. See
|
||||
| Extended Keyboard Bar | Per device | Which accessory bar phones get. Shell sessions override it while they are active. |
|
||||
| Wheel Scrolls Local History | Off | Keeps the wheel on the local buffer instead of forwarding it to the CLI. |
|
||||
| Auto Copy Selection | Off | Copies highlighted terminal text to the clipboard the moment you finish selecting it. Ctrl+C still copies on demand. |
|
||||
| Trim The Pane Margin On Copy | On | Takes the left margin a full-screen agent CLI paints down its own edge off a copy, so the text pastes flush. Each CLI declares its own width, and the strip never exceeds the indent every selected line shares, so nesting is kept. Claude Code and Codex declare a margin; a shell does not. |
|
||||
| Normal / Bold font weight | xterm defaults | Per device, each slot from 100 to 900. The bundled JetBrains Mono renders every step, so a lighter normal weight makes Claude's bold headings stand out. Applies live to the terminal, both echo overlays and open team panes. |
|
||||
| WebGL Renderer | On | With a GPU-stall watchdog that falls back to DOM rendering. |
|
||||
| Gesture Control | Off | Camera hand tracking. Also needs `CODEMAN_GESTURE=1` on the server. |
|
||||
@@ -55,12 +56,14 @@ supervised by systemd or launchd; npm installs report as non-updatable. See
|
||||
Chips for every optional header control, with a live preview of the resulting header:
|
||||
|
||||
Run, Font Size, System Stats, Redraw Terminal, Response Viewer, Away Digest, Session
|
||||
Manager, Attachments, File Viewer, Multi-monitor, Plan Usage, Lifecycle Log, Monitor,
|
||||
Manager, Attachments, File Viewer, Multi-monitor, Split, Plan Usage, Lifecycle Log, Monitor,
|
||||
Project Insights, File Browser, Subagents, Approvals Inbox, Read My Mind, Ultracode Agents,
|
||||
Ultracode Windows, Cron.
|
||||
|
||||
Most default to off. The stock desktop header is system stats, File Viewer, and the gear.
|
||||
New header controls never appear on phones.
|
||||
New header controls never appear on phones. Split is desktop-only regardless of this
|
||||
setting — the button and the feature both stay off below a ~1180px viewport, where two
|
||||
resizable panes plus their divider have nowhere to go.
|
||||
|
||||
This section also holds background-agent tracking, including whether to track agents for
|
||||
every session or only the active tab.
|
||||
@@ -92,6 +95,10 @@ Model and effort are both **soft defaults**: the model is written into the case'
|
||||
`.claude/settings.local.json` and effort is passed at start, so `/model` and `/effort`
|
||||
inside a session override them at any time.
|
||||
|
||||
**Custom model endpoints** (off by default) adds a saved-endpoint list plus a matching
|
||||
section to the Run dropdown, for pointing a harness at your own OpenAI-compatible server
|
||||
instead of its native cloud backend. See [Custom Model Endpoints](Custom-Model-Endpoints).
|
||||
|
||||
### Agents & CLIs
|
||||
|
||||
| Setting | Notes |
|
||||
|
||||
@@ -48,6 +48,7 @@ One tab per session, in your order, and that order syncs across your devices.
|
||||
| Yellow tab, blinking | The agent is waiting for input from you. |
|
||||
| Red tab, blinking | A question or permission prompt is blocking the session. |
|
||||
| No dot | The session is not running. |
|
||||
| Muted grey dot plus an `exited (137)` badge | The agent inside the pane has exited, with that exit code (or `exited (signal 9)`). A bare `exited` means tmux saw the pane die but did not report how, which is not the same as a clean `exited (0)`. Detailed sidebar and rail rows read `exited` in their pill. |
|
||||
|
||||

|
||||
|
||||
@@ -116,6 +117,7 @@ The right side of the header. Almost all of these are off until you enable them
|
||||
| Lifecycle Log | Off | Session start, exit, and kill audit trail. |
|
||||
| Cron ⏰ | Off | Scheduled jobs. |
|
||||
| Multi-monitor | Off, macOS | Opens a window spanning every display. |
|
||||
| Split | Off, desktop only | View a second session beside the active one, with a draggable divider. |
|
||||
| Tunnel indicator | When a tunnel runs | Cloudflare tunnel status. |
|
||||
| Admin panel | Multi-user only | User administration. |
|
||||
|
||||
|
||||
@@ -12,6 +12,7 @@
|
||||
|
||||
- [The Dashboard](The-Dashboard)
|
||||
- [Agent CLIs](Agent-CLIs)
|
||||
- [Custom Model Endpoints](Custom-Model-Endpoints)
|
||||
- [Working With Files](Working-With-Files)
|
||||
- [Input And Voice](Input-And-Voice)
|
||||
- [Mobile Guide](Mobile-Guide)
|
||||
|
||||
+1478
-553
File diff suppressed because it is too large
Load Diff
Generated
+3
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "aicodeman",
|
||||
"version": "1.30.0",
|
||||
"version": "1.32.1",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "aicodeman",
|
||||
"version": "1.30.0",
|
||||
"version": "1.32.1",
|
||||
"hasInstallScript": true,
|
||||
"license": "MIT",
|
||||
"workspaces": [
|
||||
@@ -55,6 +55,7 @@
|
||||
"@types/web-push": "^3.6.4",
|
||||
"@types/ws": "^8.18.1",
|
||||
"@vitest/coverage-v8": "^4.1.8",
|
||||
"@xterm/headless": "^6.0.0",
|
||||
"agent-browser": "^0.6.0",
|
||||
"esbuild": "^0.27.3",
|
||||
"eslint": "^9.0.0",
|
||||
|
||||
+2
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "aicodeman",
|
||||
"version": "1.30.0",
|
||||
"version": "1.32.1",
|
||||
"description": "Mission control for AI coding agents - run 20 autonomous agents with real-time monitoring and session persistence",
|
||||
"type": "module",
|
||||
"main": "dist/index.js",
|
||||
@@ -123,6 +123,7 @@
|
||||
"@types/web-push": "^3.6.4",
|
||||
"@types/ws": "^8.18.1",
|
||||
"@vitest/coverage-v8": "^4.1.8",
|
||||
"@xterm/headless": "^6.0.0",
|
||||
"agent-browser": "^0.6.0",
|
||||
"esbuild": "^0.27.3",
|
||||
"eslint": "^9.0.0",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "codeman",
|
||||
"description": "Drive Codeman, the self-hosted session manager for AI coding agents, from inside a Claude Code session: spawn worker sessions, prompt them, wait for them, read their answers, clean up. Acts only inside a Codeman-managed session.",
|
||||
"version": "1.30.0",
|
||||
"version": "1.32.1",
|
||||
"author": {
|
||||
"name": "Ark0N",
|
||||
"url": "https://github.com/Ark0N"
|
||||
|
||||
@@ -146,10 +146,44 @@ console.log('\n[build] content-hash cache busting');
|
||||
html = html.replaceAll(`"${original}"`, `"${hashed}"`);
|
||||
}
|
||||
writeFileSync(join(distPublic, 'index.html'), html);
|
||||
|
||||
// Rewrite sw.js from the SAME manifest that just renamed the files.
|
||||
//
|
||||
// The service worker's precache list used to be maintained by hand with the
|
||||
// pre-hash names, so after this step every entry in it pointed at a file that
|
||||
// no longer existed and `cache.add(...).catch(() => {})` hid it. Deriving it
|
||||
// here is the only way the two cannot drift.
|
||||
//
|
||||
// The cache key gets the build hash for the same reason: `activate` deletes
|
||||
// every cache that is not the current one, so a constant key meant that
|
||||
// cleanup never ran and hashed assets from every past release piled up.
|
||||
const swPath = join(distPublic, 'sw.js');
|
||||
let sw = readFileSync(swPath, 'utf8');
|
||||
const hashedAssets = Object.values(manifest);
|
||||
const buildId = createHash('md5').update(hashedAssets.join('|')).digest('hex').slice(0, 12);
|
||||
// Rewrite the two declarations. Anchored on the full `const … = …;` text so
|
||||
// each pattern occurs exactly once and cannot collide with prose in sw.js's
|
||||
// own comments — an earlier cut used bare `__BUILD_ID__` sentinels and the
|
||||
// first match landed in the comment that documented them, leaving the real
|
||||
// constant untouched and still producing a plausible-looking cache key.
|
||||
const swEdits = [
|
||||
["const BUILD_ID = 'dev';", `const BUILD_ID = '${buildId}';`],
|
||||
['const HASHED_ASSETS = [];', `const HASHED_ASSETS = [${hashedAssets.map((p) => JSON.stringify(p)).join(', ')}];`],
|
||||
];
|
||||
for (const [from, to] of swEdits) {
|
||||
const hits = sw.split(from).length - 1;
|
||||
if (hits !== 1) {
|
||||
throw new Error(`sw.js: expected exactly one \`${from}\`, found ${hits} — precache would ship stale`);
|
||||
}
|
||||
sw = sw.replace(from, to);
|
||||
}
|
||||
writeFileSync(swPath, sw);
|
||||
|
||||
console.log(' Hashed files:');
|
||||
for (const [orig, hashed] of Object.entries(manifest)) {
|
||||
console.log(` ${orig} -> ${hashed}`);
|
||||
}
|
||||
console.log(` sw.js: cache bucket codeman-${buildId}, ${hashedAssets.length} precached assets`);
|
||||
}
|
||||
|
||||
// 6. Compress with gzip + brotli
|
||||
|
||||
@@ -55,9 +55,36 @@ export function agentImageNpmPackages(catalog) {
|
||||
return packages;
|
||||
}
|
||||
|
||||
/** The `--build-arg` pairs the agent image takes. PURE. */
|
||||
export function agentImageBuildArgPairs(catalog) {
|
||||
return [['CLI_NPM_PACKAGES', agentImageNpmPackages(catalog).join(' ')]];
|
||||
/**
|
||||
* Environment variable → agent.Dockerfile ARG for the optional git-host CLIs (gh, az).
|
||||
* ⚠️ Mirrored by `GIT_HOST_CLI_BUILD_ARGS` in `src/docker-hosts.ts`; the parity test pins them.
|
||||
*/
|
||||
export const GIT_HOST_CLI_BUILD_ARGS = [
|
||||
['CODEMAN_AGENT_IMAGE_INSTALL_GH', 'CODEMAN_INSTALL_GH'],
|
||||
['CODEMAN_AGENT_IMAGE_INSTALL_AZ', 'CODEMAN_INSTALL_AZ'],
|
||||
];
|
||||
|
||||
/**
|
||||
* The `--build-arg` pairs for the optional git-host CLIs. PURE. An unset or empty variable
|
||||
* contributes NOTHING, so the Dockerfile's own default (off) applies and the argv is the same
|
||||
* as before these existed; anything other than 0/1 is refused rather than guessed at.
|
||||
*/
|
||||
export function gitHostCliBuildArgPairs(env) {
|
||||
const pairs = [];
|
||||
for (const [envName, argName] of GIT_HOST_CLI_BUILD_ARGS) {
|
||||
const value = env[envName];
|
||||
if (value === undefined || value === '') continue;
|
||||
if (value !== '0' && value !== '1') {
|
||||
throw new Error(`${envName} must be 0 or 1, got ${JSON.stringify(value)}`);
|
||||
}
|
||||
pairs.push([argName, value]);
|
||||
}
|
||||
return pairs;
|
||||
}
|
||||
|
||||
/** The `--build-arg` pairs the agent image takes. PURE given `env`. */
|
||||
export function agentImageBuildArgPairs(catalog, env = process.env) {
|
||||
return [['CLI_NPM_PACKAGES', agentImageNpmPackages(catalog).join(' ')], ...gitHostCliBuildArgPairs(env)];
|
||||
}
|
||||
|
||||
/** Read the committed catalogue. IO. */
|
||||
|
||||
+20
-1
@@ -74,6 +74,15 @@ echo "[self-update] $(date) start tag=$TAG supervisor=$SUPERVISOR repo=$REPO"
|
||||
export PATH="$(dirname "$NODE"):$HOME/.local/bin:$HOME/.npm-global/bin:/usr/local/bin:/opt/homebrew/bin:$PATH"
|
||||
export GIT_TERMINAL_PROMPT=0
|
||||
|
||||
# --node is the server's process.execPath, a VERSIONED path (Homebrew resolves it
|
||||
# into Cellar/node/<ver>/). A `brew upgrade node` under a long-running server
|
||||
# deletes it, and every status write then failed, so the status stayed "queued"
|
||||
# forever. Fall back to whatever node is on PATH.
|
||||
if [ ! -x "$NODE" ]; then
|
||||
echo "[self-update] WARN: $NODE is not executable, falling back to node on PATH"
|
||||
NODE="$(command -v node || echo node)"
|
||||
fi
|
||||
|
||||
TO_VERSION="${TAG##*@}" # codeman@0.9.4 → 0.9.4 (tag is validated upstream)
|
||||
STASH_REF=""
|
||||
MANUAL_CMD=""
|
||||
@@ -276,7 +285,17 @@ case "$SUPERVISOR" in
|
||||
# domain needs root, but we don't need it — kill the server and launchd
|
||||
# respawns it on the new dist/ within ThrottleInterval seconds.
|
||||
if [[ -n "$SERVER_PID" ]] && kill "$SERVER_PID" 2>/dev/null; then
|
||||
: # respawn is launchd's job from here
|
||||
# Respawn is launchd's job, but only once the old process EXITS. A graceful
|
||||
# shutdown that hangs leaves the port closed and the service down, so
|
||||
# escalate to SIGKILL (tmux sessions live outside the server and survive).
|
||||
for _ in $(seq 1 30); do
|
||||
kill -0 "$SERVER_PID" 2>/dev/null || break
|
||||
sleep 1
|
||||
done
|
||||
if kill -0 "$SERVER_PID" 2>/dev/null; then
|
||||
echo "[self-update] server pid $SERVER_PID still alive 30s after SIGTERM, sending SIGKILL"
|
||||
kill -9 "$SERVER_PID" 2>/dev/null || true
|
||||
fi
|
||||
else
|
||||
MANUAL_CMD="sudo launchctl kickstart -k system/com.codeman.web"
|
||||
write_status "completed-needs-manual-restart" "Update staged — restart Codeman to apply v$TO_VERSION."
|
||||
|
||||
+10
@@ -129,6 +129,8 @@ program
|
||||
|
||||
/** Same registry the server resolves case names through (mirrors `case-routes.ts`). */
|
||||
const LINKED_CASES_FILE = dataPath('linked-cases.json');
|
||||
/** Graceful shutdown budget before the process force-exits (see the SIGTERM handler). */
|
||||
const SHUTDOWN_FORCE_EXIT_MS = 10_000;
|
||||
|
||||
/**
|
||||
* Case name to directory, checking `linked-cases.json` FIRST and falling back to the
|
||||
@@ -1002,6 +1004,14 @@ webCmd.action(async (options) => {
|
||||
if (shuttingDown) return;
|
||||
shuttingDown = true;
|
||||
console.log(palette.warn(`\n${signal} received, shutting down gracefully...`));
|
||||
// A hung stop() must not keep the process alive: the listener is already
|
||||
// closed by then, and a KeepAlive LaunchDaemon only respawns the server once
|
||||
// it EXITS (systemd would SIGKILL after TimeoutStopSec; launchd does not).
|
||||
// Seen after a self-update on macOS: port closed, process alive, service down.
|
||||
setTimeout(() => {
|
||||
console.error(palette.err(`Shutdown did not finish in ${SHUTDOWN_FORCE_EXIT_MS / 1000}s, forcing exit`));
|
||||
process.exit(1);
|
||||
}, SHUTDOWN_FORCE_EXIT_MS).unref();
|
||||
try {
|
||||
await server.stop();
|
||||
} catch (err) {
|
||||
|
||||
@@ -294,6 +294,23 @@ const capabilitiesSchema = z
|
||||
effort: z.boolean(),
|
||||
agentSkillInjection: z.boolean(),
|
||||
statusLineTelemetry: z.boolean(),
|
||||
// How many columns this CLI indents its transcript body by, so a copy can take
|
||||
// that much off the clipboard. Bounded, because it is the whole strip: a copy
|
||||
// never removes more than this, nor more than every selected line shares.
|
||||
//
|
||||
// ⚠ DECLARED, not measured off the pane, and two measured attempts are why.
|
||||
// Asking whether the pane painted spaces across the unused part of each row
|
||||
// separates a TUI from a shell perfectly where it fires and never
|
||||
// over-stripped, but it is a function of pane WIDTH: that padding exists
|
||||
// only while a rendered line stops short of the CLI's own layout width, and
|
||||
// Claude Code's prose wraps to fill it — the share of padded rows on one
|
||||
// live transcript ran 44%, 6%, 6%, 7% and 87% at 123, 160, 198, 235 and 298
|
||||
// columns, so the strip did nothing at any ordinary size. Taking the
|
||||
// narrowest indent on screen instead fires everywhere and over-strips, since
|
||||
// a file listing inside the transcript can be the narrowest thing on it.
|
||||
// A declared width cannot do either. Absent means no strip, so a CLI whose
|
||||
// transcript layout nobody has measured is never touched.
|
||||
transcriptGutter: z.number().int().min(1).max(8).optional(),
|
||||
workDetect: z
|
||||
.object({
|
||||
promptGlyph: z.string().min(1).max(8),
|
||||
@@ -308,8 +325,29 @@ const capabilitiesSchema = z
|
||||
(src) => compileVersionRegex(src) !== null,
|
||||
'workingLine must be a regex compileVersionRegex() accepts: at most 200 characters, no nested quantifiers'
|
||||
),
|
||||
// Same guard, same reasons: this one runs over the foot of a pane capture every
|
||||
// time a session settles, and ~/.codeman/clis.json can set it.
|
||||
watchingLine: z
|
||||
.string()
|
||||
.min(1)
|
||||
.refine(
|
||||
(src) => compileVersionRegex(src) !== null,
|
||||
'watchingLine must be a regex compileVersionRegex() accepts: at most 200 characters, no nested quantifiers'
|
||||
)
|
||||
.optional(),
|
||||
// Bounded hard: this is how far up the screen a config file may push the search,
|
||||
// and every row it adds is one more row the agent itself may be able to write.
|
||||
watchingLines: z.number().int().min(1).max(8).optional(),
|
||||
})
|
||||
.strict()
|
||||
// A window with nothing to search is a typo, not a configuration. Refused at LOAD
|
||||
// time for the same reason `privilegedParams[].param` is checked against the params
|
||||
// the entry declares: the failure is otherwise silent and looks like a feature that
|
||||
// simply never fires.
|
||||
.refine(
|
||||
(v) => v.watchingLines === undefined || v.watchingLine !== undefined,
|
||||
'watchingLines has nothing to bound without a watchingLine'
|
||||
)
|
||||
.optional(),
|
||||
model: z
|
||||
.object({ source: z.enum(['flag', 'claude-settings-file', 'none']), param: z.string().optional() })
|
||||
@@ -340,6 +378,35 @@ const capabilitiesSchema = z
|
||||
// an env var, so it declares baseUrl/apiKey injection with no model var at all.
|
||||
modelVars: z.array(envName).max(8),
|
||||
launchModel: launchModelTemplate,
|
||||
// Optional: the env var to carry a discovered per-model context-window size
|
||||
// (claude's CLAUDE_CODE_MAX_CONTEXT_TOKENS), and/or the env var that isolates
|
||||
// this session's config/credential directory from the user's real one (claude's
|
||||
// CLAUDE_CONFIG_DIR) so an injected API key never collides with a stored OAuth
|
||||
// session. See the customModelInjection doc comment in cli-registry/types.ts.
|
||||
contextLengthVar: envName.optional(),
|
||||
configDirVar: envName.optional(),
|
||||
// Relative path, WITHIN the isolated configDirVar directory, of a trust-dialog
|
||||
// seed file the CLI itself owns the shape of — claude's `.claude.json`
|
||||
// `customApiKeyResponses.approved` list, the same field an interactive "Detected
|
||||
// a custom API key — use it?" prompt writes to on a real terminal. Only makes
|
||||
// sense alongside configDirVar (an isolated, otherwise-empty directory has none
|
||||
// of a real profile's prior approvals), and only implemented for the
|
||||
// 'claude-api-key-responses' shape today — see custom-model-injection-apply.ts.
|
||||
apiKeyTrustFile: z
|
||||
.object({ relPath: z.string().min(1).max(80), shape: z.literal('claude-api-key-responses') })
|
||||
.strict()
|
||||
.optional(),
|
||||
// An isolated config directory replays the CLI's whole first-run sequence (theme
|
||||
// picker, security notes, per-project trust dialog, bypass-permissions warning)
|
||||
// on every launch, same root cause as apiKeyTrustFile above — this reuses that
|
||||
// same file to pre-seed the state a real, already-onboarded profile carries. See
|
||||
// the customModelInjection doc comment in cli-registry/types.ts.
|
||||
skipFirstRunPrompts: z.boolean().optional(),
|
||||
// DeepSeek-only, confirmed by reading its own bundled SDK source: it concatenates
|
||||
// "/chat/completions" onto baseUrlVar's value with no "/v1" of its own, while
|
||||
// llama-swap/llama.cpp only serves the "/v1/..." path — claude/gemini must NOT
|
||||
// get this. See the customModelInjection doc comment in cli-registry/types.ts.
|
||||
appendV1Suffix: z.boolean().optional(),
|
||||
})
|
||||
.strict(),
|
||||
z
|
||||
|
||||
@@ -75,11 +75,24 @@ function agentDefaults(): Pick<
|
||||
};
|
||||
}
|
||||
|
||||
// `accent` on every entry below (except SHELL, which the frontend renders no
|
||||
// distinct color for) is measured from the actual `.btn-toolbar.btn-run.mode-<id>`
|
||||
// CSS rule's `border-color` on the OG skin (styles.css) — the single cleanest
|
||||
// representative hex each entry's own multi-stop gradient resolves around.
|
||||
// Corrected 2026-09-21 after PR #458's review found several were simply wrong
|
||||
// (e.g. claude was registered as Anthropic's brand orange, `#d97757`, but the
|
||||
// button renders blue): `docs/cli-registry.md`'s own "transcribed, not
|
||||
// authoritative, re-measure before wiring one up" warning for this
|
||||
// DECLARED-FOR-LATER field, taken literally. The one exception is GEMINI, whose
|
||||
// run-button border (#60a5fa) is the only one that disagrees with its own tab badge
|
||||
// and run-mode dot (#8ab4f8); it takes the badge colour, so every accent names the
|
||||
// same hex the frontend uses as that CLI's flat identity. This is a data-accuracy fix only —
|
||||
// `accent` still has no reader, so nothing rendered changes because of it.
|
||||
const CLAUDE: CliEntry = {
|
||||
id: 'claude' as CliEntry['id'],
|
||||
label: 'Claude',
|
||||
shortBadge: 'CC',
|
||||
accent: '#d97757',
|
||||
accent: '#3b82f6',
|
||||
enabled: true,
|
||||
stock: true,
|
||||
order: 0,
|
||||
@@ -200,12 +213,31 @@ const CLAUDE: CliEntry = {
|
||||
},
|
||||
capabilities: {
|
||||
external: false,
|
||||
// Claude indents its transcript body two columns and puts its own ●/✻/❯ markers
|
||||
// in them, so a copy can drop two and paste flush. Claude and codex are the only
|
||||
// entries that declare this, because theirs are the only gutters that have been measured.
|
||||
transcriptGutter: 2,
|
||||
// The historical hard-coded pair, now stated as data. `workingLine` matches both the
|
||||
// `✻ Actualizing… (39s · ↓ 2.0k tokens)` status line and the bare `esc to interrupt`
|
||||
// footer, because tmux repaints partially and only one of the two may land in a chunk.
|
||||
workDetect: {
|
||||
promptGlyph: '❯',
|
||||
workingLine: String.raw`…\s*\((?:\d+h\s+)?(?:\d+m\s+)?\d+s\b|esc to interrupt`,
|
||||
// Claude prints what it started in the background on the footer row beneath its
|
||||
// composer, as `⏵⏵ bypass permissions on · 1 monitor · ← for agents`. The labels are
|
||||
// the CLI's own words for each kind of background task, and group 1 is the one
|
||||
// Codeman badges the session with. Verified against a live 2.1.278 pane on
|
||||
// 2026-09-21.
|
||||
// ⚠️ Two things keep an agent from writing its own label here, and both matter.
|
||||
// The footer is the LAST row, so the default one-row window (`WATCHING_TAIL_LINES`)
|
||||
// holds nothing but Ink's own chrome — in particular it leaves out the status line
|
||||
// directly above, whose content comes from a `statusLine` command a bypassed
|
||||
// session can write into its own `.claude/settings.json`. And the leading `·` keeps
|
||||
// the match on the footer's own item list rather than on any text that happens to
|
||||
// carry a count. A footer that ever drew the chip as its only item would report no
|
||||
// watching rather than open that door. See `watchingLabel()` in
|
||||
// `session-activity.ts`.
|
||||
watchingLine: String.raw`·\s*(\d+ (?:monitors?|shells?|teams?|local agents?|cloud sessions?|MCP tasks?|background tasks?|(?:background|remote) dynamic workflows?|Artifact comment monitors?))`,
|
||||
},
|
||||
requiresMux: false,
|
||||
// Claude installs Codeman's own hooks block into every workspace it runs in, so its
|
||||
@@ -228,15 +260,31 @@ const CLAUDE: CliEntry = {
|
||||
privilegedParams: [],
|
||||
// ANTHROPIC_* is NOT in allowedPrefixes/allowedKeys above (deliberately — see the
|
||||
// allowedPrefixes comment nearby), so these are unreachable via plain envOverrides
|
||||
// today; listed here only so the dedicated custom-model route (docs/custom-model-endpoints-plan.md
|
||||
// chunk 5) clamps them for a non-granted multi-user owner the same way every other
|
||||
// CLI's injection vars are clamped, the day that route widens who can set them.
|
||||
// today. privilegedEnvKeys has exactly one consumer, ownerClampedEnvKeys() in
|
||||
// session-env-clamp.ts, which feeds the generic envOverrides clamp on
|
||||
// POST /api/sessions, POST /api/quick-start and reboot-restore — no custom-model
|
||||
// route reads this field at all, and the values it injects are merged in AFTER
|
||||
// that clamp runs regardless of what's listed here.
|
||||
privilegedEnvKeys: [
|
||||
'ANTHROPIC_BASE_URL',
|
||||
'ANTHROPIC_API_KEY',
|
||||
'ANTHROPIC_DEFAULT_SONNET_MODEL',
|
||||
'ANTHROPIC_DEFAULT_HAIKU_MODEL',
|
||||
'ANTHROPIC_DEFAULT_OPUS_MODEL',
|
||||
// CLAUDE_CODE_MAX_CONTEXT_TOKENS already matches the CLAUDE_CODE_* allowedPrefix, and
|
||||
// CLAUDE_CONFIG_DIR is already an allowed exact key (docs/wiki/Agent-CLIs.md), so both
|
||||
// were already reachable via plain envOverrides before this pair existed and this
|
||||
// feature does not strictly need either listed. They stay listed anyway, because
|
||||
// types.ts's rule ("every traffic-redirecting var this feature introduces MUST also
|
||||
// appear in privilegedEnvKeys") is meant to hold literally, not with an exception
|
||||
// carved out for the two vars that happen not to need it today. The real
|
||||
// consequence lands on the GENERIC envOverrides clamp above, not on this feature:
|
||||
// a non-granted multi-user owner can no longer set CLAUDE_CONFIG_DIR through
|
||||
// envOverrides at all (the per-client-account override, #255), and a PERSISTED one
|
||||
// is now stripped on reboot-restore for such an owner too — see
|
||||
// session-env-clamp.ts's own fileoverview.
|
||||
'CLAUDE_CODE_MAX_CONTEXT_TOKENS',
|
||||
'CLAUDE_CONFIG_DIR',
|
||||
],
|
||||
gates: { nameFlag: { minVersion: '2.1.224', failClosed: true } },
|
||||
// Custom Model Endpoint Profiles (docs/custom-model-endpoints-plan.md) — verified by hand against a real
|
||||
@@ -247,6 +295,32 @@ const CLAUDE: CliEntry = {
|
||||
baseUrlVar: 'ANTHROPIC_BASE_URL',
|
||||
apiKeyVar: 'ANTHROPIC_API_KEY',
|
||||
modelVars: ['ANTHROPIC_DEFAULT_SONNET_MODEL', 'ANTHROPIC_DEFAULT_HAIKU_MODEL', 'ANTHROPIC_DEFAULT_OPUS_MODEL'],
|
||||
// Verified via Claude Code's own docs: CLAUDE_CODE_MAX_CONTEXT_TOKENS overrides the
|
||||
// assumed context window and applies directly for a model name Claude Code doesn't
|
||||
// recognize as one of its own — exactly the custom-model case. Without it, Claude Code
|
||||
// assumes a large (200k) window for any unrecognized model id and never compacts,
|
||||
// eventually overflowing a much smaller real local context (see plan doc reasoning
|
||||
// above the interface for the confirmed failure).
|
||||
contextLengthVar: 'CLAUDE_CODE_MAX_CONTEXT_TOKENS',
|
||||
// Isolates this session's config/credential directory so an injected ANTHROPIC_API_KEY
|
||||
// never shares a directory with a stored claude.ai OAuth login — see the doc comment on
|
||||
// customModelInjection in cli-registry/types.ts for the traded-off side effect.
|
||||
configDirVar: 'CLAUDE_CONFIG_DIR',
|
||||
// ⚠️ Required alongside configDirVar, not optional in practice: verified live that an
|
||||
// isolated, otherwise-empty config directory makes claude stop at an interactive
|
||||
// "Detected a custom API key — use it?" prompt on EVERY launch, defaulting to "No" with
|
||||
// no one at the TTY to answer — silently refusing the very key this feature injected.
|
||||
// Pre-seeding this file's customApiKeyResponses.approved list (verified against a real
|
||||
// ~/.claude.json after answering the prompt once by hand) answers it in advance instead.
|
||||
apiKeyTrustFile: { relPath: '.claude.json', shape: 'claude-api-key-responses' },
|
||||
// ⚠️ Same isolated-directory root cause, one step further: verified live that on top
|
||||
// of the API-key prompt above, a fresh CLAUDE_CONFIG_DIR also replays claude's ENTIRE
|
||||
// first-run sequence on every launch — the theme picker, the security-notes screen,
|
||||
// the per-project "trust this folder?" dialog, and (running with
|
||||
// --dangerously-skip-permissions) a one-time bypass-permissions warning — none of
|
||||
// which a real, already-onboarded profile shows again. Pre-seeds that same
|
||||
// already-onboarded state instead of leaving a human to click through it.
|
||||
skipFirstRunPrompts: true,
|
||||
},
|
||||
},
|
||||
overlays: {
|
||||
@@ -326,7 +400,7 @@ const OPENCODE: CliEntry = {
|
||||
id: 'opencode' as CliEntry['id'],
|
||||
label: 'OpenCode',
|
||||
shortBadge: 'OC',
|
||||
accent: '#f59e0b',
|
||||
accent: '#10b981',
|
||||
enabled: true,
|
||||
stock: true,
|
||||
order: 10,
|
||||
@@ -412,7 +486,7 @@ const CODEX: CliEntry = {
|
||||
id: 'codex' as CliEntry['id'],
|
||||
label: 'Codex',
|
||||
shortBadge: 'CX',
|
||||
accent: '#6b7fd7',
|
||||
accent: '#a855f7',
|
||||
enabled: true,
|
||||
stock: true,
|
||||
order: 20,
|
||||
@@ -476,7 +550,43 @@ const CODEX: CliEntry = {
|
||||
// `Working (2m 49s • esc to interrupt)` above it while a turn runs. It animates no
|
||||
// braille spinner, and it never prints `esc to interrupt` at rest, so that phrase
|
||||
// alone separates a running turn from an idle one.
|
||||
workDetect: { promptGlyph: '›', workingLine: '[Ee]sc to interrupt' },
|
||||
// Codex pins a row of its own while a background terminal it started is still
|
||||
// running: ` 1 background terminal running · /ps to view · /stop to close`. Unlike
|
||||
// Claude's footer chip that row sits ABOVE the composer, which puts it third from the
|
||||
// bottom once the status line and the composer are counted, hence `watchingLines`.
|
||||
// Measured against a live codex-cli 0.154.0 pane on 2026-09-22: the row appears when
|
||||
// the terminal starts, follows the composer down as the conversation grows, and is
|
||||
// gone after `/stop`.
|
||||
// ⚠️ This entry CANNOT promise what Claude's does, and the difference is Codex's
|
||||
// layout rather than its pattern. The third row from the bottom is the chip only
|
||||
// while a terminal runs; with none running it is the last row of the transcript,
|
||||
// which the agent writes. Matching the complete row raises the bar — an assistant
|
||||
// message has to end with this exact line, to the character — but nothing here makes
|
||||
// forging it impossible, so do not read the Claude comment above as applying here.
|
||||
// What contains it is that codex declares `hooks: 'none'`: no hook event from a codex
|
||||
// session ever reaches `notePrompt()`, so there is no idle item to pre-acknowledge
|
||||
// and a forged label costs a wrong badge and nothing else. A CLI that gains hook
|
||||
// signals must not keep a pattern this soft.
|
||||
// ⚠️ Background TERMINALS are the only background work codex advertises on screen.
|
||||
// A sub-agent started without waiting outlives the turn just as a terminal does —
|
||||
// measured 2026-09-22, the sandboxed process was still running — and the pane shows
|
||||
// nothing at all for it: the last rows are the composer and the status line, and
|
||||
// `Sub-agents running` lives in the on-demand `/subagents` panel, not above the
|
||||
// composer. So a codex session waiting on a sub-agent reads as plainly idle here.
|
||||
// Nothing is misfiled by that (codex raises no idle prompts), and there is no row to
|
||||
// match until codex pins one.
|
||||
workDetect: {
|
||||
promptGlyph: '›',
|
||||
workingLine: '[Ee]sc to interrupt',
|
||||
watchingLine: String.raw`^\s{0,4}(\d+ background terminals?) running · /ps to view · /stop to close$`,
|
||||
watchingLines: 3,
|
||||
},
|
||||
// Two columns, like claude's, measured on a live 0.154.0 answer: the `•`/`›`/`⚠`
|
||||
// markers sit in the gutter, prose continuations sit at 2, and a nested YAML block
|
||||
// the model wrote rendered at 2/4/6/8 for its own 0/2/4/6. Replayed at 100, 120,
|
||||
// 160, 198, 235 and 282 columns the indents were 0, 2, 4, 6 and 8 at every one,
|
||||
// never 1, so the width is not a function of the pane.
|
||||
transcriptGutter: 2,
|
||||
transcript: 'codex-rollout',
|
||||
altScreen: 'strip-full',
|
||||
echo: { policy: 'predict', anchor: { kind: 'cursor' }, predictProfile: 'codex' },
|
||||
@@ -523,7 +633,8 @@ const GEMINI: CliEntry = {
|
||||
id: 'gemini' as CliEntry['id'],
|
||||
label: 'Gemini',
|
||||
shortBadge: 'GM',
|
||||
accent: '#4285f4',
|
||||
// The tab badge / run-mode-dot colour, not the run-button border (see the note above CLAUDE).
|
||||
accent: '#8ab4f8',
|
||||
enabled: true,
|
||||
stock: true,
|
||||
order: 30,
|
||||
@@ -615,7 +726,7 @@ const ANTIGRAVITY: CliEntry = {
|
||||
id: 'antigravity' as CliEntry['id'],
|
||||
label: 'Antigravity',
|
||||
shortBadge: 'AG',
|
||||
accent: '#8b5cf6',
|
||||
accent: '#22d3ee',
|
||||
enabled: true,
|
||||
stock: true,
|
||||
order: 40,
|
||||
@@ -685,7 +796,7 @@ const PI: CliEntry = {
|
||||
id: 'pi' as CliEntry['id'],
|
||||
label: 'Pi',
|
||||
shortBadge: 'PI',
|
||||
accent: '#10b981',
|
||||
accent: '#f472b6',
|
||||
enabled: true,
|
||||
stock: true,
|
||||
order: 50,
|
||||
@@ -809,10 +920,10 @@ const GROK: CliEntry = {
|
||||
shortBadge: 'GK',
|
||||
// Upstream hand-authored a charcoal GRADIENT across 4+ CSS spots (welcome button, tab
|
||||
// badge, run-mode dot, mobile skin overrides) rather than one flat colour; our registry's
|
||||
// `accent` is a single hex, so this is the closest single value (the run-mode-dot colour,
|
||||
// zinc-400). Nothing reads `accent` yet — the frontend is untouched in this change and
|
||||
// keeps its own hand-authored CSS; the field is here so the entry is complete.
|
||||
accent: '#a1a1aa',
|
||||
// `accent` is a single hex, so this is the closest single value (zinc-300, the run-button
|
||||
// border and tab-badge colour). Nothing reads `accent` yet: the frontend keeps its own
|
||||
// hand-authored CSS; the field is here so the entry is complete.
|
||||
accent: '#d4d4d8',
|
||||
enabled: true,
|
||||
stock: true,
|
||||
order: 70,
|
||||
@@ -944,7 +1055,7 @@ const DEEPSEEK: CliEntry = {
|
||||
id: 'deepseek' as CliEntry['id'],
|
||||
label: 'DeepSeek',
|
||||
shortBadge: 'DS',
|
||||
accent: '#4d6bfe',
|
||||
accent: '#7c93ff',
|
||||
enabled: true,
|
||||
stock: true,
|
||||
order: 80,
|
||||
@@ -1071,15 +1182,28 @@ const DEEPSEEK: CliEntry = {
|
||||
// privilege rather than granting it, and clamping it here was a real regression
|
||||
// (test/deepseek-mode.test.ts) fixed before this shipped.
|
||||
privilegedEnvKeys: ['DSH_PERMISSION_MODE', 'DSH_HOME', 'DEEPSEEK_BASE_URL'],
|
||||
// Web-researched, unverified, partial: reuses the already-existing DEEPSEEK_BASE_URL/
|
||||
// DEEPSEEK_API_KEY keys above. No modelVars — dsh's model is a profile-composition
|
||||
// entry (see `model: { source: 'none' }` above), not an env var, so forcing a specific
|
||||
// model name may not fully work; verify against a real profile before shipping.
|
||||
// Reuses the already-existing DEEPSEEK_BASE_URL/DEEPSEEK_API_KEY keys above. No
|
||||
// modelVars — dsh's model is a profile-composition entry (see `model: { source: 'none'
|
||||
// }` above), not an env var, so forcing a specific model name may not fully work;
|
||||
// verify against a real profile before shipping.
|
||||
//
|
||||
// ⚠️ appendV1Suffix is REQUIRED, not optional-nice-to-have: without it every request
|
||||
// 404s. Confirmed live and by reading dsh's own bundled source
|
||||
// (@deepseek-ai/dsh-llm-deepseek): it builds the request URL as
|
||||
// `${DEEPSEEK_BASE_URL}/chat/completions` with no "/v1" of its own (its real public
|
||||
// API, https://api.deepseek.com, expects the caller's base URL to already carry any
|
||||
// needed prefix), while llama-swap/llama.cpp only serves the OpenAI-conventional
|
||||
// "/v1/chat/completions" — a bare POST to ".../chat/completions" 404s live, and the
|
||||
// 404 reported here originally ("dsh: HTTP_404: DeepSeek API error (HTTP 404)")
|
||||
// matches dsh's own error-message template for exactly this failure. See the
|
||||
// customModelInjection doc comment in cli-registry/types.ts for the full reasoning,
|
||||
// including why claude/gemini must NOT get this.
|
||||
customModelInjection: {
|
||||
kind: 'env',
|
||||
baseUrlVar: 'DEEPSEEK_BASE_URL',
|
||||
apiKeyVar: 'DEEPSEEK_API_KEY',
|
||||
modelVars: [],
|
||||
appendV1Suffix: true,
|
||||
},
|
||||
},
|
||||
overlays: {
|
||||
@@ -1096,7 +1220,7 @@ const OMP: CliEntry = {
|
||||
id: 'omp' as CliEntry['id'],
|
||||
label: 'OMP',
|
||||
shortBadge: 'OM',
|
||||
accent: '#7c9cf5',
|
||||
accent: '#818cf8',
|
||||
enabled: true,
|
||||
stock: true,
|
||||
order: 90,
|
||||
|
||||
@@ -344,7 +344,48 @@ export interface CliCapabilities {
|
||||
promptGlyph: string;
|
||||
/** Source of a regex matching the status line this CLI draws while a turn runs. */
|
||||
workingLine: string;
|
||||
/**
|
||||
* Source of a regex matching the row this CLI draws while work it started in the
|
||||
* background is still running, e.g. Claude's `· 1 monitor ·` footer chip or Codex's
|
||||
* `1 background terminal running · /ps to view`. Capture group 1 is the label Codeman
|
||||
* shows, and the whole match stands in when the pattern declares no group. A CLI that
|
||||
* omits this reports no background work, which is what every CLI did before the field
|
||||
* existed.
|
||||
*/
|
||||
watchingLine?: string;
|
||||
/**
|
||||
* How many rows at the FOOT of the screen that row can appear in, counting non-blank
|
||||
* rows only. Claude writes its chip on the last row and keeps the default; Codex pins
|
||||
* its own above the composer, which puts it third from the bottom, so it declares
|
||||
* more. Keep each number as small as that CLI's layout allows: every extra row is
|
||||
* another row an agent might be able to write, and the label is what silences an
|
||||
* alert. See `watchingLabel()` in `session-activity.ts`.
|
||||
*/
|
||||
watchingLines?: number;
|
||||
};
|
||||
/**
|
||||
* How many columns this CLI indents its transcript body by, so a copy taken from its
|
||||
* pane can drop that much and paste flush. Claude Code indents two and puts its own
|
||||
* markers in those columns.
|
||||
*
|
||||
* ⚠ DECLARED rather than measured off the pane, and two measured attempts are why.
|
||||
* Asking whether the pane painted real spaces across the unused part of each row
|
||||
* separates a TUI from a shell perfectly where it fires and never over-stripped; it
|
||||
* is also a function of pane WIDTH, because that padding exists only while a
|
||||
* rendered line stops short of the CLI's own layout width and Claude Code's prose
|
||||
* wraps to fill it. On one live transcript the share of padded rows ran 44%, 6%, 6%,
|
||||
* 7% and 87% at 123, 160, 198, 235 and 298 columns, so at any ordinary window size
|
||||
* the strip silently did nothing. Taking the narrowest indent on the surrounding
|
||||
* rows instead fires at every width and over-strips on roughly 1% of selections,
|
||||
* because a file listing inside the transcript can be the narrowest thing on screen.
|
||||
*
|
||||
* A declared width can do neither. The strip is the lesser of this and what every
|
||||
* selected line shares, so a block can only ever shift as a unit, and it can never
|
||||
* shift further than the CLI itself says its gutter is.
|
||||
*
|
||||
* Absent means no strip at all, the same fail-safe direction `workDetect` takes.
|
||||
*/
|
||||
transcriptGutter?: number;
|
||||
/** No direct-PTY fallback: the CLI must run inside tmux (secrets ride tmux setenv). */
|
||||
requiresMux: boolean;
|
||||
/**
|
||||
@@ -496,9 +537,74 @@ export interface CliCapabilities {
|
||||
* declares). Absent = the config alone selects the model (claude's env vars,
|
||||
* opencode's blob, codex's top-level `model` key). Applied by the session's
|
||||
* respawn options through the entry's `legacyConfigField`, never by id.
|
||||
*
|
||||
* `contextLengthVar` (env kind only): the env var a discovered per-model context-window
|
||||
* size is written to when known (claude's `CLAUDE_CODE_MAX_CONTEXT_TOKENS`) — without it,
|
||||
* a CLI that assumes a large default window for an unrecognized model name keeps sending
|
||||
* full-size prompts against a much smaller local server and eventually overflows its real
|
||||
* context (verified: a 33.7K-token system prompt against a 16384-token llama-swap model).
|
||||
* Absent when the CLI has no such override, or the value is unknown for this model.
|
||||
*
|
||||
* `configDirVar` (env kind only): the env var that redirects this session's config/
|
||||
* credential directory to an isolated, per-session one (claude's `CLAUDE_CONFIG_DIR`), so
|
||||
* an injected API key never coexists with a stored claude.ai OAuth session in the same
|
||||
* directory — the CLI still warns "both claude.ai and ANTHROPIC_API_KEY set" when they
|
||||
* share a directory even though the API key wins for actual requests. Isolating it trades
|
||||
* that cosmetic warning for a documented side effect: a relocated config directory writes
|
||||
* transcripts outside `~/.claude/projects`, blinding the response viewer, subagent
|
||||
* windows, and Read My Mind for that session (see docs/wiki/Agent-CLIs.md).
|
||||
*
|
||||
* `apiKeyTrustFile` (env kind only, alongside configDirVar): an isolated config directory
|
||||
* has none of a real profile's prior "detected a custom API key, use it?" approvals, so
|
||||
* without this the CLI stops and asks interactively on every single launch — with no one
|
||||
* at a TTY to answer, that's a hang, not a warning (confirmed live: claude's own default
|
||||
* answer, "No", would silently refuse to use the very key this feature just injected).
|
||||
* `relPath`/`shape` name the file (claude's `.claude.json`) and its
|
||||
* `customApiKeyResponses.approved` field this pre-seeds — the exact field a real answered
|
||||
* prompt itself writes to, so this isn't bypassing the check, just answering it the same
|
||||
* way a one-off prior approval on a shared profile already would.
|
||||
*
|
||||
* `skipFirstRunPrompts` (env kind only, alongside apiKeyTrustFile): an isolated config
|
||||
* directory is not just missing API-key approvals — it is a brand-new profile as far as
|
||||
* the CLI is concerned, so it also replays its ENTIRE first-run sequence on every launch:
|
||||
* the theme picker, the security-notes screen, the per-project "trust this folder?"
|
||||
* dialog, and (running with a bypass-permissions flag) a one-time warning about it —
|
||||
* confirmed live, none of which a real, long-used profile ever shows again. `true`
|
||||
* pre-seeds the same state a real profile accumulates from having answered all of that
|
||||
* once: `hasCompletedOnboarding` and the launching session's own project entry in the
|
||||
* `apiKeyTrustFile` (claude's `.claude.json`), plus `skipDangerousModePermissionPrompt`
|
||||
* in claude's `settings.json` — see `seedFirstRunState`/`seedSkipBypassPermissionsPrompt`
|
||||
* in custom-model-injection-apply.ts. Requires `apiKeyTrustFile` to be set too, since it
|
||||
* reuses that file.
|
||||
*
|
||||
* `appendV1Suffix` (env kind only): the raw `endpoint.baseUrl` gets `withV1Suffix()`
|
||||
* applied before being written to `baseUrlVar`, instead of being used verbatim.
|
||||
* DeepSeek needs this and claude/gemini must NOT get it — a per-CLI asymmetry confirmed
|
||||
* by reading each SDK's own request-building source, not assumed: DeepSeek Harness's
|
||||
* bundled `@deepseek-ai/dsh-llm-deepseek` concatenates `${connection.baseURL}/chat/
|
||||
* completions` with no `/v1` insertion of its own (its real public API base,
|
||||
* `https://api.deepseek.com`, expects the caller's base URL to already carry any
|
||||
* needed prefix), while llama-swap/llama.cpp only ever serves the OpenAI-conventional
|
||||
* `/v1/chat/completions` — confirmed live: a bare `POST <baseUrl>/chat/completions`
|
||||
* 404s, `POST <baseUrl>/v1/chat/completions` succeeds, and the harness's own error
|
||||
* message template (`DeepSeek API error (HTTP ${status})`) reproduces the exact
|
||||
* `HTTP_404` this feature originally shipped with unexplained. Claude Code's own SDK,
|
||||
* by contrast, was already confirmed working end-to-end against the RAW `baseUrl` with
|
||||
* no suffix — appending one there would be wrong, not just redundant.
|
||||
*/
|
||||
customModelInjection:
|
||||
| { kind: 'env'; baseUrlVar: string; apiKeyVar: string; modelVars: string[]; launchModel?: string }
|
||||
| {
|
||||
kind: 'env';
|
||||
baseUrlVar: string;
|
||||
apiKeyVar: string;
|
||||
modelVars: string[];
|
||||
launchModel?: string;
|
||||
contextLengthVar?: string;
|
||||
apiKeyTrustFile?: { relPath: string; shape: 'claude-api-key-responses' };
|
||||
configDirVar?: string;
|
||||
skipFirstRunPrompts?: boolean;
|
||||
appendV1Suffix?: boolean;
|
||||
}
|
||||
| { kind: 'configContentEnv'; envVar: string; template: 'opencode-json'; launchModel?: string }
|
||||
| {
|
||||
kind: 'configDir';
|
||||
@@ -586,7 +692,13 @@ export interface CliEntry {
|
||||
label: string;
|
||||
/** Two-ish character tab badge, e.g. 'OC'. */
|
||||
shortBadge: string;
|
||||
/** Single hex colour. CSS derives every per-CLI gradient from it via --cli-accent. */
|
||||
/**
|
||||
* Single hex colour, measured from the CLI's actual `.btn-toolbar.btn-run.mode-<id>`
|
||||
* gradient in styles.css (see stock.ts's comment above `CLAUDE` for the exact
|
||||
* methodology). DECLARED-FOR-LATER (above) — no code reads this yet; styles.css's
|
||||
* gradients are still hand-authored per id, not derived from this field via any
|
||||
* CSS custom property. There is no `--cli-accent` variable in the codebase.
|
||||
*/
|
||||
accent: string;
|
||||
enabled: boolean;
|
||||
/** Set by the loader from the shipped catalog; a user entry can never claim it. */
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
/**
|
||||
* @fileoverview Limits shared between Wake-on-LAN parsing and its request schema.
|
||||
*
|
||||
* Its own module because `src/remote-wake.ts` is import-fenced: only
|
||||
* `web/routes/session-routes.ts` and `web/server.ts` may import it, so that no
|
||||
* watcher or boot-recovery path can WAKE a host (pinned by the wiring guard in
|
||||
* `test/remote-wake.test.ts`). `web/schemas.ts` needs the same MAC-count limit and
|
||||
* must not become a third importer, and it would drag `dgram`/`net`/`child_process`
|
||||
* into every module that validates a request body. A plain constant satisfies both.
|
||||
*/
|
||||
|
||||
/**
|
||||
* How many comma-separated MACs one `wakeMac` may carry.
|
||||
*
|
||||
* ⚠ Single source for `parseMacList()` and `RemoteHostSchema.wakeMac`. The two used
|
||||
* to disagree: the schema's 128-character cap admits seven MACs while the parser
|
||||
* rejected more than four all-or-nothing, so a five-MAC value validated, persisted to
|
||||
* `remote-hosts.json`, and then resolved to NO wake target. The host read as
|
||||
* unconfigured and the banner offered "Configure WoL" for a host the user had just
|
||||
* configured, which is the worst shape a validation gap can take: accepted, stored,
|
||||
* silently inert.
|
||||
*/
|
||||
export const MAX_WAKE_MACS = 4;
|
||||
@@ -40,6 +40,38 @@ export interface CustomModelHost {
|
||||
authStyle?: CustomModelAuthStyle;
|
||||
models?: string[];
|
||||
lastDiscoveredAt?: string;
|
||||
/**
|
||||
* The model the Run-menu picker (docs/custom-model-endpoints-plan.md) applies when
|
||||
* this endpoint is picked with no further choice — one generated menu entry per
|
||||
* (CLI, endpoint) pair, not per (CLI, endpoint, model), so it needs a single answer.
|
||||
* Must be a member of `models` when set; the picker falls back to `models[0]` when
|
||||
* this is unset, and disables the entry entirely when `models` is empty (nothing to
|
||||
* default to). Never auto-set on discovery — the previous default staying valid
|
||||
* after a re-discover is a property worth keeping even if the model list changes.
|
||||
*/
|
||||
defaultModelId?: string;
|
||||
/**
|
||||
* Discovered context-window size (tokens) per model id, keyed by the same strings as
|
||||
* `models`. Populated opportunistically during discovery (`custom-model-routes.ts`) from
|
||||
* llama.cpp/llama-swap's `GET /props?model=<id>` — the plain OpenAI-shaped `/v1/models`
|
||||
* response has no such field. Only ever probed for a model the server already reports as
|
||||
* loaded (llama-swap's `status.value === 'loaded'`); an unloaded one is deliberately never
|
||||
* probed, since llama-swap treats `/props?model=` as a routing hint that can trigger an
|
||||
* actual (slow, GPU-swapping) model load as a side effect of merely asking. A model this
|
||||
* has no entry for simply gets no context-length env override applied — never a guess.
|
||||
*/
|
||||
modelContextLengths?: Record<string, number>;
|
||||
/**
|
||||
* Discovered file size (GB) per model id, keyed by the same strings as `models`.
|
||||
* Populated during discovery by parsing llama-swap's own `description` field for an
|
||||
* auto-discovered model ("Auto-discovered 16.35 GB - parameters auto-fitted by
|
||||
* llama.cpp") — a hand-configured profile's own description has no such figure and
|
||||
* correctly gets no entry, never a guess. Used only to label the Run-menu picker's
|
||||
* "loading model" banner with a rough, unmeasured expected-time estimate
|
||||
* (the Run-menu picker's loading banner in session-ui.js) — never a guarantee, and never anything a
|
||||
* server-side check relies on.
|
||||
*/
|
||||
modelSizesGB?: Record<string, number>;
|
||||
}
|
||||
|
||||
export function customModelHostsPath(configDir: string): string {
|
||||
|
||||
@@ -10,7 +10,8 @@
|
||||
* cli-registry changes" requirement it was written against.
|
||||
*/
|
||||
|
||||
import { chmodSync, mkdirSync, writeFileSync, rmSync } from 'node:fs';
|
||||
import { chmodSync, existsSync, mkdirSync, readFileSync, writeFileSync, rmSync, symlinkSync } from 'node:fs';
|
||||
import { homedir, platform } from 'node:os';
|
||||
import { join, dirname } from 'node:path';
|
||||
import { dataPath } from './config/instance.js';
|
||||
import type { CliEntry } from './config/cli-registry/types.js';
|
||||
@@ -48,6 +49,168 @@ export function applyConfigDirInjection(baseDir: string, injection: ConfigDirInj
|
||||
return { [injection.dirEnvVar]: baseDir, ...injection.extraEnv };
|
||||
}
|
||||
|
||||
/**
|
||||
* Real, shared Claude config directory Codeman's own host process runs under — honors
|
||||
* `CLAUDE_CONFIG_DIR` the same way `claude-credentials.ts`'s `claudeCredentialsPath()`
|
||||
* does, so the symlink below points at wherever `~/.claude/projects` actually lives
|
||||
* rather than assuming the plain default.
|
||||
*/
|
||||
function realClaudeConfigDir(): string {
|
||||
const configured = typeof process.env.CLAUDE_CONFIG_DIR === 'string' && process.env.CLAUDE_CONFIG_DIR.trim();
|
||||
return configured || join(homedir(), '.claude');
|
||||
}
|
||||
|
||||
/**
|
||||
* Symlinks `<isolatedDir>/projects` back to the real, shared `~/.claude/projects`, so an
|
||||
* isolated `CLAUDE_CONFIG_DIR` (used to keep an injected API key away from a stored OAuth
|
||||
* session — see `configDirVar` on customModelInjection) doesn't also blind the response
|
||||
* viewer, subagent windows, and Read My Mind for that session (docs/wiki/Agent-CLIs.md).
|
||||
* Best-effort: a platform that refuses symlinks (unprivileged Windows without a junction
|
||||
* fallback working, e.g.) just keeps the pre-existing documented side effect instead of
|
||||
* failing the whole custom-model apply over a nice-to-have.
|
||||
*/
|
||||
function linkSharedProjectsDir(isolatedDir: string): void {
|
||||
const link = join(isolatedDir, 'projects');
|
||||
if (existsSync(link)) return; // already linked (idempotent re-apply) or real dir wrote one
|
||||
try {
|
||||
symlinkSync(join(realClaudeConfigDir(), 'projects'), link, platform() === 'win32' ? 'junction' : 'dir');
|
||||
} catch {
|
||||
// best-effort only — response viewer/subagent windows go blind for this session instead
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Pre-approves the injected API key in an isolated config directory's trust-dialog state
|
||||
* (`customModelInjection.apiKeyTrustFile`), so an otherwise-empty directory doesn't make the
|
||||
* CLI stop at an interactive "Detected a custom API key — use it?" prompt on every single
|
||||
* launch. Confirmed live: with nobody at the TTY to answer, that prompt's own default
|
||||
* ("No") silently refuses the very key this feature just injected — this isn't bypassing
|
||||
* the check, it's answering it the same field a real answered prompt itself writes to
|
||||
* (verified against a real `~/.claude.json` after answering by hand once).
|
||||
*
|
||||
* Merges rather than overwrites: the file may already carry fields the CLI itself wrote on
|
||||
* an earlier launch in this same isolated directory (machineID, userID, other approved
|
||||
* keys), and a corrupt or partially-written file (a crash mid-write) is treated as absent
|
||||
* rather than failing the whole apply over a nice-to-have.
|
||||
*/
|
||||
/**
|
||||
* The form Claude Code actually stores an approved key in: the trimmed last 20
|
||||
* characters. Mirrors the CLI's own `e.trim().slice(-20)`, which is applied on BOTH
|
||||
* the write and the lookup, so anything else never matches.
|
||||
*/
|
||||
export function truncateApiKeyForTrustFile(apiKey: string): string {
|
||||
return apiKey.trim().slice(-20);
|
||||
}
|
||||
|
||||
function seedApiKeyTrustFile(
|
||||
configDir: string,
|
||||
trustFile: { relPath: string; shape: 'claude-api-key-responses' },
|
||||
apiKey: string
|
||||
): void {
|
||||
const filePath = join(configDir, trustFile.relPath);
|
||||
let existing: Record<string, unknown> = {};
|
||||
try {
|
||||
existing = JSON.parse(readFileSync(filePath, 'utf8')) as Record<string, unknown>;
|
||||
} catch {
|
||||
existing = {};
|
||||
}
|
||||
const responses = (existing.customApiKeyResponses ?? {}) as { approved?: unknown; rejected?: unknown };
|
||||
const approved = new Set(Array.isArray(responses.approved) ? (responses.approved as string[]) : []);
|
||||
// ⚠ Claude Code stores and compares only the LAST 20 CHARACTERS of a key, never the
|
||||
// whole thing: its lookup is `approved.includes(key.trim().slice(-20))` (decompiled
|
||||
// from the 2.1.278 bundle, and corroborated by real `~/.claude.json` files, whose
|
||||
// customApiKeyResponses entries are all exactly 20 characters). Seeding the full key
|
||||
// therefore never matches for a REAL key, and claude stops at the interactive
|
||||
// "Detected a custom API key in your environment" prompt, whose default is
|
||||
// "No (recommended)" — so the launch hangs or silently refuses the key this feature
|
||||
// just injected. It went unnoticed because a keyless llama.cpp/llama-swap endpoint
|
||||
// uses DEFAULT_API_KEY ('local-dummy-key', 15 chars), where slice(-20) is the whole
|
||||
// string and the seed matches by accident. Truncating here also keeps a full
|
||||
// third-party credential from being written into a second file on disk.
|
||||
approved.add(truncateApiKeyForTrustFile(apiKey));
|
||||
const rejected = Array.isArray(responses.rejected) ? responses.rejected : [];
|
||||
existing.customApiKeyResponses = { approved: [...approved], rejected };
|
||||
try {
|
||||
writeFileSync(filePath, JSON.stringify(existing, null, 2), { encoding: 'utf8', mode: 0o600 });
|
||||
chmodSync(filePath, 0o600);
|
||||
} catch {
|
||||
// best-effort only — the interactive prompt returns instead of a hard failure here
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Pre-seeds the two remaining pieces of "already been onboarded" state a fresh
|
||||
* `CLAUDE_CONFIG_DIR` has none of (`customModelInjection.skipFirstRunPrompts`, alongside
|
||||
* apiKeyTrustFile): claude replays its whole first-run sequence — the theme picker, the
|
||||
* security-notes screen, and (per-project) the "trust this folder?" dialog — against ANY
|
||||
* config directory that has never completed it, confirmed live against a genuinely fresh
|
||||
* isolated directory. `hasCompletedOnboarding` skips the theme/security-notes screens
|
||||
* outright; `projects[workingDir].hasTrustDialogAccepted` answers the trust dialog for
|
||||
* THIS session's own working directory the same way a real profile's own prior approval
|
||||
* would — other projects in the file are left alone, and `workingDir` is used verbatim
|
||||
* (never realpath'd or slash-normalized) since that's the literal string claude itself
|
||||
* uses as the project key, being whatever string the session was actually launched with
|
||||
* as its cwd.
|
||||
*
|
||||
* Same merge-not-overwrite and corrupt-file-tolerant behavior as `seedApiKeyTrustFile`
|
||||
* (same file, so a second sequential read-modify-write here is deliberate rather than
|
||||
* folding both into one pass — keeps each seed independently testable and optional).
|
||||
*/
|
||||
function seedFirstRunOnboardingState(
|
||||
configDir: string,
|
||||
trustFile: { relPath: string; shape: 'claude-api-key-responses' },
|
||||
workingDir: string
|
||||
): void {
|
||||
const filePath = join(configDir, trustFile.relPath);
|
||||
let existing: Record<string, unknown> = {};
|
||||
try {
|
||||
existing = JSON.parse(readFileSync(filePath, 'utf8')) as Record<string, unknown>;
|
||||
} catch {
|
||||
existing = {};
|
||||
}
|
||||
existing.hasCompletedOnboarding = true;
|
||||
const projects =
|
||||
existing.projects && typeof existing.projects === 'object' && !Array.isArray(existing.projects)
|
||||
? (existing.projects as Record<string, Record<string, unknown>>)
|
||||
: {};
|
||||
const existingProject = projects[workingDir] && typeof projects[workingDir] === 'object' ? projects[workingDir] : {};
|
||||
projects[workingDir] = { ...existingProject, hasTrustDialogAccepted: true };
|
||||
existing.projects = projects;
|
||||
try {
|
||||
writeFileSync(filePath, JSON.stringify(existing, null, 2), { encoding: 'utf8', mode: 0o600 });
|
||||
chmodSync(filePath, 0o600);
|
||||
} catch {
|
||||
// best-effort only — the interactive dialogs return instead of a hard failure here
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Pre-seeds the "skip the bypass-permissions warning" setting (`customModelInjection.
|
||||
* skipFirstRunPrompts`, alongside apiKeyTrustFile) into an isolated config directory's
|
||||
* `settings.json` — a real, already-onboarded profile answers claude's one-time warning
|
||||
* about running with a bypass-permissions flag once and never sees it again, but every
|
||||
* custom-model session launches with a fresh, otherwise-empty CLAUDE_CONFIG_DIR that
|
||||
* carries none of that (confirmed live). A different file from apiKeyTrustFile's
|
||||
* `.claude.json` — this is claude's own global `settings.json`, not project-keyed —
|
||||
* so it gets its own merge-not-overwrite read-modify-write.
|
||||
*/
|
||||
function seedSkipBypassPermissionsPrompt(configDir: string): void {
|
||||
const filePath = join(configDir, 'settings.json');
|
||||
let existing: Record<string, unknown> = {};
|
||||
try {
|
||||
existing = JSON.parse(readFileSync(filePath, 'utf8')) as Record<string, unknown>;
|
||||
} catch {
|
||||
existing = {};
|
||||
}
|
||||
existing.skipDangerousModePermissionPrompt = true;
|
||||
try {
|
||||
writeFileSync(filePath, JSON.stringify(existing, null, 2), { encoding: 'utf8', mode: 0o600 });
|
||||
chmodSync(filePath, 0o600);
|
||||
} catch {
|
||||
// best-effort only — the interactive warning returns instead of a hard failure here
|
||||
}
|
||||
}
|
||||
|
||||
/** Best-effort recursive removal of a previously-written configDir. Never throws. */
|
||||
export function removeConfigDir(dir: string | undefined): void {
|
||||
if (!dir) return;
|
||||
@@ -79,14 +242,43 @@ export function applyCustomModelInjection(
|
||||
entry: Pick<CliEntry, 'capabilities'>,
|
||||
endpoint: CustomModelEndpoint,
|
||||
modelId: string,
|
||||
sessionId: string
|
||||
sessionId: string,
|
||||
/** Discovered context-window size for `modelId`, if known — see `contextLengthVar`. */
|
||||
contextLength?: number,
|
||||
/**
|
||||
* The session's own working directory — only used for `skipFirstRunPrompts`'s per-project
|
||||
* trust-dialog seed, and only when provided (boot recovery, which has no reason to
|
||||
* re-answer a dialog that already fired once, omits it rather than re-deriving it).
|
||||
*/
|
||||
workingDir?: string
|
||||
): AppliedCustomModel | undefined {
|
||||
const injection = buildCustomModelInjection(entry, endpoint, modelId);
|
||||
const injection = buildCustomModelInjection(entry, endpoint, modelId, contextLength);
|
||||
if (injection.kind === 'unsupported') return undefined;
|
||||
if (injection.kind === 'env') {
|
||||
// `configDirVar` (claude's CLAUDE_CONFIG_DIR): point it at the same isolated,
|
||||
// per-session directory the `configDir` kind uses, but write no files into it — an
|
||||
// empty directory has no stored OAuth credential to conflict with the injected API
|
||||
// key, which is the whole point. Reusing the same path keyed by sessionId keeps this
|
||||
// idempotent across a boot-recovery re-apply, same as the configDir kind below.
|
||||
let envOverrides = injection.envOverrides;
|
||||
let configDir: string | undefined;
|
||||
if (injection.configDirVar) {
|
||||
configDir = customModelConfigDir(sessionId);
|
||||
mkdirSync(configDir, { recursive: true, mode: 0o700 });
|
||||
linkSharedProjectsDir(configDir);
|
||||
if (injection.apiKeyTrustFile && injection.apiKey) {
|
||||
seedApiKeyTrustFile(configDir, injection.apiKeyTrustFile, injection.apiKey);
|
||||
}
|
||||
if (injection.skipFirstRunPrompts && injection.apiKeyTrustFile) {
|
||||
if (workingDir) seedFirstRunOnboardingState(configDir, injection.apiKeyTrustFile, workingDir);
|
||||
seedSkipBypassPermissionsPrompt(configDir);
|
||||
}
|
||||
envOverrides = { ...envOverrides, [injection.configDirVar]: configDir };
|
||||
}
|
||||
return {
|
||||
envOverrides: injection.envOverrides,
|
||||
envKeys: Object.keys(injection.envOverrides),
|
||||
envOverrides,
|
||||
envKeys: Object.keys(envOverrides),
|
||||
configDir,
|
||||
launchModel: injection.launchModel,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -16,14 +16,21 @@
|
||||
* shape was rejected by a real codex binary with "invalid type: map,
|
||||
* expected a string" — caught by `scripts/test-local-llm-harnesses.ts`),
|
||||
* but `wire_api = "responses"` is the only value codex still accepts
|
||||
* (support for `"chat"` was dropped in Feb 2026), and a plain OpenAI
|
||||
* Chat-Completions server (llama.cpp, llama-swap, most local setups) does
|
||||
* NOT implement the Responses API — so codex may still fail at the
|
||||
* PROTOCOL level even with a correctly-shaped config file. That gap is
|
||||
* real and current, not a stale warning; see docs/custom-model-endpoints-plan.md. The rest
|
||||
* (gemini/pi/grok/deepseek/omp) have their ONE-SHOT INVOCATION flags
|
||||
* confirmed against real installed binaries' own `--help` output, but
|
||||
* their custom-endpoint env/config conventions remain web-researched,
|
||||
* (support for `"chat"` was dropped in Feb 2026). ⚠️ Re-verified live
|
||||
* against a llama-swap deployment that DOES answer `/v1/responses`: a
|
||||
* plain, no-tool-call turn gets a real reply, but a real tool-call attempt
|
||||
* comes back as `agent_message` TEXT (the tool-call JSON printed as the
|
||||
* answer) rather than a `function_call` item codex would execute —
|
||||
* confirmed via `codex exec --json`'s raw event stream. Tool execution is
|
||||
* what makes codex a coding agent, so this remains not usable for real
|
||||
* work even where plain chat succeeds; see docs/custom-model-endpoints-plan.md
|
||||
* for the full picture (including the harmless `Model metadata ... not
|
||||
* found` warning every custom-endpoint codex session prints — sourced from
|
||||
* a local cache of OpenAI's OWN hosted model catalog that a custom model
|
||||
* can never appear in, confirmed to have no effect on the outcome above).
|
||||
* The rest (gemini/pi/grok/deepseek/omp) have their ONE-SHOT INVOCATION
|
||||
* flags confirmed against real installed binaries' own `--help` output,
|
||||
* but their custom-endpoint env/config conventions remain web-researched,
|
||||
* unverified.
|
||||
*/
|
||||
|
||||
@@ -44,6 +51,20 @@ export interface EnvInjection {
|
||||
envOverrides: Record<string, string>;
|
||||
/** See {@link ConfigDirInjection.launchModel}. */
|
||||
launchModel?: string;
|
||||
/**
|
||||
* Name of the env var the caller should point at an isolated, credential-free config
|
||||
* directory for this session (claude's `CLAUDE_CONFIG_DIR`), from the registry entry's
|
||||
* `customModelInjection.configDirVar`. The actual directory value isn't computed here —
|
||||
* this module is pure and has no sessionId to derive one from — the IO wrapper
|
||||
* (`custom-model-injection-apply.ts`) creates it and adds it to `envOverrides`.
|
||||
*/
|
||||
configDirVar?: string;
|
||||
/** See `customModelInjection.apiKeyTrustFile` — carried through so the IO wrapper can seed it. */
|
||||
apiKeyTrustFile?: { relPath: string; shape: 'claude-api-key-responses' };
|
||||
/** The literal API key value this injection used, for `apiKeyTrustFile` to pre-approve. */
|
||||
apiKey?: string;
|
||||
/** See `customModelInjection.skipFirstRunPrompts` — carried through so the IO wrapper can seed it. */
|
||||
skipFirstRunPrompts?: boolean;
|
||||
}
|
||||
|
||||
export interface ConfigDirInjection {
|
||||
@@ -90,7 +111,9 @@ function quoted(value: string): string {
|
||||
export function buildCustomModelInjection(
|
||||
entry: Pick<CliEntry, 'capabilities'>,
|
||||
endpoint: CustomModelEndpoint,
|
||||
modelId: string
|
||||
modelId: string,
|
||||
/** Discovered context-window size for `modelId`, if known — see `contextLengthVar`. */
|
||||
contextLength?: number
|
||||
): CustomModelInjectionResult {
|
||||
const cap = entry.capabilities.customModelInjection;
|
||||
const apiKey = endpoint.apiKey?.trim() || DEFAULT_API_KEY;
|
||||
@@ -98,11 +121,18 @@ export function buildCustomModelInjection(
|
||||
switch (cap.kind) {
|
||||
case 'env': {
|
||||
const envOverrides: Record<string, string> = {
|
||||
[cap.baseUrlVar]: endpoint.baseUrl,
|
||||
[cap.baseUrlVar]: cap.appendV1Suffix ? withV1Suffix(endpoint.baseUrl) : endpoint.baseUrl,
|
||||
[cap.apiKeyVar]: apiKey,
|
||||
};
|
||||
for (const modelVar of cap.modelVars) envOverrides[modelVar] = modelId;
|
||||
return withLaunchModel({ kind: 'env', envOverrides }, cap.launchModel, modelId);
|
||||
if (cap.contextLengthVar && contextLength !== undefined && Number.isFinite(contextLength)) {
|
||||
envOverrides[cap.contextLengthVar] = String(Math.trunc(contextLength));
|
||||
}
|
||||
let result: EnvInjection = withLaunchModel({ kind: 'env', envOverrides }, cap.launchModel, modelId);
|
||||
if (cap.configDirVar) result = { ...result, configDirVar: cap.configDirVar };
|
||||
if (cap.apiKeyTrustFile) result = { ...result, apiKeyTrustFile: cap.apiKeyTrustFile, apiKey };
|
||||
if (cap.skipFirstRunPrompts) result = { ...result, skipFirstRunPrompts: true };
|
||||
return result;
|
||||
}
|
||||
|
||||
case 'configContentEnv': {
|
||||
@@ -177,11 +207,13 @@ function renderConfigFile(
|
||||
// `env_key`, the NAME of an env var it reads the credential from at runtime, so the
|
||||
// actual value must ride along as an extra env var, never embedded in the file.
|
||||
// ⚠️ `wire_api = "responses"` is the only value codex still accepts (it dropped
|
||||
// `"chat"` support in Feb 2026) — a plain OpenAI Chat-Completions server (llama.cpp,
|
||||
// llama-swap, most local setups) does NOT implement the Responses API, so this
|
||||
// recipe may still fail at the PROTOCOL level even though the file now parses
|
||||
// correctly. That is a real, currently-unresolved compatibility gap, not a syntax
|
||||
// bug — track it before calling codex support done.
|
||||
// `"chat"` support in Feb 2026). Even against a llama-swap deployment that DOES
|
||||
// answer `/v1/responses`, a real tool-call attempt came back as plain TEXT (the
|
||||
// tool-call JSON printed as the model's answer) rather than an executable
|
||||
// `function_call` item — confirmed live via `codex exec --json`. Tool execution is
|
||||
// what makes codex a coding agent, so this remains not usable for real work even
|
||||
// where plain chat succeeds — see the confidence table in
|
||||
// docs/custom-model-endpoints-plan.md, not a syntax bug in this file.
|
||||
const content = [
|
||||
`model = ${quoted(modelId)}`,
|
||||
`model_provider = "custom"`,
|
||||
|
||||
+76
-5
@@ -606,9 +606,36 @@ export function agentImageNpmPackages(): string[] {
|
||||
return packages;
|
||||
}
|
||||
|
||||
/** The `--build-arg` pairs the agent image takes. */
|
||||
export function agentImageBuildArgPairs(): Array<[string, string]> {
|
||||
return [['CLI_NPM_PACKAGES', agentImageNpmPackages().join(' ')]];
|
||||
/**
|
||||
* Environment variable → agent.Dockerfile ARG for the optional git-host CLIs (gh, az).
|
||||
* ⚠️ Mirrors `GIT_HOST_CLI_BUILD_ARGS` in `scripts/lib/cli-catalog.mjs`; the parity test pins them.
|
||||
*/
|
||||
export const GIT_HOST_CLI_BUILD_ARGS: ReadonlyArray<readonly [string, string]> = [
|
||||
['CODEMAN_AGENT_IMAGE_INSTALL_GH', 'CODEMAN_INSTALL_GH'],
|
||||
['CODEMAN_AGENT_IMAGE_INSTALL_AZ', 'CODEMAN_INSTALL_AZ'],
|
||||
];
|
||||
|
||||
/**
|
||||
* The `--build-arg` pairs for the optional git-host CLIs. PURE. An unset or empty variable
|
||||
* contributes NOTHING, so the Dockerfile's own default (off) applies and the argv is the same
|
||||
* as before these existed; anything other than 0/1 is refused rather than guessed at.
|
||||
*/
|
||||
export function gitHostCliBuildArgPairs(env: NodeJS.ProcessEnv): Array<[string, string]> {
|
||||
const pairs: Array<[string, string]> = [];
|
||||
for (const [envName, argName] of GIT_HOST_CLI_BUILD_ARGS) {
|
||||
const value = env[envName];
|
||||
if (value === undefined || value === '') continue;
|
||||
if (value !== '0' && value !== '1') {
|
||||
throw new Error(`${envName} must be 0 or 1, got ${JSON.stringify(value)}`);
|
||||
}
|
||||
pairs.push([argName, value]);
|
||||
}
|
||||
return pairs;
|
||||
}
|
||||
|
||||
/** The `--build-arg` pairs the agent image takes. PURE given `env`. */
|
||||
export function agentImageBuildArgPairs(env: NodeJS.ProcessEnv = process.env): Array<[string, string]> {
|
||||
return [['CLI_NPM_PACKAGES', agentImageNpmPackages().join(' ')], ...gitHostCliBuildArgPairs(env)];
|
||||
}
|
||||
|
||||
// ========== Credential mount resolution (IO) ==========
|
||||
@@ -785,6 +812,14 @@ interface CredStorePolicy {
|
||||
seedFiles?: string[];
|
||||
/** Seed the WHOLE dir (RO mount → cp -a) — for stores with no shared/host-read state. */
|
||||
seedWhole?: boolean;
|
||||
/**
|
||||
* Seed this store ONLY when this environment variable is exactly `1`, read when
|
||||
* the container is created. For credentials that belong to an opt-in tool rather
|
||||
* than to an agent CLI every case already trusts: they are not inert just because
|
||||
* the image lacks the tool (a gh `hosts.yml` token or an Azure refresh token is
|
||||
* usable by anything in the container, and the agent in it is prompt-injectable).
|
||||
*/
|
||||
enabledByEnv?: string;
|
||||
}
|
||||
|
||||
const CRED_STORES: CredStorePolicy[] = [
|
||||
@@ -829,6 +864,31 @@ const CRED_STORES: CredStorePolicy[] = [
|
||||
},
|
||||
{ rel: '.config/gcloud', seedWhole: true },
|
||||
{ rel: '.config/opencode', seedWhole: true },
|
||||
// GitHub CLI: `hosts.yml` holds the token wherever no system keyring exists (the
|
||||
// Docker server image, a headless Linux host), `config.yml` the preferences. An
|
||||
// agent image built with CODEMAN_INSTALL_GH=1 routes github.com git credentials
|
||||
// through `gh`, so this seed is what lets an agent clone/push a private repo. A
|
||||
// token that lives in a desktop keyring is not in `hosts.yml` and does not carry
|
||||
// in; sign `gh` in inside the container. OPT-IN: seeded only when the same switch
|
||||
// that builds gh into the agent image is on, never merely because the file exists.
|
||||
{ rel: '.config/gh', seedFiles: ['hosts.yml', 'config.yml'], enabledByEnv: 'CODEMAN_AGENT_IMAGE_INSTALL_GH' },
|
||||
// Azure CLI: only the sign-in state. `~/.azure` also accumulates `logs/`,
|
||||
// `commands/`, telemetry and (on a bare host) `cliextensions/`, none of which is
|
||||
// needed to authenticate; the agent image carries its own extensions outside HOME.
|
||||
// `msal_token_cache.json` is plaintext only on Linux (Windows/macOS encrypt it), so
|
||||
// this carries a sign-in from the Docker server image or a Linux host.
|
||||
// OPT-IN like gh: the MSAL cache holds refresh tokens for the whole Azure account.
|
||||
{
|
||||
rel: '.azure',
|
||||
enabledByEnv: 'CODEMAN_AGENT_IMAGE_INSTALL_AZ',
|
||||
seedFiles: [
|
||||
'azureProfile.json',
|
||||
'msal_token_cache.json',
|
||||
'service_principal_entries.json',
|
||||
'clouds.config',
|
||||
'config',
|
||||
],
|
||||
},
|
||||
// OMP keeps its config in `~/.omp/agent` (config.yml/mcp.json/models.yml/
|
||||
// settings.yml — small, no bigger than grok's config.toml/pager.toml), but
|
||||
// that dir ALSO holds agent.db/history.db/models.db (SQLite caches) and
|
||||
@@ -853,10 +913,14 @@ const CRED_STORES: CredStorePolicy[] = [
|
||||
* session state back into the host). Every path is existsSync-gated (on most hosts
|
||||
* only a subset exists). Pure-ish IO (no writes; just existence checks + mount specs).
|
||||
*/
|
||||
export function resolveDockerCredentialArtifacts(home: string = homedir()): DockerClaudeArtifacts {
|
||||
export function resolveDockerCredentialArtifacts(
|
||||
home: string = homedir(),
|
||||
env: NodeJS.ProcessEnv = process.env
|
||||
): DockerClaudeArtifacts {
|
||||
const mounts: DockerMount[] = [];
|
||||
const seedCopies: DockerSeedCopy[] = [];
|
||||
for (const store of CRED_STORES) {
|
||||
if (store.enabledByEnv && env[store.enabledByEnv] !== '1') continue;
|
||||
const hostBase = join(home, store.rel);
|
||||
if (!existsSync(hostBase)) continue;
|
||||
const containerBase = `${CONTAINER_HOME}/${store.rel}`;
|
||||
@@ -1136,10 +1200,17 @@ function buildAgentImage(
|
||||
error: `docker/agent.Dockerfile not found in this install; clone the repo or build ${image} manually`,
|
||||
});
|
||||
}
|
||||
let buildArgPairs: Array<[string, string]>;
|
||||
try {
|
||||
buildArgPairs = agentImageBuildArgPairs();
|
||||
} catch (err) {
|
||||
// A malformed CODEMAN_AGENT_IMAGE_INSTALL_* value: report it like any other build failure.
|
||||
return Promise.resolve({ ok: false, built: false, alreadyPresent: false, error: String((err as Error).message) });
|
||||
}
|
||||
const argv = dockerEngineArgv(docker);
|
||||
const args = [
|
||||
...argv.slice(1),
|
||||
...agentImageBuildArgs(resolved.dockerfile, image, resolved.contextDir, opts.noCache, agentImageBuildArgPairs()),
|
||||
...agentImageBuildArgs(resolved.dockerfile, image, resolved.contextDir, opts.noCache, buildArgPairs),
|
||||
];
|
||||
return new Promise<EnsureImageResult>((resolve) => {
|
||||
// async spawn (NEVER spawnSync) so a multi-minute build never wedges the event loop.
|
||||
|
||||
+30
-6
@@ -195,6 +195,8 @@ export interface CloneOptions {
|
||||
/** `--depth 1`: history-less but much faster on large repos. */
|
||||
shallow?: boolean;
|
||||
timeoutMs?: number;
|
||||
/** Clear every git credential helper for this run (see `GIT_NO_CREDENTIAL_HELPERS`). */
|
||||
withoutCredentialHelpers?: boolean;
|
||||
}
|
||||
|
||||
export type CloneResult = { ok: true; stderr: string } | { ok: false; failure: GitFailure };
|
||||
@@ -436,12 +438,27 @@ export function isSafeGitRef(ref: string): boolean {
|
||||
|
||||
// ─── Pure: argv + env ────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Global git options that empty the credential-helper list for one run.
|
||||
*
|
||||
* Every Codeman user in multi-user mode runs git as the SAME OS account, so a
|
||||
* helper that account has (the Docker image's opt-in `gh`/`az` helpers, or a
|
||||
* user's own `gh auth setup-git`) would read private repositories on the
|
||||
* signed-in admin's behalf for anyone who can reach Clone Repo. An empty
|
||||
* `credential.helper` resets the helper list, and a command-line `-c` is read
|
||||
* last, so it also drops the URL-scoped `credential.<url>.helper` entries the
|
||||
* image configures (verified against a real private repo: refs with the helper,
|
||||
* `could not read Username` with it cleared). Public repositories are
|
||||
* unaffected. It must precede the subcommand.
|
||||
*/
|
||||
export const GIT_NO_CREDENTIAL_HELPERS: readonly string[] = ['-c', 'credential.helper='];
|
||||
|
||||
/**
|
||||
* argv for the clone. `--` separates flags from operands so neither the
|
||||
* repository nor the destination can ever be read as an option.
|
||||
*/
|
||||
export function buildCloneArgs(opts: CloneOptions): string[] {
|
||||
const args = ['clone'];
|
||||
const args = [...(opts.withoutCredentialHelpers ? GIT_NO_CREDENTIAL_HELPERS : []), 'clone'];
|
||||
// `--single-branch` is what makes "just this tag/branch" cheap on a big repo.
|
||||
if (opts.ref) args.push('--single-branch', '--branch', opts.ref);
|
||||
if (opts.shallow) args.push('--depth', '1');
|
||||
@@ -450,8 +467,14 @@ export function buildCloneArgs(opts: CloneOptions): string[] {
|
||||
}
|
||||
|
||||
/** argv for the preflight. `--symref` is what reveals the remote's default branch. */
|
||||
export function buildLsRemoteArgs(repository: string): string[] {
|
||||
return ['ls-remote', '--symref', '--', repository];
|
||||
export function buildLsRemoteArgs(repository: string, opts: { withoutCredentialHelpers?: boolean } = {}): string[] {
|
||||
return [
|
||||
...(opts.withoutCredentialHelpers ? GIT_NO_CREDENTIAL_HELPERS : []),
|
||||
'ls-remote',
|
||||
'--symref',
|
||||
'--',
|
||||
repository,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -579,7 +602,7 @@ export function classifyGitFailure(stderr: string, timedOut: boolean, spawnError
|
||||
return {
|
||||
code: 'AUTH_REQUIRED',
|
||||
message:
|
||||
'That repository needs authentication. Codeman clones without credentials, so private repositories have to be cloned outside Codeman and added with Link Existing.',
|
||||
"That repository needs authentication. Codeman never asks for credentials, so sign this server's git in first (for example `gh auth login` or `az login` from a shell session; the Docker image can include both, see docker/README.md), or clone it outside Codeman and add it with Link Existing.",
|
||||
stderr: clean,
|
||||
};
|
||||
}
|
||||
@@ -790,7 +813,8 @@ export function isGitAvailable(): boolean {
|
||||
*/
|
||||
export async function probeGitRemote(
|
||||
repository: string,
|
||||
timeoutMs = GIT_LS_REMOTE_TIMEOUT_MS
|
||||
timeoutMs = GIT_LS_REMOTE_TIMEOUT_MS,
|
||||
opts: { withoutCredentialHelpers?: boolean } = {}
|
||||
): Promise<GitRemoteProbe> {
|
||||
if (!isGitAvailable()) {
|
||||
return {
|
||||
@@ -800,7 +824,7 @@ export async function probeGitRemote(
|
||||
failure: classifyGitFailure('', false, 'ENOENT: git not found'),
|
||||
};
|
||||
}
|
||||
const run = await runGit(buildLsRemoteArgs(repository), timeoutMs, MAX_LS_REMOTE_BYTES);
|
||||
const run = await runGit(buildLsRemoteArgs(repository, opts), timeoutMs, MAX_LS_REMOTE_BYTES);
|
||||
if (run.code !== 0 || run.spawnError) {
|
||||
return {
|
||||
reachable: false,
|
||||
|
||||
@@ -24,6 +24,7 @@ import type {
|
||||
OmpConfig,
|
||||
SessionRemote,
|
||||
SessionDocker,
|
||||
PaneExit,
|
||||
} from './types.js';
|
||||
|
||||
/**
|
||||
@@ -56,6 +57,23 @@ export interface MuxSession {
|
||||
respawnConfig?: PersistedRespawnConfig;
|
||||
/** Whether Ralph / Todo tracking is enabled */
|
||||
ralphEnabled?: boolean;
|
||||
/**
|
||||
* This record was rebuilt from the tmux socket rather than from Codeman's own
|
||||
* bookkeeping, so everything on it but the name and the pid is a guess. Its
|
||||
* synthetic `restored-<fragment>` id cannot find the session's `state.json`
|
||||
* entry either, which means a remote or docker session rediscovered this way
|
||||
* arrives with no `remote`/`docker` metadata and looks local. Anything that
|
||||
* would be WRONG about such a session rather than merely vague must fail
|
||||
* closed on this flag.
|
||||
*
|
||||
* ⚠ It is PERMANENT, not merely true for the boot that rediscovered the
|
||||
* session: `saveSessions()` serializes the whole record to
|
||||
* `mux-sessions.json` and `loadSessions()` restores it, so a genuinely local
|
||||
* session rediscovered once stays opted out of everything keyed on this for
|
||||
* the life of that record. That is the safe direction to fail, and it costs
|
||||
* only the guess Codeman is declining to make.
|
||||
*/
|
||||
discovered?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -159,6 +177,15 @@ export interface PaneCaptureOptions {
|
||||
* the 1MB execSync default (ENOBUFS).
|
||||
*/
|
||||
maxCaptureBytes?: number;
|
||||
/**
|
||||
* Filled in by the implementation with the pane geometry the capture was
|
||||
* really taken at, which is not always the geometry the caller last asked
|
||||
* for: a resize and a capture can race, and a pane whose size a desktop
|
||||
* viewport has claimed ignores a smaller client's resize outright. A
|
||||
* visible-frame capture addresses every row absolutely, so a consumer
|
||||
* rendering it needs the real height to know the frame fits.
|
||||
*/
|
||||
capturedGeometry?: { cols: number; rows: number };
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -171,6 +198,7 @@ export interface PaneCaptureOptions {
|
||||
* - `sessionKilled` (data: { sessionId: string }) - Session terminated
|
||||
* - `sessionDied` (data: { sessionId: string }) - Session died unexpectedly
|
||||
* - `statsUpdated` (sessions: MuxSessionWithStats[]) - Stats refreshed
|
||||
* - `paneExitsUpdated` () - A pane read finished; ask `getPaneExit()` per session
|
||||
*/
|
||||
export interface TerminalMultiplexer extends EventEmitter {
|
||||
/** Which backend this instance uses */
|
||||
@@ -285,6 +313,24 @@ export interface TerminalMultiplexer extends EventEmitter {
|
||||
/** Check if the pane in a session is dead (command exited but remain-on-exit keeps it alive) */
|
||||
isPaneDead(muxName: string): boolean;
|
||||
|
||||
/**
|
||||
* What the last pane read saw of this session's agent, or `undefined` for
|
||||
* UNKNOWN (Ark0N/Codeman#446). Unlike `isPaneDead()` this costs nothing: it
|
||||
* reads a map the batched watcher fills, so it answers no fresher than that
|
||||
* watcher's interval and the three synchronous `isPaneDead()` callers still
|
||||
* need their own probe. See {@link PaneExit}.
|
||||
*/
|
||||
getPaneExit?(muxName: string): PaneExit | undefined;
|
||||
|
||||
/** Forget a session's exit observation, e.g. once its pane has been respawned. */
|
||||
clearPaneExit?(muxName: string): void;
|
||||
|
||||
/** Start polling every pane on the socket for an exited agent. */
|
||||
startPaneExitWatcher?(intervalMs?: number): void;
|
||||
|
||||
/** Stop the pane-exit watcher. */
|
||||
stopPaneExitWatcher?(): void;
|
||||
|
||||
/** Respawn a dead pane with a fresh command. Returns the new PID or null on failure. */
|
||||
respawnPane(options: RespawnPaneOptions): Promise<number | null>;
|
||||
|
||||
|
||||
@@ -540,6 +540,32 @@ export function remoteDisplayPath(
|
||||
return `${remote.username}@${remote.host}:${path}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Refresh HOST-level config on a RESTORED `SessionRemote`.
|
||||
*
|
||||
* A session's `remote` block is persisted at launch time (mux-sessions.json /
|
||||
* state.json) and recovery uses that snapshot, so a field ADDED to the host config
|
||||
* later never reaches an already-running session — not even across a Codeman
|
||||
* restart. That is exactly how a `wakeCommand` added to `remote-hosts.json` would
|
||||
* silently do nothing until the session is relaunched (which for an owned remote
|
||||
* session means killing the remote tmux).
|
||||
*
|
||||
* Deliberately narrow: ONLY `wakeCommand`/`wakeMac` are taken from the host config,
|
||||
* and the host is authoritative for them (removing one in the config turns that
|
||||
* wake path off again). The other host-level fields (`commands`, ssh options) stay as
|
||||
* persisted so this cannot silently change how an existing pane connects.
|
||||
*/
|
||||
export function rehydrateRemoteHostFields<T extends { hostId: string; wakeCommand?: string; wakeMac?: string }>(
|
||||
remote: T | undefined,
|
||||
hostsById: ReadonlyMap<string, RemoteHost>
|
||||
): T | undefined {
|
||||
if (!remote) return remote;
|
||||
const host = hostsById.get(remote.hostId);
|
||||
if (!host) return remote;
|
||||
if (remote.wakeCommand === host.wakeCommand && remote.wakeMac === host.wakeMac) return remote;
|
||||
return { ...remote, wakeCommand: host.wakeCommand, wakeMac: host.wakeMac };
|
||||
}
|
||||
|
||||
export function toSessionRemote(host: RemoteHost, remoteCase: RemoteCase): SessionRemote {
|
||||
return {
|
||||
hostId: host.id,
|
||||
@@ -549,6 +575,10 @@ export function toSessionRemote(host: RemoteHost, remoteCase: RemoteCase): Sessi
|
||||
port: host.port,
|
||||
remotePath: remoteCase.remotePath,
|
||||
commands: host.commands,
|
||||
// Wake-on-LAN command/MAC travel with the session so the input route can wake a
|
||||
// sleeping host without a second config read (see remote-wake.ts).
|
||||
wakeCommand: host.wakeCommand,
|
||||
wakeMac: host.wakeMac,
|
||||
// COD-105 — the COD-104 launch path creates the remote session, so we own it
|
||||
// (an explicit kill may propagate a remote kill-session). Discovered+attached
|
||||
// sessions go through `toAttachedSessionRemote` with `owned: false`.
|
||||
@@ -587,6 +617,10 @@ export function toAttachedSessionRemote(
|
||||
port: host.port,
|
||||
remotePath,
|
||||
commands: host.commands,
|
||||
// An attached session can be woken exactly the same way — the identity of the
|
||||
// creator does not change whether the host is asleep.
|
||||
wakeCommand: host.wakeCommand,
|
||||
wakeMac: host.wakeMac,
|
||||
// Discovered + attached — another Codeman created it. Detach-not-kill.
|
||||
owned: false,
|
||||
remoteSessionName,
|
||||
|
||||
+1035
File diff suppressed because it is too large
Load Diff
@@ -21,6 +21,8 @@
|
||||
* in 12/12 windows and the four idle ones in 0/12.
|
||||
*/
|
||||
|
||||
import { stripAnsi } from './utils/regex-patterns.js';
|
||||
|
||||
/**
|
||||
* A gap longer than this ends a run of continuous output. Claude repaints at
|
||||
* least once a second while working, so this leaves generous headroom.
|
||||
@@ -91,3 +93,68 @@ export function isSustainedActivity(streak: ActivityStreak | null, streakMs: num
|
||||
export function isPaneQuiet(lastActivityAt: number, now: number, silenceMs: number = IDLE_SILENCE_MS): boolean {
|
||||
return now - lastActivityAt >= silenceMs;
|
||||
}
|
||||
|
||||
/**
|
||||
* How many rows at the foot of a pane capture may hold the background-work row, for a
|
||||
* CLI that declares no number of its own (`capabilities.workDetect.watchingLines`).
|
||||
*
|
||||
* One, because the tightest window is the right default and Claude Code needs no more:
|
||||
* it draws its chip on the LAST row of the screen. Blank rows are dropped before the
|
||||
* window is taken, so a trailing blank costs nothing, and a CLI that ever prints a row
|
||||
* BELOW its chip loses the badge rather than gaining a hole.
|
||||
*
|
||||
* ⚠️ The size of this window is a trust boundary, not a tidiness measure, and the row
|
||||
* it excludes first is the one that taught us so: Claude's status line sits directly
|
||||
* above the footer, its content comes from a `statusLine` command, and a session running
|
||||
* with permissions bypassed can write that command into `.claude/settings.json` in its
|
||||
* own workspace. A window of two therefore let an agent print `· 1 monitor ·` onto a row
|
||||
* of its own and silence its own idle alert. Every row added here is another row
|
||||
* somebody may be able to write, so widen this only for a CLI whose layout forces it,
|
||||
* and never to a whole-pane search.
|
||||
*/
|
||||
export const WATCHING_TAIL_LINES = 1;
|
||||
|
||||
/** Longest label a badge will carry. A footer chip is a handful of words. */
|
||||
export const MAX_WATCHING_LABEL_CHARS = 40;
|
||||
|
||||
/**
|
||||
* What a pane says is still running in the background, e.g. `1 monitor` or `2 shells`.
|
||||
*
|
||||
* The CLI writes that chip while a monitor, a backgrounded shell or a cloud session it
|
||||
* started is still going, which is exactly the case where the agent has ended its turn
|
||||
* without wanting anything from the user. `pattern` comes from the CLI's own registry
|
||||
* entry (`capabilities.workDetect.watchingLine`); group 1 is the label when the pattern
|
||||
* declares one, and the whole match stands in when it does not.
|
||||
*
|
||||
* Each candidate row is tested on its own, bottom row first, so a pattern can anchor
|
||||
* itself with `^` or `$` against a single row rather than against a joined block. Blank
|
||||
* rows are dropped before the window is taken, because a CLI that leaves a blank line
|
||||
* between its chrome rows would otherwise spend the window on nothing. The answer is
|
||||
* stripped of ANSI and capped, because it ends up on a badge and in an approval card.
|
||||
*
|
||||
* @param tailLines how many non-blank rows from the bottom to look at, defaulting to
|
||||
* `WATCHING_TAIL_LINES`; a CLI declares its own when its row is not the last one
|
||||
* @returns the label, or null when the pane shows no background work
|
||||
*/
|
||||
export function watchingLabel(
|
||||
paneText: string | null | undefined,
|
||||
pattern: RegExp,
|
||||
tailLines: number = WATCHING_TAIL_LINES
|
||||
): string | null {
|
||||
if (!paneText) return null;
|
||||
const lines = stripAnsi(paneText)
|
||||
.split('\n')
|
||||
.map((line) => line.trimEnd())
|
||||
.filter((line) => line !== '');
|
||||
for (const line of lines.slice(-Math.max(1, tailLines)).reverse()) {
|
||||
// A pattern compiled by compileVersionRegex() never carries the `g` flag, but a
|
||||
// caller reaching in from a test or a config reload might, and a stale lastIndex
|
||||
// would make the same screen match every other call.
|
||||
pattern.lastIndex = 0;
|
||||
const match = pattern.exec(line);
|
||||
if (!match) continue;
|
||||
const label = (match[1] ?? match[0]).trim().slice(0, MAX_WATCHING_LABEL_CHARS);
|
||||
if (label) return label;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -6,13 +6,18 @@
|
||||
* stripped before the session is built. The create and resume routes are what
|
||||
* this bites on: they clamp what a request asked for.
|
||||
*
|
||||
* The reboot-restore route calls it as defence in depth, and today it can strip
|
||||
* nothing. `Session.getEnvOverridesForPersist()` keeps only `CLAUDE_CODE_*` and
|
||||
* `CLAUDE_CONFIG_DIR` out of a session's overrides, claude's `privilegedEnvKeys`
|
||||
* are the five `ANTHROPIC_*` names, and that pass admits claude alone — so a
|
||||
* persisted record cannot carry a clamped key. The call is there for the day the
|
||||
* persisted set widens. The grant re-resolution that does bite on that path is
|
||||
* `resolveClaudeModeForUsername`, which recomputes the permission mode.
|
||||
* The reboot-restore route calls it as defence in depth, and it CAN strip
|
||||
* something today: `Session.getEnvOverridesForPersist()` keeps only
|
||||
* `CLAUDE_CODE_*` and `CLAUDE_CONFIG_DIR` out of a session's overrides, and
|
||||
* claude's `privilegedEnvKeys` now includes both `CLAUDE_CODE_MAX_CONTEXT_TOKENS`
|
||||
* and `CLAUDE_CONFIG_DIR` (Custom Model Endpoint Profiles, since both can
|
||||
* redirect a claude session's traffic — see stock.ts's own comment on why they
|
||||
* are listed despite not needing the clamp for that feature). So a non-granted
|
||||
* owner's persisted `CLAUDE_CONFIG_DIR` (the per-client-account override, #255)
|
||||
* is now stripped on reboot-restore, silently returning that session to the
|
||||
* default Claude account rather than the account it was pointed at. The grant
|
||||
* re-resolution that ALSO bites on that path is `resolveClaudeModeForUsername`,
|
||||
* which recomputes the permission mode.
|
||||
*
|
||||
* This lives outside `web/routes` on purpose. The question it answers is about
|
||||
* session privilege rather than about HTTP, and `cron/cron-service.ts` sets the
|
||||
|
||||
+428
-23
@@ -60,8 +60,11 @@ import {
|
||||
type SessionDocker,
|
||||
type SessionNameSource,
|
||||
type SessionWriteOptions,
|
||||
type PaneExit,
|
||||
} from './types.js';
|
||||
import { resolveAndClaimOmpSessionId } from './utils/omp-session-resolver.js';
|
||||
import { claudeTranscriptExists } from './utils/claude-transcript.js';
|
||||
import { matchesPattern } from './config/cli-registry/patterns.js';
|
||||
import { probeDockerCliVersion } from './docker-hosts.js';
|
||||
import { probeRemoteCliVersion } from './remote-hosts.js';
|
||||
import type { TerminalMultiplexer, MuxSession } from './mux-interface.js';
|
||||
@@ -81,6 +84,8 @@ import {
|
||||
trackActivityStreak,
|
||||
isSustainedActivity,
|
||||
isPaneQuiet,
|
||||
watchingLabel,
|
||||
WATCHING_TAIL_LINES,
|
||||
IDLE_RECHECK_MS,
|
||||
PANE_PROBE_MIN_INTERVAL_MS,
|
||||
PANE_PROBE_RECHECK_MS,
|
||||
@@ -497,8 +502,35 @@ export class Session extends EventEmitter {
|
||||
private _activityStreak: ActivityStreak | null = null; // Unbroken run of PTY repaints (working detection)
|
||||
private _lastPaneProbeAt = 0; // Throttle for the tmux screen probe
|
||||
private _lastPaneProbeWorking: boolean | null = null; // Its last verdict (null = could not read)
|
||||
/**
|
||||
* Background work the pane's own footer reports, e.g. `1 monitor`; null for none.
|
||||
*
|
||||
* Cached BESIDE `_lastPaneProbeWorking` and refreshed only by a capture that really
|
||||
* happened, so it goes stale exactly as that verdict does. The probe returns its
|
||||
* cached boolean without re-capturing inside `PANE_PROBE_MIN_INTERVAL_MS`, and a
|
||||
* label derived from a capture nobody took would be a guess wearing a fact's clothes.
|
||||
*
|
||||
* ⚠️ It then FREEZES once `_confirmIdle()` concludes: `activityTimeout` is null from
|
||||
* there, and nothing looks at the pane again until it produces output. That is
|
||||
* correct rather than merely tolerable, because work ending repaints the pane either
|
||||
* way — a monitor firing wakes the agent, and codex drops its background-terminal row
|
||||
* on its own. Do not add a timer to keep this fresh; it would spend a `capture-pane`
|
||||
* per idle session per tick to learn nothing.
|
||||
*
|
||||
* A server restart is not a hole in that either, though it looks like one: this field
|
||||
* is live state and starts empty. Reconciliation re-attaches the pane, the attach
|
||||
* repaint carries the composer glyph, and the idle confirmation that arms on it probes
|
||||
* and re-reads the label with no input from anyone — measured 2026-09-23 on a restarted
|
||||
* instance, back within ~20 s for a session whose background terminal was still
|
||||
* running. A session that comes back with no label has no chip on its screen.
|
||||
*/
|
||||
private _watching: string | null = null;
|
||||
/** Lazily compiled `capabilities.workDetect.workingLine`. See _workingLinePattern(). */
|
||||
private _workingLineRe: RegExp | undefined = undefined;
|
||||
/** Lazily compiled `capabilities.workDetect.watchingLine`. See _watchingLinePattern(). */
|
||||
private _watchingLineRe: RegExp | null | undefined = undefined;
|
||||
/** Resolved with the pattern above: how many rows at the foot of the screen to search. */
|
||||
private _watchingWindow = WATCHING_TAIL_LINES;
|
||||
private _trustDialogAccepted: boolean = false; // Stops the trust-dialog scan (answered, or given up)
|
||||
private _trustDialogAttempts = 0; // Keystrokes sent at the trust dialog
|
||||
private _lastTrustDialogScanAt = 0; // Throttle for the trust-dialog screen read
|
||||
@@ -542,6 +574,20 @@ export class Session extends EventEmitter {
|
||||
private _mux: TerminalMultiplexer | null = null;
|
||||
private _muxSession: MuxSession | null = null;
|
||||
private _useMux: boolean = false;
|
||||
/**
|
||||
* The agent in this session's local tmux pane has exited (Ark0N/Codeman#446).
|
||||
* `null` is the UNKNOWN arm of the tri-state and is what {@link setPaneExit}
|
||||
* stores for every session shape the field does not apply to. See
|
||||
* {@link PaneExit} for the shapes and for why an unknown answer must never be
|
||||
* rendered as "alive".
|
||||
*/
|
||||
private _paneExit: PaneExit | null = null;
|
||||
/**
|
||||
* This session was rebuilt from the tmux socket rather than from Codeman's
|
||||
* own records, so its `remote`/`docker` metadata is missing rather than known
|
||||
* to be absent. See {@link MuxSession.discovered}.
|
||||
*/
|
||||
private _discoveredMuxSession = false;
|
||||
// Flag to prevent new timers after session is stopped
|
||||
private _isStopped: boolean = false;
|
||||
|
||||
@@ -718,6 +764,10 @@ export class Session extends EventEmitter {
|
||||
lastSubmitAt?: number;
|
||||
/** Restored conversation chain, oldest first (see `claudeSessionChain`). */
|
||||
claudeSessionChain?: string[];
|
||||
/** Restored agent-exit observation for this session's pane (see `paneExit`). */
|
||||
paneExit?: PaneExit;
|
||||
/** This session was rebuilt from the tmux socket, so its metadata is a guess. */
|
||||
discoveredMuxSession?: boolean;
|
||||
/** Restored wall-clock ms of the pane's last output (recovery only; see `_wireActivityAt`). */
|
||||
lastActivityAt?: number;
|
||||
/** Remote execution metadata for sessions launched through SSH inside local tmux. */
|
||||
@@ -870,6 +920,16 @@ export class Session extends EventEmitter {
|
||||
this._remote = config.remote;
|
||||
this._docker = config.docker;
|
||||
this._owner = config.owner;
|
||||
this._discoveredMuxSession = config.discoveredMuxSession === true;
|
||||
// Restored so a record that says the agent exited survives a server restart
|
||||
// rather than being blanked by the first persist after boot. It runs here
|
||||
// because the scoping reads `_remote`, `_docker` and the mux fields, all of
|
||||
// which are set by now. It is a claim about a pane this process has not
|
||||
// looked at yet, so every path that starts or re-attaches a pane drops it
|
||||
// (see `_setupOrAttachMuxSession`) and the pane-exit watcher's own tick
|
||||
// replaces it with a first-hand reading. NOT the stats collector, which a
|
||||
// browser panel arms and disarms — see `startPaneExitWatcher`.
|
||||
this.setPaneExit(config.paneExit);
|
||||
// Never self-parent: a session pointing at itself would draw a zero-length
|
||||
// lineage arc under its own tab. Only reachable via the recovery path, where
|
||||
// both the id and the saved parent come from disk.
|
||||
@@ -1097,6 +1157,75 @@ export class Session extends EventEmitter {
|
||||
return this._muxSession?.muxName ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* True when a tmux pane's death would mean THIS session's agent has exited.
|
||||
*
|
||||
* Four shapes fail the test, and each would otherwise publish a death that is
|
||||
* not the agent's. A direct-PTY session owns no pane at all. A remote SSH
|
||||
* session's local pane holds the ssh client, whose death means a transport
|
||||
* drop OR an exit, which is the ambiguity PR #355 was about. A docker case's
|
||||
* local pane holds a `docker exec` into the container's own tmux.
|
||||
*
|
||||
* The fourth is a session rebuilt from the socket. Absent `remote`/`docker`
|
||||
* normally means "this is local", but on a discovered record it only means
|
||||
* "Codeman never found the metadata": the synthetic `restored-<fragment>` id
|
||||
* matches no `state.json` entry, so a remote session rediscovered after
|
||||
* `mux-sessions.json` was lost arrives looking local, and its next transport
|
||||
* drop would be published as an agent exit. Unproven locality fails closed.
|
||||
*/
|
||||
private get paneExitApplies(): boolean {
|
||||
if (this._discoveredMuxSession) return false;
|
||||
return this._useMux && this._muxSession !== null && !this._remote && !this._docker;
|
||||
}
|
||||
|
||||
/** What Codeman last observed of this pane's agent, or undefined for UNKNOWN. */
|
||||
get paneExit(): PaneExit | undefined {
|
||||
return this._paneExit ?? undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Forget this pane's exit, on both this record and the mux layer's cache.
|
||||
* Every path that starts or relaunches a command in the pane calls it, and
|
||||
* the mux half also invalidates a pane read already in flight.
|
||||
*
|
||||
* It does not persist or broadcast by itself; the caller owns both. ⚠ That
|
||||
* caller MUST persist, and the pane-exit watcher is not a fallback for it:
|
||||
* the watcher's next tick reads UNKNOWN, finds this field already cleared,
|
||||
* reports no change and therefore writes nothing, so a caller that only
|
||||
* broadcasts leaves `state.json` saying the agent exited for as long as the
|
||||
* session stays quiet. `/interactive` and `/shell` did exactly that until
|
||||
* Ark0N/Codeman#446 review; both now persist on their success path.
|
||||
*/
|
||||
private clearPaneExitForNewPane(): void {
|
||||
this.setPaneExit(undefined);
|
||||
if (this._muxSession) this._mux?.clearPaneExit?.(this._muxSession.muxName);
|
||||
}
|
||||
|
||||
/**
|
||||
* Record what the mux layer observed of this pane's agent, and say whether
|
||||
* that changed the answer. The caller persists and broadcasts on a true.
|
||||
*
|
||||
* A session the field does not apply to is forced to UNKNOWN here rather than
|
||||
* at the reporting end, so the rule lives in one place and the mux layer stays
|
||||
* free to report the raw pane reading its own remote-reconnect watcher needs.
|
||||
*/
|
||||
setPaneExit(next: PaneExit | undefined): boolean {
|
||||
const resolved = this.paneExitApplies ? (next ?? null) : null;
|
||||
const prev = this._paneExit;
|
||||
if (prev === resolved) return false;
|
||||
if (
|
||||
prev !== null &&
|
||||
resolved !== null &&
|
||||
prev.status === resolved.status &&
|
||||
prev.signal === resolved.signal &&
|
||||
prev.at === resolved.at
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
this._paneExit = resolved;
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* True when this session's PTY is a tmux client rather than the program itself.
|
||||
* Read by the replay-side alt-screen strip, which must apply the same
|
||||
@@ -1118,6 +1247,16 @@ export class Session extends EventEmitter {
|
||||
return this._isWorking;
|
||||
}
|
||||
|
||||
/**
|
||||
* What the pane says is still running in the background, e.g. `1 monitor`, or null when
|
||||
* nothing is. A session with a label here has ended its turn without wanting anything
|
||||
* from the user, so a surface that would otherwise file it under "needs you" can say
|
||||
* what it is waiting for instead.
|
||||
*/
|
||||
get watching(): string | null {
|
||||
return this._watching;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if the session's process tree has active child processes beyond Claude itself.
|
||||
* Detects running bash tools, test suites, builds, servers, etc. that Claude spawned.
|
||||
@@ -1620,6 +1759,10 @@ export class Session extends EventEmitter {
|
||||
// by the constructor: a Codeman restart starts with a fresh breaker so boot
|
||||
// recovery can re-attach.
|
||||
respawnBlocked: this._respawnBlocked || undefined,
|
||||
// Ark0N/Codeman#446 — the agent in this pane has exited, published here so
|
||||
// it rides the existing `session:updated` broadcast and lands in state.json
|
||||
// through the same persist. `status` and `pid` above stay untouched by it.
|
||||
paneExit: this._paneExit ?? undefined,
|
||||
attachmentHistory: this.attachmentHistory.length > 0 ? this.attachmentHistory : undefined,
|
||||
lastSubmitAt: this._lastSubmitAt || undefined,
|
||||
// Only a chain the CLI's own hooks vouched for is persisted, and only when
|
||||
@@ -1675,6 +1818,7 @@ export class Session extends EventEmitter {
|
||||
totalCost: this._totalCost,
|
||||
messageCount: this._messages.length,
|
||||
isWorking: this._isWorking,
|
||||
watching: this._watching,
|
||||
lastPromptTime: this._lastPromptTime,
|
||||
// Buffer statistics for monitoring long-running sessions
|
||||
bufferStats: {
|
||||
@@ -1731,41 +1875,82 @@ export class Session extends EventEmitter {
|
||||
respawnPaneOptions: import('./mux-interface.js').RespawnPaneOptions;
|
||||
createSessionOptions: import('./mux-interface.js').CreateSessionOptions;
|
||||
spawnErrLabel: string;
|
||||
}): Promise<{ isRestored: boolean }> {
|
||||
}): Promise<{ isRestored: boolean; respawnedResumeId?: string; respawnedDeadPane: boolean }> {
|
||||
const mux = this._mux!;
|
||||
|
||||
// Verify stale mux session — tmux may have been destroyed (e.g., killed externally)
|
||||
// Verify stale mux session — tmux may have been destroyed (e.g., killed externally).
|
||||
// A session that HAD a mux session relaunches its CLI below just like a failed
|
||||
// respawn does (tmux kill-server, a tmux crash, an external kill-session), so
|
||||
// its transcript collides with the bare `--session-id` the same way. A
|
||||
// genuinely new session starts with `_muxSession` null and never sets this.
|
||||
let muxSessionVanished = false;
|
||||
if (this._muxSession && !mux.muxSessionExists(this._muxSession.muxName)) {
|
||||
console.log('[Session] Stale mux session detected (tmux gone):', this._muxSession.muxName);
|
||||
this._muxSession = null;
|
||||
muxSessionVanished = true;
|
||||
}
|
||||
|
||||
// Check if session exists but pane is dead (remain-on-exit keeps it alive)
|
||||
// Respawn the pane instead of creating a whole new session — preserves tmux scrollback
|
||||
let needsNewSession = false;
|
||||
let respawnedDeadPane = false;
|
||||
let respawnedResumeId: string | undefined;
|
||||
if (this._muxSession && mux.isPaneDead(this._muxSession.muxName)) {
|
||||
console.log('[Session] Dead pane detected, respawning:', this._muxSession.muxName);
|
||||
// Confirmed dead — safe to resolve/pin now (see `_pinOmpRespawnId()`).
|
||||
// `options.respawnPaneOptions` was built eagerly before this dead-pane
|
||||
// check ran, so it still carries the pre-pin ompConfig; rebuild it.
|
||||
this._pinOmpRespawnId();
|
||||
const newPid = await mux.respawnPane(this._buildRespawnPaneOptions());
|
||||
const respawnOptions = await this._buildRespawnPaneOptionsWithResumePin();
|
||||
const newPid = await mux.respawnPane(respawnOptions);
|
||||
if (!newPid) {
|
||||
console.error('[Session] Failed to respawn pane, will create new session');
|
||||
needsNewSession = true;
|
||||
} else {
|
||||
respawnedDeadPane = true;
|
||||
respawnedResumeId = respawnOptions.resumeSessionId;
|
||||
this._pendingEnvUnsets.clear();
|
||||
// Wait a moment for the respawned process to fully start
|
||||
await new Promise((resolve) => setTimeout(resolve, MUX_STARTUP_DELAY_MS));
|
||||
}
|
||||
}
|
||||
|
||||
// Whatever the last reading said about the OLD command in this pane is now
|
||||
// history: the branch above either respawned the pane or found it alive, and
|
||||
// the branch below creates a new one. The paths that reach here are boot
|
||||
// recovery and an explicit start, NOT a click on an exited tab — the browser
|
||||
// re-attaches only on a null pid, and the premise of Ark0N/Codeman#446 is
|
||||
// that an exited pane keeps its pid. `restartCli()` clears separately.
|
||||
this.clearPaneExitForNewPane();
|
||||
|
||||
// Check if we already have a mux session (restored session)
|
||||
const isRestored = this._muxSession !== null && !needsNewSession;
|
||||
if (isRestored) {
|
||||
console.log('[Session] Attaching to existing mux session:', this._muxSession!.muxName);
|
||||
} else {
|
||||
// Create a new mux session
|
||||
// Create a new mux session. When this is the FALLBACK after a failed
|
||||
// respawn, the eagerly-built create options still carry the unpinned
|
||||
// launch seed, so a session whose transcript exists would meet the same
|
||||
// `--session-id ... already in use` refusal the respawn just lost to —
|
||||
// the recovery of last resort failing for the very reason it was needed.
|
||||
// A genuinely new session has no transcript under any of its candidate
|
||||
// ids, so nothing is pinned and its command shape is unchanged.
|
||||
//
|
||||
// `_resumeSessionId` is written alongside, not just the create options:
|
||||
// this branch leaves `isRestored` false, so the block that sets
|
||||
// `_claudeSessionId` below reads that field and would otherwise settle on
|
||||
// `this.id` while the CLI resumes the chain tail. The response viewer,
|
||||
// Read My Mind and the unified-list alias map all read `_claudeSessionId`
|
||||
// until the next first-hand hook, so the two have to name the same
|
||||
// conversation. The vanished-tmux-session branch above relaunches for the
|
||||
// same reason and takes the same pin.
|
||||
if (needsNewSession || muxSessionVanished) {
|
||||
const pinned = (await this._buildRespawnPaneOptionsWithResumePin()).resumeSessionId;
|
||||
if (pinned) {
|
||||
options.createSessionOptions.resumeSessionId = pinned;
|
||||
this._resumeSessionId = pinned;
|
||||
}
|
||||
}
|
||||
this._muxSession = await mux.createSession(options.createSessionOptions);
|
||||
console.log('[Session] Created mux session:', this._muxSession.muxName);
|
||||
// No extra sleep — createSession() already waits for tmux readiness
|
||||
@@ -1800,13 +1985,14 @@ export class Session extends EventEmitter {
|
||||
env: buildMuxAttachEnv(cliExportsTruecolor(this.mode)),
|
||||
})
|
||||
);
|
||||
this._notePtySpawnGeometry(ptyCols, ptyRows);
|
||||
} catch (spawnErr) {
|
||||
console.error(`[Session] Failed to spawn PTY for ${options.spawnErrLabel}:`, spawnErr);
|
||||
this.emit('error', `Failed to attach to mux session: ${spawnErr}`);
|
||||
throw spawnErr;
|
||||
}
|
||||
|
||||
return { isRestored };
|
||||
return { isRestored, respawnedResumeId, respawnedDeadPane };
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -1844,6 +2030,9 @@ export class Session extends EventEmitter {
|
||||
console.error('[Session] reattachRemote: respawnPane failed for', this._muxSession.muxName);
|
||||
return false;
|
||||
}
|
||||
// No-op for the record (a remote session's field is always UNKNOWN), but the
|
||||
// mux layer's cache is keyed by muxName and this pane now runs a new client.
|
||||
this.clearPaneExitForNewPane();
|
||||
console.log('[Session] reattachRemote: reattached remote session', this._muxSession.muxName, 'pid', newPid);
|
||||
return true;
|
||||
}
|
||||
@@ -1878,26 +2067,16 @@ export class Session extends EventEmitter {
|
||||
}
|
||||
|
||||
this._pinOmpRespawnId();
|
||||
const options = this._buildRespawnPaneOptions();
|
||||
// Unlike the dead-pane respawn, this one kills a WORKING pane whose conversation
|
||||
// already has a transcript, and a CLI that launches with `--session-id <id>` refuses
|
||||
// an id that is already in use (claude: `Error: Session ID ... is already in use.`),
|
||||
// which turned an endpoint switch into a dead pane and a lost session. A launch that
|
||||
// declares a `fallback` chain renders `resume || new` once a resume id is set, the
|
||||
// same `--resume <id> || --session-id <id>` shape the docker and remote pane commands
|
||||
// already use, so pin the live conversation id for THIS respawn only. The registry
|
||||
// shape is the gate, not the CLI's name: an entry whose resume id is minted by the
|
||||
// CLI itself (codex/pi/omp/grok) never declares that chain, and its resume field is
|
||||
// read from its own `<Mode>Config` rather than this top-level one anyway.
|
||||
if (!options.resumeSessionId && getCli(this.mode)?.launch.chain === 'fallback') {
|
||||
options.resumeSessionId = this._claudeSessionId ?? this.id;
|
||||
}
|
||||
const newPid = await mux.respawnPane(options);
|
||||
const newPid = await mux.respawnPane(await this._buildRespawnPaneOptionsWithResumePin());
|
||||
if (!newPid) {
|
||||
console.error('[Session] restartCli: respawnPane failed for', this._muxSession.muxName);
|
||||
return false;
|
||||
}
|
||||
this._pendingEnvUnsets.clear();
|
||||
// A relaunch in the same pane, so any exit observed of the previous command
|
||||
// is history. Without this the caller's persist-and-broadcast writes the old
|
||||
// exit straight back onto a session that is running again.
|
||||
this.clearPaneExitForNewPane();
|
||||
console.log('[Session] restartCli: restarted CLI for', this._muxSession.muxName, 'pid', newPid);
|
||||
return true;
|
||||
}
|
||||
@@ -1947,6 +2126,112 @@ export class Session extends EventEmitter {
|
||||
return this._withCustomModelLaunchModel(options);
|
||||
}
|
||||
|
||||
/**
|
||||
* Respawn options for a pane whose command is being REPLACED, with the
|
||||
* conversation pinned so the relaunch resumes rather than collides.
|
||||
*
|
||||
* A CLI that launches with `--session-id <id>` refuses an id that is already
|
||||
* in use (claude: `Error: Session ID ... is already in use.`), and every
|
||||
* session whose agent has been prompted owns a transcript under that id. So
|
||||
* relaunching such a pane with the bare launch line fails, the pane dies
|
||||
* again immediately, and the user's conversation is stranded. A launch that
|
||||
* declares a `fallback` chain renders `resume || new` once a resume id is
|
||||
* set, which is the shape that survives both cases.
|
||||
*
|
||||
* Three candidates are tried in priority order — the conversation chain's
|
||||
* tail, the launch seed, then the session's own id — and the first one a
|
||||
* transcript backs is pinned. Four conditions gate that walk, each protecting
|
||||
* against a way of resuming the WRONG conversation or of making a working
|
||||
* relaunch fail.
|
||||
*
|
||||
* ⚠️ **A remote or docker session is never pinned.** Unlike `restartCli()`,
|
||||
* whose route refuses both, the dead-pane respawn is reached by every session
|
||||
* shape. Their pane commands (`buildRemoteLaunchCommand`,
|
||||
* `claudeDockerPaneCommand`) already render a SELF-HEALING
|
||||
* `--session-id <sid> || --resume <sid>`, and both flip to resume-first the
|
||||
* moment the resume id differs from the session id. The conversation lives on
|
||||
* the far side, so a local id pinned onto it resolves to nothing there, the
|
||||
* resume fails, and the `--session-id` fallback then collides with the
|
||||
* transcript the far side really does hold — both branches fail and the pane
|
||||
* dies. `_pinOmpRespawnId()` refuses remote for the same reason.
|
||||
*
|
||||
* ⚠️ **The candidates come from the conversation CHAIN, never from
|
||||
* `_claudeSessionId`.** That field holds either a first-hand id from the
|
||||
* CLI's own hook payload or a history correlation, which is a guess keyed on
|
||||
* the working directory. `_recordClaudeSessionInChain()` refuses a guess
|
||||
* precisely so it cannot "write a foreign conversation into this pane's
|
||||
* permanent record", and launching from one would do worse than the display
|
||||
* bug that rule exists to prevent: the relaunched CLI would open and WRITE to
|
||||
* a conversation that was never this pane's. The chain's tail is the live
|
||||
* conversation and is hook-vouched, so it leads the walk, ahead of the launch
|
||||
* seed, which is written once at construction and never moves off a `/clear`.
|
||||
*
|
||||
* ⚠️ **Every candidate must be backed by a transcript, the session's own id
|
||||
* included, and a candidate that has none is passed over rather than ending
|
||||
* the walk.** A pin that differs from the session id leaves
|
||||
* `--session-id <this.id>` in the fallback branch, so a resume that finds
|
||||
* nothing collides there and the pane dies exactly as it did before this
|
||||
* pinning existed. Pinning `this.id` renders the self-healing
|
||||
* `--resume <id> || --session-id <id>`, which is correct whether or not a
|
||||
* transcript exists, but a pane that has none pays for the shape twice:
|
||||
* claude prints "No conversation found" into the scrollback of a session that
|
||||
* is brand new, and `wrapWithNice()` prefixes only the FIRST branch of the
|
||||
* rendered `a || b`, so the branch that actually runs loses its priority for
|
||||
* the life of the session. Falling off the end of the walk therefore adds
|
||||
* no pin (the options keep any launch seed they already carried), which is
|
||||
* the right answer: with no transcript anywhere there is nothing for the
|
||||
* bare `--session-id <this.id>` to collide with.
|
||||
*
|
||||
* The create route pre-validates a resume id for the same reason, though it
|
||||
* additionally requires the transcript be substantial — here mere existence
|
||||
* is the question, because a one-line transcript still makes `--session-id`
|
||||
* collide.
|
||||
*
|
||||
* The registry shape is the last gate, not the CLI's name: an entry whose
|
||||
* resume id is minted by the CLI itself (codex/pi/omp/grok) declares no
|
||||
* `fallback` chain and reads its resume field from its own `<Mode>Config`.
|
||||
*
|
||||
* `reattachRemote()` deliberately does NOT call this. It re-runs the remote
|
||||
* session command, which attaches to the durable remote tmux with the agent
|
||||
* still inside it and renders no local `--session-id` to collide.
|
||||
*/
|
||||
private async _buildRespawnPaneOptionsWithResumePin(): Promise<import('./mux-interface.js').RespawnPaneOptions> {
|
||||
const options = this._buildRespawnPaneOptions();
|
||||
if (this._remote || this._docker) return options;
|
||||
const entry = getCli(this.mode);
|
||||
if (entry?.launch.chain !== 'fallback') return options;
|
||||
|
||||
const resumeIdPattern = entry.launch.params?.resumeId;
|
||||
const configDir = this._claudeConfigDir();
|
||||
const chainTail = this._claudeSessionChain[this._claudeSessionChain.length - 1];
|
||||
const candidates = [chainTail, options.resumeSessionId, this.id].filter((v): v is string => !!v);
|
||||
for (const candidate of candidates) {
|
||||
// A session Codeman DISCOVERED on the socket rather than created carries a
|
||||
// synthetic `restored-<fragment>` id, which fails claude's `uuid` token
|
||||
// pattern. The renderer would silently drop the resume flag and emit the
|
||||
// unpinned command, so say so here rather than letting the caller believe
|
||||
// the pane was pinned.
|
||||
if (resumeIdPattern?.type === 'token' && !matchesPattern(resumeIdPattern.pattern, candidate)) {
|
||||
console.log(`[Session] Not pinning resume id ${candidate} for relaunch: the CLI cannot accept that id shape`);
|
||||
continue;
|
||||
}
|
||||
if (!(await claudeTranscriptExists(candidate, configDir))) continue;
|
||||
options.resumeSessionId = candidate;
|
||||
return options;
|
||||
}
|
||||
// Nothing on disk to collide with, so the bare `--session-id <this.id>` the
|
||||
// unpinned options already carry is the correct command.
|
||||
return options;
|
||||
}
|
||||
|
||||
/** The session's Claude config dir when it has been relocated (#255), else undefined. */
|
||||
private _claudeConfigDir(): string | undefined {
|
||||
// Trimmed like `claudeProjectsDir()` trims the process-wide override: the
|
||||
// envOverrides schema validates keys only, and a whitespace-only value would
|
||||
// otherwise resolve to a relative path and read "no transcript" for everything.
|
||||
return this._envOverrides?.CLAUDE_CONFIG_DIR?.trim() || undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Force the custom-model selection's `launchModel` (pi/omp `custom/<id>`, grok's
|
||||
* `[model.<name>]` block name) onto the CLI's `model` launch param. Where that param
|
||||
@@ -2268,7 +2553,7 @@ export class Session extends EventEmitter {
|
||||
// If mux wrapping is enabled, create or attach to a mux session
|
||||
if (this._useMux && this._mux) {
|
||||
try {
|
||||
const { isRestored } = await this._setupOrAttachMuxSession({
|
||||
const { isRestored, respawnedResumeId, respawnedDeadPane } = await this._setupOrAttachMuxSession({
|
||||
// Single source of truth shared with reattachRemote() (COD-108).
|
||||
respawnPaneOptions: this._buildRespawnPaneOptions(),
|
||||
createSessionOptions: {
|
||||
@@ -2315,7 +2600,18 @@ export class Session extends EventEmitter {
|
||||
// persisted chain's tail is that conversation, reported first-hand by
|
||||
// the CLI's own hook, so it outranks every fallback here. A NEW pane has
|
||||
// an empty chain and falls through to the resume/alias fallbacks.
|
||||
restoredConversation = isRestored ? this._claudeSessionChain[this._claudeSessionChain.length - 1] : undefined;
|
||||
//
|
||||
// A dead-pane respawn is NOT that case for a CLI whose relaunch the resume
|
||||
// pin walk governs (`launch.chain === 'fallback'`): the CLI did stop, and
|
||||
// the walk may have passed over a chain tail with no transcript behind it,
|
||||
// so the conversation is whatever the respawn actually resumed. Undefined
|
||||
// there means the pane launched unpinned, which the fallbacks below name.
|
||||
const pinGovernsRespawn = respawnedDeadPane && getCli(this.mode)?.launch.chain === 'fallback';
|
||||
restoredConversation = pinGovernsRespawn
|
||||
? respawnedResumeId
|
||||
: isRestored
|
||||
? this._claudeSessionChain[this._claudeSessionChain.length - 1]
|
||||
: undefined;
|
||||
this._claudeSessionId =
|
||||
restoredConversation ||
|
||||
this._resumeSessionId ||
|
||||
@@ -2416,6 +2712,7 @@ export class Session extends EventEmitter {
|
||||
env: { ...buildClaudeEnv(this.id), ...(this._envOverrides ?? {}) },
|
||||
})
|
||||
);
|
||||
this._notePtySpawnGeometry(120, 40);
|
||||
} catch (spawnErr) {
|
||||
console.error('[Session] Failed to spawn Claude PTY:', spawnErr);
|
||||
this._status = 'stopped';
|
||||
@@ -2709,9 +3006,63 @@ export class Session extends EventEmitter {
|
||||
this._lastPaneProbeAt = now;
|
||||
const text = this._mux.capturePaneText?.(this._muxSession.muxName) ?? null;
|
||||
this._lastPaneProbeWorking = text === null ? null : this._workingLinePattern().test(text);
|
||||
this._readWatching(text);
|
||||
return this._lastPaneProbeWorking;
|
||||
}
|
||||
|
||||
/**
|
||||
* Read the background-work chip off the same capture the working probe just took.
|
||||
*
|
||||
* The two questions are different. A turn that is running is work the user is waiting
|
||||
* for; a monitor, a backgrounded shell or a cloud session the agent started is work
|
||||
* the AGENT is waiting for, and it is the reason a pane can sit at its composer with
|
||||
* nothing to say and still not want anything from the user. `_confirmIdle` takes this
|
||||
* capture at exactly the moment the turn ends, which is the moment the answer starts
|
||||
* mattering.
|
||||
*
|
||||
* A capture that could not be read CLEARS the label rather than keeping the last one.
|
||||
* The two wrong answers are not symmetric: a stale label opens the next idle prompt
|
||||
* already acknowledged, so a failed capture would silence a real alert, while a dropped
|
||||
* label only costs a card and an alert that the next readable capture takes back.
|
||||
* Degrading toward the alert is the rule the whole signal is built on.
|
||||
*/
|
||||
private _readWatching(paneText: string | null): void {
|
||||
const pattern = this._watchingLinePattern();
|
||||
if (!pattern) return;
|
||||
// `null` is "the screen could not be read". That is no evidence either way, so the
|
||||
// label falls to null (and the change is announced below like any other).
|
||||
const label = paneText === null ? null : watchingLabel(paneText, pattern, this._watchingWindow);
|
||||
if (label === this._watching) return;
|
||||
this._watching = label;
|
||||
// ⚠️ This CHANGES while the session's status does not, so it needs an event of its
|
||||
// own. The label is usually set on the idle transition, which broadcasts anyway, but
|
||||
// it CLEARS when the work ends — and for a CLI whose background work ends without
|
||||
// taking a turn (measured on codex: a background terminal finishing repaints the row
|
||||
// away and nothing else happens) the session is idle before and after. Without this,
|
||||
// the server knew the badge was gone and every open page went on drawing it until
|
||||
// some unrelated event arrived.
|
||||
this.emit('watchingChanged');
|
||||
}
|
||||
|
||||
/**
|
||||
* The regex matching this CLI's background-work chip, or null for a CLI whose registry
|
||||
* entry declares none. Compiled once per session, like the working-line pattern, and
|
||||
* null rather than a fallback: no other CLI has been measured drawing such a chip, and
|
||||
* guessing one would badge sessions on the strength of an unread screen.
|
||||
*/
|
||||
private _watchingLinePattern(): RegExp | null {
|
||||
if (this._watchingLineRe === undefined) {
|
||||
// The pattern and the window it runs over are one decision, so they are resolved
|
||||
// together: how far up the screen a CLI's row can sit is as much a property of its
|
||||
// layout as the row itself. Claude writes on the last row and keeps the default,
|
||||
// Codex pins one above its composer and declares more.
|
||||
const detect = getCli(this.mode)?.capabilities.workDetect;
|
||||
this._watchingLineRe = detect?.watchingLine ? compileVersionRegex(detect.watchingLine) : null;
|
||||
this._watchingWindow = detect?.watchingLines ?? WATCHING_TAIL_LINES;
|
||||
}
|
||||
return this._watchingLineRe;
|
||||
}
|
||||
|
||||
/**
|
||||
* The regex matching this CLI's "a turn is running" status line.
|
||||
*
|
||||
@@ -2984,6 +3335,7 @@ export class Session extends EventEmitter {
|
||||
env: buildShellEnv(this.id),
|
||||
})
|
||||
);
|
||||
this._notePtySpawnGeometry(120, 40);
|
||||
} catch (spawnErr) {
|
||||
console.error('[Session] Failed to spawn shell PTY:', spawnErr);
|
||||
this._status = 'stopped';
|
||||
@@ -3093,6 +3445,7 @@ export class Session extends EventEmitter {
|
||||
env: { ...buildClaudeEnv(this.id), ...(this._envOverrides ?? {}) },
|
||||
})
|
||||
);
|
||||
this._notePtySpawnGeometry(120, 40);
|
||||
} catch (spawnErr) {
|
||||
console.error('[Session] Failed to spawn Claude PTY for runPrompt:', spawnErr);
|
||||
this.emit(
|
||||
@@ -3758,7 +4111,18 @@ export class Session extends EventEmitter {
|
||||
? null
|
||||
: this._mux.capturePaneText?.(this._muxSession.muxName),
|
||||
sendEnter: () => this._mux?.sendInput(this.id, '\r'),
|
||||
glyph: () => getCli(this.mode)?.capabilities.workDetect?.promptGlyph ?? '❯',
|
||||
// ⚠ NO fallback glyph here, unlike the screen-reading probe elsewhere in this file.
|
||||
// Only claude and codex declare a promptGlyph; the other eight modes would fall back
|
||||
// to claude's `❯`, which is ALSO starship's default shell prompt (and pure's, and
|
||||
// spaceship's, and p10k lean's). On a shell session the line `❯ npm run build` sits
|
||||
// on screen for as long as the command runs, promptStillInComposer() reads that as
|
||||
// "still unsubmitted", and the verifier presses Enter into the running program's
|
||||
// stdin on its 2s..60s schedule. Mostly a stray blank line; not harmless against a
|
||||
// y/N prompt, `read -p`, an installer or a pager, where it takes the default.
|
||||
// promptStillInComposer() returns undefined for an empty glyph, so this makes the
|
||||
// verifier inert for every CLI that does not declare one, which is what the Claude
|
||||
// Code 2.1.277 defect it exists for actually calls for.
|
||||
glyph: () => getCli(this.mode)?.capabilities.workDetect?.promptGlyph ?? '',
|
||||
log: (m) => console.log(`[Session ${this.id.slice(0, 8)}] ${m}`),
|
||||
});
|
||||
this._submitVerifier.arm(text);
|
||||
@@ -3768,6 +4132,40 @@ export class Session extends EventEmitter {
|
||||
private _ptyCols = 120;
|
||||
private _ptyRows = 40;
|
||||
|
||||
/**
|
||||
* Record the geometry a PTY was just spawned at. A reattached pane keeps the
|
||||
* tmux window's size, not the constructor's 120x40, and without this
|
||||
* `ptyGeometry` reported the old numbers for a live pane and the dedupe in
|
||||
* `resize()` skipped a real resize that happened to match them.
|
||||
*/
|
||||
private _notePtySpawnGeometry(cols: number, rows: number): void {
|
||||
this._ptyCols = cols;
|
||||
this._ptyRows = rows;
|
||||
}
|
||||
|
||||
/**
|
||||
* The geometry the CLI is actually drawing for, or null when nothing is
|
||||
* drawing.
|
||||
*
|
||||
* Exposed because `resize()` can decline a request outright (arbitration
|
||||
* below) and the asking client has no other way to find out: a browser
|
||||
* terminal that keeps a shape the PTY refused renders garbled output rather
|
||||
* than wrong-sized output, because Claude Code's repaints are computed from
|
||||
* the width it was told (issue #464). Both transports report this back.
|
||||
*
|
||||
* ⚠️ NULL WITHOUT A PANE, never the field values. The fields are seeded at
|
||||
* spawn (`_notePtySpawnGeometry`) and moved by `resize()`, but a session with
|
||||
* a dead pane (or one created through the API and never started) still
|
||||
* holds the constructor defaults of 120x40, or the size of a pane that is
|
||||
* gone. Reporting those made a client adopt a size no process had ever
|
||||
* been told, and on anything narrower than 120 columns it claimed another
|
||||
* device owned the pane when none existed. `reconcilePtyGeometry` treats a
|
||||
* report with no finite numbers as no evidence, which is the truth here.
|
||||
*/
|
||||
get ptyGeometry(): { cols: number; rows: number } | null {
|
||||
return this.ptyProcess ? { cols: this._ptyCols, rows: this._ptyRows } : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Live WebSocket connections that have announced a desktop viewport for this
|
||||
* session. While at least one is registered, small-viewport (mobile/tablet)
|
||||
@@ -3853,6 +4251,10 @@ export class Session extends EventEmitter {
|
||||
}
|
||||
if (isSmallViewport && this._desktopSizeClaims.size > 0) {
|
||||
if (Date.now() - this._lastDesktopActivityAt < Session.DESKTOP_CLAIM_IDLE_MS) {
|
||||
// Declined. The caller is told nothing here on purpose — the decision
|
||||
// belongs to the session, not the socket — but the caller MUST report
|
||||
// `ptyGeometry` back afterwards so the asking client can adopt
|
||||
// the shape it did not get. Both transports do; see issue #464.
|
||||
return;
|
||||
}
|
||||
this._mobileSizeOverride = true;
|
||||
@@ -3952,6 +4354,9 @@ export class Session extends EventEmitter {
|
||||
async stop(killMux: boolean = true): Promise<void> {
|
||||
// Set stopped flag first to prevent new timers from being created
|
||||
this._isStopped = true;
|
||||
// A pane that is gone is watching nothing. Nothing probes a stopped session, so
|
||||
// without this the last chip it drew would ride along on its row forever.
|
||||
this._watching = null;
|
||||
|
||||
this._clearAllTimers();
|
||||
|
||||
|
||||
+373
-15
@@ -58,6 +58,7 @@ import {
|
||||
type SessionRemote,
|
||||
type SessionDocker,
|
||||
type DockerCommandMode,
|
||||
type PaneExit,
|
||||
} from './types.js';
|
||||
import { getCli } from './config/cli-registry/registry.js';
|
||||
import { missingCliMessage, resolveCliBinDir } from './utils/cli-resolver.js';
|
||||
@@ -152,6 +153,16 @@ const GRACEFUL_SHUTDOWN_WAIT_MS = 100;
|
||||
/** Default stats collection interval (2 seconds) */
|
||||
const DEFAULT_STATS_INTERVAL_MS = 2000;
|
||||
|
||||
/**
|
||||
* How often the pane-exit watcher re-reads every pane on the socket. The
|
||||
* watcher owns this cadence: it does NOT ride `startStatsCollection()`, whose
|
||||
* lifetime a browser panel controls (see {@link TmuxManager.startPaneExitWatcher}).
|
||||
* Matched to the stats cadence above because both cost one batched tmux read.
|
||||
* ⚠ It does NOT bound a read: EXEC_TIMEOUT_MS is 5000 ms, so a slow read can
|
||||
* outlive two ticks, which is exactly why `paneExitReadInFlight` exists.
|
||||
*/
|
||||
const DEFAULT_PANE_EXIT_INTERVAL_MS = 2000;
|
||||
|
||||
/** Default remote-reconnect watcher poll interval (5 seconds) — COD-108 */
|
||||
const DEFAULT_REMOTE_RECONNECT_INTERVAL_MS = 5000;
|
||||
|
||||
@@ -219,8 +230,18 @@ const DEFAULT_CODEMAN_TMUX_SOCKET = DEFAULT_TMUX_SOCKET;
|
||||
*/
|
||||
const PANE_LIST_SEP = '|';
|
||||
|
||||
/** Format string for `tmux list-panes -F`. Keep in sync with {@link parsePaneList}. */
|
||||
const PANE_LIST_FORMAT = `#{session_name}${PANE_LIST_SEP}#{pane_pid}`;
|
||||
/**
|
||||
* Format string for `tmux list-panes -F`. Keep in sync with {@link parsePaneRows}.
|
||||
*
|
||||
* The three `pane_dead*` fields carry the agent-exit signal of Ark0N/Codeman#446.
|
||||
* Appending them is backward compatible in both directions. A tmux that does not
|
||||
* know a variable substitutes the empty string rather than failing, which is how
|
||||
* tmux 3.2a answers `#{pane_dead_signal}` (added in 3.4), and the parser reads a
|
||||
* short row as "pid known, deadness unknown" rather than discarding it.
|
||||
*/
|
||||
const PANE_LIST_FORMAT =
|
||||
`#{session_name}${PANE_LIST_SEP}#{pane_pid}` +
|
||||
`${PANE_LIST_SEP}#{pane_dead}${PANE_LIST_SEP}#{pane_dead_status}${PANE_LIST_SEP}#{pane_dead_signal}`;
|
||||
|
||||
/**
|
||||
* 构建 pane 启动前的 nofile 修复命令。
|
||||
@@ -235,26 +256,141 @@ export function buildNofileLimitCommand(targetLimit = CLAUDE_CODE_NOFILE_LIMIT):
|
||||
return `ulimit -Sn ${safeLimit} 2>/dev/null || ulimit -n ${safeLimit} 2>/dev/null || true`;
|
||||
}
|
||||
|
||||
/** One pane of one tmux session, as {@link parsePaneRows} reads it off the wire. */
|
||||
export interface PaneRow {
|
||||
/** tmux session this pane belongs to. Repeats once per pane of a split session. */
|
||||
sessionName: string;
|
||||
/** `#{pane_pid}` — the process tmux started in the pane. */
|
||||
pid: number;
|
||||
/** `#{pane_dead}` — true for 1, false for 0, undefined when tmux said nothing. */
|
||||
dead?: boolean;
|
||||
/** `#{pane_dead_status}` — the exit code, absent when tmux reported none. */
|
||||
exitStatus?: number;
|
||||
/** `#{pane_dead_signal}` — the killing signal, absent before tmux 3.4 and when unsignalled. */
|
||||
exitSignal?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse the output of `tmux list-panes -a -F '#{session_name}|#{pane_pid}'`
|
||||
* into a Map of session-name → pane pid. Exported for unit testing.
|
||||
* One pane-exit reading, with the pane pid that produced it.
|
||||
*
|
||||
* The pid never leaves this module. It is what distinguishes "the same dead
|
||||
* pane, seen again" from "a second command in the same pane that also exited
|
||||
* with the same status", so the `at` stamp can hold across the first and must
|
||||
* not across the second. {@link PaneExit} itself stays free of it: the pid on
|
||||
* the session record is the attach client's, and a second pid there would
|
||||
* invite exactly the confusion Ark0N/Codeman#446 is about.
|
||||
*/
|
||||
export interface PaneExitObservation {
|
||||
/** `#{pane_pid}` of the pane this reading came from. */
|
||||
panePid: number;
|
||||
/** What to publish on the session record. */
|
||||
exit: PaneExit;
|
||||
}
|
||||
|
||||
/** Read one optional numeric field; a blank or non-numeric value is "not reported". */
|
||||
function paneField(fields: string[], index: number): number | undefined {
|
||||
const raw = fields[index];
|
||||
if (raw === undefined || raw === '') return undefined;
|
||||
const value = parseInt(raw, 10);
|
||||
return Number.isNaN(value) ? undefined : value;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse the output of `tmux list-panes -a -F` under {@link PANE_LIST_FORMAT}
|
||||
* into one row per pane, in tmux's own order. Exported for unit testing.
|
||||
*
|
||||
* - Skips empty lines and lines without the separator.
|
||||
* - Skips entries with a non-numeric pid or empty name.
|
||||
* - Leaves every field after the pid undefined when it is blank or absent, so a
|
||||
* row from an older tmux still yields its pid.
|
||||
*/
|
||||
export function parsePaneList(output: string): Map<string, number> {
|
||||
const result = new Map<string, number>();
|
||||
export function parsePaneRows(output: string): PaneRow[] {
|
||||
const rows: PaneRow[] = [];
|
||||
for (const line of output.split('\n')) {
|
||||
if (!line) continue;
|
||||
const sep = line.indexOf(PANE_LIST_SEP);
|
||||
if (sep === -1) continue;
|
||||
const name = line.slice(0, sep);
|
||||
const pid = parseInt(line.slice(sep + 1), 10);
|
||||
if (name && !Number.isNaN(pid)) {
|
||||
result.set(name, pid);
|
||||
}
|
||||
if (!line.includes(PANE_LIST_SEP)) continue;
|
||||
const fields = line.split(PANE_LIST_SEP);
|
||||
const sessionName = fields[0];
|
||||
const pid = parseInt(fields[1] ?? '', 10);
|
||||
if (!sessionName || Number.isNaN(pid)) continue;
|
||||
const deadFlag = fields[2];
|
||||
rows.push({
|
||||
sessionName,
|
||||
pid,
|
||||
dead: deadFlag === '1' ? true : deadFlag === '0' ? false : undefined,
|
||||
exitStatus: paneField(fields, 3),
|
||||
exitSignal: paneField(fields, 4),
|
||||
});
|
||||
}
|
||||
return result;
|
||||
return rows;
|
||||
}
|
||||
|
||||
/**
|
||||
* Decide, from every pane tmux listed, which tmux sessions have an exited agent.
|
||||
* Exported for unit testing. Returns one entry per session with a known answer;
|
||||
* a session absent from the map is UNKNOWN, which must never render as alive.
|
||||
*
|
||||
* Two rules make a positive answer trustworthy:
|
||||
*
|
||||
* A session answers only when tmux listed EXACTLY ONE pane for it. Codeman
|
||||
* creates one pane per session and `isPaneDead()` reads one pane, so a session
|
||||
* the user has split by hand has no single "the agent" to report on, and
|
||||
* guessing which of its panes speaks for the session could report a live
|
||||
* session as exited.
|
||||
*
|
||||
* A pane answers only when `#{pane_dead}` said 1 or 0. An empty field is a tmux
|
||||
* that did not answer, not a live pane.
|
||||
*
|
||||
* `status` and `signal` stay absent when tmux did not report them. Measured on
|
||||
* tmux 3.2a, a SIGKILLed pane reports neither, so folding an absent status into
|
||||
* 0 would turn an unexplained death into a clean exit.
|
||||
*/
|
||||
export function derivePaneExits(rows: PaneRow[], now: number): Map<string, PaneExitObservation> {
|
||||
const panesPerSession = new Map<string, number>();
|
||||
for (const row of rows) {
|
||||
panesPerSession.set(row.sessionName, (panesPerSession.get(row.sessionName) ?? 0) + 1);
|
||||
}
|
||||
const exits = new Map<string, PaneExitObservation>();
|
||||
for (const row of rows) {
|
||||
if (panesPerSession.get(row.sessionName) !== 1) continue;
|
||||
if (row.dead !== true) continue;
|
||||
exits.set(row.sessionName, {
|
||||
panePid: row.pid,
|
||||
exit: {
|
||||
...(row.exitStatus !== undefined ? { status: row.exitStatus } : {}),
|
||||
...(row.exitSignal !== undefined ? { signal: row.exitSignal } : {}),
|
||||
at: now,
|
||||
},
|
||||
});
|
||||
}
|
||||
return exits;
|
||||
}
|
||||
|
||||
/**
|
||||
* Could any of these tmux sessions ever produce a pane-exit answer? Exported
|
||||
* for unit testing.
|
||||
*
|
||||
* Mirrors `Session.paneExitApplies`, which is where the rule is enforced. A
|
||||
* remote session's local pane holds the ssh client, a docker case's holds a
|
||||
* `docker exec` into the container's own tmux, and a record rebuilt from the
|
||||
* socket carries no provenance at all, so the session end forces all three to
|
||||
* UNKNOWN whatever tmux reports. A tick that sees only those has nothing to
|
||||
* learn, and `refreshPaneExits()` skips its tmux read rather than paying for
|
||||
* the answer.
|
||||
*
|
||||
* ⚠ This gates the READ, never the watcher. The watcher is always-on by
|
||||
* design (see {@link TmuxManager.startPaneExitWatcher}), so it keeps ticking
|
||||
* with nothing to observe and picks the read straight back up as soon as one
|
||||
* local session exists.
|
||||
*/
|
||||
export function hasObservablePaneSession(sessions: Iterable<MuxSession>): boolean {
|
||||
for (const session of sessions) {
|
||||
if (session.remote) continue;
|
||||
if (session.docker) continue;
|
||||
if (session.discovered === true) continue;
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -1527,6 +1663,30 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
|
||||
private mouseSyncInterval: NodeJS.Timeout | null = null;
|
||||
/** Track last-known pane count per session to avoid unnecessary tmux set-option calls */
|
||||
private lastPaneCount: Map<string, number> = new Map();
|
||||
/**
|
||||
* muxName → the exited agent the pane-exit watcher last observed
|
||||
* (Ark0N/Codeman#446). Absence is the UNKNOWN arm of the tri-state, so an
|
||||
* entry goes the moment tmux stops reporting the pane dead, and the map is
|
||||
* empty until the first read runs. The manager reports what tmux says and
|
||||
* nothing more: the scoping that hides this for remote and docker sessions
|
||||
* lives on `Session`, because the remote-reconnect watcher above needs the
|
||||
* raw pane reading.
|
||||
*/
|
||||
private paneExits: Map<string, PaneExitObservation> = new Map();
|
||||
/** The pane-exit watcher's own interval. Runs whether or not stats are on. */
|
||||
private paneExitInterval: NodeJS.Timeout | null = null;
|
||||
/**
|
||||
* True while a pane read is in flight. `EXEC_TIMEOUT_MS` is 5000 ms against a
|
||||
* poll interval of 2000 ms, so without this a slow read overlaps the next two
|
||||
* and the older one can resolve last and win.
|
||||
*/
|
||||
private paneExitReadInFlight = false;
|
||||
/**
|
||||
* Bumped by every deliberate {@link clearPaneExit}. A read that started before
|
||||
* a clear carries the older generation and is discarded rather than writing
|
||||
* the death back over the pane that has just replaced it.
|
||||
*/
|
||||
private paneExitGeneration = 0;
|
||||
|
||||
// ── COD-108 remote-reconnect watcher state ────────────────────────────────
|
||||
/** Periodic watcher that re-establishes dropped remote sessions. */
|
||||
@@ -2297,6 +2457,11 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
|
||||
);
|
||||
// Wait for the respawned process to start
|
||||
await new Promise((resolve) => setTimeout(resolve, TMUX_CREATION_WAIT_MS));
|
||||
// The pane now runs a fresh command, so whatever the last read observed of
|
||||
// the old one is history. Clearing it here rather than waiting for the next
|
||||
// poll also invalidates any read already in flight, which would otherwise
|
||||
// write the old death back over the pane that just replaced it.
|
||||
this.clearPaneExit(muxName);
|
||||
const pid = this.getPanePid(muxName);
|
||||
if (pid) session.pid = pid;
|
||||
return pid;
|
||||
@@ -2508,6 +2673,7 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
|
||||
}
|
||||
}
|
||||
this.lastPaneCount.delete(session.muxName);
|
||||
this.clearPaneExit(session.muxName);
|
||||
this.sessions.delete(sessionId);
|
||||
this.clearRemoteReconnectState(sessionId);
|
||||
this.saveSessions();
|
||||
@@ -2618,6 +2784,7 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
|
||||
}
|
||||
|
||||
this.lastPaneCount.delete(session.muxName);
|
||||
this.clearPaneExit(session.muxName);
|
||||
this.sessions.delete(sessionId);
|
||||
this.clearRemoteReconnectState(sessionId);
|
||||
this.saveSessions();
|
||||
@@ -2671,7 +2838,14 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
|
||||
encoding: 'utf-8',
|
||||
timeout: EXEC_TIMEOUT_MS,
|
||||
}).trim();
|
||||
active = parsePaneList(output);
|
||||
const rows = parsePaneRows(output);
|
||||
active = new Map(rows.map((row) => [row.sessionName, row.pid]));
|
||||
// The same read answers both questions, so recovery starts with a pane-exit
|
||||
// reading rather than waiting for the first stats tick — which may never
|
||||
// come, since the collector only starts when boot found a live session.
|
||||
if (rows.length > 0) {
|
||||
this.applyPaneExits(derivePaneExits(rows, Date.now()));
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('[TmuxManager] Failed to list tmux panes:', err);
|
||||
active = new Map();
|
||||
@@ -2686,6 +2860,7 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
|
||||
} else {
|
||||
dead.push(sessionId);
|
||||
this.sessions.delete(sessionId);
|
||||
this.clearPaneExit(session.muxName);
|
||||
this.clearRemoteReconnectState(sessionId);
|
||||
this.emit('sessionDied', { sessionId });
|
||||
}
|
||||
@@ -2722,6 +2897,13 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
|
||||
mode: 'claude',
|
||||
attached: false,
|
||||
name: `Restored: ${sessionName}`,
|
||||
// Every field above except the name and the pid is a guess: this record
|
||||
// was rebuilt from the socket because Codeman's own bookkeeping did not
|
||||
// have it. The synthetic id also cannot find the session's state.json
|
||||
// entry, so a remote or docker session rediscovered this way arrives
|
||||
// looking local. Consumers that would be wrong about such a session
|
||||
// read this flag and fail closed — see `Session.paneExitApplies`.
|
||||
discovered: true,
|
||||
};
|
||||
this.sessions.set(sessionId, session);
|
||||
knownMuxNames.add(sessionName);
|
||||
@@ -2882,6 +3064,172 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* What the last pane read saw of this tmux session's agent. `undefined` is
|
||||
* the UNKNOWN answer and must never be rendered as "alive": it covers a pane
|
||||
* that is running, a tmux session that no longer exists, a probe that failed,
|
||||
* and every poll that has not run yet. See {@link PaneExit}.
|
||||
*/
|
||||
getPaneExit(muxName: string): PaneExit | undefined {
|
||||
return this.paneExits.get(muxName)?.exit;
|
||||
}
|
||||
|
||||
/**
|
||||
* Re-read every pane on the socket and refresh {@link paneExits}. ONE batched
|
||||
* `tmux list-panes -a` answers for every session at once, which is why this
|
||||
* polls rather than probing per session.
|
||||
*
|
||||
* A failed or empty probe leaves the previous answers ALONE rather than
|
||||
* clearing them, because the two cannot be told apart: the command ends in
|
||||
* `|| true`, so a tmux that errored and a socket with genuinely no panes both
|
||||
* arrive as empty output. Treating that as "tmux did not answer" is the
|
||||
* conservative reading — clearing on it would turn a transient failure into a
|
||||
* silent retraction of a death Codeman had already observed, and the cost of
|
||||
* being wrong the other way is one stale entry for a socket that no longer
|
||||
* has the pane. A NON-empty read is different: `list-panes -a` lists
|
||||
* every pane on the socket, so it is authoritative and {@link applyPaneExits}
|
||||
* prunes against it.
|
||||
*
|
||||
* Two guards keep a slow read from undoing a fast one. A read already in
|
||||
* flight suppresses the next poll, and a read that started before a
|
||||
* {@link clearPaneExit} is discarded when it lands.
|
||||
*
|
||||
* A third guard skips the read entirely while no session on this manager
|
||||
* could produce an answer ({@link hasObservablePaneSession}). Skipping
|
||||
* retracts nothing, for the same reason a failed read does not: the map
|
||||
* still holds what the last real read saw, and every path that puts a new
|
||||
* command in a pane calls {@link clearPaneExit} itself.
|
||||
*/
|
||||
async refreshPaneExits(now: number = Date.now()): Promise<void> {
|
||||
// Nothing on this socket could answer, so do not read tmux to find that
|
||||
// out. See `hasObservablePaneSession`: the watcher above still ticks.
|
||||
if (!hasObservablePaneSession(this.sessions.values())) return;
|
||||
if (this.paneExitReadInFlight) return;
|
||||
|
||||
const generation = this.paneExitGeneration;
|
||||
this.paneExitReadInFlight = true;
|
||||
let rows: PaneRow[];
|
||||
try {
|
||||
rows = await this.readPaneRows();
|
||||
} finally {
|
||||
this.paneExitReadInFlight = false;
|
||||
}
|
||||
if (rows.length === 0) return;
|
||||
// A pane was respawned or killed while this read was out, so what it saw is
|
||||
// already history. Dropping it is what stops a freshly respawned pane from
|
||||
// being republished as exited.
|
||||
if (generation !== this.paneExitGeneration) return;
|
||||
|
||||
this.applyPaneExits(derivePaneExits(rows, now));
|
||||
}
|
||||
|
||||
/**
|
||||
* Read every pane on the socket. The ONLY part of the pane-exit watcher that
|
||||
* touches tmux, which is what lets a test subclass drive the guards in
|
||||
* {@link refreshPaneExits} — the in-flight suppression, the generation
|
||||
* check, the empty-read retraction rule and the read gate — against rows it
|
||||
* chooses. Split out for the reason `runRemoteReconnectTick` is: a guard no
|
||||
* test can reach is a guard that can be deleted without anything failing.
|
||||
*
|
||||
* A failed read answers with NO rows, which the caller treats as "tmux did
|
||||
* not answer" and which therefore retracts nothing.
|
||||
*/
|
||||
protected async readPaneRows(): Promise<PaneRow[]> {
|
||||
// The test-mode gate lives HERE rather than at the top of the tick, so that
|
||||
// what tests cannot do is spawn a process, not exercise the bookkeeping.
|
||||
if (IS_TEST_MODE) return [];
|
||||
try {
|
||||
// execAsync, not execSync: this runs on a 2000 ms timer, and a synchronous
|
||||
// exec freezes the port while the process stays alive (see the
|
||||
// event-loop-monitor note in CLAUDE.md). The three `isPaneDead()` callers
|
||||
// stay synchronous because each is answering one request right then.
|
||||
const { stdout } = await execAsync(`${this.tmux()} list-panes -a -F '${PANE_LIST_FORMAT}' 2>/dev/null || true`, {
|
||||
encoding: 'utf-8',
|
||||
timeout: EXEC_TIMEOUT_MS,
|
||||
});
|
||||
return parsePaneRows(stdout.trim());
|
||||
} catch (err) {
|
||||
console.error('[TmuxManager] Failed to read pane exit state:', err);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Fold one authoritative observation into {@link paneExits}. Split out from
|
||||
* the tmux call so the merge rules are unit-testable.
|
||||
*
|
||||
* `observed` comes from a read of EVERY pane on the socket, so a session
|
||||
* missing from it has no exit to report and its entry goes. That is what
|
||||
* keeps the map from growing without bound as tmux sessions come and go
|
||||
* outside `killSession()`. Only the caller may decide a read is authoritative:
|
||||
* a failed or empty one never reaches here.
|
||||
*
|
||||
* An entry keeps the `at` of the FIRST read that saw that exit, so the stamp
|
||||
* says when the agent was found gone rather than when the last poll ran. A
|
||||
* changed status, a changed signal, or a different pane pid all start a new
|
||||
* observation — the pid is what catches a second command in the same pane
|
||||
* that happened to exit the same way.
|
||||
*/
|
||||
applyPaneExits(observed: Map<string, PaneExitObservation>): void {
|
||||
for (const muxName of [...this.paneExits.keys()]) {
|
||||
if (!observed.has(muxName)) this.paneExits.delete(muxName);
|
||||
}
|
||||
for (const [muxName, next] of observed) {
|
||||
const prev = this.paneExits.get(muxName);
|
||||
const sameExit =
|
||||
prev !== undefined &&
|
||||
prev.panePid === next.panePid &&
|
||||
prev.exit.status === next.exit.status &&
|
||||
prev.exit.signal === next.exit.signal;
|
||||
this.paneExits.set(muxName, sameExit ? prev : next);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Forget a session's exit observation, e.g. once its pane has been respawned.
|
||||
* Also invalidates any read already in flight, so the answer this retracts
|
||||
* cannot be written back a moment later.
|
||||
*/
|
||||
clearPaneExit(muxName: string): void {
|
||||
this.paneExits.delete(muxName);
|
||||
this.paneExitGeneration++;
|
||||
}
|
||||
|
||||
/**
|
||||
* Poll for exited agents, on the manager's own interval.
|
||||
*
|
||||
* Deliberately NOT part of `startStatsCollection()`. That collector is armed
|
||||
* when the browser opens the Monitor panel and DISARMED when it closes it
|
||||
* (`panels-ui.js`), and it is skipped at boot entirely when no session was
|
||||
* recovered — so riding it would leave a session created on a freshly booted
|
||||
* server reporting nothing at all, and would let one browser turn exit
|
||||
* detection off for every other. Started unconditionally, like the mouse-mode
|
||||
* sync and the remote-reconnect watcher below.
|
||||
*
|
||||
* The `paneExitsUpdated` event is internal to the server; nothing here adds an
|
||||
* SSE event, and the field reaches the browser on `session:updated`.
|
||||
*/
|
||||
startPaneExitWatcher(intervalMs: number = DEFAULT_PANE_EXIT_INTERVAL_MS): void {
|
||||
if (this.paneExitInterval) {
|
||||
clearInterval(this.paneExitInterval);
|
||||
}
|
||||
this.paneExitInterval = setInterval(() => {
|
||||
// No IS_TEST_MODE guard: `readPaneRows()` is the only thing that would
|
||||
// spawn a process and it refuses under test, so a test can drive this
|
||||
// whole loop with fake timers instead of being locked out of it.
|
||||
void this.refreshPaneExits()
|
||||
.then(() => this.emit('paneExitsUpdated'))
|
||||
.catch((err) => console.error('[TmuxManager] Pane exit watcher error:', err));
|
||||
}, intervalMs);
|
||||
}
|
||||
|
||||
stopPaneExitWatcher(): void {
|
||||
if (this.paneExitInterval) {
|
||||
clearInterval(this.paneExitInterval);
|
||||
this.paneExitInterval = null;
|
||||
}
|
||||
}
|
||||
|
||||
startStatsCollection(intervalMs: number = DEFAULT_STATS_INTERVAL_MS): void {
|
||||
if (this.statsInterval) {
|
||||
clearInterval(this.statsInterval);
|
||||
@@ -3101,6 +3449,8 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
|
||||
|
||||
destroy(): void {
|
||||
this.stopStatsCollection();
|
||||
this.stopPaneExitWatcher();
|
||||
this.paneExits.clear();
|
||||
this.stopMouseModeSync();
|
||||
this.stopRemoteReconnectWatcher();
|
||||
this.reconnectState.clear();
|
||||
@@ -3482,6 +3832,14 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
|
||||
{ encoding: 'utf-8', timeout: EXEC_TIMEOUT_MS }
|
||||
)
|
||||
);
|
||||
// Report the size the pane was really drawing at. The visible-frame path
|
||||
// below addresses every row absolutely, so a consumer whose terminal is
|
||||
// shorter than this piles the overflow rows onto its last line and loses
|
||||
// the rows it overwrote. The full-history path instead ends in a RELATIVE
|
||||
// cursor move, which costs it nothing when the two sizes disagree, so the
|
||||
// geometry is reported there for diagnosis rather than for repair. Only
|
||||
// the caller can see both sizes, so hand it this one.
|
||||
if (opts && geometry) opts.capturedGeometry = { cols: geometry.cols, rows: geometry.rows };
|
||||
|
||||
if (fullHistory) {
|
||||
// Without geometry there is no cursor move, so fall back to the old trim.
|
||||
|
||||
@@ -28,8 +28,11 @@
|
||||
import type { ApprovalItem, ApprovalOption } from '../web/approval-inbox.js';
|
||||
import type { TuiApprovalAnswer } from './tui-client.js';
|
||||
|
||||
/** Card severity, in the same red/yellow vocabulary the web inbox uses. */
|
||||
export type TuiApprovalTone = 'err' | 'warn';
|
||||
/**
|
||||
* Card severity, in the same red/yellow vocabulary the web inbox uses, plus the quiet
|
||||
* third case: an item that opened acknowledged asks for nothing and reads grey.
|
||||
*/
|
||||
export type TuiApprovalTone = 'err' | 'warn' | 'info';
|
||||
|
||||
export interface TuiApprovalCard {
|
||||
tone: TuiApprovalTone;
|
||||
@@ -50,8 +53,14 @@ function clean(text: string | undefined): string {
|
||||
return (text ?? '').replace(/\s+/g, ' ').trim().slice(0, MAX_CARD_TEXT);
|
||||
}
|
||||
|
||||
/**
|
||||
* How loud the card is. An idle prompt the inbox opened ALREADY acknowledged is not
|
||||
* asking for anything — the session is watching work it started itself — so it drops to
|
||||
* `info` and out of the warning vocabulary the other two share with the web inbox.
|
||||
*/
|
||||
export function approvalTone(item: ApprovalItem): TuiApprovalTone {
|
||||
return item.kind === 'idle' ? 'warn' : 'err';
|
||||
if (item.kind !== 'idle') return 'err';
|
||||
return item.acknowledgedReason ? 'info' : 'warn';
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -65,9 +74,13 @@ export function approvalCard(item: ApprovalItem): TuiApprovalCard {
|
||||
const summary = clean(item.toolSummary) || clean(item.toolName);
|
||||
|
||||
if (item.kind === 'idle') {
|
||||
// Say what it is waiting for rather than asking for a reply, in the same words the
|
||||
// web drawer uses for the same item. The prompt is still answerable, so the hint
|
||||
// stays either way.
|
||||
const quiet = clean(item.acknowledgedReason);
|
||||
return {
|
||||
tone: 'warn',
|
||||
title: message || 'waiting for your reply',
|
||||
tone: approvalTone(item),
|
||||
title: quiet ? `quiet, ${quiet}` : message || 'waiting for your reply',
|
||||
detail: [],
|
||||
options: [],
|
||||
hint: 'p to reply',
|
||||
|
||||
+18
-2
@@ -74,13 +74,25 @@ export function isLiveRow(session: TuiSessionRow): boolean {
|
||||
* outranks a stale `busy` status because the hook is the newer signal. An
|
||||
* errored session has no state of its own here and joins the waiting tier,
|
||||
* since it is equally something only a human can clear.
|
||||
*
|
||||
* ⚠️ An ACKNOWLEDGED item no longer decides the row. `acknowledgedAt` means the
|
||||
* alert this prompt armed has been spent, either because somebody opened the
|
||||
* session on another device or because the inbox opened the item that way for a
|
||||
* session watching its own background work. The item itself stays pending and
|
||||
* answerable, so the row keeps carrying it and the approval card still renders;
|
||||
* it simply stops dragging the session into NEEDS YOU. The web has honoured
|
||||
* that since acknowledgement existed (`approvals-ui.js` clears the pending hook
|
||||
* that `_mobileOverviewState` reads), and this gate is where the TUI had been
|
||||
* reading past it: acknowledging on a phone cleared the alert everywhere except
|
||||
* here. Only `idle` can be acknowledged, so a permission or question dialog is
|
||||
* unaffected by construction, and both are checked ahead of the flag anyway.
|
||||
*/
|
||||
export function classifySession(session: TuiSessionRow, approval?: ApprovalItem): TuiSessionState {
|
||||
if (!isLiveRow(session)) return 'recent';
|
||||
if (approval) {
|
||||
if (approval.kind === 'permission') return 'blocked-permission';
|
||||
if (approval.kind === 'question') return 'blocked-question';
|
||||
return 'waiting';
|
||||
if (!approval.acknowledgedAt) return 'waiting';
|
||||
}
|
||||
if (session.status === 'error') return 'waiting';
|
||||
if (session.isWorking === true || session.status === 'busy') return 'working';
|
||||
@@ -96,7 +108,11 @@ export function classifySession(session: TuiSessionRow, approval?: ApprovalItem)
|
||||
* turn's own start is the pane's last Enter.
|
||||
*/
|
||||
export function stateSince(state: TuiSessionState, session: TuiSessionRow, approval?: ApprovalItem): number {
|
||||
if (approval) return approval.createdAt;
|
||||
// The prompt's own age measures the state only while the prompt is what put the
|
||||
// row in that state. An acknowledged item still rides along on a row that is
|
||||
// plainly idle or working, and dating such a row from it would report how long
|
||||
// ago the prompt arrived as though it were how long the session has been quiet.
|
||||
if (approval && STATE_GROUP[state] === 'needs-you') return approval.createdAt;
|
||||
if (state === 'working') return session.lastSubmitAt ?? session.createdAt ?? 0;
|
||||
return session.lastActivityAt ?? session.createdAt ?? 0;
|
||||
}
|
||||
|
||||
+15
-5
@@ -462,7 +462,8 @@ export function computeListWindow(
|
||||
* The pending dialog, drawn above the tail: the question, the parsed options
|
||||
* with their digits, and the keys that answer them. Red for a permission or
|
||||
* question prompt, yellow for an idle one, the same severity vocabulary the web
|
||||
* inbox uses.
|
||||
* inbox uses. An idle prompt that opened acknowledged carries neither: it reads
|
||||
* grey with the idle glyph, because nothing about it wants the reader.
|
||||
*/
|
||||
export function renderApprovalCard(
|
||||
item: ApprovalItem,
|
||||
@@ -472,8 +473,8 @@ export function renderApprovalCard(
|
||||
): string[] {
|
||||
const paint = painterFor(opts.color);
|
||||
const card = approvalCard(item);
|
||||
const color = card.tone === 'err' ? SGR.red : SGR.yellow;
|
||||
const glyph = card.tone === 'err' ? glyphs.blockedPermission : glyphs.waiting;
|
||||
const color = card.tone === 'err' ? SGR.red : card.tone === 'warn' ? SGR.yellow : SGR.gray;
|
||||
const glyph = card.tone === 'err' ? glyphs.blockedPermission : card.tone === 'warn' ? glyphs.waiting : glyphs.idle;
|
||||
const lines: string[] = [];
|
||||
const push = (text: string, style: string): void => {
|
||||
lines.push(padDisplay(paint(clipStyledLine(text, width), style), width));
|
||||
@@ -571,10 +572,19 @@ function previewBody(
|
||||
// Chrome
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
/** Sessions with a prompt waiting on a human, which is what the badge counts. */
|
||||
/**
|
||||
* Sessions with a prompt waiting on a human, which is what the badge counts.
|
||||
*
|
||||
* An ACKNOWLEDGED item is not one of them. Its alert has been spent, either by somebody
|
||||
* opening the session elsewhere or because the inbox opened it that way for a session
|
||||
* watching its own background work, and the row has already left NEEDS YOU by the same
|
||||
* flag (`classifySession`). Counting it here would put a number in the header for a
|
||||
* group the reader can see is empty.
|
||||
*/
|
||||
export function pendingApprovalCount(model: TuiRenderModel): number {
|
||||
let count = 0;
|
||||
for (const group of model.groups()) for (const row of group.rows) if (row.approval) count++;
|
||||
for (const group of model.groups())
|
||||
for (const row of group.rows) if (row.approval && !row.approval.acknowledgedAt) count++;
|
||||
return count;
|
||||
}
|
||||
|
||||
|
||||
@@ -115,6 +115,25 @@ export interface RemoteHost extends RemoteSshOptions {
|
||||
username: string;
|
||||
port?: number;
|
||||
commands?: Partial<Record<RemoteCommandMode, string>>;
|
||||
/**
|
||||
* Optional Wake-on-LAN MAC address(es), comma-separated (e.g.
|
||||
* `04:d9:f5:80:c6:58`). Codeman sends the magic packet itself (UDP port 9
|
||||
* broadcast), so the common case needs no external script. A SLEEPING host's
|
||||
* port-22 probe still fails, which is what triggers the wake — this only
|
||||
* controls HOW the host is woken.
|
||||
*/
|
||||
wakeMac?: string;
|
||||
/**
|
||||
* Optional Wake-on-LAN command that powers this host on from SLEEP (e.g. a
|
||||
* wrapper script like `/home/joe/bin/whuff`). TAKES PRECEDENCE over `wakeMac`
|
||||
* (an explicit override for hosts that need a router/other-host wake). Absent
|
||||
* = no wake support and today's behavior exactly. Executed WITHOUT a shell (a
|
||||
* single executable path, never a command line), only from user input or an
|
||||
* explicit wake request on a session whose host is unreachable — never from
|
||||
* the auto-reconnect/boot-recovery path, which would re-wake a host seconds
|
||||
* after each suspend.
|
||||
*/
|
||||
wakeCommand?: string;
|
||||
}
|
||||
|
||||
export interface RemoteCase {
|
||||
@@ -155,6 +174,13 @@ export interface SessionRemote extends RemoteSshOptions {
|
||||
* session was created elsewhere. Only meaningful when `owned === false`.
|
||||
*/
|
||||
remoteSessionName?: string;
|
||||
/**
|
||||
* Wake-on-LAN command carried over from the host config (see `RemoteHost.wakeCommand`)
|
||||
* so the input route can wake a sleeping host without re-reading the host list.
|
||||
*/
|
||||
wakeCommand?: string;
|
||||
/** Wake-on-LAN MAC address(es) from the host config (see `RemoteHost.wakeMac`). */
|
||||
wakeMac?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -599,6 +625,54 @@ export interface CustomModelBookkeeping extends CustomModelSelection {
|
||||
launchModel?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* The agent inside a LOCAL tmux pane has exited, and the pane survived it.
|
||||
*
|
||||
* Codeman creates every pane with `remain-on-exit on`, so `/exit` ends the CLI
|
||||
* while tmux keeps the pane, the tmux session and the `tmux attach-session`
|
||||
* process Codeman records as the session's pid. No PTY exit handler runs, so
|
||||
* without this record the session reads as a live idle one (Ark0N/Codeman#446).
|
||||
*
|
||||
* The field is TRI-STATE, and the third state is the absence of the field:
|
||||
* `undefined` means Codeman does not know, and it must never be rendered as
|
||||
* "alive". It is absent for a direct-PTY session (no pane exists), for a remote
|
||||
* SSH session (the local pane holds the ssh client, whose death means transport
|
||||
* drop OR exit) and for a docker case (the local pane holds a `docker exec`
|
||||
* into the container's own tmux).
|
||||
*
|
||||
* `status` and `signal` are independently optional because tmux may know that
|
||||
* the pane died without reporting how. Measured on tmux 3.2a: a SIGKILLed pane
|
||||
* reports `pane_dead=1` with BOTH `#{pane_dead_status}` and `#{pane_dead_signal}`
|
||||
* empty, and `#{pane_dead_signal}` does not exist at all before tmux 3.4. So an
|
||||
* absent `status` means "the exit code is unknown", never "the exit code is 0".
|
||||
*
|
||||
* ⚠ AN ABSENT `status` STAYS ABSENT. Never write `status ?? 0`, and never read
|
||||
* "no signal was reported" as "the exit must have been clean". On tmux 3.2a
|
||||
* the absent status IS how a signal death presents, so absent-stays-absent is
|
||||
* the only thing keeping a future clean-exit sweep away from crashed agents:
|
||||
* an agent SIGKILLed by the OOM killer would otherwise read as a user typing
|
||||
* `/exit` and be swept. Nothing here fails when somebody adds that `??` — the
|
||||
* types allow it, the label still renders, and the damage shows up only once
|
||||
* the sweep lands. The rule is enforced in `derivePaneExits()`
|
||||
* (`tmux-manager.ts`), which omits the key rather than defaulting it.
|
||||
*/
|
||||
export interface PaneExit {
|
||||
/**
|
||||
* tmux `#{pane_dead_status}` — the command's exit code. Absent when tmux
|
||||
* reported none, which means UNKNOWN and never 0. See the ⚠ above before
|
||||
* giving this a default anywhere.
|
||||
*/
|
||||
status?: number;
|
||||
/** tmux `#{pane_dead_signal}` — the signal that killed the command. Absent when unsignalled or unsupported. */
|
||||
signal?: number;
|
||||
/**
|
||||
* Wall-clock ms when THIS server process first observed the pane dead. It is
|
||||
* not when the agent exited, which nothing records, and a restart that finds
|
||||
* the pane still dead respawns it rather than re-timing the old exit.
|
||||
*/
|
||||
at: number;
|
||||
}
|
||||
|
||||
export interface SessionState {
|
||||
/** Unique session identifier */
|
||||
id: string;
|
||||
@@ -754,6 +828,21 @@ export interface SessionState {
|
||||
* (COD-118). Runtime-only: never restored on boot (fresh server = fresh breaker).
|
||||
*/
|
||||
respawnBlocked?: boolean;
|
||||
/**
|
||||
* The agent in this session's LOCAL tmux pane has exited (Ark0N/Codeman#446).
|
||||
* See {@link PaneExit} for the tri-state rule and for which session shapes
|
||||
* leave it absent. `status` and `pid` are deliberately untouched by it: the
|
||||
* PTY-exit breaker owns `status: 'error'`, and a null `pid` is what makes the
|
||||
* browser re-attach and launch a fresh CLI.
|
||||
*
|
||||
* Persisted so a reboot restore can tell a session whose agent exited from one
|
||||
* that was merely idle when the power went. `reboot-restore.ts` reads the
|
||||
* persisted record and never builds a `Session`, so the record is the only
|
||||
* place that survives the reboot to carry it. Nothing reads it there YET:
|
||||
* making the restore refuse such a session is a behavior change, and it
|
||||
* belongs with the part of Ark0N/Codeman#446 that closes exited sessions.
|
||||
*/
|
||||
paneExit?: PaneExit;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -0,0 +1,75 @@
|
||||
/**
|
||||
* @fileoverview Does a Claude conversation transcript exist on this host?
|
||||
*
|
||||
* Claude writes one `<conversation-id>.jsonl` per conversation under
|
||||
* `<config dir>/projects/<mangled cwd>/`. Two launch decisions turn on whether
|
||||
* such a file exists: `--resume <id>` needs one, and `--session-id <id>` is
|
||||
* REFUSED when one exists (`Error: Session ID ... is already in use.`).
|
||||
*
|
||||
* The project directory name is derived from the working directory, and a case
|
||||
* that has been moved or renamed leaves its transcript under the OLD name, so
|
||||
* the search is across every project directory rather than the one that matches
|
||||
* the pane's cwd today.
|
||||
*
|
||||
* ⚠️ Existence is the whole question here, with no size floor. The create route
|
||||
* additionally requires ~4 KB before it will resume, which is a "is this
|
||||
* conversation worth resuming" judgement; for a relaunch the question is the
|
||||
* opposite one — a one-line transcript still makes `--session-id` collide.
|
||||
*
|
||||
* ⚠️ A false answer is not the conservative one. Skipping a resume leaves the
|
||||
* relaunch on `--session-id <id>`, which is safe only when no transcript backs
|
||||
* that id either, so a lookup that misses the real config dir turns a
|
||||
* recoverable pane into the collision this module exists to prevent.
|
||||
*
|
||||
* @dependencies none
|
||||
* @consumedby session (relaunch resume pinning)
|
||||
*
|
||||
* @module utils/claude-transcript
|
||||
*/
|
||||
|
||||
import { readdir, stat } from 'node:fs/promises';
|
||||
import { homedir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
|
||||
/**
|
||||
* `<config dir>/projects`, honouring a session's relocated `CLAUDE_CONFIG_DIR`
|
||||
* (#255) and, failing that, the server process's own.
|
||||
*
|
||||
* ⚠️ The process env is not optional here. A pane inherits the server's
|
||||
* environment through tmux, so on an install that exports `CLAUDE_CONFIG_DIR`
|
||||
* the CLI writes its transcripts there and a lookup under `~/.claude` answers
|
||||
* "no transcript" for every conversation on the host. `claudeCredentialsPath()`
|
||||
* (claude-credentials.ts) and `realClaudeConfigDir()`
|
||||
* (custom-model-injection-apply.ts) resolve the same directory the same way.
|
||||
*/
|
||||
export function claudeProjectsDir(configDir?: string): string {
|
||||
const fromEnv = typeof process.env.CLAUDE_CONFIG_DIR === 'string' && process.env.CLAUDE_CONFIG_DIR.trim();
|
||||
return join(configDir || fromEnv || join(homedir(), '.claude'), 'projects');
|
||||
}
|
||||
|
||||
/**
|
||||
* True when a transcript for `conversationId` exists under any project
|
||||
* directory. Returns false for a missing projects dir or an unreadable one,
|
||||
* which leaves the caller unpinned: safe where nothing else can collide with
|
||||
* the bare `--session-id`, and the reason the caller walks its candidates down
|
||||
* to the session's own id rather than treating one false answer as final.
|
||||
*/
|
||||
export async function claudeTranscriptExists(conversationId: string, configDir?: string): Promise<boolean> {
|
||||
if (!conversationId) return false;
|
||||
const projectsDir = claudeProjectsDir(configDir);
|
||||
let projectDirs: string[];
|
||||
try {
|
||||
projectDirs = await readdir(projectsDir);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
for (const projectDir of projectDirs) {
|
||||
try {
|
||||
await stat(join(projectsDir, projectDir, `${conversationId}.jsonl`));
|
||||
return true;
|
||||
} catch {
|
||||
// Not in this project directory; keep looking.
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
@@ -71,6 +71,15 @@ export interface ApprovalItem {
|
||||
* and reach the user's other devices. See `acknowledge()`.
|
||||
*/
|
||||
acknowledgedAt?: number;
|
||||
/**
|
||||
* Why the item arrived already acknowledged, for display only: the inbox
|
||||
* writes `watching 1 monitor` for a session that went quiet because work it
|
||||
* started itself is still running. A human acknowledgement leaves this unset,
|
||||
* so a card can say "quiet, watching 1 monitor" rather than implying somebody
|
||||
* looked. ⚠️ Pane-derived text, so it is bounded at the source and must not
|
||||
* reach the DOM as markup — see `watchingLabel()` in `session-activity.ts`.
|
||||
*/
|
||||
acknowledgedReason?: string;
|
||||
/**
|
||||
* Present only when the frame parsed confidently. Gates which digits the
|
||||
* answer endpoint accepts; absent → only approve('1')/deny(Esc) are allowed.
|
||||
@@ -93,6 +102,12 @@ interface NotePromptArgs {
|
||||
toolSummary?: string;
|
||||
message?: string;
|
||||
cwd?: string;
|
||||
/**
|
||||
* What the session's pane says is still running in the background
|
||||
* (`Session.watching`, e.g. `1 monitor`). An idle prompt from such a session
|
||||
* opens ALREADY acknowledged: see `notePrompt()`.
|
||||
*/
|
||||
watching?: string | null;
|
||||
/** Returns the raw (ANSI-bearing) pane frame, or null when unavailable. */
|
||||
capture?: () => string | null;
|
||||
}
|
||||
@@ -217,6 +232,22 @@ export class ApprovalInbox {
|
||||
* Record a prompt for a session, superseding any previous item, and return
|
||||
* the new item. Captures context immediately and once more after a short
|
||||
* delay (see RECAPTURE_DELAY_MS).
|
||||
*
|
||||
* ⚠️ An idle prompt from a session that is WATCHING its own background work
|
||||
* opens already acknowledged (`args.watching`). Claude Code ends the turn
|
||||
* after arming a monitor or backgrounding a shell and then reports the pane
|
||||
* idle a minute later, so the alert that follows asks a human to look at a
|
||||
* session that wants nothing from them. Acknowledging is deliberately what
|
||||
* happens here rather than skipping the item: the prompt is real and stays
|
||||
* pending, answerable and available as Read My Mind context, and only the
|
||||
* alert it would have armed is spent. A wrong label therefore costs a card
|
||||
* that does not blink, never an alert that was never created.
|
||||
*
|
||||
* It re-arms by itself. The next idle prompt supersedes this item and builds
|
||||
* a fresh one, so once the background work ends and the session goes quiet
|
||||
* for an ordinary reason, that item carries no acknowledgement and alerts
|
||||
* normally. Only `idle` is eligible: a permission or question dialog blocks
|
||||
* the agent whatever else it started, so its alert must survive.
|
||||
*/
|
||||
notePrompt(args: NotePromptArgs): ApprovalItem {
|
||||
this.resolveForSession(args.sessionId, 'superseded');
|
||||
@@ -231,6 +262,10 @@ export class ApprovalInbox {
|
||||
message: args.message,
|
||||
cwd: args.cwd,
|
||||
};
|
||||
if (args.kind === 'idle' && args.watching) {
|
||||
item.acknowledgedAt = item.createdAt;
|
||||
item.acknowledgedReason = `watching ${args.watching}`;
|
||||
}
|
||||
this.applyCapture(item, args.capture);
|
||||
this.items.set(args.sessionId, item);
|
||||
if (args.capture) this.captures.set(args.sessionId, args.capture);
|
||||
|
||||
+717
-76
File diff suppressed because it is too large
Load Diff
@@ -194,8 +194,21 @@ Object.assign(CodemanApp.prototype, {
|
||||
document.querySelector('.btn-approvals')?.setAttribute('aria-expanded', 'false');
|
||||
},
|
||||
|
||||
/**
|
||||
* Items still waiting on a human. An acknowledged item (a human already looked, or the
|
||||
* session opened it acknowledged because it is watching its own background work) keeps
|
||||
* its card but arms no alert, so it must not light the bell either; this is the same
|
||||
* count `pendingApprovalCount()` gives `codeman tui`.
|
||||
*/
|
||||
pendingApprovalsCount() {
|
||||
if (!this.approvals) return 0;
|
||||
let count = 0;
|
||||
for (const item of this.approvals.values()) if (!item.acknowledgedAt) count++;
|
||||
return count;
|
||||
},
|
||||
|
||||
renderApprovals() {
|
||||
const count = this.approvals ? this.approvals.size : 0;
|
||||
const count = this.pendingApprovalsCount();
|
||||
const btn = document.querySelector('.btn-approvals');
|
||||
if (btn) {
|
||||
// Marker-class visibility (base header rules are display !important):
|
||||
@@ -222,6 +235,15 @@ Object.assign(CodemanApp.prototype, {
|
||||
return;
|
||||
}
|
||||
list.innerHTML = items.map((item) => this._approvalCardHtml(item)).join('');
|
||||
// The quiet reason is the only pane-derived string on a card, and it is the one
|
||||
// an agent could write itself (it prints its own footer row), so it reaches the
|
||||
// DOM as text and never as markup. The card leaves an empty span for it.
|
||||
for (const item of items) {
|
||||
if (!item.acknowledgedReason) continue;
|
||||
const card = list.querySelector(`[data-approval-id="${CSS.escape(item.id)}"]`);
|
||||
const slot = card && card.querySelector('.approval-quiet');
|
||||
if (slot) slot.textContent = 'quiet, ' + item.acknowledgedReason;
|
||||
}
|
||||
},
|
||||
|
||||
_approvalCardHtml(item) {
|
||||
@@ -260,6 +282,9 @@ Object.assign(CodemanApp.prototype, {
|
||||
`<span class="approval-session" data-i18n-skip>${escapeHtml(item.sessionName || item.sessionId.slice(0, 8))}</span>` +
|
||||
`<span class="approval-age" data-i18n-skip>${age}</span>` +
|
||||
`</div>` +
|
||||
// Filled by renderApprovalsDrawer through textContent, never here: see the note
|
||||
// there. An item a human acknowledged carries no reason and gets no line.
|
||||
(item.acknowledgedReason ? `<div class="approval-quiet" data-i18n-skip></div>` : '') +
|
||||
(summary ? `<div class="approval-summary" data-i18n-skip>${escapeHtml(summary)}</div>` : '') +
|
||||
(item.context ? `<pre class="approval-context">${escapeHtml(item.context)}</pre>` : '') +
|
||||
`<div class="approval-actions">${actions}</div>` +
|
||||
|
||||
@@ -806,6 +806,96 @@ function decideAutoCopy({ enabled, text, lastCopied, pending } = {}) {
|
||||
return 'copy';
|
||||
}
|
||||
|
||||
// The text a copy should put on the clipboard, given xterm's raw selection.
|
||||
// Pure: the caller reads the selection and decides the mode, this transforms.
|
||||
//
|
||||
// xterm hands back whole screen ROWS, and its own trim only drops cells that
|
||||
// were never written to. A full-screen TUI writes real spaces across the part
|
||||
// of a row it is not using, so that padding counts as content and rides along
|
||||
// to the clipboard: measured against Claude Code in a 282-column pane, single
|
||||
// lines arrived carrying 138 trailing spaces. Native terminals trim it on copy
|
||||
// (Windows Terminal, iTerm2 and GNOME Terminal all do), decideAutoCopy above
|
||||
// already calls a wall of spaces "never what the gesture meant", and
|
||||
// _selectTouchSelectionLine already treats those cells as padding. This is that
|
||||
// same rule for the mouse and keyboard paths, which never had it.
|
||||
//
|
||||
// A LEADING margin is stripped too, but only the one the CLI in the pane
|
||||
// DECLARES as its transcript gutter, passed in as `options.margin`. Called with
|
||||
// no options this trims trailing padding and nothing else, which is what keeps
|
||||
// every caller that has no declared gutter on the old behaviour.
|
||||
//
|
||||
// ⚠ The failure modes are not symmetrical, and that asymmetry sets how much
|
||||
// evidence a leading strip has to show before it fires. A wrong trailing trim
|
||||
// costs nothing. A wrong dedent silently deletes information that was on the
|
||||
// screen, with no signal to the user and nothing in the clipboard to hint at
|
||||
// it, and it is wrong on `git log` bodies, on indented code read out of `cat`
|
||||
// (semantic in Python), on `git diff` context rows where the leading space is
|
||||
// the marker, and on stack traces.
|
||||
//
|
||||
// ⚠ The declared gutter is a CEILING, not the answer. The strip is the lesser
|
||||
// of it and the run every selected line shares, so a block can only ever shift
|
||||
// as a unit: the relative structure inside a selection survives by
|
||||
// construction, and a selection reaching column 0 loses nothing at all.
|
||||
//
|
||||
// ⚠ Deriving the width from the text instead is what fails, twice over. The
|
||||
// selection's own shared indent cannot tell a margin from content, because a
|
||||
// three-row window of nested YAML shares an indent for the same reason a margin
|
||||
// does — it fired on 73% of ordinary indented text. Taking the narrowest indent
|
||||
// on the surrounding rows fails more quietly: a file listing inside the
|
||||
// transcript can be the narrowest thing on screen, which over-stripped about 1%
|
||||
// of selections across six pane widths.
|
||||
function cleanCopiedSelection(text, options) {
|
||||
if (typeof text !== 'string' || !text) return '';
|
||||
// Split on \n and leave any \r in place: xterm joins rows with \r\n on
|
||||
// Windows, and the clipboard should keep the endings xterm chose.
|
||||
// Scanned rather than matched. A selection can run to the 50 000-row
|
||||
// scrollback ceiling, and `/[ \t]+(\r?)$/` is QUADRATIC on a line whose spaces
|
||||
// are followed by any non-space character, which is what right-aligned or
|
||||
// centred TUI content looks like: the engine retries the run from every
|
||||
// whitespace position and backtracks over it. Measured over 50 000 rows with a
|
||||
// 280-column run, that regex took 2.9s against 1.3ms for the scan below, and a
|
||||
// 2 000-column run took 16s. It is also the faster of the two on an ordinary
|
||||
// padded row. A length is returned rather than a trimmed string so a
|
||||
// \r-terminated line costs no substring either.
|
||||
const trimEnd = (line) => {
|
||||
let end = line.length;
|
||||
if (end > 0 && line[end - 1] === '\r') end--;
|
||||
let cut = end;
|
||||
while (cut > 0 && (line[cut - 1] === ' ' || line[cut - 1] === '\t')) cut--;
|
||||
return cut === end ? line : line.slice(0, cut) + line.slice(end);
|
||||
};
|
||||
const lines = text.split('\n');
|
||||
for (let i = 0; i < lines.length; i++) lines[i] = trimEnd(lines[i]);
|
||||
|
||||
const margin = Math.max(0, Math.trunc(Number(options?.margin) || 0));
|
||||
if (!margin) return lines.join('\n');
|
||||
|
||||
// The first line of a selection that began mid-row carries no margin — the
|
||||
// mousedown cut it off — so it neither votes on the shared indent nor gets
|
||||
// stripped. This is the ONE thing the mousedown column still decides, and it
|
||||
// decides it for that line alone. Whether the rest of the block is dedented
|
||||
// no longer depends on where the click landed, which is what made the same
|
||||
// three rows produce three different clipboard results before.
|
||||
const from = options?.firstLinePartial === true ? 1 : 0;
|
||||
|
||||
// The pane's margin is a ceiling, not the answer. Strip the narrower of it
|
||||
// and what every selected line shares, so the block shifts as a unit and no
|
||||
// line can lose indentation another line keeps.
|
||||
let shared = margin;
|
||||
for (let i = from; i < lines.length && shared > 0; i++) {
|
||||
const line = lines[i];
|
||||
if (!line || line === '\r') continue; // a padding-only row, already trimmed away
|
||||
let run = 0;
|
||||
while (run < line.length && line[run] === ' ') run++;
|
||||
if (run < shared) shared = run;
|
||||
}
|
||||
if (!shared) return lines.join('\n');
|
||||
for (let i = from; i < lines.length; i++) {
|
||||
if (lines[i] && lines[i] !== '\r') lines[i] = lines[i].slice(shared);
|
||||
}
|
||||
return lines.join('\n');
|
||||
}
|
||||
|
||||
if (typeof window !== 'undefined') {
|
||||
window.WEBGL_FALLBACK = WEBGL_FALLBACK;
|
||||
window.evaluateWebGLLongTaskTrip = evaluateWebGLLongTaskTrip;
|
||||
@@ -854,6 +944,9 @@ if (typeof window !== 'undefined') {
|
||||
decide: decideAutoCopy,
|
||||
MAX_CHARS: AUTO_COPY_MAX_CHARS,
|
||||
};
|
||||
window.CodemanCopySelection = {
|
||||
clean: cleanCopiedSelection,
|
||||
};
|
||||
window.CodemanTerminalFont = {
|
||||
DEFAULT_STACK: TERMINAL_FONT_DEFAULT_STACK,
|
||||
resolve: resolveTerminalFontFamily,
|
||||
@@ -1057,6 +1150,9 @@ const SSE_EVENTS = {
|
||||
REMOTE_SESSION_DROPPED: 'remote:sessionDropped',
|
||||
REMOTE_SESSION_RECONNECTED: 'remote:sessionReconnected',
|
||||
REMOTE_RECONNECT_EXHAUSTED: 'remote:reconnectExhausted',
|
||||
// Wake-on-LAN from user input on a sleeping remote host
|
||||
REMOTE_HOST_WAKING: 'remote:hostWaking',
|
||||
REMOTE_HOST_WAKE_FAILED: 'remote:hostWakeFailed',
|
||||
|
||||
// Ralph
|
||||
SESSION_RALPH_LOOP_UPDATE: 'session:ralphLoopUpdate',
|
||||
@@ -1094,6 +1190,9 @@ const SSE_EVENTS = {
|
||||
APPROVAL_UPDATED: 'approval:updated',
|
||||
APPROVAL_RESOLVED: 'approval:resolved',
|
||||
|
||||
// Custom Model Endpoint Profiles
|
||||
CUSTOM_MODEL_SWAPPED_OUT: 'custom-model:swapped-out',
|
||||
|
||||
// Subagents (Claude Code background agents)
|
||||
SUBAGENT_DISCOVERED: 'subagent:discovered',
|
||||
SUBAGENT_UPDATED: 'subagent:updated',
|
||||
@@ -1442,8 +1541,296 @@ function terminalLogicalLine(buffer, row, cols, maxRows) {
|
||||
return { startRow, endRow, text, offsetToCell, cellToOffset };
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
// Split-Pane Sessions — pure helpers (divider math, picker list)
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
|
||||
// Desktop-only, same reasoning and same threshold as HOME_SESSIONS_MIN_WIDTH
|
||||
// (home-sessions.js): two 240px min-width panes plus the divider need ~486px,
|
||||
// which a phone or narrow tablet cannot give them, and the divider has no
|
||||
// touch handlers. A dedicated constant rather than reusing
|
||||
// HOME_SESSIONS_MIN_WIDTH directly — that name lives in home-sessions.js,
|
||||
// which loads AFTER this file (load order 12.56 vs 7.5), so referencing it
|
||||
// from module-evaluation-time code here would be a ReferenceError.
|
||||
const SPLIT_PANE_MIN_WIDTH = 1180;
|
||||
|
||||
function clampDividerPercent(rawPercent, min = 20, max = 80) {
|
||||
if (rawPercent < min) return min;
|
||||
if (rawPercent > max) return max;
|
||||
return rawPercent;
|
||||
}
|
||||
|
||||
function buildSplitPickerSessions(sessions, sessionOrder, excludeId, detachedIds) {
|
||||
const result = [];
|
||||
for (const id of sessionOrder) {
|
||||
if (id === excludeId) continue;
|
||||
// A detached (popped-out) session's own window already yields its PTY
|
||||
// size (see sendResize's detachedElsewhere guard in terminal-ui.js) —
|
||||
// Pane B's SplitTerminalPane._sendResize() has no such check, so letting
|
||||
// one into the picker put its detached window and Pane B in a fight over
|
||||
// the same PTY's dimensions.
|
||||
if (detachedIds?.has?.(id)) continue;
|
||||
const session = sessions.get(id);
|
||||
if (!session) continue;
|
||||
// A session with no PTY attached (exited CLI, a crash-looped session
|
||||
// whose breaker tripped, a restore that failed to re-attach) has nothing
|
||||
// reading its tmux pane. SplitTerminalPane never does selectSession()'s
|
||||
// re-attach POST, so its socket would open onto a pane nothing feeds:
|
||||
// no terminal events, and Session.write() silently drops every keystroke
|
||||
// with no ack either way (Pane B sends no `seq`), so the loss is
|
||||
// invisible — the healthy socket never trips the disconnect banner.
|
||||
if (session.pid === null) continue;
|
||||
result.push({ id, label: session.name || 'Session' });
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
// ── Renderer liveness ──────────────────────────────────────────────────────
|
||||
//
|
||||
// iOS DISCARDS scheduled requestAnimationFrame callbacks when a PWA goes to
|
||||
// the background — not deferred, never delivered. xterm's RenderDebouncer only
|
||||
// clears its `_animationFrame` handle from INSIDE that callback:
|
||||
//
|
||||
// refresh() {
|
||||
// if (this._animationFrame !== undefined) return; // <- stale forever
|
||||
// this._animationFrame = requestAnimationFrame(() => this._innerRefresh());
|
||||
// }
|
||||
// _innerRefresh() { this._animationFrame = undefined; ... } // never runs
|
||||
//
|
||||
// So after one backgrounding the handle is permanently non-undefined and EVERY
|
||||
// later render request returns on line one. Parsing is decoupled from
|
||||
// rendering, so bytes keep filling the buffer correctly and nothing throws —
|
||||
// the terminal is simply frozen. Closing and reopening fixes it because that
|
||||
// constructs a new Terminal, and therefore a new debouncer.
|
||||
//
|
||||
// Codeman is MORE exposed than a per-session-terminal app: there is exactly one
|
||||
// xterm instance for the whole page load, so a single backgrounding can wedge
|
||||
// it until a full reload.
|
||||
//
|
||||
// This is the pure decision half. The signature that distinguishes this from
|
||||
// every other way a terminal can look stuck is that bytes were WRITTEN and the
|
||||
// element is VISIBLE, yet onRender has not fired since:
|
||||
//
|
||||
// frozen = wroteAt > renderedAt && now - wroteAt >= threshold && visible
|
||||
//
|
||||
// Deliberately NOT a "no output at all" check: a quiet terminal is the normal
|
||||
// state and must never be kicked. And `visible` is required because a hidden
|
||||
// terminal legitimately stops rendering (xterm pauses it), so kicking there
|
||||
// would fire constantly on every backgrounded tab.
|
||||
const RENDER_STALL_MS = 4000;
|
||||
|
||||
// How often the watchdog checks. Deliberately coarse: the failure it catches is
|
||||
// permanent until healed, so detecting it a second late costs nothing, while a
|
||||
// tight interval would burn a wakeup per second on every idle phone.
|
||||
const RENDER_LIVENESS_POLL_MS = 2000;
|
||||
|
||||
/**
|
||||
* Should the renderer be kicked? Pure so the CI gate can cover it — the DOM
|
||||
* half (cancelling the stale handle) lives in terminal-ui.js.
|
||||
*
|
||||
* @param {{wroteAt:number, renderedAt:number, now:number, visible:boolean,
|
||||
* thresholdMs?:number}} s
|
||||
* @returns {boolean}
|
||||
*/
|
||||
function shouldKickRenderer(s) {
|
||||
if (!s || !s.visible) return false;
|
||||
const wroteAt = Number(s.wroteAt) || 0;
|
||||
const renderedAt = Number(s.renderedAt) || 0;
|
||||
const now = Number(s.now) || 0;
|
||||
// Nothing written yet — a fresh terminal has no render to be missing.
|
||||
if (wroteAt <= 0) return false;
|
||||
// A render landed at or after the last write: the pipeline is alive.
|
||||
if (renderedAt >= wroteAt) return false;
|
||||
const threshold = Number.isFinite(s.thresholdMs) && s.thresholdMs > 0 ? s.thresholdMs : RENDER_STALL_MS;
|
||||
return now - wroteAt >= threshold;
|
||||
}
|
||||
|
||||
// ── Fetch deadlines ────────────────────────────────────────────────────────
|
||||
//
|
||||
// No terminal fetch carried any deadline, including `?full=1`, which the code
|
||||
// itself describes as "unbounded-ish work: at the default history limit it can
|
||||
// be megabytes". On a stalled mobile link that request hangs on the browser
|
||||
// default with no retry and no path back to a usable terminal short of a
|
||||
// reload.
|
||||
//
|
||||
// A single fixed timeout is wrong in both directions — too short for a full
|
||||
// scrollback capture on a slow uplink, too long for a small tail on a dead
|
||||
// connection. So the deadline is scaled by what is actually being asked for,
|
||||
// and by how many captures are already in flight: on a slow link those bytes
|
||||
// must drain before this request's own bytes start moving, and its timer is
|
||||
// already running the whole time.
|
||||
const FETCH_DEADLINE_TAIL_MS = 15000;
|
||||
const FETCH_DEADLINE_FULL_MS = 45000;
|
||||
const FETCH_DEADLINE_MAX_MS = 120000;
|
||||
|
||||
/**
|
||||
* Deadline in ms for a terminal capture.
|
||||
*
|
||||
* @param {{full?:boolean, inflight?:number}} s - `full` = the ?full=1 capture;
|
||||
* `inflight` = captures already running (this one included or not, it only
|
||||
* scales the budget).
|
||||
* @returns {number}
|
||||
*/
|
||||
function terminalFetchDeadlineMs(s) {
|
||||
const full = !!(s && s.full);
|
||||
const base = full ? FETCH_DEADLINE_FULL_MS : FETCH_DEADLINE_TAIL_MS;
|
||||
const inflight = Math.max(0, Number(s && s.inflight) || 0);
|
||||
// Each already-queued capture gets the newcomer one more base budget to wait
|
||||
// through. Linear rather than clever: the point is only that eight tabs
|
||||
// resuming do not all time out together because each assumed it was alone.
|
||||
return Math.min(FETCH_DEADLINE_MAX_MS, base * (1 + inflight));
|
||||
}
|
||||
|
||||
// ── Diagnostics hygiene ────────────────────────────────────────────────────
|
||||
//
|
||||
// The crash trail is joined with '\n' into ONE localStorage value and beaconed
|
||||
// to the server, and at least one call site interpolates server-controlled text
|
||||
// (a WebSocket close `reason`). An embedded newline there forges extra entries
|
||||
// in the trail; an unbounded string can fill the storage quota. Both are cheap
|
||||
// to close, and the trail is something a user may be asked to paste into an
|
||||
// issue.
|
||||
const DIAG_ENTRY_MAX_CHARS = 300;
|
||||
|
||||
/** Flatten a diagnostic message to one bounded, newline-free line. */
|
||||
function sanitizeDiagEntry(msg) {
|
||||
return String(msg == null ? '' : msg)
|
||||
.replace(/[\r\n\u2028\u2029]+/g, ' ')
|
||||
.slice(0, DIAG_ENTRY_MAX_CHARS);
|
||||
}
|
||||
|
||||
// ── Recovering a dropped output frame ──────────────────────────────────────
|
||||
//
|
||||
// `_onSessionTerminal` drops an incoming frame when the app-owned render queues
|
||||
// already hold 128KB, which is the right call — the alternative is an unbounded
|
||||
// backlog — but a hole in a TUI byte stream is a desynced cursor, and a desynced
|
||||
// cursor is muffled text (issue #464). So the drop is only half of it: the
|
||||
// recovery has to actually happen.
|
||||
//
|
||||
// ⚠️ It used to be a fire-and-forget timer. `_onSessionNeedsRefresh` opens with
|
||||
// four early returns, and two of them — a buffer load in flight, a refresh
|
||||
// already owning this session — are MOST likely to be true during exactly the
|
||||
// output burst that caused the drop. The timer nulled itself before the call,
|
||||
// so a skipped refresh lost the recovery silently and the dropped bytes were
|
||||
// never replayed.
|
||||
//
|
||||
// Bounded, because the early returns it retries past are transient contention
|
||||
// that clears in seconds, and a permanently failing refresh must not become a
|
||||
// forever-loop against the API. A refresh that hit the capture fetch DEADLINE
|
||||
// is not contention but a stalled link, and is not retried at all: each retry
|
||||
// would be another `?full=1` capture waiting out a deadline of up to two
|
||||
// minutes, where the old code cost exactly one. Giving up after the cap leaves
|
||||
// exactly the garbled frames the old code left, so the floor is no worse.
|
||||
const DROP_RECOVERY_DELAY_MS = 2000;
|
||||
const DROP_RECOVERY_MAX_ATTEMPTS = 5;
|
||||
|
||||
/**
|
||||
* Should a dropped-output recovery run again?
|
||||
*
|
||||
* @param {{repainted: boolean, timedOut?: boolean, attempt: number, stillActive: boolean}} state
|
||||
* `repainted` — whether `_onSessionNeedsRefresh` actually rewrote the buffer.
|
||||
* `timedOut` - whether it failed at the capture fetch deadline.
|
||||
* `attempt` — how many have already run, zero-based.
|
||||
* `stillActive` — whether the dropped session is still the one on screen.
|
||||
* @returns {boolean}
|
||||
*/
|
||||
function shouldRetryDroppedOutputRecovery({ repainted, timedOut = false, attempt, stillActive }) {
|
||||
// Switched away: `selectSession` repaints from the server on its own, so a
|
||||
// retry here would be a second replay of a buffer that is about to be written.
|
||||
if (!stillActive) return false;
|
||||
if (repainted) return false;
|
||||
if (timedOut) return false;
|
||||
return attempt + 1 < DROP_RECOVERY_MAX_ATTEMPTS;
|
||||
}
|
||||
|
||||
// ── Terminal geometry: xterm and the PTY must never disagree ───────────────
|
||||
//
|
||||
// Issue #464 ("text gets muffled"). Claude Code's TUI repaints by wrapping its
|
||||
// frame at the width the PTY reported and walking the cursor up that many
|
||||
// ROWS. So a browser terminal whose width differs from the PTY's makes every
|
||||
// repaint arithmetic wrong: a logical line occupies more physical rows than
|
||||
// Ink counted, `eraseLines(n)` clears too few of them, and the new frame paints
|
||||
// over rows that were never erased. Measured against a real xterm — a PTY
|
||||
// believing 120 columns against a 62-column terminal renders each wrapped line
|
||||
// twice, and a shorter replacement line leaves the tail of the old one behind.
|
||||
// That is exactly the doubled rows and half-overwritten prose in the report.
|
||||
//
|
||||
// The floor exists because a PTY a handful of columns wide makes any CLI wrap
|
||||
// every word; it is NOT a display preference, so the browser terminal has to
|
||||
// honour it too. Three separate call sites used to fit xterm to the RAW
|
||||
// proposal and report the CLAMPED one, which is how the two drifted apart with
|
||||
// nothing to notice: resize is write-only, so nobody could see the disagreement.
|
||||
const TERMINAL_MIN_COLS = 40;
|
||||
const TERMINAL_MIN_ROWS = 10;
|
||||
|
||||
/**
|
||||
* The geometry to apply AND report — there is only ever one answer to both.
|
||||
* @param {{cols: number, rows: number}|null|undefined} proposed
|
||||
* @returns {{cols: number, rows: number}|null}
|
||||
*/
|
||||
function clampTerminalDimensions(proposed) {
|
||||
if (!proposed || !Number.isFinite(proposed.cols) || !Number.isFinite(proposed.rows)) return null;
|
||||
return {
|
||||
cols: Math.max(Math.trunc(proposed.cols), TERMINAL_MIN_COLS),
|
||||
rows: Math.max(Math.trunc(proposed.rows), TERMINAL_MIN_ROWS),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* What to do when the server reports the PTY's real geometry.
|
||||
*
|
||||
* The server is the authority: it owns the PTY the CLI is drawing for, and it
|
||||
* can refuse a resize outright (`Session.resize` ignores small-viewport
|
||||
* requests while a desktop connection holds an active sizing claim) without
|
||||
* the asking client ever being told. A terminal that keeps its own WIDTH after
|
||||
* such a refusal renders garbage, because Ink wraps its frame and counts its
|
||||
* erase rows at the width it was told.
|
||||
*
|
||||
* ⚠️ COLUMNS ONLY. Rows are deliberately left alone, and adopting them was a
|
||||
* real regression: a phone that took a desktop's 43 rows into a viewport with
|
||||
* room for 18 painted an `.xterm-screen` far taller than its container, and
|
||||
* because xterm's own viewport then had nothing to scroll, the bottom of the
|
||||
* frame — the CLI's input line — sat below the container with no gesture that
|
||||
* could reach it. Output visible, typing invisible, for as long as the claim
|
||||
* stayed hot. Width is the axis the wrap arithmetic depends on; rows only
|
||||
* decide how much is on screen at once, and keeping the local row count keeps
|
||||
* the composer at the bottom of a viewport that scrolls.
|
||||
*
|
||||
* @param {{cols: number, rows: number}|null} local - what xterm currently holds
|
||||
* @param {{cols: number, rows: number}|null} pty - what the server just reported
|
||||
* @returns {{adopt: boolean, cols: number|null}}
|
||||
*/
|
||||
function reconcilePtyGeometry(local, pty) {
|
||||
if (!pty || !Number.isFinite(pty.cols)) return { adopt: false, cols: null };
|
||||
if (!local || !Number.isFinite(local.cols) || local.cols === pty.cols) return { adopt: false, cols: null };
|
||||
return { adopt: true, cols: pty.cols };
|
||||
}
|
||||
|
||||
if (typeof window !== 'undefined') {
|
||||
window.CodemanHistoryFormat = { formatHistoryBytes, computeHistoryTruncationNotice, computeRewriteScrollLine };
|
||||
window.CodemanFilePaths = { absoluteFilePathPattern, previewsInFileViewer, FILE_PREVIEW_EXTENSIONS };
|
||||
window.CodemanTerminalLines = { terminalLogicalLine };
|
||||
window.CodemanSplitPane = {
|
||||
clampDividerPercent,
|
||||
buildSplitPickerSessions,
|
||||
SPLIT_PANE_MIN_WIDTH,
|
||||
};
|
||||
window.CodemanRenderLiveness = { shouldKickRenderer, RENDER_STALL_MS, RENDER_LIVENESS_POLL_MS };
|
||||
window.CodemanFetchDeadline = {
|
||||
terminalFetchDeadlineMs,
|
||||
FETCH_DEADLINE_TAIL_MS,
|
||||
FETCH_DEADLINE_FULL_MS,
|
||||
FETCH_DEADLINE_MAX_MS,
|
||||
};
|
||||
window.CodemanDiag = { sanitizeDiagEntry, DIAG_ENTRY_MAX_CHARS };
|
||||
window.CodemanDroppedOutput = {
|
||||
shouldRetryDroppedOutputRecovery,
|
||||
DROP_RECOVERY_DELAY_MS,
|
||||
DROP_RECOVERY_MAX_ATTEMPTS,
|
||||
};
|
||||
window.CodemanTerminalGeometry = {
|
||||
clampTerminalDimensions,
|
||||
reconcilePtyGeometry,
|
||||
TERMINAL_MIN_COLS,
|
||||
TERMINAL_MIN_ROWS,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -212,6 +212,9 @@ Object.assign(CodemanApp.prototype, {
|
||||
dir: this._shortenHomePath ? this._shortenHomePath(session.workingDir) : session.workingDir || '',
|
||||
state,
|
||||
pill: HOME_SESSIONS_PILL_LABEL[state] || state,
|
||||
// What the pane's footer says is still running in the background, straight off
|
||||
// the session payload. Same field, same meaning as on the phone overview.
|
||||
watching: typeof session.watching === 'string' ? session.watching : '',
|
||||
// Epoch ms, straight off the session payload; formatting happens at
|
||||
// render time so the clock below can redo it without a re-render.
|
||||
createdAt: Number(session.createdAt) || 0,
|
||||
@@ -450,6 +453,12 @@ Object.assign(CodemanApp.prototype, {
|
||||
// what stops it ellipsizing.
|
||||
const meta = this._buildHomeSessionsMeta(row);
|
||||
meta.appendChild(pill);
|
||||
// Built by the phone overview so both home screens word the badge identically.
|
||||
// Guarded like every other cross-file call here: a stale cached mobile-overview.js
|
||||
// must cost the badge, not the rail.
|
||||
if (row.watching && typeof this._buildWatchingBadge === 'function') {
|
||||
meta.appendChild(this._buildWatchingBadge(row.watching, 'home-sessions-pill'));
|
||||
}
|
||||
item.appendChild(meta);
|
||||
|
||||
return item;
|
||||
|
||||
@@ -0,0 +1,440 @@
|
||||
/**
|
||||
* @fileoverview Remote-host wake-on-LAN: the "host unreachable" banner + its config dialog.
|
||||
*
|
||||
* A sleeping remote host does not fail loudly. The local tmux pane runs `ssh`, and when
|
||||
* the machine suspends, that ssh child stalls: `tmux send-keys` still SUCCEEDS, so typed
|
||||
* input disappears with no error and the pane looks alive. The server side
|
||||
* (`src/remote-wake.ts`) buffers input and wakes the host when the user types; this
|
||||
* module makes the state VISIBLE and gives it a button, which is what turns "why is
|
||||
* nothing happening" into one click.
|
||||
*
|
||||
* Behavior:
|
||||
* - Asks `GET /api/sessions/:id/reachability` for the ACTIVE remote session only:
|
||||
* once when the tab is activated (a user action), and every `POLL_MS` while the tab
|
||||
* is visible ONLY for a host with a wake target. The timer is the one thing here that
|
||||
* is not user-driven, and each poll is a TCP connect to the host — the same
|
||||
* timer-driven traffic invariant #2 rejects keepalives for: it cannot wake a host,
|
||||
* but it can keep an activity-based suspend timer from firing. So a host Codeman
|
||||
* could not wake anyway is never polled on a timer. A host behind a jump host or
|
||||
* SOCKS proxy (`probeable: false`) is never polled at all: the probe cannot reach
|
||||
* it, so its answer would only ever be a false "asleep". The endpoint shares the
|
||||
* server's probe cache with the input path, so opening the tab also primes the
|
||||
* wake path.
|
||||
* - Unreachable + a configured wake target → "Wake" button → `POST /api/sessions/:id/wake`
|
||||
* (which wakes, waits, reattaches the pane and flushes buffered input).
|
||||
* - Unreachable + NO wake target → "Configure WoL" → `#wakeConfigModal`, a small form
|
||||
* for this host's MAC/command that saves via `PUT /api/remote-hosts/:id`. The server
|
||||
* re-resolves host config while the session is live, so saving takes effect without
|
||||
* restarting the session.
|
||||
* - SSE (`remote:hostWaking`, `remote:hostWakeFailed`, `remote:sessionReconnected`)
|
||||
* keeps the banner in sync while a wake is running.
|
||||
*
|
||||
* @mixin Extends CodemanApp.prototype via Object.assign
|
||||
* @dependency app.js (CodemanApp class, this.sessions, this.activeSessionId, showToast)
|
||||
* @dependency constants.js (SSE_EVENTS — the remote:hostWaking / remote:hostWakeFailed names)
|
||||
* @loadorder 12.2 — loaded after session-ui.js, before webview-tabs.js
|
||||
*/
|
||||
|
||||
const HOST_WAKE_POLL_MS = 30_000;
|
||||
|
||||
Object.assign(CodemanApp.prototype, {
|
||||
/** Per-tab banner state (single active session at a time). */
|
||||
_hostWake: null,
|
||||
/** The page-wide poller interval (created once, see `_ensureHostWakePoller`). */
|
||||
_hostWakeTimer: null,
|
||||
|
||||
/** Fresh state for a session we just switched to. */
|
||||
_hostWakeState() {
|
||||
return {
|
||||
sessionId: null,
|
||||
/** Last reachability answer, or null before the first poll. */
|
||||
reachable: null,
|
||||
/** 'command' | 'mac' | 'none' — what the banner action should do. */
|
||||
wakeConfigured: 'none',
|
||||
host: '',
|
||||
label: '',
|
||||
/**
|
||||
* False for a host the server's probe cannot reach (behind a jump host or SOCKS
|
||||
* proxy): its reachability is unknown, so there is no banner and no polling.
|
||||
*/
|
||||
probeable: true,
|
||||
/** True between clicking Wake and the answer coming back. */
|
||||
waking: false,
|
||||
/**
|
||||
* True only when the server is actually holding bytes for this session (the typing
|
||||
* path buffers them). Browser keystrokes go over the WebSocket, which never passes
|
||||
* through the wake registry — so the Wake BUTTON must not claim input is queued.
|
||||
*/
|
||||
queuedInput: false,
|
||||
/** Set when the last wake attempt or poll failed. */
|
||||
error: '',
|
||||
};
|
||||
},
|
||||
|
||||
/**
|
||||
* Entry point from the session switcher — called for every active session, remote or
|
||||
* not, so it must be cheap and must clear the banner for local sessions.
|
||||
*
|
||||
* ⚠️ The POLLER is page-wide and independent of this call on purpose: a session
|
||||
* switch is not the only way the active tab changes (boot restore, a page loaded with
|
||||
* the tab already active, and `selectSession`'s own early return for the tab you are
|
||||
* already on), and the banner must not depend on any single one of those paths
|
||||
* running — that is exactly how it could silently never appear.
|
||||
*/
|
||||
refreshHostWakeBanner(sessionId) {
|
||||
this._ensureHostWakePoller();
|
||||
const state = this._hostWake;
|
||||
if (state && state.sessionId && state.sessionId !== sessionId) this._hostWake = null;
|
||||
this._hostWakeTick();
|
||||
},
|
||||
|
||||
/** Create the page-wide poller once (interval + a visibility wake-up). */
|
||||
_ensureHostWakePoller() {
|
||||
if (this._hostWakeTimer) return;
|
||||
this._hostWakeTimer = setInterval(() => this._hostWakeTick({ periodic: true }), HOST_WAKE_POLL_MS);
|
||||
document.addEventListener('visibilitychange', () => {
|
||||
if (document.visibilityState === 'visible') this._hostWakeTick({ periodic: true });
|
||||
});
|
||||
},
|
||||
|
||||
/**
|
||||
* One poller tick: resolve the ACTIVE session, reset the banner when it changed, and
|
||||
* ask the server. No-op while the page is hidden (a background tab must not poll).
|
||||
*
|
||||
* `periodic` marks the timer (and the visibility wake-up) as opposed to a tab
|
||||
* activation: a periodic tick polls only a host with a wake target, see the module
|
||||
* comment. The activation poll is what still offers "Configure WoL" for a sleeping
|
||||
* host that has none — one connect, on a user action.
|
||||
*/
|
||||
_hostWakeTick({ periodic = false } = {}) {
|
||||
if (typeof document !== 'undefined' && document.visibilityState === 'hidden') return;
|
||||
const sessionId = this.activeSessionId;
|
||||
const session = sessionId && this.sessions ? this.sessions.get(sessionId) : null;
|
||||
if (!sessionId || !session || !session.remote) {
|
||||
// Render unconditionally: `refreshHostWakeBanner` clears `_hostWake` BEFORE
|
||||
// calling this tick, so a guard here would skip the repaint and leave the
|
||||
// banner up on every chat (the clear and the repaint must not be coupled to
|
||||
// whoever cleared the state). Idempotent — with a null state it just hides.
|
||||
this._hostWake = null;
|
||||
this._renderHostWakeBanner();
|
||||
return;
|
||||
}
|
||||
let state = this._hostWake;
|
||||
let fresh = false;
|
||||
if (!state || state.sessionId !== sessionId) {
|
||||
fresh = true;
|
||||
state = this._hostWake = this._hostWakeState();
|
||||
state.sessionId = sessionId;
|
||||
state.host = session.remote.host || '';
|
||||
state.label = session.remote.label || 'Remote host';
|
||||
// Text from the session payload first (instant, no round trip), corrected by the
|
||||
// poll — a session whose wake config was added after launch only knows it after
|
||||
// the server resolves host config. The kind matters: the payload can say WHICH
|
||||
// path is configured, so a command-only host is not mislabelled 'mac' until the
|
||||
// first poll lands.
|
||||
state.wakeConfigured = session.remote.wakeMac ? 'mac' : session.remote.wakeCommand ? 'command' : 'none';
|
||||
// Known from the payload already: a proxied host is not probeable (the server
|
||||
// says so too, on every answer), so not even the activation poll is worth a
|
||||
// round trip whose verdict could only be a wrong "asleep".
|
||||
state.probeable = !(session.remote.jumpHost || session.remote.socksProxy);
|
||||
this._renderHostWakeBanner();
|
||||
}
|
||||
if (!state.probeable) return;
|
||||
if (periodic && !fresh && state.wakeConfigured === 'none') return;
|
||||
this._pollHostReachability();
|
||||
},
|
||||
|
||||
/** One reachability check for the active remote session. */
|
||||
async _pollHostReachability(force = false) {
|
||||
const state = this._hostWake;
|
||||
if (!state || !state.sessionId) return;
|
||||
const sessionId = state.sessionId;
|
||||
try {
|
||||
const res = await fetch(`/api/sessions/${encodeURIComponent(sessionId)}/reachability${force ? '?force=1' : ''}`);
|
||||
const data = await res.json();
|
||||
if (!data.success) return;
|
||||
// The tab may have changed while this was in flight.
|
||||
if (this._hostWake !== state || state.sessionId !== sessionId) return;
|
||||
// `reachable` is `null` (unknown, not unreachable) for a host the probe cannot
|
||||
// reach — only a PROVEN `false` may raise the banner.
|
||||
state.reachable = data.data.reachable !== false;
|
||||
if (data.data.probeable === false) state.probeable = false;
|
||||
state.wakeConfigured = data.data.wakeConfigured || 'none';
|
||||
if (data.data.host) state.host = data.data.host;
|
||||
if (data.data.label) state.label = data.data.label;
|
||||
if (state.reachable) {
|
||||
state.waking = false;
|
||||
state.error = '';
|
||||
}
|
||||
this._renderHostWakeBanner();
|
||||
} catch {
|
||||
/* A failed poll is not a state change: leave the banner as it was. */
|
||||
}
|
||||
},
|
||||
|
||||
/** Draw the banner from `_hostWake`. */
|
||||
_renderHostWakeBanner() {
|
||||
const state = this._hostWake;
|
||||
const banner = this.$('hostWakeBanner');
|
||||
const text = this.$('hostWakeBannerText');
|
||||
const detail = this.$('hostWakeBannerDetail');
|
||||
const action = this.$('hostWakeBannerAction');
|
||||
if (!banner || !text || !action) return;
|
||||
|
||||
const visible = Boolean(state && state.sessionId && state.reachable === false);
|
||||
banner.hidden = !visible;
|
||||
if (!visible) return;
|
||||
|
||||
const hasTarget = state.wakeConfigured !== 'none';
|
||||
const target = state.label || state.host || 'Remote host';
|
||||
if (state.waking) {
|
||||
text.textContent = `Waking ${target} …`;
|
||||
} else if (state.error) {
|
||||
text.textContent = `${target} did not wake up`;
|
||||
} else {
|
||||
text.textContent = `${target} is not reachable`;
|
||||
}
|
||||
if (detail) {
|
||||
detail.textContent = state.waking
|
||||
? state.queuedInput
|
||||
? 'input is queued until it is back'
|
||||
: 'waiting for the host to come back'
|
||||
: hasTarget
|
||||
? `ssh ${state.host}`
|
||||
: 'no wake-on-LAN configured';
|
||||
}
|
||||
// After a FAILED wake the only useful next step is fixing the target (wrong MAC,
|
||||
// host moved NIC, command gone) — otherwise a configured-but-broken host would be
|
||||
// stuck behind a button that keeps failing with no way to edit it.
|
||||
const offerConfig = !hasTarget || Boolean(state.error);
|
||||
action.textContent = state.waking ? 'Waking …' : offerConfig ? 'Configure WoL' : 'Wake';
|
||||
action.disabled = state.waking;
|
||||
},
|
||||
|
||||
/** Banner button: wake the host, or open the setup dialog when nothing is configured. */
|
||||
hostWakeAction() {
|
||||
const state = this._hostWake;
|
||||
if (!state || !state.sessionId || state.waking) return;
|
||||
if (state.wakeConfigured === 'none' || state.error) {
|
||||
this.openWakeConfigDialog();
|
||||
return;
|
||||
}
|
||||
this.wakeRemoteHost();
|
||||
},
|
||||
|
||||
/** POST the manual wake for the active session and follow the result. */
|
||||
async wakeRemoteHost() {
|
||||
const state = this._hostWake;
|
||||
if (!state || !state.sessionId) return;
|
||||
const sessionId = state.sessionId;
|
||||
state.waking = true;
|
||||
// The button path holds nothing: whatever the user typed went into the stalled pane
|
||||
// over the WebSocket and is gone. Saying otherwise is a promise the next keystroke
|
||||
// disproves.
|
||||
state.queuedInput = false;
|
||||
state.error = '';
|
||||
this._renderHostWakeBanner();
|
||||
try {
|
||||
const res = await fetch(`/api/sessions/${encodeURIComponent(sessionId)}/wake`, { method: 'POST' });
|
||||
const data = await res.json();
|
||||
if (this._hostWake !== state || state.sessionId !== sessionId) return;
|
||||
state.waking = false;
|
||||
if (!data.success) {
|
||||
// The ROUTE is the authority on whether a target is configured, so ask it again
|
||||
// (`/reachability` reports `wakeConfigured`) rather than pattern-matching the
|
||||
// error message: the message is prose, and the code is generic (`INVALID_INPUT`
|
||||
// covers "Not a remote session" too).
|
||||
state.error = data.error || 'Wake failed';
|
||||
this._renderHostWakeBanner();
|
||||
await this._pollHostReachability(true);
|
||||
return;
|
||||
}
|
||||
state.reachable = data.data.reachable !== false;
|
||||
state.wakeConfigured = data.data.wakeConfigured || state.wakeConfigured;
|
||||
if (state.reachable) {
|
||||
this.showToast(`${state.label || 'Remote host'} is awake`, 'success');
|
||||
} else {
|
||||
state.error = 'timeout';
|
||||
}
|
||||
this._renderHostWakeBanner();
|
||||
} catch (err) {
|
||||
if (this._hostWake !== state) return;
|
||||
state.waking = false;
|
||||
state.error = err && err.message ? err.message : 'Wake failed';
|
||||
this._renderHostWakeBanner();
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Why the host could not be read. In multi-user mode `GET /api/remote-hosts` returns
|
||||
* `[]` to a non-admin, so "Remote host not found" would blame a config the user simply
|
||||
* is not allowed to see — the save is admin-only, and that is what it should say.
|
||||
*/
|
||||
_wakeConfigUnavailableMessage() {
|
||||
const me = window.__codemanUser || {};
|
||||
return me.multiUser && me.role !== 'admin' ? 'Wake-on-LAN configuration is admin-only' : 'Remote host not found';
|
||||
},
|
||||
|
||||
/** Open the small WoL dialog for the banner's host, pre-filled from the host config. */
|
||||
async openWakeConfigDialog() {
|
||||
const state = this._hostWake;
|
||||
const session = state && state.sessionId && this.sessions ? this.sessions.get(state.sessionId) : null;
|
||||
if (!session || !session.remote) return;
|
||||
const hostId = session.remote.hostId;
|
||||
const label = this.$('wakeConfigHostLabel');
|
||||
const mac = this.$('wakeConfigMac');
|
||||
const command = this.$('wakeConfigCommand');
|
||||
const status = this.$('wakeConfigStatus');
|
||||
if (!mac || !command) return;
|
||||
|
||||
mac.value = session.remote.wakeMac || '';
|
||||
command.value = session.remote.wakeCommand || '';
|
||||
if (label) label.textContent = session.remote.label || hostId;
|
||||
if (status) status.textContent = '';
|
||||
this._wakeConfigHostId = hostId;
|
||||
const modal = this.$('wakeConfigModal');
|
||||
if (modal) modal.classList.add('active');
|
||||
|
||||
// Read the saved host so the dialog shows what is actually persisted (the session
|
||||
// payload may predate a change made in another tab).
|
||||
try {
|
||||
const res = await fetch('/api/remote-hosts');
|
||||
const data = await res.json();
|
||||
const hosts = data.success ? data.data : [];
|
||||
const host = Array.isArray(hosts) ? hosts.find((item) => item.id === hostId) : null;
|
||||
if (host && this._wakeConfigHostId === hostId) {
|
||||
mac.value = host.wakeMac || '';
|
||||
command.value = host.wakeCommand || '';
|
||||
} else if (!host && this._wakeConfigHostId === hostId && status) {
|
||||
// Say it up front rather than only when Save fails.
|
||||
status.textContent = this._wakeConfigUnavailableMessage();
|
||||
}
|
||||
} catch {
|
||||
/* The form is already usable from the session payload. */
|
||||
}
|
||||
},
|
||||
|
||||
closeWakeConfigDialog() {
|
||||
const modal = this.$('wakeConfigModal');
|
||||
if (modal) modal.classList.remove('active');
|
||||
this._wakeConfigHostId = null;
|
||||
},
|
||||
|
||||
/** Save MAC/command for the host, then re-check whether the session can wake now. */
|
||||
async saveWakeConfig() {
|
||||
const hostId = this._wakeConfigHostId;
|
||||
const mac = this.$('wakeConfigMac');
|
||||
const command = this.$('wakeConfigCommand');
|
||||
const status = this.$('wakeConfigStatus');
|
||||
const save = this.$('wakeConfigSave');
|
||||
if (!hostId || !mac || !command) return;
|
||||
|
||||
const macValue = mac.value.trim();
|
||||
const commandValue = command.value.trim();
|
||||
if (
|
||||
macValue &&
|
||||
!/^[0-9a-fA-F]{2}([:-][0-9a-fA-F]{2}){5}(\s*,\s*[0-9a-fA-F]{2}([:-][0-9a-fA-F]{2}){5})*$/.test(macValue)
|
||||
) {
|
||||
if (status) status.textContent = 'MAC must look like 04:d9:f5:80:c6:58 (comma-separated for several).';
|
||||
return;
|
||||
}
|
||||
if (commandValue && /\s/.test(commandValue)) {
|
||||
if (status) status.textContent = 'The wake command must be a single executable path (no arguments).';
|
||||
return;
|
||||
}
|
||||
|
||||
if (save) save.disabled = true;
|
||||
if (status) status.textContent = 'Saving …';
|
||||
try {
|
||||
const listRes = await fetch('/api/remote-hosts');
|
||||
const listData = await listRes.json();
|
||||
const hosts = listData.success ? listData.data : [];
|
||||
const host = Array.isArray(hosts) ? hosts.find((item) => item.id === hostId) : null;
|
||||
if (!host) throw new Error(this._wakeConfigUnavailableMessage());
|
||||
// PUT takes the whole host (schema-validated), so send back everything we know and
|
||||
// only replace the wake fields. `undefined` drops the key entirely.
|
||||
const payload = {
|
||||
...host,
|
||||
wakeMac: macValue || undefined,
|
||||
wakeCommand: commandValue || undefined,
|
||||
};
|
||||
const res = await fetch(`/api/remote-hosts/${encodeURIComponent(hostId)}`, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
const data = await res.json();
|
||||
if (!data.success) throw new Error(data.error || 'Save failed');
|
||||
this.showToast('Wake settings saved', 'success');
|
||||
this.closeWakeConfigDialog();
|
||||
// The server re-resolves host config for live sessions, so the banner can offer
|
||||
// the wake right away — probe fresh instead of waiting out the poll interval.
|
||||
await this._pollHostReachability(true);
|
||||
} catch (err) {
|
||||
if (status) status.textContent = err && err.message ? err.message : 'Save failed';
|
||||
} finally {
|
||||
if (save) save.disabled = false;
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* SSE `remote:hostWaking` — a wake is running (ours or one started by typing).
|
||||
*
|
||||
* ⚠️ The ONLY definition of this handler: `panels-ui.js` must not define it too.
|
||||
* Both mix into `Codeman.prototype` and this file loads later, so a second copy
|
||||
* would be silently shadowed (the guard in `sse-dispatch-table.test.ts` sees that a
|
||||
* handler exists, not that two modules claim the same name). The toast is
|
||||
* deliberately UNCONDITIONAL — a wake can start for a background session (input on
|
||||
* a non-active tab) where there is no banner to update.
|
||||
*/
|
||||
_onRemoteHostWaking(data) {
|
||||
const label = data && data.label ? data.label : 'Remote host';
|
||||
// A create-path wake (the user pressed Run / Attach) has no session yet, so
|
||||
// nothing is queued behind it — the wording has to say what actually happens.
|
||||
const forNewSession = Boolean(data && data.forNewSession);
|
||||
// Only the typing path buffers bytes; the wake button and the send-and-wait path
|
||||
// hold none, and a browser keystroke never reaches the registry at all.
|
||||
const queuedInput = Boolean(data && data.queuedInput);
|
||||
// Long enough to cover the wake + attach (~10s measured on a warm S3), and it
|
||||
// is replaced by `remote:sessionReconnected` the moment the pane is back.
|
||||
this.showToast(
|
||||
forNewSession
|
||||
? `Waking ${label} … the session starts when it is back`
|
||||
: queuedInput
|
||||
? `Waking ${label} … input is queued`
|
||||
: `Waking ${label} … waiting for it to come back`,
|
||||
'info',
|
||||
{ duration: 12000 }
|
||||
);
|
||||
const state = this._hostWake;
|
||||
if (!state || !data || state.sessionId !== data.sessionId) return;
|
||||
state.waking = true;
|
||||
state.queuedInput = queuedInput;
|
||||
state.error = '';
|
||||
if (data.label) state.label = data.label;
|
||||
this._renderHostWakeBanner();
|
||||
},
|
||||
|
||||
/** SSE `remote:hostWakeFailed` — the host did not come back in time. */
|
||||
_onRemoteHostWakeFailed(data) {
|
||||
const label = data && data.label ? data.label : 'Remote host';
|
||||
const forNewSession = Boolean(data && data.forNewSession);
|
||||
const queuedInput = Boolean(data && data.queuedInput);
|
||||
this.showToast(
|
||||
forNewSession
|
||||
? `${label} did not wake up — no session was started`
|
||||
: queuedInput
|
||||
? `${label} did not wake up — queued input is still held`
|
||||
: `${label} did not wake up`,
|
||||
'error',
|
||||
{ duration: 15000 }
|
||||
);
|
||||
const state = this._hostWake;
|
||||
if (!state || !data || state.sessionId !== data.sessionId) return;
|
||||
state.waking = false;
|
||||
state.queuedInput = queuedInput;
|
||||
state.error = 'timeout';
|
||||
state.reachable = false;
|
||||
this._renderHostWakeBanner();
|
||||
},
|
||||
});
|
||||
@@ -73,6 +73,10 @@
|
||||
'File Viewer': '文件查看器',
|
||||
'Open file viewer': '打开文件查看器',
|
||||
'Open Codeman across all displays': '在所有显示器上打开 {name}',
|
||||
'Split: open a second session beside this one': '分屏:在旁边打开第二个会话',
|
||||
'Split: close the second session': '分屏:关闭第二个会话',
|
||||
'Close split': '关闭分屏',
|
||||
'No other sessions to split with': '没有其他可用于分屏的会话',
|
||||
'Ultracode / Workflow agents': 'Ultracode / Workflow 智能体',
|
||||
'Open ultracode workflow agents': '打开 Ultracode 工作流智能体',
|
||||
Notifications: '通知',
|
||||
@@ -286,6 +290,36 @@
|
||||
'Prompt sent': '提示已发送',
|
||||
'Inserted, press Enter in the terminal to send': '已插入,在终端中按 Enter 发送',
|
||||
'Could not reach the session': '无法连接到会话',
|
||||
'Custom model endpoints': '自定义模型端点',
|
||||
'Point a harness at your own OpenAI-compatible server (llama.cpp, vLLM, DGX Spark, Azure AI Foundry, OpenRouter) instead of its native cloud backend. When on, the Run menu offers an extra entry per harness that supports it, per saved endpoint.':
|
||||
'让工具指向您自己的兼容 OpenAI 服务器(llama.cpp、vLLM、DGX Spark、Azure AI Foundry、OpenRouter),而非其原生云端后端。开启后,"运行"菜单会为每个支持此功能的工具、每个已保存的端点新增一个条目。',
|
||||
'Enable custom model endpoints': '启用自定义模型端点',
|
||||
'Adds a per-endpoint entry to the Run menu for every harness that can redirect to one.':
|
||||
'为每个可重定向到端点的工具,在"运行"菜单中添加对应条目。',
|
||||
'No endpoints yet. Add one below to point a harness at a local or cloud OpenAI-compatible server.':
|
||||
'暂无端点。请在下方添加一个,以便将工具指向本地或云端的兼容 OpenAI 服务器。',
|
||||
Discover: '发现模型',
|
||||
'+ Add endpoint': '+ 添加端点',
|
||||
'Add endpoint': '添加端点',
|
||||
Id: 'ID',
|
||||
'Short, stable — used in URLs, never shown to the CLI.': '简短且固定 — 用于 URL,不会展示给 CLI。',
|
||||
Label: '标签',
|
||||
'Base URL': '基础 URL',
|
||||
'API key': 'API 密钥',
|
||||
'Optional. Left blank on edit keeps the existing key.': '可选。编辑时留空将保留现有密钥。',
|
||||
'Auth header': '认证请求头',
|
||||
'Never send both — some servers hang indefinitely.': '切勿同时发送两者 — 部分服务器会因此无限期挂起。',
|
||||
'Authorization: Bearer (default)': 'Authorization: Bearer(默认)',
|
||||
'api-key header (Azure)': 'api-key 请求头(Azure)',
|
||||
'Default model': '默认模型',
|
||||
'What the Run-menu picker applies for this endpoint. Discover models first.':
|
||||
'运行菜单选择器会为此端点应用该模型。请先发现可用模型。',
|
||||
'Custom Endpoints': '自定义端点',
|
||||
'Choose a model': '选择模型',
|
||||
'Currently loaded': '当前已加载',
|
||||
'Last used': '上次使用',
|
||||
'That endpoint no longer exists': '该端点已不存在',
|
||||
'No models discovered for this endpoint yet': '此端点尚未发现任何模型',
|
||||
'Subagent Options': '子智能体选项',
|
||||
'Enable Tracking': '启用跟踪',
|
||||
'Active Tab Only': '仅活动标签页',
|
||||
@@ -417,6 +451,17 @@
|
||||
'Show Shortcuts': '显示快捷键',
|
||||
'Full shortcut reference': '完整快捷键参考',
|
||||
|
||||
// Mobile prompt composer (keyboard-accessory.js). The textarea's own
|
||||
// placeholder and label are looked up by the module at build time, since
|
||||
// the DOM translator skips <textarea> subtrees.
|
||||
'Compose prompt': '撰写提示词',
|
||||
'Compose prompt, draft saved': '撰写提示词,草稿已保存',
|
||||
'Resume saved prompt draft': '继续编辑已保存的提示词草稿',
|
||||
'Enter adds a new line': '按 Enter 换行',
|
||||
'Write your prompt…': '请输入提示词…',
|
||||
'Use terminal keyboard': '使用终端键盘',
|
||||
'Uploading…': '上传中…',
|
||||
|
||||
// Mobile overview (phone home screen)
|
||||
'Needs you': '需要你',
|
||||
'Current sessions': '当前会话',
|
||||
@@ -521,6 +566,7 @@
|
||||
'Respawn Blocked': '重生已阻止',
|
||||
'Task Complete': '任务完成',
|
||||
'Copied to clipboard': '已复制到剪贴板',
|
||||
'Nothing to copy': '没有可复制的内容',
|
||||
// Terminal touch-selection bar (long-press to select). The bar is a sibling of
|
||||
// `.xterm`, not a descendant, so SKIP_SELECTOR does not cover it and these apply.
|
||||
Copy: '复制',
|
||||
|
||||
@@ -124,12 +124,15 @@ Object.assign(CodemanApp.prototype, {
|
||||
// 20 photos don't crawl through serially.
|
||||
_uploadConcurrency: 3,
|
||||
|
||||
async _uploadAndInsertImages(fileList) {
|
||||
/** Upload a batch and normally insert its paths into the active terminal.
|
||||
* The prompt composer passes `{ insert: false }` so it can put those paths
|
||||
* into its textarea instead. Returns successful paths in selection order. */
|
||||
async _uploadAndInsertImages(fileList, options = {}) {
|
||||
const sessionId = this.activeSessionId;
|
||||
if (!sessionId) return;
|
||||
if (!sessionId) return [];
|
||||
|
||||
let files = Array.from(fileList || []);
|
||||
if (files.length === 0) return;
|
||||
if (files.length === 0) return [];
|
||||
|
||||
// Cap the batch and tell the user what got dropped (no silent truncation).
|
||||
let capped = false;
|
||||
@@ -175,7 +178,7 @@ Object.assign(CodemanApp.prototype, {
|
||||
await Promise.all(Array.from({ length: Math.min(this._uploadConcurrency, total) }, () => worker()));
|
||||
|
||||
const paths = results.filter(Boolean);
|
||||
if (paths.length > 0) {
|
||||
if (paths.length > 0 && options.insert !== false) {
|
||||
// Insert all paths in one shot, space-separated, in selection order.
|
||||
await this.sendInput(paths.join(' '));
|
||||
}
|
||||
@@ -187,6 +190,7 @@ Object.assign(CodemanApp.prototype, {
|
||||
if (capped) parts.push(`max ${this._maxBatchImages} per batch`);
|
||||
const tone = paths.length > 0 ? (failed > 0 || capped ? 'info' : 'success') : 'error';
|
||||
this.showToast(parts.join(' · ') || 'No images uploaded', tone);
|
||||
return paths;
|
||||
},
|
||||
|
||||
async _uploadPasteImage(sessionId, file) {
|
||||
|
||||
@@ -191,6 +191,7 @@
|
||||
</button>
|
||||
<button class="btn-icon-header btn-file-viewer" onclick="app.toggleFileBrowserButton()" title="File Viewer" aria-label="Open file viewer" aria-expanded="false"><svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M3 7a2 2 0 0 1 2-2h4l2 2h8a2 2 0 0 1 2 2v8a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z"/></svg></button>
|
||||
<button class="btn-icon-header btn-multimonitor btn-multimonitor--hidden" onclick="app.launchMultiMonitor()" title="Open Codeman across all displays" aria-label="Open Codeman across all displays"><svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect x="2" y="4" width="13" height="9" rx="1.5"/><rect x="11" y="9" width="11" height="8" rx="1.5"/></svg></button>
|
||||
<button class="btn-icon-header btn-split btn-split--hidden" onclick="app.openSplitPicker(event)" title="Split: open a second session beside this one" aria-label="Split: open a second session beside this one" aria-pressed="false"><svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect x="2" y="3" width="20" height="18" rx="2"/><line x1="12" y1="3" x2="12" y2="21"/></svg></button>
|
||||
<button class="btn-icon-header btn-ultracode-agents btn-ultracode-agents--hidden" onclick="app.toggleUltracodeAgentsPanel()" title="Ultracode / Workflow agents" aria-label="Open ultracode workflow agents"><svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="6" cy="6" r="2.5"/><circle cx="6" cy="18" r="2.5"/><circle cx="18" cy="12" r="2.5"/><path d="M8.2 7.2 15.6 11M8.2 16.8 15.6 13"/></svg></button>
|
||||
<div class="header-plan-usage header-plan-usage--hidden" id="planUsageChip" title="Claude and Codex plan usage limits">—</div>
|
||||
<button class="btn-icon-header btn-notifications" onclick="app.toggleNotifications()" title="Notifications" aria-label="Toggle notifications" style="display:none;">
|
||||
@@ -213,6 +214,18 @@
|
||||
<button class="offline-banner-retry" id="offlineBannerRetry" onclick="app.retryConnection()">Retry now</button>
|
||||
</div>
|
||||
|
||||
<!-- Remote-host unreachable: the machine SLEEPS, the local ssh pane stalls
|
||||
silently (send-keys succeeds against it, so typed input would vanish) and
|
||||
Codeman can wake it. Amber, not red: the session is fine, the host is
|
||||
asleep. Without a configured wake target the action becomes "Configure
|
||||
WoL" and opens the small config dialog. -->
|
||||
<div class="offline-banner host-wake-banner" id="hostWakeBanner" role="status" hidden>
|
||||
<span class="offline-banner-dot" aria-hidden="true"></span>
|
||||
<span class="offline-banner-text" id="hostWakeBannerText">Remote host is unreachable</span>
|
||||
<span class="offline-banner-detail" id="hostWakeBannerDetail"></span>
|
||||
<button class="offline-banner-retry" id="hostWakeBannerAction" onclick="app.hostWakeAction()">Wake</button>
|
||||
</div>
|
||||
|
||||
<!-- Reboot-restore offer: shown when the server found sessions a host reboot
|
||||
killed and is asking whether to rebuild them. Populated by
|
||||
reboot-restore-ui.js; nothing is created until the user clicks. -->
|
||||
@@ -668,6 +681,14 @@
|
||||
<button class="run-mode-option" data-mode="omp" onclick="app.setRunMode('omp')">
|
||||
<span class="run-mode-dot omp"></span>OMP
|
||||
</button>
|
||||
<!-- Custom Model Endpoint Profiles (docs/custom-model-endpoints-plan.md): one
|
||||
generated entry per (harness, saved endpoint) pair, e.g. "Claude Code
|
||||
(llama.cpp)". Built entirely by _refreshCustomModelRunOptions() — hidden
|
||||
when the feature is off or no endpoint has a usable default model, never
|
||||
a fixed per-harness duplicate in this markup. -->
|
||||
<div class="run-mode-sep" id="runModeCustomModelSep" style="display: none;"></div>
|
||||
<div class="run-mode-header" id="runModeCustomModelHeader" style="display: none;">Custom Endpoints</div>
|
||||
<div class="run-mode-custom-models" id="runModeCustomModels"></div>
|
||||
<div class="run-mode-sep"></div>
|
||||
<button class="run-mode-option" data-mode="shell" onclick="app.setRunMode('shell')">
|
||||
<span class="run-mode-dot shell"></span>Terminal / Shell
|
||||
@@ -920,6 +941,67 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Custom Model Endpoint Profiles: "which model" picker (docs/custom-model-endpoints-plan.md).
|
||||
Shown only when the chosen endpoint has more than one discovered model — see
|
||||
selectCustomModelEntry() in session-ui.js, which skips straight to launch otherwise. -->
|
||||
<div class="modal" id="customModelPickModal">
|
||||
<div class="modal-backdrop" onclick="app.closeCustomModelPickModal()"></div>
|
||||
<div class="modal-content modal-sm">
|
||||
<div class="modal-header">
|
||||
<h3 id="customModelPickTitle">Choose a model</h3>
|
||||
<button class="modal-close" onclick="app.closeCustomModelPickModal()" aria-label="Close model picker">×</button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<p class="form-hint" id="customModelPickHint"></p>
|
||||
<div id="customModelPickList" class="run-mode-custom-models"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Custom Model Endpoint Profiles: llama-swap model-swap confirmation
|
||||
(docs/custom-model-endpoints-plan.md) — replaces a native confirm()
|
||||
popup, shown when switching would unload a model another live
|
||||
session is actively using. See _confirmModelSwap() in session-ui.js. -->
|
||||
<div class="modal" id="customModelSwapConfirmModal">
|
||||
<div class="modal-backdrop" onclick="app._resolveModelSwapConfirm(false)"></div>
|
||||
<div class="modal-content modal-sm">
|
||||
<div class="modal-header">
|
||||
<h3>Switch models?</h3>
|
||||
<button class="modal-close" onclick="app._resolveModelSwapConfirm(false)" aria-label="Cancel">×</button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<p class="form-hint" id="customModelSwapConfirmMessage"></p>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button class="btn-toolbar" onclick="app._resolveModelSwapConfirm(false)">Cancel</button>
|
||||
<button class="btn-toolbar btn-primary" onclick="app._resolveModelSwapConfirm(true)">Switch anyway</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Custom Model Endpoint Profiles: context-window-too-small warning
|
||||
(docs/custom-model-endpoints-plan.md) — shown before launching a CLI
|
||||
whose own fixed system-prompt/tool-schema overhead exceeds the
|
||||
model's real discovered context, which guarantees a first-message
|
||||
failure regardless of CLAUDE_CODE_MAX_CONTEXT_TOKENS. See
|
||||
_confirmContextWarning() in session-ui.js. -->
|
||||
<div class="modal" id="customModelContextWarningModal">
|
||||
<div class="modal-backdrop" onclick="app._resolveContextWarningConfirm(false)"></div>
|
||||
<div class="modal-content modal-sm">
|
||||
<div class="modal-header">
|
||||
<h3>Context window too small</h3>
|
||||
<button class="modal-close" onclick="app._resolveContextWarningConfirm(false)" aria-label="Cancel">×</button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<p class="form-hint" id="customModelContextWarningMessage" style="white-space: pre-wrap;"></p>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button class="btn-toolbar" onclick="app._resolveContextWarningConfirm(false)">Cancel</button>
|
||||
<button class="btn-toolbar btn-primary" onclick="app._resolveContextWarningConfirm(true)">Launch anyway</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Cron Jobs Modal -->
|
||||
<div class="modal" id="cronModal">
|
||||
<div class="modal-backdrop" onclick="app.closeCron()"></div>
|
||||
@@ -1709,6 +1791,13 @@
|
||||
</div>
|
||||
<label class="switch switch-sm"><input type="checkbox" id="appSettingsAutoCopySelection"><span class="slider"></span></label>
|
||||
</div>
|
||||
<div class="set-row" data-search="copy indent margin gutter dedent trim leading whitespace paste">
|
||||
<div class="set-row-text">
|
||||
<span class="set-row-label">Trim the pane margin on copy</span>
|
||||
<span class="set-row-desc">Drop the left margin a full-screen agent CLI paints down its own edge, so copied text pastes flush instead of indented. Each CLI declares its own width, and the strip is never wider than the indent every selected line shares, so nesting inside the selection is kept. Claude Code and Codex declare a margin today; a shell, and any CLI that declares none, is left alone.</span>
|
||||
</div>
|
||||
<label class="switch switch-sm"><input type="checkbox" id="appSettingsCopyStripMargin"><span class="slider"></span></label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1843,6 +1932,7 @@
|
||||
<label class="set-chip" data-preview="header" data-preview-order="9"><input type="checkbox" id="appSettingsShowAttachmentsButton"><svg class="set-chip-ico" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.9" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="m21.44 11.05-9.19 9.19a6 6 0 0 1-8.49-8.49l9.19-9.19a4 4 0 0 1 5.66 5.66l-9.2 9.19a2 2 0 0 1-2.83-2.83l8.49-8.48"/></svg><span>Attachments</span></label>
|
||||
<label class="set-chip" data-preview="header" data-preview-order="10"><input type="checkbox" id="appSettingsShowFileViewerButton"><svg class="set-chip-ico" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.9" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M3 7a2 2 0 0 1 2-2h4l2 2h8a2 2 0 0 1 2 2v8a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z"/></svg><span>File Viewer</span></label>
|
||||
<label class="set-chip" data-preview="header" data-preview-order="11"><input type="checkbox" id="appSettingsShowMultiMonitorButton"><svg class="set-chip-ico" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.9" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect x="2" y="4" width="13" height="9" rx="1.5"/><rect x="11" y="9" width="11" height="8" rx="1.5"/></svg><span>Multi-monitor</span></label>
|
||||
<label class="set-chip" data-preview="header" data-preview-order="11.5"><input type="checkbox" id="appSettingsShowSplitButton"><svg class="set-chip-ico" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.9" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect x="2" y="3" width="20" height="18" rx="2"/><line x1="12" y1="3" x2="12" y2="21"/></svg><span>Split</span></label>
|
||||
<label class="set-chip" data-preview="header" data-preview-order="13" data-preview-text="42%"><input type="checkbox" id="appSettingsShowPlanUsageLimits"><svg class="set-chip-ico" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.9" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M4 18a8 8 0 1 1 16 0"/><path d="M12 18l4.5-5"/></svg><span>Plan Usage</span></label>
|
||||
<label class="set-chip" data-preview="header" data-preview-order="14"><input type="checkbox" id="appSettingsShowLifecycleLog"><svg class="set-chip-ico" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.9" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/><line x1="16" y1="13" x2="8" y2="13"/><line x1="16" y1="17" x2="8" y2="17"/></svg><span>Lifecycle Log</span></label>
|
||||
</div>
|
||||
@@ -2216,6 +2306,60 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="set-group" id="customModelEndpointsGroup">
|
||||
<div class="set-group-head"><h4>Custom model endpoints</h4><span class="set-scope">synced</span></div>
|
||||
<p class="set-group-hint">Point a harness at your own OpenAI-compatible server (llama.cpp, vLLM, DGX Spark, Azure AI Foundry, OpenRouter) instead of its native cloud backend. When on, the Run menu offers an extra entry per harness that supports it, per saved endpoint.</p>
|
||||
<div class="set-group-body">
|
||||
<div class="set-row" data-search="custom model endpoint llama.cpp local llm run menu picker">
|
||||
<div class="set-row-text">
|
||||
<span class="set-row-label">Enable custom model endpoints</span>
|
||||
<span class="set-row-desc">Adds a per-endpoint entry to the Run menu for every harness that can redirect to one.</span>
|
||||
</div>
|
||||
<label class="switch switch-sm"><input type="checkbox" id="appSettingsCustomModelEndpoints" onchange="app.applyCustomModelEndpointsVisibility()"><span class="slider"></span></label>
|
||||
</div>
|
||||
<!-- Gated on the toggle above (applyCustomModelEndpointsVisibility): with the
|
||||
feature off, a list of endpoints that do nothing is worse than nothing. -->
|
||||
<div id="customModelEndpointsBody" style="display:none">
|
||||
<div id="customModelHostsList" class="set-group-body" data-search="endpoints"></div>
|
||||
<button type="button" class="btn-toolbar btn-sm" id="customModelHostAddBtn" onclick="app.openCustomModelHostEditor()">+ Add endpoint</button>
|
||||
<div id="customModelHostEditor" class="set-inline-form" style="display:none">
|
||||
<h5 id="customModelHostEditorTitle">Add endpoint</h5>
|
||||
<div class="set-row has-field">
|
||||
<div class="set-row-text"><span class="set-row-label">Id</span><span class="set-row-desc">Short, stable — used in URLs, never shown to the CLI.</span></div>
|
||||
<input type="text" id="customModelHostId" class="set-input" placeholder="llama-cpp-local">
|
||||
</div>
|
||||
<div class="set-row has-field">
|
||||
<div class="set-row-text"><span class="set-row-label">Label</span></div>
|
||||
<input type="text" id="customModelHostLabel" class="set-input" placeholder="llama.cpp (local)">
|
||||
</div>
|
||||
<div class="set-row has-field">
|
||||
<div class="set-row-text"><span class="set-row-label">Base URL</span></div>
|
||||
<input type="text" id="customModelHostBaseUrl" class="set-input" placeholder="http://192.168.1.50:8080">
|
||||
</div>
|
||||
<div class="set-row has-field">
|
||||
<div class="set-row-text"><span class="set-row-label">API key</span><span class="set-row-desc">Optional. Left blank on edit keeps the existing key.</span></div>
|
||||
<input type="password" id="customModelHostApiKey" class="set-input" autocomplete="new-password">
|
||||
</div>
|
||||
<div class="set-row has-field">
|
||||
<div class="set-row-text"><span class="set-row-label">Auth header</span><span class="set-row-desc">Never send both — some servers hang indefinitely.</span></div>
|
||||
<select id="customModelHostAuthStyle" class="set-select">
|
||||
<option value="bearer">Authorization: Bearer (default)</option>
|
||||
<option value="api-key">api-key header (Azure)</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="set-row has-field">
|
||||
<div class="set-row-text"><span class="set-row-label">Default model</span><span class="set-row-desc">What the Run-menu picker applies for this endpoint. Discover models first.</span></div>
|
||||
<select id="customModelHostDefaultModel" class="set-select" disabled></select>
|
||||
</div>
|
||||
<div class="set-row-actions">
|
||||
<button type="button" class="btn-toolbar btn-sm" onclick="app.saveCustomModelHostFromEditor()">Save</button>
|
||||
<button type="button" class="btn-toolbar btn-sm" onclick="app.closeCustomModelHostEditor()">Cancel</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- ══ Agents & CLIs ═════════════════════════════════════════ -->
|
||||
@@ -2878,6 +3022,11 @@
|
||||
<input type="number" id="remoteHostPort" placeholder="22" min="1" max="65535" autocomplete="off">
|
||||
<span class="form-hint">Optional. Leave blank for the default port 22.</span>
|
||||
</div>
|
||||
<div class="form-row">
|
||||
<label>Wake-on-LAN MAC</label>
|
||||
<input type="text" id="remoteHostWakeMac" placeholder="04:d9:f5:80:c6:58" autocomplete="off" autocapitalize="off" spellcheck="false">
|
||||
<span class="form-hint">Optional. Comma-separated for several NICs. Codeman sends the magic packet itself so a sleeping host can be woken from the session banner.</span>
|
||||
</div>
|
||||
<div class="form-row">
|
||||
<label>Codex Command Override</label>
|
||||
<input type="text" id="remoteHostCodexCommand" placeholder="exec codx personal" autocomplete="off" autocapitalize="off" spellcheck="false">
|
||||
@@ -2886,6 +3035,11 @@
|
||||
<details class="advanced-options">
|
||||
<summary><svg class="set-adv-chev" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M6 9l6 6 6-6"/></svg><span>Advanced SSH</span></summary>
|
||||
<div class="advanced-options-content">
|
||||
<div class="form-row">
|
||||
<label>Wake Command</label>
|
||||
<input type="text" id="remoteHostWakeCommand" placeholder="/home/user/bin/wake-this-host" autocomplete="off" autocapitalize="off" autocorrect="off" spellcheck="false">
|
||||
<span class="form-hint">Optional override for the MAC above (takes precedence). A single executable path, run without a shell — use it when the host needs a router/other machine to send the packet.</span>
|
||||
</div>
|
||||
<div class="form-row">
|
||||
<label>Identity File</label>
|
||||
<input type="text" id="remoteHostIdentityFile" placeholder="~/.ssh/remote_ed25519" autocomplete="off" autocapitalize="off" autocorrect="off" spellcheck="false">
|
||||
@@ -3481,6 +3635,36 @@
|
||||
text is set via value/textContent only: predictor output derives from
|
||||
observable (injectable) content, and the explicit click here is the
|
||||
security boundary (nothing is ever auto-sent). -->
|
||||
<!-- Wake-on-LAN setup for a remote host whose session cannot be woken yet. Kept
|
||||
deliberately small (host is fixed, only the wake fields are editable) so it can
|
||||
be opened from the banner with one click. Persists via PUT /api/remote-hosts/:id. -->
|
||||
<div class="modal" id="wakeConfigModal">
|
||||
<div class="modal-backdrop" onclick="app.closeWakeConfigDialog()"></div>
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h3>Wake-on-LAN · <span id="wakeConfigHostLabel"></span></h3>
|
||||
<button class="modal-close" onclick="app.closeWakeConfigDialog()" aria-label="Close">×</button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<div class="form-row">
|
||||
<label>MAC address(es)</label>
|
||||
<input type="text" id="wakeConfigMac" placeholder="04:d9:f5:80:c6:58" autocomplete="off" autocapitalize="off" autocorrect="off" spellcheck="false">
|
||||
<span class="form-hint">Comma-separated for several NICs. Codeman sends the magic packet itself (UDP port 9, broadcast).</span>
|
||||
</div>
|
||||
<div class="form-row">
|
||||
<label>Wake command (optional)</label>
|
||||
<input type="text" id="wakeConfigCommand" placeholder="/home/user/bin/wake-this-host" autocomplete="off" autocapitalize="off" autocorrect="off" spellcheck="false">
|
||||
<span class="form-hint">Takes precedence over the MAC. A single executable path, run without a shell.</span>
|
||||
</div>
|
||||
<div class="form-hint" id="wakeConfigStatus"></div>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button class="btn-toolbar" onclick="app.closeWakeConfigDialog()">Cancel</button>
|
||||
<button class="btn-toolbar btn-primary" id="wakeConfigSave" onclick="app.saveWakeConfig()">Save</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="modal" id="readMyMindModal">
|
||||
<div class="modal-backdrop" onclick="app.closeReadMyMind()"></div>
|
||||
<div class="modal-content readmymind-modal">
|
||||
@@ -3544,6 +3728,7 @@
|
||||
<script defer src="app.js"></script>
|
||||
<script defer src="tab-rail-resize.js"></script>
|
||||
<script defer src="terminal-ui.js"></script>
|
||||
<script defer src="terminal-split.js"></script>
|
||||
<script defer src="respawn-ui.js"></script>
|
||||
<script defer src="ralph-panel.js"></script>
|
||||
<script defer src="orchestrator-panel.js"></script>
|
||||
@@ -3556,6 +3741,7 @@
|
||||
<script defer src="reboot-restore-ui.js"></script>
|
||||
<script defer src="admin-ui.js"></script>
|
||||
<script defer src="session-ui.js"></script>
|
||||
<script defer src="host-wake-ui.js"></script>
|
||||
<script defer src="webview-tabs.js"></script>
|
||||
<script defer src="mobile-overview.js"></script>
|
||||
<script defer src="home-sessions.js"></script>
|
||||
|
||||
@@ -4,13 +4,14 @@
|
||||
* Defines three exports:
|
||||
*
|
||||
* - KeyboardAccessoryBar (singleton object) — Quick action buttons shown above the virtual
|
||||
* keyboard on mobile: arrow up/down, /init, Tab, paste, Esc, and dismiss (the extended
|
||||
* keyboard on mobile: arrow up/down, /init, Tab, Compose, Esc, and dismiss (the extended
|
||||
* bar adds /clear, /compact, Shift+Tab and more). Shift+Left/Right ship in both agent
|
||||
* layouts but are revealed only on Codex sessions (`codex-enabled` marker class on the
|
||||
* bar, synced on every session switch), since they are Codex bindings. Tab flushes any locally-buffered
|
||||
* prompt text to the PTY before sending \t, so completion applies to what was typed.
|
||||
* The paste button opens a dialog that handles both text paste and image attach
|
||||
* (native picker + best-effort image paste, routed through app._uploadAndInsertImages).
|
||||
* Agent bars expose a Compose dialog with an autocorrect-aware multiline textarea,
|
||||
* per-session in-memory drafts and image attach. Shell bars keep the direct Paste
|
||||
* dialog because shell input is not an agent prompt.
|
||||
* Destructive actions (/clear, /compact, extended bar only) require double-tap confirmation (2s amber state).
|
||||
* Commands are sent as text + Enter separately for Ink compatibility.
|
||||
* Only initializes on touch devices (MobileDetection.isTouchDevice guard).
|
||||
@@ -635,6 +636,16 @@ function applyOneShotCtrl(data) {
|
||||
return { data, consumed: true };
|
||||
}
|
||||
|
||||
// The composer's Send goes out as ONE bracketed-paste frame on the WebSocket
|
||||
// input path, and ws-routes.ts drops a frame longer than MAX_INPUT_LENGTH
|
||||
// (config/terminal-limits.ts: 64 KiB, compared in UTF-16 code units) WITHOUT
|
||||
// an ACK, which would wedge the durable input queue. So the prompt budget is
|
||||
// that limit minus the two markers, derived here once so the refusal in
|
||||
// _sendComposedPrompt() and the toast that names the maximum cannot drift.
|
||||
const COMPOSER_INPUT_FRAME_LIMIT = 64 * 1024;
|
||||
const COMPOSER_PASTE_START = '\x1b[200~';
|
||||
const COMPOSER_PASTE_END = '\x1b[201~';
|
||||
|
||||
/**
|
||||
* KeyboardAccessoryBar - Quick action buttons shown above keyboard when typing.
|
||||
*/
|
||||
@@ -648,8 +659,15 @@ const KeyboardAccessoryBar = {
|
||||
_baseMode: 'simple',
|
||||
// One-shot Ctrl modifier (shell bar only). See handleAction('ctrl').
|
||||
_ctrlArmed: false,
|
||||
// Prompt drafts intentionally stay in memory: prompts routinely contain secrets,
|
||||
// so persistence would need the same treatment as the 0600 intent store.
|
||||
_composerDrafts: new Map(),
|
||||
_composerUploads: new Map(),
|
||||
_composerOverlay: null,
|
||||
// Longest prompt Send accepts: the input frame limit minus both markers.
|
||||
_composerMaxLength: COMPOSER_INPUT_FRAME_LIMIT - COMPOSER_PASTE_START.length - COMPOSER_PASTE_END.length,
|
||||
|
||||
/** HTML for simple mode: arrows, commands, paste, Esc, dismiss */
|
||||
/** HTML for simple mode: arrows, commands, Compose, Esc, dismiss */
|
||||
_simpleButtons: `
|
||||
<button class="accessory-btn accessory-btn-arrow" data-action="scroll-up" title="Arrow up">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5">
|
||||
@@ -665,10 +683,10 @@ const KeyboardAccessoryBar = {
|
||||
<button class="accessory-btn" data-action="tab" title="Tab">Tab</button>
|
||||
<button class="accessory-btn accessory-btn-codex" data-action="shift-left" title="Shift+Left (Codex: edit queued message)" aria-label="Shift+Left (Codex: edit queued message)">⇧←</button>
|
||||
<button class="accessory-btn accessory-btn-codex" data-action="shift-right" title="Shift+Right (Codex: prompt stack back)" aria-label="Shift+Right (Codex: prompt stack back)">⇧→</button>
|
||||
<button class="accessory-btn" data-action="paste" title="Paste from clipboard">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<path d="M16 4h2a2 2 0 0 1 2 2v14a2 2 0 0 1-2 2H6a2 2 0 0 1-2-2V6a2 2 0 0 1 2-2h2"/>
|
||||
<rect x="8" y="2" width="8" height="4" rx="1" ry="1"/>
|
||||
<button class="accessory-btn accessory-btn-compose" data-action="compose" title="Compose prompt" aria-label="Compose prompt">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true">
|
||||
<path d="M12 20h9"/>
|
||||
<path d="M16.5 3.5a2.12 2.12 0 0 1 3 3L7 19l-4 1 1-4Z"/>
|
||||
</svg>
|
||||
</button>
|
||||
<button class="accessory-btn accessory-btn-rmm" data-action="readmymind" title="Read My Mind: predict your next prompt">🧠</button>
|
||||
@@ -740,10 +758,10 @@ const KeyboardAccessoryBar = {
|
||||
<path d="M9 5l7 7-7 7"/>
|
||||
</svg>
|
||||
</button>
|
||||
<button class="accessory-btn" data-action="paste" title="Paste from clipboard">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<path d="M16 4h2a2 2 0 0 1 2 2v14a2 2 0 0 1-2 2H6a2 2 0 0 1-2-2V6a2 2 0 0 1 2-2h2"/>
|
||||
<rect x="8" y="2" width="8" height="4" rx="1" ry="1"/>
|
||||
<button class="accessory-btn accessory-btn-compose" data-action="compose" title="Compose prompt" aria-label="Compose prompt">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true">
|
||||
<path d="M12 20h9"/>
|
||||
<path d="M16.5 3.5a2.12 2.12 0 0 1 3 3L7 19l-4 1 1-4Z"/>
|
||||
</svg>
|
||||
</button>
|
||||
<button class="accessory-btn" data-action="pick-path" title="Insert a file or folder path">📁 Path</button>
|
||||
@@ -781,6 +799,7 @@ const KeyboardAccessoryBar = {
|
||||
// The ⇧←/⇧→ keys are Codex bindings: same shape, gated on the active
|
||||
// session's mode instead of a setting.
|
||||
this.syncCodexKeys();
|
||||
this._syncComposerDraftIndicator();
|
||||
|
||||
// Add click handlers — preventDefault stops event from reaching terminal
|
||||
this.element.addEventListener('click', (e) => {
|
||||
@@ -823,8 +842,13 @@ const KeyboardAccessoryBar = {
|
||||
* the next one. */
|
||||
refreshForActiveSession() {
|
||||
this.clearCtrl();
|
||||
const activeSessionId = typeof app !== 'undefined' ? app.activeSessionId : null;
|
||||
if (this._composerOverlay && this._composerOverlay.dataset.sessionId !== activeSessionId) {
|
||||
this._composerOverlay._closeComposer?.({ restoreFocus: false });
|
||||
}
|
||||
this._applyLayout(this._resolveMode());
|
||||
this.syncCodexKeys();
|
||||
this._syncComposerDraftIndicator();
|
||||
},
|
||||
|
||||
/** Which layout the current state calls for. */
|
||||
@@ -852,6 +876,20 @@ const KeyboardAccessoryBar = {
|
||||
this.clearCtrl();
|
||||
this.element.innerHTML =
|
||||
mode === 'shell' ? this._shellButtons : mode === 'extended' ? this._extendedButtons : this._simpleButtons;
|
||||
this._syncComposerDraftIndicator();
|
||||
},
|
||||
|
||||
/** Show when the active session has a prompt parked in memory. The marker
|
||||
* keeps non-Send closes visible without copying the draft back into the PTY
|
||||
* and creating a second source of truth. */
|
||||
_syncComposerDraftIndicator() {
|
||||
const button = this.element?.querySelector('[data-action="compose"]');
|
||||
if (!button) return;
|
||||
const sessionId = typeof app !== 'undefined' ? app.activeSessionId : null;
|
||||
const hasDraft = !!(sessionId && this._composerDrafts.get(sessionId));
|
||||
button.classList.toggle('has-draft', hasDraft);
|
||||
button.title = hasDraft ? 'Resume saved prompt draft' : 'Compose prompt';
|
||||
button.setAttribute('aria-label', hasDraft ? 'Compose prompt, draft saved' : 'Compose prompt');
|
||||
},
|
||||
|
||||
// ── One-shot Ctrl modifier (shell bar) ──────────────────────────────────
|
||||
@@ -977,6 +1015,9 @@ const KeyboardAccessoryBar = {
|
||||
case 'paste':
|
||||
this.pasteFromClipboard();
|
||||
break;
|
||||
case 'compose':
|
||||
this.composePrompt();
|
||||
break;
|
||||
case 'pick-path':
|
||||
this.pickPath();
|
||||
break;
|
||||
@@ -1130,6 +1171,230 @@ const KeyboardAccessoryBar = {
|
||||
});
|
||||
},
|
||||
|
||||
/** Move the whole editable terminal prompt into the composer. Pending text
|
||||
* exists only in the overlay; flushed text already reached the PTY, so erase
|
||||
* that prefix before making the textarea authoritative. */
|
||||
_takePendingLocalEcho(sessionId) {
|
||||
if (!app._localEchoEnabled || !app._localEchoOverlay) return '';
|
||||
const pending = app._localEchoOverlay.pendingText || '';
|
||||
const overlayFlushed = app._localEchoOverlay.getFlushed?.() || {};
|
||||
const flushedText = overlayFlushed.text || app._flushedTexts?.get(sessionId) || '';
|
||||
const flushedLength = Array.from(flushedText).length;
|
||||
app._localEchoOverlay.clear();
|
||||
app._localEchoOverlay.suppressBufferDetection?.();
|
||||
app._flushedOffsets?.delete(sessionId);
|
||||
app._flushedTexts?.delete(sessionId);
|
||||
if (flushedLength > 0) {
|
||||
app._sendInputAsync(sessionId, '\x7f'.repeat(flushedLength), { useMux: true });
|
||||
}
|
||||
return flushedText + pending;
|
||||
},
|
||||
|
||||
/** Insert text at the textarea selection, adding one separating space when
|
||||
* an attachment path would otherwise run into neighboring prompt text. */
|
||||
_insertComposerText(textarea, text) {
|
||||
if (!textarea || !text) return;
|
||||
const start = Number.isInteger(textarea.selectionStart) ? textarea.selectionStart : textarea.value.length;
|
||||
const end = Number.isInteger(textarea.selectionEnd) ? textarea.selectionEnd : start;
|
||||
const before = textarea.value.slice(0, start);
|
||||
const after = textarea.value.slice(end);
|
||||
const prefix = before && !/\s$/.test(before) ? ' ' : '';
|
||||
const suffix = after && !/^\s/.test(after) ? ' ' : '';
|
||||
const inserted = `${prefix}${text}${suffix}`;
|
||||
textarea.setRangeText(inserted, start, end, 'end');
|
||||
textarea.dispatchEvent(new Event('input', { bubbles: true }));
|
||||
},
|
||||
|
||||
/** Deliver one complete prompt as an explicit bracketed paste. xterm loses
|
||||
* its DECSET 2004 mirror after terminal replay, even though the CLI still
|
||||
* expects bracketed input, so build the byte-identical sequence directly on
|
||||
* the durable session-bound path. Enter stays a separate delayed write
|
||||
* because Codex drops keys sharing a PTY read with a bracketed paste. */
|
||||
_sendComposedPrompt(sessionId, text) {
|
||||
if (!sessionId || !text || typeof app._sendInputAsync !== 'function') return false;
|
||||
app._predictiveEcho?.clearPredictions();
|
||||
// Match xterm's prepareTextForTerminal(): CR keeps embedded newlines inside
|
||||
// the single-line input transport and is what terminal.paste() emitted.
|
||||
const pasteText = text.replace(/\r?\n/g, '\r');
|
||||
if (pasteText.length > this._composerMaxLength) {
|
||||
app.showToast?.(`Prompt is too long to send (maximum ${this._composerMaxLength.toLocaleString()} characters)`, 'error');
|
||||
return false;
|
||||
}
|
||||
app._sendInputAsync(sessionId, `${COMPOSER_PASTE_START}${pasteText}${COMPOSER_PASTE_END}`);
|
||||
setTimeout(() => app._sendInputAsync(sessionId, '\r', { useMux: true }), 120);
|
||||
return true;
|
||||
},
|
||||
|
||||
/** Forget a draft when its target session no longer exists. */
|
||||
discardComposerDraft(sessionId) {
|
||||
this._composerDrafts.delete(sessionId);
|
||||
this._composerUploads.delete(sessionId);
|
||||
this._syncComposerDraftIndicator();
|
||||
if (this._composerOverlay?.dataset.sessionId === sessionId) {
|
||||
this._composerOverlay._closeComposer?.({ preserveDraft: false });
|
||||
}
|
||||
},
|
||||
|
||||
/** Open the manual agent prompt composer. Enter remains a newline; only the
|
||||
* Send button submits. Cancel/backdrop/Escape preserve the per-session draft. */
|
||||
composePrompt() {
|
||||
if (typeof app === 'undefined' || !app.activeSessionId) return;
|
||||
if (this._isShellSession()) {
|
||||
this.pasteFromClipboard();
|
||||
return;
|
||||
}
|
||||
|
||||
const sessionId = app.activeSessionId;
|
||||
const pending = this._takePendingLocalEcho(sessionId);
|
||||
const saved = this._composerDrafts.get(sessionId) || '';
|
||||
const initial = saved + pending;
|
||||
|
||||
this._composerOverlay?._closeComposer?.({ restoreFocus: false });
|
||||
const overlay = document.createElement('div');
|
||||
overlay.className = 'paste-overlay prompt-composer-overlay';
|
||||
overlay.dataset.sessionId = sessionId;
|
||||
overlay.setAttribute('role', 'dialog');
|
||||
overlay.setAttribute('aria-modal', 'true');
|
||||
overlay.setAttribute('aria-label', 'Compose prompt');
|
||||
overlay.innerHTML = `
|
||||
<div class="paste-dialog prompt-composer-dialog">
|
||||
<div class="prompt-composer-header">
|
||||
<strong>Compose prompt</strong>
|
||||
<span>Enter adds a new line</span>
|
||||
</div>
|
||||
<textarea class="paste-textarea prompt-composer-textarea" aria-label="Prompt" placeholder="Write your prompt…" autocorrect="on" autocapitalize="sentences" spellcheck="true"></textarea>
|
||||
<div class="paste-actions prompt-composer-actions">
|
||||
<button type="button" class="prompt-composer-terminal">Use terminal keyboard</button>
|
||||
<button type="button" class="paste-image">🖼 Image</button>
|
||||
<button type="button" class="paste-cancel">Cancel</button>
|
||||
<button type="button" class="paste-send">Send</button>
|
||||
</div>
|
||||
<input type="file" class="paste-file-input" accept="image/*" multiple hidden>
|
||||
</div>
|
||||
`;
|
||||
|
||||
this._composerOverlay = overlay;
|
||||
const textarea = overlay.querySelector('.prompt-composer-textarea');
|
||||
// i18n.js skips <textarea> subtrees (what is typed there is user content),
|
||||
// so the placeholder and label are translated here, when the dialog is built.
|
||||
const i18n = typeof window !== 'undefined' ? window.CodemanI18n : undefined;
|
||||
if (typeof i18n?.t === 'function') {
|
||||
textarea.placeholder = i18n.t('Write your prompt…');
|
||||
textarea.setAttribute('aria-label', i18n.t('Prompt'));
|
||||
}
|
||||
const fileInput = overlay.querySelector('.paste-file-input');
|
||||
const imageButton = overlay.querySelector('.paste-image');
|
||||
const sendButton = overlay.querySelector('.paste-send');
|
||||
const focusTrap = new FocusTrap(overlay);
|
||||
textarea.value = initial;
|
||||
if (initial) this._composerDrafts.set(sessionId, initial);
|
||||
this._syncComposerDraftIndicator();
|
||||
const initialUploads = this._composerUploads.get(sessionId) || 0;
|
||||
imageButton.disabled = initialUploads > 0;
|
||||
sendButton.disabled = initialUploads > 0;
|
||||
if (initialUploads > 0) imageButton.textContent = 'Uploading…';
|
||||
|
||||
const saveDraft = () => {
|
||||
if (textarea.value) this._composerDrafts.set(sessionId, textarea.value);
|
||||
else this._composerDrafts.delete(sessionId);
|
||||
this._syncComposerDraftIndicator();
|
||||
};
|
||||
const close = ({ focusTerminal = false, preserveDraft = true, restoreFocus = true } = {}) => {
|
||||
if (preserveDraft) saveDraft();
|
||||
focusTrap.deactivate({ restoreFocus });
|
||||
overlay.remove();
|
||||
if (this._composerOverlay === overlay) this._composerOverlay = null;
|
||||
if (focusTerminal) app.terminal?.focus();
|
||||
};
|
||||
overlay._closeComposer = close;
|
||||
const send = () => {
|
||||
// Whitespace-only counts as empty (it would submit blank lines), but the
|
||||
// text goes out untrimmed so deliberate leading or trailing lines survive.
|
||||
const text = textarea.value;
|
||||
if (!text.trim() || !this._sendComposedPrompt(sessionId, text)) return;
|
||||
app._echoPassthroughSessions?.delete(sessionId);
|
||||
this._composerDrafts.delete(sessionId);
|
||||
this._syncComposerDraftIndicator();
|
||||
close({ preserveDraft: false });
|
||||
};
|
||||
const handleImages = async (files) => {
|
||||
const images = Array.from(files || []).filter((file) => file.type.startsWith('image/'));
|
||||
if (images.length === 0 || typeof app._uploadAndInsertImages !== 'function') return;
|
||||
saveDraft();
|
||||
this._composerUploads.set(sessionId, (this._composerUploads.get(sessionId) || 0) + 1);
|
||||
const syncUploadUi = () => {
|
||||
const currentOverlay =
|
||||
this._composerOverlay?.isConnected && this._composerOverlay.dataset.sessionId === sessionId
|
||||
? this._composerOverlay
|
||||
: null;
|
||||
const count = this._composerUploads.get(sessionId) || 0;
|
||||
const currentImageButton = currentOverlay?.querySelector('.paste-image');
|
||||
const currentSendButton = currentOverlay?.querySelector('.paste-send');
|
||||
if (currentImageButton) {
|
||||
currentImageButton.disabled = count > 0;
|
||||
currentImageButton.textContent = count > 0 ? 'Uploading…' : '🖼 Image';
|
||||
}
|
||||
if (currentSendButton) currentSendButton.disabled = count > 0;
|
||||
};
|
||||
syncUploadUi();
|
||||
try {
|
||||
const paths = await app._uploadAndInsertImages(images, { insert: false });
|
||||
if (paths?.length) {
|
||||
const currentOverlay =
|
||||
this._composerOverlay?.isConnected && this._composerOverlay.dataset.sessionId === sessionId
|
||||
? this._composerOverlay
|
||||
: null;
|
||||
const currentTextarea = currentOverlay?.querySelector('.prompt-composer-textarea');
|
||||
if (currentTextarea) this._insertComposerText(currentTextarea, paths.join(' '));
|
||||
else if (app.sessions?.has(sessionId)) {
|
||||
const draft = this._composerDrafts.get(sessionId) || '';
|
||||
this._composerDrafts.set(sessionId, `${draft}${draft && !/\s$/.test(draft) ? ' ' : ''}${paths.join(' ')}`);
|
||||
this._syncComposerDraftIndicator();
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
const remaining = Math.max(0, (this._composerUploads.get(sessionId) || 1) - 1);
|
||||
if (remaining > 0) this._composerUploads.set(sessionId, remaining);
|
||||
else this._composerUploads.delete(sessionId);
|
||||
syncUploadUi();
|
||||
if (overlay.isConnected) fileInput.value = '';
|
||||
}
|
||||
};
|
||||
|
||||
textarea.addEventListener('input', saveDraft);
|
||||
textarea.addEventListener('paste', (event) => {
|
||||
const items = event.clipboardData?.items;
|
||||
if (!items) return;
|
||||
const images = Array.from(items)
|
||||
.filter((item) => item.type.startsWith('image/'))
|
||||
.map((item) => item.getAsFile())
|
||||
.filter(Boolean);
|
||||
if (images.length > 0) {
|
||||
event.preventDefault();
|
||||
void handleImages(images);
|
||||
}
|
||||
});
|
||||
overlay.addEventListener('keydown', (event) => {
|
||||
if (event.key === 'Escape') {
|
||||
event.preventDefault();
|
||||
close();
|
||||
}
|
||||
});
|
||||
imageButton.addEventListener('click', () => fileInput.click());
|
||||
fileInput.addEventListener('change', () => void handleImages(fileInput.files));
|
||||
overlay.querySelector('.prompt-composer-terminal').addEventListener('click', () => close({ focusTerminal: true }));
|
||||
overlay.querySelector('.paste-cancel').addEventListener('click', () => close());
|
||||
sendButton.addEventListener('click', send);
|
||||
overlay.addEventListener('click', (event) => {
|
||||
if (event.target === overlay) close();
|
||||
});
|
||||
|
||||
document.body.appendChild(overlay);
|
||||
focusTrap.activate();
|
||||
textarea.focus();
|
||||
textarea.selectionStart = textarea.selectionEnd = textarea.value.length;
|
||||
},
|
||||
|
||||
/** Show a paste overlay for iOS compatibility.
|
||||
* Handles three input paths from one dialog:
|
||||
* - Text: long-press the textarea → Paste → Send (unchanged).
|
||||
@@ -1259,9 +1524,9 @@ class FocusTrap {
|
||||
});
|
||||
}
|
||||
|
||||
deactivate() {
|
||||
deactivate({ restoreFocus = true } = {}) {
|
||||
this.element.removeEventListener('keydown', this.boundHandleKeydown);
|
||||
if (this.previouslyFocused && typeof this.previouslyFocused.focus === 'function') {
|
||||
if (restoreFocus && this.previouslyFocused && typeof this.previouslyFocused.focus === 'function') {
|
||||
this.previouslyFocused.focus();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -581,11 +581,10 @@ const KeyboardHandler = {
|
||||
this._settleRestoreScroll = false;
|
||||
|
||||
if (typeof app !== 'undefined' && app.terminal) {
|
||||
if (app.fitAddon) {
|
||||
try {
|
||||
app.fitAddon.fit();
|
||||
} catch {}
|
||||
}
|
||||
// Floored fit, not a bare fitAddon.fit(): _shrinkPaddingToFit measures
|
||||
// the leftover gap under the LAST row, so it has to run against the
|
||||
// geometry xterm will actually keep (issue #464).
|
||||
app.syncTerminalGeometry?.();
|
||||
if (this.keyboardVisible) this._shrinkPaddingToFit();
|
||||
// Following live output → bottom, as before. Reading history → back to
|
||||
// the pre-reflow anchor instead of being yanked down (#259).
|
||||
@@ -601,25 +600,23 @@ const KeyboardHandler = {
|
||||
}, this.VIEWPORT_SETTLE_MS);
|
||||
},
|
||||
|
||||
/** Send current terminal dimensions to the server (one-shot, for keyboard open/close) */
|
||||
/**
|
||||
* Send the settled terminal dimensions to the server (one-shot, for keyboard
|
||||
* open/close — `throttledResize` deliberately holds the PTY's shape for the
|
||||
* whole animation, so this is what stops it going stale).
|
||||
*
|
||||
* ⚠️ Delegates rather than computing its own numbers. This used to re-read
|
||||
* `proposeDimensions()` and floor only what it POSTed, so on a phone with the
|
||||
* keyboard up — where the proposal is routinely under ten rows — the PTY was
|
||||
* told ten and xterm kept six, which is the #464 divergence. Worse, it read
|
||||
* the proposal AFTER `_shrinkPaddingToFit()` had moved the container, so even
|
||||
* unfloored its answer could differ from the fit above it. `sendResize` fits,
|
||||
* floors and applies in one step, and additionally gets the WS fast path and
|
||||
* the detached-session yield this hand-rolled POST never had.
|
||||
*/
|
||||
_sendTerminalResize() {
|
||||
if (typeof app === 'undefined' || !app.activeSessionId || !app.fitAddon) return;
|
||||
try {
|
||||
const dims = app.fitAddon.proposeDimensions();
|
||||
if (dims) {
|
||||
const cols = Math.max(dims.cols, 40);
|
||||
const rows = Math.max(dims.rows, 10);
|
||||
app._lastResizeDims = { cols, rows };
|
||||
// Declare the viewport type so resize arbitration can ignore this
|
||||
// while a desktop connection is sizing the same session.
|
||||
const viewportType = MobileDetection.getDeviceType ? MobileDetection.getDeviceType() : 'mobile';
|
||||
fetch(`/api/sessions/${app.activeSessionId}/resize`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ cols, rows, viewportType }),
|
||||
}).catch(() => {});
|
||||
}
|
||||
} catch {}
|
||||
if (typeof app === 'undefined' || !app.activeSessionId) return;
|
||||
app.sendResize?.(app.activeSessionId)?.catch?.(() => {});
|
||||
},
|
||||
|
||||
/**
|
||||
@@ -676,10 +673,8 @@ const KeyboardHandler = {
|
||||
const currentPadding = parseInt(main.style.paddingBottom) || 0;
|
||||
const floor = Math.min(currentPadding, this._fixedBottomBarsHeight());
|
||||
main.style.paddingBottom = Math.max(floor, currentPadding - gap) + 'px';
|
||||
if (app.fitAddon)
|
||||
try {
|
||||
app.fitAddon.fit();
|
||||
} catch {}
|
||||
// Floored, like every other fit of the main terminal (#464).
|
||||
app.syncTerminalGeometry?.();
|
||||
}
|
||||
} catch {}
|
||||
},
|
||||
|
||||
@@ -60,6 +60,14 @@ const MOBILE_OVERVIEW_RUN_MODES = [
|
||||
{ mode: 'shell', label: 'Terminal / Shell', short: 'Shell' },
|
||||
];
|
||||
|
||||
/**
|
||||
* The one word every surface puts on the watching badge, and the tooltip that says
|
||||
* what the pane actually reported. Both live here so the phone overview, the desktop
|
||||
* home rail and the rich sidebar rows cannot word the same badge three ways.
|
||||
*/
|
||||
const WATCHING_BADGE_TEXT = 'watching';
|
||||
const watchingBadgeTitle = (label) => 'Still running in the background: ' + label;
|
||||
|
||||
/** Pill copy per state. Kept short: a phone row has ~90px for it. */
|
||||
const MOBILE_OVERVIEW_PILL_LABEL = {
|
||||
needs: 'needs you',
|
||||
@@ -185,6 +193,10 @@ Object.assign(CodemanApp.prototype, {
|
||||
dir: this._shortenHomePath ? this._shortenHomePath(session.workingDir) : session.workingDir || '',
|
||||
state,
|
||||
pill: MOBILE_OVERVIEW_PILL_LABEL[state] || state,
|
||||
// What the pane's own footer says is still running in the background ("1 monitor",
|
||||
// "2 shells"), straight off the session payload. A row that has one is quiet
|
||||
// because the agent is waiting for that, not because it is waiting for you.
|
||||
watching: typeof session.watching === 'string' ? session.watching : '',
|
||||
// Epoch ms, straight off the session payload; formatting happens at
|
||||
// render time so the clock can redo it without a re-render.
|
||||
createdAt: Number(session.createdAt) || 0,
|
||||
@@ -713,6 +725,8 @@ Object.assign(CodemanApp.prototype, {
|
||||
pill.textContent = row.pill;
|
||||
item.appendChild(pill);
|
||||
|
||||
if (row.watching) item.appendChild(this._buildWatchingBadge(row.watching, 'mobile-overview-pill'));
|
||||
|
||||
const chevron = document.createElement('span');
|
||||
chevron.className = 'mobile-overview-chevron';
|
||||
chevron.setAttribute('aria-hidden', 'true');
|
||||
@@ -734,6 +748,40 @@ Object.assign(CodemanApp.prototype, {
|
||||
return item;
|
||||
},
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
// Watching badge
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
|
||||
/**
|
||||
* The badge a session wears while work it started in the background is still
|
||||
* running: a monitor, a backgrounded shell, a cloud session.
|
||||
*
|
||||
* It says one word, and the label the pane itself printed ("1 monitor") rides in
|
||||
* the tooltip, because the badge shares a row with the state pill on the narrowest
|
||||
* screen this app renders on. It does NOT replace that pill: an agent can arm a
|
||||
* monitor and ask the user a question in the same breath, so the row still says
|
||||
* "needs you" and this says what else is going on.
|
||||
*
|
||||
* Shared with the desktop home rail (home-sessions.js), for the same reason
|
||||
* `_mobileOverviewState` is: one badge, one wording, one place to change it. The
|
||||
* caller names its own pill class, because each surface styles its pills itself and
|
||||
* the phone's live inside a media query the desktop never enters.
|
||||
*/
|
||||
_buildWatchingBadge(label, baseClass) {
|
||||
const badge = document.createElement('span');
|
||||
const base = baseClass || 'mobile-overview-pill';
|
||||
badge.className = base + ' ' + base + '--watching';
|
||||
badge.setAttribute('data-i18n-skip', '');
|
||||
badge.textContent = WATCHING_BADGE_TEXT;
|
||||
// The label rides in BOTH, because a tooltip is desktop-only: a phone has no hover
|
||||
// target, and a screen reader gets the one word either way. This is the surface the
|
||||
// badge was built for first, so "watching" with no way to learn what would be the
|
||||
// wrong place to save a line.
|
||||
badge.title = watchingBadgeTitle(label);
|
||||
badge.setAttribute('aria-label', watchingBadgeTitle(label));
|
||||
return badge;
|
||||
},
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
// Age stamps: started / how long in this state
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
|
||||
+45
-16
@@ -529,6 +529,11 @@ html.mobile-init .file-browser-panel {
|
||||
.btn-icon-header.btn-approvals {
|
||||
display: none !important;
|
||||
}
|
||||
/* Split-pane is hidden here too, but the AUTHORITATIVE hard gate is the
|
||||
wider `@media (max-width: 1179px)` rule in styles.css — this file only
|
||||
loads up to 1023px, which does not cover the 1024-1179px tablet range
|
||||
the split feature also needs to stay off (two 240px min-width panes plus
|
||||
the divider need ~486px; the divider also has no touch handlers). */
|
||||
|
||||
/* Read My Mind 🧠 header button: never in the phone header; the phone
|
||||
surface is the keyboard-accessory 🧠 key (same `readMyMindEnabled` gate,
|
||||
@@ -689,6 +694,22 @@ html.mobile-init .file-browser-panel {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* The exited-agent mute, a third time, for the phone (Ark0N/Codeman#446).
|
||||
The rule above enlarges the working dot and gives it a green glow with
|
||||
!important, and `status` deliberately stays `busy` for a pane whose agent
|
||||
died mid-turn — so without this a phone renders a 9px grey dot still
|
||||
wearing the green halo, beside a badge reading "exited". Measured; the
|
||||
desktop rules cannot reach it, since they declare no box-shadow and lose
|
||||
to !important anyway. !important here for the reason the glow needs it:
|
||||
the skin block in styles.css outranks any plain class rule in this file.
|
||||
⚠ The alert classes and the error state are excluded exactly as they are
|
||||
on desktop — a colour that means "this needs you" outranks "it exited". */
|
||||
.session-tab.tab-agent-exited:not(.tab-alert-action):not(.tab-alert-idle) .tab-status:not(.error) {
|
||||
background: var(--text-muted) !important;
|
||||
box-shadow: none !important;
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
/* Truncate tab names more aggressively on mobile */
|
||||
.session-tab .tab-name {
|
||||
max-width: 50px;
|
||||
@@ -928,40 +949,41 @@ html.mobile-init .file-browser-panel {
|
||||
border-color: rgba(16, 185, 129, 0.5);
|
||||
}
|
||||
|
||||
/* Gemini mode colors on mobile */
|
||||
/* Gemini mode colors on mobile. Same `!important` rationale as the pi block below. */
|
||||
.btn-toolbar.btn-run.mode-gemini,
|
||||
.btn-toolbar.btn-run-gear.mode-gemini {
|
||||
background: #10243f;
|
||||
border-color: rgba(96, 165, 250, 0.3);
|
||||
color: #dbeafe;
|
||||
background: #10243f !important;
|
||||
border-color: rgba(96, 165, 250, 0.3) !important;
|
||||
color: #dbeafe !important;
|
||||
}
|
||||
|
||||
.btn-toolbar.btn-run.mode-gemini:active,
|
||||
.btn-toolbar.btn-run-gear.mode-gemini:active {
|
||||
background: #174ea6;
|
||||
border-color: rgba(96, 165, 250, 0.5);
|
||||
background: #174ea6 !important;
|
||||
border-color: rgba(96, 165, 250, 0.5) !important;
|
||||
}
|
||||
|
||||
/* Antigravity mode colors on mobile */
|
||||
/* Antigravity mode colors on mobile. Same `!important` rationale as the pi block below. */
|
||||
.btn-toolbar.btn-run.mode-antigravity,
|
||||
.btn-toolbar.btn-run-gear.mode-antigravity {
|
||||
background: #0b2b33;
|
||||
border-color: rgba(34, 211, 238, 0.3);
|
||||
color: #cffafe;
|
||||
background: #0b2b33 !important;
|
||||
border-color: rgba(34, 211, 238, 0.3) !important;
|
||||
color: #cffafe !important;
|
||||
}
|
||||
|
||||
.btn-toolbar.btn-run.mode-antigravity:active,
|
||||
.btn-toolbar.btn-run-gear.mode-antigravity:active {
|
||||
background: #0e7490;
|
||||
border-color: rgba(34, 211, 238, 0.5);
|
||||
background: #0e7490 !important;
|
||||
border-color: rgba(34, 211, 238, 0.5) !important;
|
||||
}
|
||||
|
||||
/* Pi mode colors on mobile.
|
||||
`!important` is load-bearing here, not noise: styles.css nests its skin rules
|
||||
inside `html:not([data-skin="og"])`, so a bare `.btn-toolbar.btn-run` in there
|
||||
resolves to (0,2,1) and outranks this (0,2,0) `.mode-pi` pair regardless of
|
||||
load order. The antigravity block right above omits it and is consequently
|
||||
dead on every non-og skin (i.e. on the default) — do not copy that. */
|
||||
inside `html:not([data-skin="og"])`, so a bare `.btn-toolbar.btn-run` or
|
||||
`.btn-toolbar.btn-run-gear` in there outranks this `.mode-pi` pair regardless
|
||||
of load order. Without it the gear half keeps the skin accent while the body
|
||||
takes the nested block's mode colour, so the split button renders two-tone
|
||||
(gemini and antigravity shipped that way until they got `!important` too). */
|
||||
.btn-toolbar.btn-run.mode-pi,
|
||||
.btn-toolbar.btn-run-gear.mode-pi {
|
||||
background: #33121f !important;
|
||||
@@ -2956,6 +2978,13 @@ html.mobile-init .file-browser-panel {
|
||||
color: var(--green);
|
||||
}
|
||||
|
||||
/* Accent, and none of the three above: a session watching work it started itself
|
||||
is not asking the user for anything, and red and yellow are what say it is. */
|
||||
.mobile-overview-pill--watching {
|
||||
border-color: var(--accent);
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
.mobile-overview-chevron {
|
||||
flex-shrink: 0;
|
||||
color: var(--text-muted);
|
||||
|
||||
@@ -512,8 +512,10 @@ class NotificationManager {
|
||||
}
|
||||
// Re-fit terminal and send resize to PTY so this client's dimensions win.
|
||||
// Fixes broken layout when switching between desktop and mobile on the same session.
|
||||
if (this.app?.fitAddon && this.app?.activeSessionId) {
|
||||
this.app.fitAddon.fit();
|
||||
// sendResize fits (floored) as its first synchronous step, so the bare
|
||||
// fit that used to precede it was both redundant and a chance to leave
|
||||
// xterm at the unfloored proposal (#464).
|
||||
if (this.app?.activeSessionId) {
|
||||
this.app.sendResize(this.app.activeSessionId);
|
||||
}
|
||||
}
|
||||
|
||||
+143
-8
@@ -92,6 +92,9 @@ Object.assign(CodemanApp.prototype, {
|
||||
_onRemoteSessionReconnected(data) {
|
||||
const id = this.getShortId(data.sessionId);
|
||||
this.showToast(`Remote session ${id} reconnected`, 'success');
|
||||
// A successful reattach (the wake flow's own, or the watcher's) means the host is
|
||||
// back: drop the "unreachable" banner without waiting out the poll interval.
|
||||
if (this.activeSessionId === data.sessionId) this._pollHostReachability?.(true);
|
||||
},
|
||||
|
||||
_onRemoteReconnectExhausted(data) {
|
||||
@@ -116,6 +119,16 @@ Object.assign(CodemanApp.prototype, {
|
||||
},
|
||||
|
||||
|
||||
// Wake-on-LAN from user input on a sleeping remote host (see remote-wake.ts).
|
||||
// ⚠️ The `remote:hostWaking` / `remote:hostWakeFailed` HANDLERS live in
|
||||
// `host-wake-ui.js`, which owns the banner state. They are NOT redefined here:
|
||||
// both files mix into `CodemanApp.prototype` and `host-wake-ui.js` is loaded
|
||||
// later, so a second definition would silently shadow the banner update (and the
|
||||
// toast would never fire — the exact silent no-op `sse-dispatch-table.test.ts`
|
||||
// exists to prevent, which cannot see shadowing). The toasts are shown from the
|
||||
// host-wake-ui handlers instead.
|
||||
|
||||
|
||||
// Bash tools
|
||||
_onBashToolStart(data) {
|
||||
this.handleBashToolStart(data.sessionId, data.tool);
|
||||
@@ -5263,8 +5276,10 @@ Object.assign(CodemanApp.prototype, {
|
||||
try { localStorage.removeItem('codeman-active-session'); } catch {}
|
||||
this.renderSessionTabs();
|
||||
this.renderMuxSessions();
|
||||
this.terminal.clear();
|
||||
this.terminal.reset();
|
||||
// Not a replay path, so the ordering hazard does not apply here — but
|
||||
// there is one way to clear this terminal and this is it, so a future
|
||||
// caller cannot copy a clear()+reset() pair out of here into one.
|
||||
this._resetTerminalForReplay();
|
||||
this.toast('All sessions and tmux killed', 'success');
|
||||
}
|
||||
} else {
|
||||
@@ -5273,8 +5288,7 @@ Object.assign(CodemanApp.prototype, {
|
||||
this.activeSessionId = null;
|
||||
try { localStorage.removeItem('codeman-active-session'); } catch {}
|
||||
this.renderSessionTabs();
|
||||
this.terminal.clear();
|
||||
this.terminal.reset();
|
||||
this._resetTerminalForReplay();
|
||||
this.toast('All tabs removed, tmux still running', 'info');
|
||||
}
|
||||
} catch (err) {
|
||||
@@ -5484,12 +5498,25 @@ Object.assign(CodemanApp.prototype, {
|
||||
return this.showToast(message, type);
|
||||
},
|
||||
|
||||
/**
|
||||
* `duration` defaults to 3000ms for every toast type. A message worth
|
||||
* reading rather than glancing at (e.g. "Session started on the native
|
||||
* backend — could not apply the custom endpoint: <the actual reason>")
|
||||
* passes an explicit `opts.duration: 0` at its own call site instead of
|
||||
* widening the default: this used to default every `error` toast to
|
||||
* sticky, and with no cap on `.toast-container` and no eviction, a
|
||||
* repeatedly failing path (a flapping SSE reconnect, a poll loop) stacked
|
||||
* sticky toasts off the bottom of the viewport where they could not be
|
||||
* read or dismissed. Every toast still gets an explicit close button
|
||||
* regardless of duration.
|
||||
*/
|
||||
showToast(message, type = 'info', opts = {}) {
|
||||
const { duration = 3000, action } = opts;
|
||||
const toast = document.createElement('div');
|
||||
toast.className = `toast toast-${type}`;
|
||||
|
||||
const msgSpan = document.createElement('span');
|
||||
msgSpan.className = 'toast-message';
|
||||
msgSpan.textContent = message;
|
||||
toast.appendChild(msgSpan);
|
||||
|
||||
@@ -5501,6 +5528,20 @@ Object.assign(CodemanApp.prototype, {
|
||||
toast.appendChild(btn);
|
||||
}
|
||||
|
||||
let dismissTimer = null;
|
||||
const dismiss = () => {
|
||||
if (dismissTimer) clearTimeout(dismissTimer);
|
||||
toast.classList.remove('show');
|
||||
setTimeout(() => toast.remove(), 200);
|
||||
};
|
||||
|
||||
const closeBtn = document.createElement('button');
|
||||
closeBtn.className = 'toast-close';
|
||||
closeBtn.textContent = '×';
|
||||
closeBtn.setAttribute('aria-label', 'Dismiss');
|
||||
closeBtn.onclick = (e) => { e.stopPropagation(); dismiss(); };
|
||||
toast.appendChild(closeBtn);
|
||||
|
||||
// Cache toast container reference
|
||||
if (!this._toastContainer) {
|
||||
this._toastContainer = document.querySelector('.toast-container');
|
||||
@@ -5514,10 +5555,104 @@ Object.assign(CodemanApp.prototype, {
|
||||
|
||||
requestAnimationFrame(() => toast.classList.add('show'));
|
||||
|
||||
setTimeout(() => {
|
||||
toast.classList.remove('show');
|
||||
setTimeout(() => toast.remove(), 200);
|
||||
}, duration);
|
||||
if (duration > 0) {
|
||||
dismissTimer = setTimeout(dismiss, duration);
|
||||
}
|
||||
|
||||
// Most callers ignore this — a handle exists for a long-running toast a caller needs
|
||||
// to update or dismiss itself once its own condition resolves (e.g. a "loading model"
|
||||
// toast a poll loop dismisses once the model reports ready).
|
||||
return { dismiss, setMessage: (text) => { msgSpan.textContent = text; } };
|
||||
},
|
||||
|
||||
/**
|
||||
* A prominent, screen-centred status banner — for the small set of messages that are
|
||||
* genuinely worth interrupting the eye for rather than living in the corner with every
|
||||
* other toast (currently: a custom-model session's "switching backends" and "loading
|
||||
* model" states, both of which can sit on screen for well over a minute and are easy to
|
||||
* mistake for nothing happening). Non-blocking (`pointer-events: none` on the wrapper,
|
||||
* restored only on the card) — an info banner is never a gate the user has to dismiss to
|
||||
* keep working. Only one is ever shown at a time (the DOM node is created once and
|
||||
* reused), which matches every current caller: each hands off to the next rather than
|
||||
* stacking.
|
||||
*
|
||||
* `opts.type` — `'info'` (default, spinner, no close button — a caller ends it itself via
|
||||
* `dismiss()`) or `'error'` (no spinner — nothing is in progress once this shows — with a
|
||||
* close button, since a sticky error the user cannot dismiss would just sit there). The
|
||||
* DOM is rebuilt fresh each call rather than patched, since which children exist differs
|
||||
* by type; `setMessage` still only ever touches the text node afterwards.
|
||||
*
|
||||
* `opts.onCancel` — when given (any type, but in practice only 'info': an 'error' banner
|
||||
* already has its own close button), renders a "Cancel" button that calls it on click.
|
||||
* The callback owns everything that follows (dismissing the banner, stopping whatever
|
||||
* loop this was showing progress for, closing a session it was for) — this helper only
|
||||
* renders the button and wires the click, the same "caller decides what cancel means"
|
||||
* split as `_confirmModelSwap`'s promise-resolving buttons.
|
||||
*/
|
||||
_showCenterStatus(message, opts = {}) {
|
||||
const { type = 'info', onCancel } = opts;
|
||||
let el = document.getElementById('customModelCenterStatus');
|
||||
if (!el) {
|
||||
el = document.createElement('div');
|
||||
el.id = 'customModelCenterStatus';
|
||||
document.body.appendChild(el);
|
||||
}
|
||||
// A pending hide from a PREVIOUS dismiss() (e.g. switchingToast.dismiss() right
|
||||
// before this same-origin call reopens the banner within its 200ms fade) must
|
||||
// never fire against the node this call is about to show — clear it before
|
||||
// reusing the shared DOM node, or the old timer hides the fresh banner ~200ms in.
|
||||
if (el._hideTimer) {
|
||||
clearTimeout(el._hideTimer);
|
||||
el._hideTimer = null;
|
||||
}
|
||||
el.className = `center-status-banner center-status-${type}`;
|
||||
el.innerHTML = '';
|
||||
const dismiss = () => {
|
||||
el.classList.remove('show');
|
||||
el._hideTimer = setTimeout(() => {
|
||||
el.hidden = true;
|
||||
el._hideTimer = null;
|
||||
}, 200);
|
||||
};
|
||||
if (type !== 'error') {
|
||||
const spinner = document.createElement('span');
|
||||
spinner.className = 'center-status-spinner';
|
||||
spinner.setAttribute('aria-hidden', 'true');
|
||||
el.appendChild(spinner);
|
||||
}
|
||||
const text = document.createElement('span');
|
||||
text.className = 'center-status-text';
|
||||
text.textContent = message;
|
||||
el.appendChild(text);
|
||||
if (type === 'error') {
|
||||
const closeBtn = document.createElement('button');
|
||||
closeBtn.className = 'center-status-close';
|
||||
closeBtn.textContent = '×';
|
||||
closeBtn.setAttribute('aria-label', 'Dismiss');
|
||||
closeBtn.onclick = (e) => {
|
||||
e.stopPropagation();
|
||||
dismiss();
|
||||
};
|
||||
el.appendChild(closeBtn);
|
||||
} else if (onCancel) {
|
||||
const cancelBtn = document.createElement('button');
|
||||
cancelBtn.className = 'center-status-cancel';
|
||||
cancelBtn.textContent = 'Cancel';
|
||||
cancelBtn.onclick = (e) => {
|
||||
e.stopPropagation();
|
||||
onCancel();
|
||||
};
|
||||
el.appendChild(cancelBtn);
|
||||
}
|
||||
el.hidden = false;
|
||||
requestAnimationFrame(() => el.classList.add('show'));
|
||||
return {
|
||||
dismiss,
|
||||
setMessage: (next) => {
|
||||
const t = el.querySelector('.center-status-text');
|
||||
if (t) t.textContent = next;
|
||||
},
|
||||
};
|
||||
},
|
||||
|
||||
|
||||
|
||||
@@ -155,10 +155,9 @@ Object.assign(CodemanApp.prototype, {
|
||||
if (xtermViewport && scrollTop !== undefined) {
|
||||
xtermViewport.scrollTop = scrollTop;
|
||||
}
|
||||
// Refit terminal to new container size
|
||||
if (this.terminal && this.fitAddon) {
|
||||
this.fitAddon.fit();
|
||||
}
|
||||
// Refit terminal to new container size. Through the one owner so the
|
||||
// floor that is reported to the PTY is also the one xterm holds (#464).
|
||||
this.syncTerminalGeometry?.();
|
||||
});
|
||||
},
|
||||
|
||||
|
||||
+962
-438
File diff suppressed because it is too large
Load Diff
@@ -14,6 +14,14 @@
|
||||
Object.assign(CodemanApp.prototype, {
|
||||
// Hooks (Claude Code hook events)
|
||||
_onHookIdlePrompt(data) {
|
||||
// A prompt the server opened ALREADY acknowledged raises no alert here. Today that
|
||||
// means the session is watching work it started itself (`acknowledgedReason` reads
|
||||
// "watching 1 monitor"), so the pane is quiet because the agent is waiting for its
|
||||
// own monitor, not for you. The item still exists and still shows in the drawer;
|
||||
// only the tab alert and the desktop notification are declined. A page that reloads
|
||||
// instead of receiving this event reaches the same conclusion from `acknowledgedAt`
|
||||
// in seedApprovals (approvals-ui.js).
|
||||
if (data.acknowledgedReason) return;
|
||||
// Always track pending hook - alert will show when switching away from session
|
||||
if (data.sessionId) {
|
||||
this.setPendingHook(data.sessionId, 'idle_prompt');
|
||||
@@ -395,11 +403,19 @@ Object.assign(CodemanApp.prototype, {
|
||||
document.getElementById('appSettingsShowUltracodeAgents').checked = settings.showUltracodeAgents ?? defaults.showUltracodeAgents ?? false;
|
||||
// Approvals Inbox: synced, default OFF (opt-in; only an explicit true enables).
|
||||
document.getElementById('appSettingsApprovalsInbox').checked = settings.approvalsInboxEnabled === true;
|
||||
// Custom Model Endpoint Profiles: synced, default OFF. The toggle governs both
|
||||
// the Run-menu picker's generated entries and this settings panel's visibility;
|
||||
// the endpoint list itself is server state, loaded on demand below.
|
||||
document.getElementById('appSettingsCustomModelEndpoints').checked = settings.customModelEndpointsEnabled === true;
|
||||
// Assigning .checked above does not fire onchange, so the body's visibility
|
||||
// (and its lazy load) needs an explicit sync on every open, not just a save.
|
||||
this.applyCustomModelEndpointsVisibility();
|
||||
// Read My Mind: synced, default OFF (opt-in; capture + prediction cost real tokens).
|
||||
document.getElementById('appSettingsReadMyMind').checked = settings.readMyMindEnabled === true;
|
||||
document.getElementById('appSettingsUltracodeFloatingWindows').checked =
|
||||
settings.ultracodeFloatingWindows ?? defaults.ultracodeFloatingWindows ?? false;
|
||||
document.getElementById('appSettingsShowMultiMonitorButton').checked = settings.showMultiMonitorButton ?? defaults.showMultiMonitorButton ?? false;
|
||||
document.getElementById('appSettingsShowSplitButton').checked = settings.showSplitButton ?? defaults.showSplitButton ?? false;
|
||||
document.getElementById('appSettingsShowPlanUsageLimits').checked = this.planUsageChipEnabled(settings);
|
||||
document.getElementById('appSettingsShowRedrawButton').checked = settings.showRedrawButton ?? defaults.showRedrawButton ?? false;
|
||||
// Phone overview home screen: only meaningful under 600px, so the row is
|
||||
@@ -438,6 +454,8 @@ Object.assign(CodemanApp.prototype, {
|
||||
// overwrites the system clipboard on a gesture the user may have meant only as
|
||||
// a way to read, so it is opt-in rather than a default anyone has to discover.
|
||||
document.getElementById('appSettingsAutoCopySelection').checked = settings.autoCopySelection === true;
|
||||
// Default ON, so an absent key reads as enabled rather than as off.
|
||||
document.getElementById('appSettingsCopyStripMargin').checked = settings.copyStripMargin !== false;
|
||||
document.getElementById('appSettingsTerminalFont').value = settings.terminalFontFamily || '';
|
||||
this.populateTerminalFontWeight(document.getElementById('appSettingsTerminalFontWeight'), settings.terminalFontWeight);
|
||||
this.populateTerminalFontWeight(
|
||||
@@ -509,6 +527,9 @@ Object.assign(CodemanApp.prototype, {
|
||||
document.getElementById('appSettingsNiceValue').value = niceSettings.niceValue ?? 10;
|
||||
// Model configuration (loaded from server)
|
||||
this.loadModelConfigForSettings();
|
||||
// Custom Model Endpoint Profiles' own load is gated on the toggle above (see
|
||||
// applyCustomModelEndpointsVisibility) — unlike model config, this GET is
|
||||
// pointless work with the feature off, so it is not fired unconditionally.
|
||||
// Notification settings
|
||||
const notifPrefs = this.notificationManager?.preferences || {};
|
||||
document.getElementById('appSettingsNotifEnabled').checked = notifPrefs.enabled ?? true;
|
||||
@@ -2106,9 +2127,11 @@ Object.assign(CodemanApp.prototype, {
|
||||
showSubagents: document.getElementById('appSettingsShowSubagents').checked,
|
||||
showUltracodeAgents: document.getElementById('appSettingsShowUltracodeAgents').checked,
|
||||
approvalsInboxEnabled: document.getElementById('appSettingsApprovalsInbox').checked,
|
||||
customModelEndpointsEnabled: document.getElementById('appSettingsCustomModelEndpoints').checked,
|
||||
readMyMindEnabled: document.getElementById('appSettingsReadMyMind').checked,
|
||||
ultracodeFloatingWindows: document.getElementById('appSettingsUltracodeFloatingWindows').checked,
|
||||
showMultiMonitorButton: document.getElementById('appSettingsShowMultiMonitorButton').checked,
|
||||
showSplitButton: document.getElementById('appSettingsShowSplitButton').checked,
|
||||
showPlanUsageLimits: document.getElementById('appSettingsShowPlanUsageLimits').checked,
|
||||
showRedrawButton: document.getElementById('appSettingsShowRedrawButton').checked,
|
||||
mobileOverviewEnabled: document.getElementById('appSettingsMobileOverview').checked,
|
||||
@@ -2124,6 +2147,7 @@ Object.assign(CodemanApp.prototype, {
|
||||
tunnelEnabled: document.getElementById('appSettingsTunnelEnabled').checked,
|
||||
localEchoEnabled: document.getElementById('appSettingsLocalEcho').checked,
|
||||
autoCopySelection: document.getElementById('appSettingsAutoCopySelection').checked,
|
||||
copyStripMargin: document.getElementById('appSettingsCopyStripMargin').checked,
|
||||
terminalFontFamily: document.getElementById('appSettingsTerminalFont').value.trim(),
|
||||
terminalFontWeight: this.readTerminalFontWeight(document.getElementById('appSettingsTerminalFontWeight')),
|
||||
terminalFontWeightBold: this.readTerminalFontWeight(
|
||||
@@ -2339,6 +2363,10 @@ Object.assign(CodemanApp.prototype, {
|
||||
showPlanUsageLimits: _pul,
|
||||
showAttachmentsButton: _ahb,
|
||||
showFileViewerButton: _fvb,
|
||||
// Desktop-only header button, per-device, and absent from
|
||||
// SettingsUpdateSchema (.strict()) — sending it 400s the whole PUT
|
||||
// (moving it into displayKeys alone is not the strip; this is).
|
||||
showSplitButton: _ssp,
|
||||
webglRendererEnabled: _wgl,
|
||||
terminalWheelLocalScrollback: _twls,
|
||||
// Copy-on-select. Per-device (clipboard access differs by device and by
|
||||
@@ -2346,6 +2374,10 @@ Object.assign(CodemanApp.prototype, {
|
||||
// and absent from SettingsUpdateSchema (.strict()), so sending it would
|
||||
// 400 the whole settings PUT.
|
||||
autoCopySelection: _acs,
|
||||
// What the clipboard gets is a property of what this device is looking
|
||||
// at, and the key is absent from SettingsUpdateSchema (.strict()), so
|
||||
// sending it would 400 the whole settings PUT.
|
||||
copyStripMargin: _csm,
|
||||
// Per-device by nature (the font must exist on the device) and absent
|
||||
// from SettingsUpdateSchema (.strict()) — sending it would 400 the PUT.
|
||||
terminalFontFamily: _tff,
|
||||
@@ -2487,6 +2519,209 @@ Object.assign(CodemanApp.prototype, {
|
||||
}
|
||||
},
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
// Custom Model Endpoint Profiles (docs/custom-model-endpoints-plan.md)
|
||||
//
|
||||
// CRUD against /api/model-endpoints, rendered into the Models settings section.
|
||||
// Deliberately its own load/save pair rather than folded into openAppSettings/
|
||||
// saveAppSettings: these are server-side infra records (like remote/docker
|
||||
// hosts), not a settings-payload field, so the app-settings-structure guard's
|
||||
// by-id contract does not apply to them — only the `customModelEndpointsEnabled`
|
||||
// toggle itself goes through that path.
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
|
||||
/**
|
||||
* Toggles the endpoint-management body's visibility to match the setting and,
|
||||
* turning it on, lazily loads the endpoint list. Assigning `.checked` (as the
|
||||
* settings load path does) fires no `change` event, so this must be called
|
||||
* explicitly on open as well as wired to the checkbox's own onchange — a
|
||||
* gate that only worked one of those two ways would show a stale "off"
|
||||
* body right after opening, or a stale "on" one right after saving it off.
|
||||
* With the feature off the body is a list of controls that do nothing, so it
|
||||
* is hidden entirely rather than shown disabled.
|
||||
*/
|
||||
applyCustomModelEndpointsVisibility() {
|
||||
const enabled = document.getElementById('appSettingsCustomModelEndpoints').checked;
|
||||
const body = document.getElementById('customModelEndpointsBody');
|
||||
if (body) body.style.display = enabled ? '' : 'none';
|
||||
if (enabled) this.loadCustomModelEndpointsForSettings();
|
||||
else this.closeCustomModelHostEditor();
|
||||
this._applyCustomModelAdminGate();
|
||||
},
|
||||
|
||||
/**
|
||||
* Endpoint writes are admin-only in multi-user mode (custom-model-routes.ts),
|
||||
* and GET already answers a non-admin with an empty list, which hides every
|
||||
* per-row Edit/Discover/Delete button on its own. The "+ Add endpoint" button
|
||||
* has no row to hide behind, so it needs its own gate — otherwise a non-admin
|
||||
* can open the form, fill it in, and get a 403 toast on Save. Wired to the
|
||||
* `codeman:me` event (admin-ui.js) as well as called from
|
||||
* applyCustomModelEndpointsVisibility(), because `window.__codemanUser`'s
|
||||
* real role can resolve AFTER settings have already been opened once.
|
||||
*/
|
||||
_applyCustomModelAdminGate() {
|
||||
const addBtn = document.getElementById('customModelHostAddBtn');
|
||||
if (!addBtn) return;
|
||||
const me = window.__codemanUser || {};
|
||||
const blocked = me.multiUser && me.role !== 'admin';
|
||||
addBtn.style.display = blocked ? 'none' : '';
|
||||
},
|
||||
|
||||
async loadCustomModelEndpointsForSettings() {
|
||||
// GET /api/model-endpoints wraps its body in the { success, data } envelope
|
||||
// like every other /api route (server.ts's preSerialization hook applies to
|
||||
// arrays too) — _apiJson() unwraps it. A raw fetch().json() here would
|
||||
// silently see the envelope object instead of the array and this panel
|
||||
// would read as "No endpoints yet" forever, even with endpoints saved.
|
||||
const hosts = await this._apiJson('/api/model-endpoints');
|
||||
this._customModelHosts = Array.isArray(hosts) ? hosts : [];
|
||||
this.renderCustomModelHostsList();
|
||||
},
|
||||
|
||||
renderCustomModelHostsList() {
|
||||
const list = document.getElementById('customModelHostsList');
|
||||
if (!list) return;
|
||||
const hosts = this._customModelHosts || [];
|
||||
if (hosts.length === 0) {
|
||||
list.innerHTML = '<p class="set-group-hint">No endpoints yet. Add one below to point a harness at a local or cloud OpenAI-compatible server.</p>';
|
||||
return;
|
||||
}
|
||||
list.innerHTML = hosts
|
||||
.map((h) => {
|
||||
const modelCount = (h.models || []).length;
|
||||
const modelSummary = modelCount === 0
|
||||
? 'No models discovered yet'
|
||||
: `${modelCount} model${modelCount === 1 ? '' : 's'}${h.defaultModelId ? ` · default: ${escapeHtml(h.defaultModelId)}` : ' · no default set'}`;
|
||||
// escapeHtml(JSON.stringify(h.id)) — not JSON.stringify(h.id) alone —
|
||||
// because JSON.stringify's own double quotes would otherwise terminate
|
||||
// this double-quoted attribute at the first one, and everything after
|
||||
// parses as raw tag content rather than the rest of the quoted string.
|
||||
// Same idiom as deleteCase's onclick in session-ui.js. h.id is
|
||||
// regex-constrained server-side (safe either way) but the pattern must
|
||||
// match everywhere it is used, including where the argument is not.
|
||||
const idArg = escapeHtml(JSON.stringify(h.id));
|
||||
return `
|
||||
<div class="set-row" data-endpoint-id="${escapeHtml(h.id)}">
|
||||
<div class="set-row-text">
|
||||
<span class="set-row-label">${escapeHtml(h.label)}</span>
|
||||
<span class="set-row-desc">${escapeHtml(h.baseUrl)} — ${modelSummary}</span>
|
||||
</div>
|
||||
<div class="set-row-actions">
|
||||
<button type="button" class="btn-toolbar btn-sm" onclick="app.discoverCustomModelHostModels(${idArg})">Discover</button>
|
||||
<button type="button" class="btn-toolbar btn-sm" onclick="app.openCustomModelHostEditor(${idArg})">Edit</button>
|
||||
<button type="button" class="btn-toolbar btn-danger btn-sm" onclick="app.deleteCustomModelHost(${idArg})">Delete</button>
|
||||
</div>
|
||||
</div>`;
|
||||
})
|
||||
.join('');
|
||||
},
|
||||
|
||||
/** Opens the inline add/edit form. Pass no id to add a new endpoint. */
|
||||
openCustomModelHostEditor(hostId) {
|
||||
const host = hostId ? (this._customModelHosts || []).find((h) => h.id === hostId) : null;
|
||||
this._editingCustomModelHostId = host ? host.id : null;
|
||||
document.getElementById('customModelHostEditorTitle').textContent = host ? `Edit ${host.label}` : 'Add endpoint';
|
||||
document.getElementById('customModelHostId').value = host?.id || '';
|
||||
document.getElementById('customModelHostId').disabled = !!host; // id is immutable once created
|
||||
document.getElementById('customModelHostLabel').value = host?.label || '';
|
||||
document.getElementById('customModelHostBaseUrl').value = host?.baseUrl || '';
|
||||
document.getElementById('customModelHostApiKey').value = ''; // the server never returns the real value (apiKeySet is a bool)
|
||||
document.getElementById('customModelHostApiKey').placeholder = host?.apiKeySet ? '•••••••• (unchanged if left blank)' : '';
|
||||
document.getElementById('customModelHostAuthStyle').value = host?.authStyle || 'bearer';
|
||||
this._populateCustomModelDefaultSelect(host);
|
||||
document.getElementById('customModelHostEditor').style.display = '';
|
||||
},
|
||||
|
||||
closeCustomModelHostEditor() {
|
||||
document.getElementById('customModelHostEditor').style.display = 'none';
|
||||
this._editingCustomModelHostId = null;
|
||||
},
|
||||
|
||||
_populateCustomModelDefaultSelect(host) {
|
||||
const select = document.getElementById('customModelHostDefaultModel');
|
||||
const models = host?.models || [];
|
||||
select.innerHTML =
|
||||
'<option value="">No default (picker uses the first discovered model)</option>' +
|
||||
models.map((m) => `<option value="${escapeHtml(m)}">${escapeHtml(m)}</option>`).join('');
|
||||
select.value = host?.defaultModelId || '';
|
||||
select.disabled = models.length === 0;
|
||||
},
|
||||
|
||||
async saveCustomModelHostFromEditor() {
|
||||
const id = document.getElementById('customModelHostId').value.trim();
|
||||
const label = document.getElementById('customModelHostLabel').value.trim();
|
||||
const baseUrl = document.getElementById('customModelHostBaseUrl').value.trim();
|
||||
const apiKeyInput = document.getElementById('customModelHostApiKey').value;
|
||||
const authStyle = document.getElementById('customModelHostAuthStyle').value;
|
||||
const defaultModelId = document.getElementById('customModelHostDefaultModel').value || undefined;
|
||||
if (!id || !label || !baseUrl) {
|
||||
this.showToast('Id, label and base URL are all required', 'warning');
|
||||
return;
|
||||
}
|
||||
const editing = this._editingCustomModelHostId;
|
||||
// PUT (server-side) treats an absent apiKey as "keep the stored one" — the
|
||||
// browser never holds the real value to resend deliberately unchanged (see
|
||||
// openCustomModelHostEditor and custom-model-routes.ts's applyStoredApiKey),
|
||||
// so a blank field here means omitting the key entirely, not resending
|
||||
// something we do not have. models/lastDiscoveredAt DO still need
|
||||
// re-sending: PUT replaces the whole record, and this cached copy still
|
||||
// carries both (only apiKey is redacted from what GET hands back).
|
||||
const existing = editing ? (this._customModelHosts || []).find((h) => h.id === editing) : null;
|
||||
const body = {
|
||||
id,
|
||||
label,
|
||||
baseUrl,
|
||||
authStyle,
|
||||
defaultModelId,
|
||||
apiKey: apiKeyInput || undefined,
|
||||
models: existing?.models,
|
||||
lastDiscoveredAt: existing?.lastDiscoveredAt,
|
||||
};
|
||||
try {
|
||||
const res = await fetch(editing ? `/api/model-endpoints/${encodeURIComponent(editing)}` : '/api/model-endpoints', {
|
||||
method: editing ? 'PUT' : 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
const data = await res.json();
|
||||
if (!data.success) {
|
||||
this.showToast(data.error || 'Failed to save endpoint', 'error');
|
||||
return;
|
||||
}
|
||||
this.showToast(editing ? 'Endpoint updated' : 'Endpoint added', 'success');
|
||||
this.closeCustomModelHostEditor();
|
||||
await this.loadCustomModelEndpointsForSettings();
|
||||
} catch (err) {
|
||||
this.showToast(`Failed to save endpoint: ${err.message}`, 'error');
|
||||
}
|
||||
},
|
||||
|
||||
async discoverCustomModelHostModels(hostId) {
|
||||
this.showToast('Discovering models…', 'info');
|
||||
try {
|
||||
const res = await fetch(`/api/model-endpoints/${encodeURIComponent(hostId)}/discover-models`, { method: 'POST' });
|
||||
const data = await res.json();
|
||||
if (!data.success) {
|
||||
this.showToast(data.error || 'Discovery failed', 'error');
|
||||
return;
|
||||
}
|
||||
this.showToast(`Found ${data.data.models.length} model${data.data.models.length === 1 ? '' : 's'}`, 'success');
|
||||
await this.loadCustomModelEndpointsForSettings();
|
||||
} catch (err) {
|
||||
this.showToast(`Discovery failed: ${err.message}`, 'error');
|
||||
}
|
||||
},
|
||||
|
||||
async deleteCustomModelHost(hostId) {
|
||||
const host = (this._customModelHosts || []).find((h) => h.id === hostId);
|
||||
if (!confirm(`Delete endpoint "${host?.label || hostId}"? Any session currently pointed at it keeps running until cleared.`)) return;
|
||||
try {
|
||||
await fetch(`/api/model-endpoints/${encodeURIComponent(hostId)}`, { method: 'DELETE' });
|
||||
await this.loadCustomModelEndpointsForSettings();
|
||||
} catch (err) {
|
||||
this.showToast(`Failed to delete endpoint: ${err.message}`, 'error');
|
||||
}
|
||||
},
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
// Visibility Settings & Device-Specific Defaults
|
||||
@@ -2528,6 +2763,7 @@ Object.assign(CodemanApp.prototype, {
|
||||
showUltracodeAgents: false,
|
||||
ultracodeFloatingWindows: false,
|
||||
showMultiMonitorButton: false,
|
||||
showSplitButton: false,
|
||||
// Desktop defaults this ON (see planUsageChipEnabled); handhelds keep it
|
||||
// OFF so the phone header stays minimal and the mobile-header-buttons
|
||||
// policy guard keeps passing.
|
||||
@@ -2733,6 +2969,13 @@ Object.assign(CodemanApp.prototype, {
|
||||
multiMonitorBtn.classList.toggle('btn-multimonitor--hidden', !showMultiMonitorButton);
|
||||
}
|
||||
|
||||
// Split button — hidden by default, and hard-gated to desktop widths
|
||||
// regardless of the setting (window.CodemanSplitPane.SPLIT_PANE_MIN_WIDTH,
|
||||
// matching HOME_SESSIONS_MIN_WIDTH's JS-check + media-query-backstop
|
||||
// pattern — the CSS in styles.css is the backstop, this is the check).
|
||||
const showSplitButton = settings.showSplitButton ?? defaults.showSplitButton ?? false;
|
||||
this._applySplitButtonVisibility?.(showSplitButton);
|
||||
|
||||
// Ultracode/Workflow agents launcher — hidden by default; reveal when enabled.
|
||||
// Marker class only (base is display:inline-flex !important) so it's auto-excluded
|
||||
// from the mobile-header-buttons-policy guard.
|
||||
@@ -2869,7 +3112,7 @@ Object.assign(CodemanApp.prototype, {
|
||||
const changed = orientationChanged || previousDetail !== detail || previousSort !== sort;
|
||||
if (orientationChanged) {
|
||||
this.updateTabOverflowMode?.();
|
||||
if (!settleRailWidth) this.fitAddon?.fit();
|
||||
if (!settleRailWidth) this.syncTerminalGeometry?.();
|
||||
}
|
||||
// applyTabWrapSettings() is the ONE owner of tabs-show-folder and is
|
||||
// rail-aware, so it has to run AFTER the two attributes above — the
|
||||
@@ -3144,11 +3387,12 @@ Object.assign(CodemanApp.prototype, {
|
||||
'terminalFontFamily', 'terminalFontWeight', 'terminalFontWeightBold',
|
||||
'language',
|
||||
'terminalWheelLocalScrollback',
|
||||
'autoCopySelection',
|
||||
'autoCopySelection', 'copyStripMargin',
|
||||
'showSessionButton', 'showAwayDigestButton', 'showCronButton',
|
||||
'showTabDetachButton',
|
||||
'mobileOverviewEnabled',
|
||||
'sessionLineageLines',
|
||||
'showSplitButton',
|
||||
]);
|
||||
// The plan-usage chip is a PER-DEVICE display setting (desktop default ON,
|
||||
// handheld default OFF): desktop can show it while mobile stays hidden. Drop
|
||||
@@ -3543,3 +3787,15 @@ Object.assign(CodemanApp.prototype, {
|
||||
this.subagentPanelVisible = false;
|
||||
},
|
||||
});
|
||||
|
||||
// window.__codemanUser's real role can resolve after settings have already been
|
||||
// opened once (admin-ui.js fetches /api/me asynchronously and dispatches this on
|
||||
// arrival), so the Custom Model Endpoints admin gate needs to be re-applied when
|
||||
// it does, not just when the modal opens. Optional chaining on addEventListener
|
||||
// itself: several frontend tests (run-mode-ui.test.ts) load this file into a vm
|
||||
// context with a minimal fake `document` that has no event-target methods at
|
||||
// all, and a module-level statement that throws there fails the whole file's
|
||||
// evaluation, not just this feature.
|
||||
document.addEventListener?.('codeman:me', () => {
|
||||
window.app?._applyCustomModelAdminGate?.();
|
||||
});
|
||||
|
||||
+683
-5
@@ -675,6 +675,12 @@ body.tab-rail-resizing * {
|
||||
user-select: none !important;
|
||||
}
|
||||
|
||||
body.split-pane-resizing,
|
||||
body.split-pane-resizing * {
|
||||
cursor: col-resize !important;
|
||||
user-select: none !important;
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.tab-rail,
|
||||
.tab-rail-resize-handle {
|
||||
@@ -2282,6 +2288,39 @@ html[data-tab-orientation='vertical'] .tab-rail .session-tab .tab-name-prefix {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
/* Split-pane header button: hidden by default (opt-in via App Settings →
|
||||
Header & Panels → Header buttons → Split, `showSplitButton`). Pure
|
||||
client-side toggle applied by applyHeaderVisibilitySettings(). */
|
||||
.btn-split--hidden {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
/* While a split is open, the button's own click closes it instead of opening
|
||||
the picker — the accent fill is what tells the user which of its two
|
||||
behaviours the next click will get (aria-pressed carries the same state
|
||||
for assistive tech). */
|
||||
.btn-icon-header.btn-split.split-open {
|
||||
background: var(--accent);
|
||||
color: var(--accent-ink);
|
||||
}
|
||||
|
||||
.btn-icon-header.btn-split.split-open:hover {
|
||||
background: var(--accent);
|
||||
color: var(--accent-ink);
|
||||
}
|
||||
|
||||
/* Split-pane hard desktop gate, independent of the showSplitButton setting:
|
||||
two 240px min-width panes plus the divider need ~486px, the divider has no
|
||||
touch handlers, and mobile.css only loads up to 1023px so it cannot cover
|
||||
the 1024-1179px tablet range on its own. Same threshold and reasoning as
|
||||
HOME_SESSIONS_MIN_WIDTH (home-sessions.js) / SPLIT_PANE_MIN_WIDTH
|
||||
(constants.js) — keep the three in sync. */
|
||||
@media (max-width: 1179px) {
|
||||
.btn-icon-header.btn-split {
|
||||
display: none !important;
|
||||
}
|
||||
}
|
||||
|
||||
.btn-icon-header.btn-settings {
|
||||
width: 30px;
|
||||
height: 30px;
|
||||
@@ -2394,6 +2433,51 @@ html[data-tab-orientation='vertical'] .tab-rail .session-tab .tab-name-prefix {
|
||||
display: inline-flex;
|
||||
}
|
||||
|
||||
/* The agent inside the pane has exited (Ark0N/Codeman#446). Unlike the badge
|
||||
above this one carries text the server computed, so it is added and removed
|
||||
by the renderer rather than toggled by a class. */
|
||||
.session-tab .tab-exited-badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
flex: 0 0 auto;
|
||||
font-size: 0.55rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.04em;
|
||||
text-transform: uppercase;
|
||||
padding: 1px 4px;
|
||||
margin-left: 4px;
|
||||
border-radius: 3px;
|
||||
white-space: nowrap;
|
||||
background: rgba(248, 113, 113, 0.16);
|
||||
color: var(--danger);
|
||||
}
|
||||
|
||||
/* An exited agent quiets the status dot, which otherwise reports `status` and
|
||||
so stays green (or pulses, for a session the exit caught mid-turn) beside a
|
||||
badge saying the agent is gone. `status` itself is deliberately untouched —
|
||||
`error` is the PTY-exit breaker's value and makes the browser offer a restart
|
||||
— so this is a rendering rule only, and it matches `.tab-status.ended`.
|
||||
|
||||
⚠ Three states are excluded BY HAND rather than by cascade, the same way the
|
||||
rich-rail dot rules below do it: a dot turning red or yellow because a
|
||||
session is blocked on a human outranks "the agent exited", and this selector
|
||||
is specific enough (0,5,0) to have beaten those (0,3,0) rules otherwise. The
|
||||
third is `.tab-status.error` (0,2,0), which is the PTY-exit breaker's state
|
||||
and the one the browser answers with a "restart it?" confirm — the same
|
||||
argument that protects the two alert classes, and it reaches the dot rather
|
||||
than the tab, which is why the `:not()` sits on `.tab-status` here and on
|
||||
`.session-tab` there. The spinner ring is a pseudo-element the skin blocks
|
||||
cannot reach, so it needs hiding explicitly rather than by unsetting the
|
||||
animation. */
|
||||
.session-tab.tab-agent-exited:not(.tab-alert-action):not(.tab-alert-idle) .tab-status:not(.error) {
|
||||
background: var(--text-muted);
|
||||
opacity: 0.5;
|
||||
animation: none;
|
||||
}
|
||||
.session-tab.tab-agent-exited:not(.tab-alert-action):not(.tab-alert-idle) .tab-status:not(.error)::after {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* ===== Tab action icons: active tab only =================================
|
||||
All three per-tab icons live in a .tab-actions wrapper (in flow; it adds
|
||||
no width of its own while the children keep the width:0 collapse above).
|
||||
@@ -2547,6 +2631,7 @@ body.solo-mode .header-system-stats,
|
||||
body.solo-mode .header-tokens,
|
||||
body.solo-mode .btn-notifications,
|
||||
body.solo-mode .btn-multimonitor,
|
||||
body.solo-mode .btn-split,
|
||||
body.solo-mode .header-plan-usage,
|
||||
/* A solo window shows ONE session and has no tab strip to put restored ones in,
|
||||
so offering to rebuild a list of them there is an offer it cannot show the
|
||||
@@ -3777,6 +3862,37 @@ body.solo-mode .btn-lifecycle-log {
|
||||
background: transparent !important;
|
||||
}
|
||||
|
||||
/* The terminal is wider than the box that shows it (issue #464). Two causes,
|
||||
one affordance: another device holds the sizing claim so this terminal has
|
||||
adopted a width it did not ask for, or the 40-column floor has widened it
|
||||
past a narrow container. Either way the text is rendered CORRECTLY and simply
|
||||
does not fit, and without horizontal reach the right-hand columns sit behind
|
||||
.terminal-container's clip with no gesture that can get to them — measured at
|
||||
360px, font 24: 218px of the pane, 38% of it, unreachable.
|
||||
Present only while that is true; _syncTerminalOverflowAffordance() owns it. */
|
||||
.terminal-container.term-overflows-x {
|
||||
/* ⚠️ BOTH axes, explicitly. mobile.css loads after this file and sets
|
||||
`.terminal-container { overflow: visible }` — a bare `overflow-x` would
|
||||
then leave overflow-y computing to `auto` (CSS promotes a `visible` paired
|
||||
with a non-visible axis), handing the browser a vertical scroll container
|
||||
the terminal's own touch handler does not know about. */
|
||||
overflow-x: auto;
|
||||
overflow-y: hidden;
|
||||
}
|
||||
/* xterm's own element is width:100% above, so the container would see no
|
||||
overflow to scroll even though .xterm-screen is wider than both. */
|
||||
.terminal-container.term-overflows-x .xterm {
|
||||
width: max-content;
|
||||
min-width: 100%;
|
||||
}
|
||||
/* ⚠️ NO `touch-action: pan-x` here, deliberately. The terminal's own touchmove
|
||||
handler pans this container (see `canPanHorizontally` in terminal-ui.js),
|
||||
because `touchstart` preventDefault()s every 'content' tap and that cancels
|
||||
a native pan before it can start. Granting the browser pan-x as well would
|
||||
double-handle the gestures where that preventDefault does NOT run — a tap on
|
||||
a scrolled-up viewport — moving the pane twice for one finger. The
|
||||
`touch-action: none` the other rules set is what keeps JS the sole owner. */
|
||||
|
||||
/* Touch devices: prevent browser from claiming the touch gesture before
|
||||
our JS touchmove handler fires. Without this, the browser starts native
|
||||
scrolling during the first few px of finger travel and ignores our
|
||||
@@ -6807,6 +6923,88 @@ body.touch-device .terminal-container .xterm .xterm-helper-textarea {
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
/* Custom Model Endpoint Profiles' "which model" picker: same bounded-height +
|
||||
scrollable-body shape as .modal-lg above, scoped by id rather than added to
|
||||
.modal-sm itself (three other modals share that class for short, fixed
|
||||
content and do not need a height cap). Without this the modal had no
|
||||
max-height at all, so an endpoint with many discovered models grew the
|
||||
dialog past the viewport with nothing to scroll — "the whole page" and
|
||||
"the list is truncated" turned out to be one and the same bug. `min(70vh,
|
||||
520px)` scales with the monitor (a phone gets 70% of its height, a 4K
|
||||
display never gets a needlessly tall dialog) rather than a fixed value
|
||||
that would be wrong at one end or the other. */
|
||||
#customModelPickModal .modal-content {
|
||||
max-height: min(70vh, 520px);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
#customModelPickModal .modal-body {
|
||||
overflow-y: auto;
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
/* The picker's row tags ("Currently loaded", "Last used", "Default") reuse the
|
||||
settings surface's .set-scope pill, but that pill is styled only inside
|
||||
:is(#appSettingsModal, #sessionOptionsModal, #createCaseModal) (see the
|
||||
settings-surface block), so in here it rendered as plain body text and
|
||||
"qwen3 Currently loaded" read as one model name. Same pill, same skin
|
||||
tokens (never a hardcoded colour: --text-muted / --border are what each
|
||||
html[data-skin] block redefines), and nothing about the modal's layout or
|
||||
z-index. The row is a flex container, so the pills sit after the name
|
||||
with the row's own gap and the trailing whitespace collapses. */
|
||||
#customModelPickModal .set-scope {
|
||||
font-size: 0.52rem;
|
||||
letter-spacing: 0.06em;
|
||||
text-transform: uppercase;
|
||||
border-radius: 4px;
|
||||
padding: 1px 4px;
|
||||
white-space: nowrap;
|
||||
color: var(--text-muted);
|
||||
border: 1px solid var(--border);
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
/* Custom Model Endpoint Profiles: llama-swap model-swap confirmation — replaces a native
|
||||
confirm() popup (docs/custom-model-endpoints-plan.md) so it looks and feels like the
|
||||
rest of the app instead of a browser chrome dialog. Shares the context-window-too-small
|
||||
modal's fixes below since both can appear mid-launch, in the same spot, for the same
|
||||
reason — including this rule itself: there is no bare `.modal-footer` base style
|
||||
anywhere in this file, and `.btn-toolbar` is `display: flex` (a block-level flex
|
||||
container with no explicit `inline-flex`), so with no row layout of its own each
|
||||
button took its own full-width line and the two stacked instead of sitting side by
|
||||
side. Centred rather than flex-end per feedback — a two-button Cancel/confirm footer
|
||||
reads better centred than pinned to one edge. */
|
||||
#customModelSwapConfirmModal .modal-footer,
|
||||
#customModelContextWarningModal .modal-footer {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
gap: 0.5rem;
|
||||
padding: 0.75rem 1rem;
|
||||
border-top: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
/* Both dialogs can appear while the centred llama-swap status banner (10001, see
|
||||
.center-status-banner) is still on screen — right after "Claude started — switching
|
||||
to llama-swap…" — and .modal's own z-index (1000) sat well under it, so the dialog
|
||||
rendered fully hidden behind the banner (confirmed live, reported against the
|
||||
context-window one but structurally identical for the swap-confirm modal too). */
|
||||
#customModelSwapConfirmModal,
|
||||
#customModelContextWarningModal {
|
||||
z-index: 10010;
|
||||
}
|
||||
|
||||
/* Both messages ARE the modal's whole explanatory content, not a one-line caption under
|
||||
a form field, so .form-hint's 0.65rem caption size (right for what it was designed for)
|
||||
read as illegibly small here, worst on the multi-sentence context-window explanation. */
|
||||
#customModelSwapConfirmMessage,
|
||||
#customModelContextWarningMessage {
|
||||
font-size: 0.85rem;
|
||||
line-height: 1.5;
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
|
||||
/* Mobile Case Picker - Base Styles */
|
||||
.mobile-case-picker-sheet {
|
||||
@@ -8445,6 +8643,9 @@ kbd {
|
||||
}
|
||||
|
||||
.toast {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
background: var(--bg-card);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 6px;
|
||||
@@ -8456,6 +8657,7 @@ kbd {
|
||||
opacity: 0;
|
||||
transition: all 0.2s ease;
|
||||
pointer-events: auto;
|
||||
max-width: 420px;
|
||||
}
|
||||
|
||||
.toast.show {
|
||||
@@ -8463,6 +8665,149 @@ kbd {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.toast-message {
|
||||
flex: 1;
|
||||
/* A sticky toast (showToast's opts.duration: 0) can carry a longer, specific
|
||||
message — let it wrap instead of clipping. */
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
/* Every toast gets one, sticky or not: a sticky toast with no way to close it
|
||||
would just accumulate on screen across repeated failures. */
|
||||
.toast-close {
|
||||
flex-shrink: 0;
|
||||
background: none;
|
||||
border: none;
|
||||
color: inherit;
|
||||
opacity: 0.6;
|
||||
font-size: 1.1rem;
|
||||
line-height: 1;
|
||||
padding: 0 0.15rem;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.toast-close:hover {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
/* Custom Model Endpoint Profiles: the "switching backends" / "loading model" states
|
||||
(docs/custom-model-endpoints-plan.md) — a small set of messages prominent and
|
||||
screen-centred rather than corner toasts, since they can sit on screen for well
|
||||
over a minute (a real llama-swap model load) and are easy to mistake for nothing
|
||||
happening. Non-blocking: `pointer-events: none` on the wrapper (no backdrop, no
|
||||
click-catcher) with `auto` restored only on the card itself, purely so the text
|
||||
inside remains selectable — there is nothing to click to dismiss it early. */
|
||||
.center-status-banner {
|
||||
position: fixed;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
transform: translate(-50%, -50%) scale(0.96);
|
||||
z-index: 10001;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
background: var(--bg-card);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 10px;
|
||||
padding: 1rem 1.5rem;
|
||||
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.4);
|
||||
font-size: 0.95rem;
|
||||
font-weight: 500;
|
||||
color: var(--text);
|
||||
max-width: min(90vw, 460px);
|
||||
text-align: left;
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
transition:
|
||||
opacity 0.2s ease,
|
||||
transform 0.2s ease;
|
||||
}
|
||||
|
||||
.center-status-banner.show {
|
||||
opacity: 1;
|
||||
transform: translate(-50%, -50%) scale(1);
|
||||
}
|
||||
|
||||
/* `hidden` has to be re-asserted over the `display: flex` above, or `dismiss()`
|
||||
setting `el.hidden = true` does nothing (same trap as `.home-sessions[hidden]`
|
||||
below): the card stays laid out at `opacity: 0` with its text/cancel/close
|
||||
children still `pointer-events: auto`, an invisible click-blocker dead centre
|
||||
over the terminal until the page reloads. */
|
||||
.center-status-banner[hidden] {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.center-status-spinner {
|
||||
flex-shrink: 0;
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
border-radius: 50%;
|
||||
border: 2px solid var(--border);
|
||||
border-top-color: var(--accent, var(--text));
|
||||
animation: center-status-spin 0.8s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes center-status-spin {
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
.center-status-text {
|
||||
flex: 1;
|
||||
pointer-events: auto;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
/* Error variant: the load didn't finish in time — nothing is "in progress" anymore (no
|
||||
spinner), and since this one doesn't dismiss itself, it needs a close button the user
|
||||
can actually click, so pointer-events is restored here too (see the wrapper's own
|
||||
comment on why that's `none` by default). */
|
||||
.center-status-error {
|
||||
border-color: rgba(239, 68, 68, 0.5);
|
||||
}
|
||||
|
||||
.center-status-close {
|
||||
flex-shrink: 0;
|
||||
pointer-events: auto;
|
||||
background: none;
|
||||
border: none;
|
||||
color: inherit;
|
||||
opacity: 0.6;
|
||||
font-size: 1.2rem;
|
||||
line-height: 1;
|
||||
padding: 0 0.15rem;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.center-status-close:hover {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
/* The Cancel button on an 'info' banner (e.g. the model-loading banner) — a real button
|
||||
rather than the bare "×" close glyph above, since "Cancel" is an action with a
|
||||
consequence (the caller's onCancel closes a session), not a plain dismiss. */
|
||||
.center-status-cancel {
|
||||
flex-shrink: 0;
|
||||
pointer-events: auto;
|
||||
background: none;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 6px;
|
||||
color: inherit;
|
||||
opacity: 0.75;
|
||||
font-size: 0.8rem;
|
||||
font-weight: 500;
|
||||
padding: 0.25rem 0.6rem;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.center-status-cancel:hover {
|
||||
opacity: 1;
|
||||
border-color: var(--text-muted, var(--border));
|
||||
}
|
||||
|
||||
.toast-success { border-color: rgba(34, 197, 94, 0.4); }
|
||||
.toast-error { border-color: rgba(239, 68, 68, 0.4); }
|
||||
.toast-warning { border-color: rgba(234, 179, 8, 0.4); }
|
||||
@@ -12113,6 +12458,17 @@ kbd {
|
||||
color: var(--text-dim);
|
||||
font-size: 0.68rem;
|
||||
}
|
||||
/* Why this card is not blinking at anyone: the session is watching work it
|
||||
started itself. Accent, like the watching badge on a session row, and never
|
||||
the red or yellow that mean a human is needed. */
|
||||
.approval-quiet {
|
||||
margin-bottom: 6px;
|
||||
color: var(--accent);
|
||||
font-size: 0.68rem;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.approval-summary {
|
||||
color: var(--text);
|
||||
font-size: 0.76rem;
|
||||
@@ -13405,6 +13761,14 @@ body.touch-device.cjk-input-visible .main {
|
||||
background: var(--control-bg-hover);
|
||||
}
|
||||
|
||||
.accessory-btn-compose.has-draft::after {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
background: var(--yellow);
|
||||
border-radius: 50%;
|
||||
content: '';
|
||||
}
|
||||
|
||||
.accessory-btn svg {
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
@@ -13525,6 +13889,64 @@ body.touch-device.cjk-input-visible .main {
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.prompt-composer-overlay {
|
||||
overflow-y: auto;
|
||||
padding: min(15dvh, 72px) 0 calc(12px + env(safe-area-inset-bottom));
|
||||
}
|
||||
|
||||
.prompt-composer-dialog {
|
||||
max-width: 560px;
|
||||
/* The overlay's top inset and bottom gutter, plus the strip a folding device
|
||||
reserves below the dialog (0px everywhere else; see the fold rules at the
|
||||
end of this file). */
|
||||
max-height: calc(100dvh - min(15dvh, 72px) - 12px - env(safe-area-inset-bottom) - var(--fold-block-end));
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.prompt-composer-header {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
margin: 2px 2px 10px;
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.prompt-composer-header span {
|
||||
color: var(--text-dim);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.prompt-composer-textarea {
|
||||
min-height: min(34dvh, 240px);
|
||||
max-height: 50dvh;
|
||||
resize: vertical;
|
||||
}
|
||||
|
||||
.prompt-composer-actions {
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.prompt-composer-actions button {
|
||||
min-height: var(--touch-target-min);
|
||||
}
|
||||
|
||||
.prompt-composer-terminal {
|
||||
flex: 1 0 100%;
|
||||
padding: 8px 12px;
|
||||
color: var(--text-dim);
|
||||
font-size: 14px;
|
||||
background: transparent;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 8px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.prompt-composer-terminal:active {
|
||||
color: var(--text);
|
||||
background: var(--bg-input);
|
||||
}
|
||||
|
||||
/* Shared lazy filesystem path picker (case linking + mobile input). */
|
||||
.path-input-group {
|
||||
display: flex;
|
||||
@@ -14807,11 +15229,31 @@ html:not([data-skin="og"]) {
|
||||
color: #061c20;
|
||||
}
|
||||
.btn-toolbar.btn-run.mode-codex:hover { box-shadow: 0 0 14px -2px rgba(43, 203, 187, 0.45); }
|
||||
/* Pi keeps its rose identity on the non-og skins. This rule has to live INSIDE
|
||||
this nested block: the generic `.btn-toolbar.btn-run` above resolves to (0,3,1)
|
||||
here and would otherwise beat the base sheet's (0,3,0) `.mode-pi` pair, which
|
||||
is exactly why gemini's and antigravity's gradients render as generic claude
|
||||
blue on the default skin. */
|
||||
/* Gemini, Antigravity, Pi, OMP, Grok and DeepSeek all keep their own identity
|
||||
on the non-og skins. Each rule has to live INSIDE this nested block: the
|
||||
generic `.btn-toolbar.btn-run` above resolves to (0,3,1) here and would
|
||||
otherwise beat the base sheet's (0,3,0) `.mode-<id>` pair — which is exactly
|
||||
what gemini/antigravity/omp did until this fix, rendering as generic claude
|
||||
blue on `daylight-blue`, the actual DEFAULT skin for a fresh install
|
||||
(`index.html`'s pre-paint script), not just a corner case on `og`. */
|
||||
.btn-toolbar.btn-run.mode-gemini {
|
||||
background: linear-gradient(135deg, #174ea6, #4f46e5);
|
||||
border-color: #123a7a;
|
||||
color: #dbeafe;
|
||||
}
|
||||
.btn-toolbar.btn-run.mode-gemini:hover { box-shadow: 0 0 14px -2px rgba(96, 165, 250, 0.45); }
|
||||
.btn-toolbar.btn-run.mode-antigravity {
|
||||
background: linear-gradient(135deg, #0e7490, #0891b2);
|
||||
border-color: #0b5566;
|
||||
color: #cffafe;
|
||||
}
|
||||
.btn-toolbar.btn-run.mode-antigravity:hover { box-shadow: 0 0 14px -2px rgba(34, 211, 238, 0.45); }
|
||||
.btn-toolbar.btn-run.mode-omp {
|
||||
background: linear-gradient(135deg, #4f46e5, #6366f1);
|
||||
border-color: #3730a3;
|
||||
color: #e0e7ff;
|
||||
}
|
||||
.btn-toolbar.btn-run.mode-omp:hover { box-shadow: 0 0 14px -2px rgba(129, 140, 248, 0.45); }
|
||||
.btn-toolbar.btn-run.mode-pi {
|
||||
background: linear-gradient(135deg, #be185d, #f472b6);
|
||||
border-color: #be185d;
|
||||
@@ -15091,6 +15533,13 @@ html[data-skin="daylight-blue"] .welcome-btn-tunnel.active:hover {
|
||||
}
|
||||
.main.webview-active .webview-layer { display: flex; }
|
||||
.main.webview-active .terminal-wrap { display: none; }
|
||||
/* A split (Pane A + divider + Pane B) hides as one unit when a web tab is
|
||||
active, mirroring the .terminal-wrap rule above — .terminal-wrap is
|
||||
reparented INSIDE .terminal-split-container while a split is open, so
|
||||
hiding only .terminal-wrap would leave Pane B and the divider stranded on
|
||||
screen over the dashboard iframe. No state is destroyed, so returning to
|
||||
the session tab shows the split intact. */
|
||||
.main.webview-active .terminal-split-container { display: none; }
|
||||
|
||||
.webview-frame {
|
||||
display: none;
|
||||
@@ -15138,6 +15587,12 @@ html[data-skin="daylight-blue"] .welcome-btn-tunnel.active:hover {
|
||||
|
||||
.run-mode-dot.web { background: #38bdf8; }
|
||||
.run-mode-webviews { max-height: 180px; overflow-y: auto; }
|
||||
/* Custom Model Endpoint Profiles' generated entries: `.run-mode-menu.active`'s
|
||||
own `gap: 2px` only spaces its DIRECT children, and this container (like
|
||||
`.run-mode-webviews` above) is one such child holding several buttons of
|
||||
its own, so it needs the same gap repeated one level down or its rows sit
|
||||
flush against each other. */
|
||||
.run-mode-custom-models { display: flex; flex-direction: column; gap: 2px; }
|
||||
|
||||
/* A saved URL is a ROW: open on the left, edit + delete on the right, so a URL can
|
||||
be changed or removed without first opening it as a tab. The side buttons stay
|
||||
@@ -15389,6 +15844,18 @@ html[data-skin="daylight-blue"] .welcome-btn-tunnel.active:hover {
|
||||
background: rgba(255, 255, 255, 0.24);
|
||||
}
|
||||
|
||||
/* Remote-host unreachable (host asleep, can be woken). Reuses the offline-banner
|
||||
layout and children; amber instead of red because the Codeman session itself is
|
||||
perfectly healthy — only the machine is asleep. */
|
||||
.host-wake-banner {
|
||||
background: linear-gradient(90deg, #b45309, #92400e);
|
||||
}
|
||||
|
||||
.host-wake-banner .offline-banner-retry:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
/* Above the mobile fixed header (1200) and modals (1300): this is a blocking
|
||||
"nothing works right now" state, and it only appears before any session
|
||||
state has loaded, so there is no modal underneath to bury. Stays below the
|
||||
@@ -15889,6 +16356,17 @@ html[data-tab-orientation='vertical'] .home-sessions {
|
||||
color: color-mix(in srgb, var(--green) 45%, var(--text-muted));
|
||||
}
|
||||
|
||||
/* Accent, deliberately none of the three above: a session that is watching something
|
||||
it started (a monitor, a backgrounded shell, a cloud session) is not asking for
|
||||
anything, so it must not borrow the red or the yellow that mean it is. This badge
|
||||
sits BESIDE the state pill rather than replacing it, since an agent can arm a
|
||||
monitor and ask a question in the same breath. */
|
||||
.home-sessions-pill--watching {
|
||||
background: color-mix(in srgb, var(--accent) 14%, transparent);
|
||||
border-color: color-mix(in srgb, var(--accent) 40%, transparent);
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
/* Row accents: same language as the session tabs and the phone overview — red
|
||||
means a question is pending, yellow means it wants input, green means work is
|
||||
happening. Nothing else on this screen may reuse these colors. */
|
||||
@@ -16290,6 +16768,29 @@ html[data-tab-orientation='vertical'] .home-sessions {
|
||||
gap: 3px;
|
||||
}
|
||||
|
||||
/* Custom Model Endpoint Profiles' inline add/edit form: a nested panel rather
|
||||
than a modal, so it needs its own border to read as a distinct sub-section
|
||||
inside .set-group-body's flat row stack. `--control-bg` rather than a
|
||||
hardcoded black alpha — CLAUDE.md records that literal fill turning the
|
||||
settings live preview into a grey slab on the light skins, and this panel
|
||||
sits in the very same modal. */
|
||||
:is(#appSettingsModal, #sessionOptionsModal, #createCaseModal) .set-inline-form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 3px;
|
||||
margin-top: 6px;
|
||||
padding: 10px 12px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 8px;
|
||||
background: var(--control-bg);
|
||||
}
|
||||
|
||||
:is(#appSettingsModal, #sessionOptionsModal, #createCaseModal) .set-inline-form h5 {
|
||||
margin: 0 0 4px;
|
||||
font-size: 0.72rem;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
/* ── rows ─────────────────────────────────────────────────────────────── */
|
||||
:is(#appSettingsModal, #sessionOptionsModal, #createCaseModal) .set-row {
|
||||
display: flex;
|
||||
@@ -17928,6 +18429,25 @@ html[data-tab-orientation='vertical'][data-tab-rail-detail='rich']:not(.tab-rail
|
||||
color: color-mix(in srgb, var(--green) 45%, var(--text-muted));
|
||||
}
|
||||
|
||||
/* An exited agent (Ark0N/Codeman#446): neutral, like the muted dot beside it.
|
||||
No green at all, since nothing is running behind this row. */
|
||||
html[data-sidebar-detail="rich"] .session-sidebar .tab-pill--exited,
|
||||
html[data-tab-orientation='vertical'][data-tab-rail-detail='rich']:not(.tab-rail-compact) .tab-rail .tab-pill--exited {
|
||||
background: color-mix(in srgb, var(--text-muted) 10%, transparent);
|
||||
border-color: color-mix(in srgb, var(--text-muted) 30%, var(--border));
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
/* Accent, deliberately none of the three above: a watching session is waiting for
|
||||
work it started itself, not for the user, and the red and yellow here are spoken
|
||||
for by sessions that ARE waiting for the user. */
|
||||
html[data-sidebar-detail="rich"] .session-sidebar .tab-pill--watching,
|
||||
html[data-tab-orientation='vertical'][data-tab-rail-detail='rich']:not(.tab-rail-compact) .tab-rail .tab-pill--watching {
|
||||
background: color-mix(in srgb, var(--accent) 14%, transparent);
|
||||
border-color: color-mix(in srgb, var(--accent) 40%, transparent);
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
/* Three lines of content per row instead of two, so give them room to breathe
|
||||
and stop the row actions crowding the pill.
|
||||
|
||||
@@ -18074,6 +18594,26 @@ html[data-tab-orientation='vertical'][data-tab-rail-detail='rich']:not(.tab-rail
|
||||
border-width: 2px;
|
||||
}
|
||||
|
||||
/* The exited-agent mute, again, for the rich rail (Ark0N/Codeman#446).
|
||||
The strip's rule is (0,5,0) and the three state rules above are (0,9,1), so
|
||||
on this rail an exited session kept a full green dot and the working halo
|
||||
beside a badge reading "exited" — measured, the contradiction the mute
|
||||
exists to remove. This twin matches their (0,9,1) exactly and therefore MUST
|
||||
stay below them in source order; moving it above silently restores the green
|
||||
dot. It also clears the halo, which is a box-shadow the strip's rule never
|
||||
had to think about.
|
||||
⚠ The alert exclusions are repeated by hand for the same reason they are on
|
||||
the rules above: a dot turning red or yellow because a session is blocked on
|
||||
a human outranks "the agent exited". */
|
||||
html[data-tab-orientation='vertical'][data-tab-rail-detail='rich']:not(.tab-rail-compact)
|
||||
.tab-rail
|
||||
.session-tab.tab-agent-exited:not(.tab-alert-action):not(.tab-alert-idle)
|
||||
.tab-status:not(.error) {
|
||||
background: var(--text-muted);
|
||||
opacity: 0.5;
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
/* Card accents: the same three colours as every other session surface, and the
|
||||
same two blinks the home rail runs. `tab-alert-*` draws its own ::before ring
|
||||
on top of this for the sessions that are genuinely blocked on a human; these
|
||||
@@ -18303,6 +18843,19 @@ html[data-session-list="sidebar"][data-sidebar="collapsed"] .btn-sidebar-toggle
|
||||
padding-bottom: var(--fold-block-end);
|
||||
}
|
||||
|
||||
/* The mobile prompt composer is a .paste-overlay with a gutter of its own: a
|
||||
three-value `padding` shorthand whose bottom is 12px plus the safe area. The
|
||||
generic rule above is a later longhand at the same specificity, so it ERASED
|
||||
that gutter (measured at 393x852: padding-bottom 0 instead of 12px, and with
|
||||
the fold variables set the hinge strip stood in for the gutter instead of
|
||||
adding to it). Restate the composer's bottom gutter on top of the strip; the
|
||||
side has no gutter of its own. The dialog's height cap subtracts the same
|
||||
strip where it is declared (.prompt-composer-dialog). */
|
||||
.prompt-composer-overlay {
|
||||
padding-right: var(--fold-inline-end);
|
||||
padding-bottom: calc(12px + env(safe-area-inset-bottom) + var(--fold-block-end));
|
||||
}
|
||||
|
||||
/* The response viewer is a bottom sheet, so a vertical hinge running through it
|
||||
is fine, since it is a wide surface like the terminal and inset dialogs are what
|
||||
the fold guidance is about. A horizontal hinge is not: in tabletop pose the
|
||||
@@ -18316,3 +18869,128 @@ html[data-session-list="sidebar"][data-sidebar="collapsed"] .btn-sidebar-toggle
|
||||
max-height: min(88vh, env(viewport-segment-height 0 1, 88vh));
|
||||
}
|
||||
}
|
||||
|
||||
/* Split-Pane Sessions: container inserted only while a split is active.
|
||||
.terminal-wrap (Pane A) is reparented into this as the first child; it
|
||||
keeps every existing rule unchanged since nothing here restyles it. */
|
||||
.terminal-split-container {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.terminal-split-container > .terminal-wrap {
|
||||
/* flex-shrink 1 (not 0): the divider's own 6px is fixed-width, and Pane A
|
||||
+Pane B's inline flex-basis (openSplitPane/onMove) always sums to 100%,
|
||||
so with flex-shrink 0 on both panes the row is 100% + 6px wide and
|
||||
.main's overflow clips Pane B's right edge by exactly the divider's
|
||||
width. Shrinking lets the two panes give up that 6px between them. */
|
||||
flex: 0 1 auto;
|
||||
min-width: 240px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.split-divider {
|
||||
flex: 0 0 6px;
|
||||
cursor: col-resize;
|
||||
background: var(--border-color, #333);
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.split-divider:hover,
|
||||
.split-divider.dragging {
|
||||
background: var(--accent-color, #4a9eff);
|
||||
}
|
||||
|
||||
.terminal-pane-b {
|
||||
/* flex-shrink 1, matching .terminal-wrap above — see its comment. */
|
||||
flex: 0 1 auto;
|
||||
min-width: 240px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.terminal-pane-b-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 4px 8px;
|
||||
font-size: 12px;
|
||||
background: var(--bg-secondary, #1a1a1a);
|
||||
border-bottom: 1px solid var(--border-color, #333);
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
.terminal-pane-b-close {
|
||||
/* A native <button> now backs this (keyboard-reachable close), so reset its
|
||||
default chrome back to the plain glyph this rule always drew. */
|
||||
border: none;
|
||||
background: none;
|
||||
font: inherit;
|
||||
color: inherit;
|
||||
cursor: pointer;
|
||||
padding: 0 6px;
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
.terminal-pane-b-close:hover {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.terminal-pane-b-container {
|
||||
flex: 1 1 auto;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
/* Split-picker menu: a small popover listing sessions to split with, appended
|
||||
to document.body and positioned `fixed` by openSplitPicker() (JS sets
|
||||
top/right against the Split header button's own rect). z-index above the
|
||||
header (100) with headroom to spare, matching the sibling .run-mode-menu
|
||||
dropdown's 1000. Dismissed by openSplitPane()'s own inline onclick, or by
|
||||
the click-outside/Escape listeners installed alongside it. */
|
||||
.split-picker-menu {
|
||||
position: fixed;
|
||||
z-index: 1000;
|
||||
min-width: 200px;
|
||||
max-width: 320px;
|
||||
padding: 4px;
|
||||
background: var(--floating-bg);
|
||||
backdrop-filter: blur(20px);
|
||||
-webkit-backdrop-filter: blur(20px);
|
||||
border: 1px solid var(--control-border);
|
||||
border-radius: 10px;
|
||||
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.5), 0 2px 8px rgba(0, 0, 0, 0.3);
|
||||
}
|
||||
|
||||
.split-picker-item {
|
||||
/* A native <button> now backs each row (keyboard-reachable picker), so
|
||||
reset its default chrome back to the plain list-row look this always
|
||||
drew — full-width, left-aligned, no border/background of its own. */
|
||||
display: block;
|
||||
width: 100%;
|
||||
text-align: left;
|
||||
border: none;
|
||||
background: none;
|
||||
font: inherit;
|
||||
color: inherit;
|
||||
padding: 8px 10px;
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
font-size: 13px;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.split-picker-item:hover {
|
||||
background: var(--control-bg-hover, rgba(255, 255, 255, 0.08));
|
||||
}
|
||||
|
||||
.split-picker-empty {
|
||||
padding: 8px 10px;
|
||||
font-size: 13px;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
+36
-17
@@ -18,7 +18,16 @@
|
||||
* @see src/push-store.ts -- server-side VAPID key management and subscription CRUD
|
||||
*/
|
||||
|
||||
const CACHE_NAME = 'codeman-v1';
|
||||
// Build identity. scripts/build.mjs rewrites this declaration after it content-
|
||||
// hashes the assets; the literal below is what dev serves, and dev wants a
|
||||
// stable key.
|
||||
//
|
||||
// Why the cache key MUST carry it: `activate` deletes every cache whose key is
|
||||
// not the current one, so the old constant key meant that cleanup never deleted
|
||||
// anything — hashed assets from every release ever deployed accumulated in one
|
||||
// bucket until the origin hit its storage quota.
|
||||
const BUILD_ID = 'dev';
|
||||
const CACHE_NAME = `codeman-${BUILD_ID}`;
|
||||
|
||||
// Reverse-proxy base path: the worker is served at `<base>/sw.js`, so its own
|
||||
// location tells us the mount prefix ('' at root, or '/codeman'). Every URL below
|
||||
@@ -27,27 +36,27 @@ const CACHE_NAME = 'codeman-v1';
|
||||
const SW_BASE = self.location.pathname.replace(/\/sw\.js$/, '');
|
||||
const B = (p) => (p && p[0] === '/' ? SW_BASE + p : p);
|
||||
|
||||
// Content-hashed assets. scripts/build.mjs rewrites this declaration with the
|
||||
// filenames it actually emitted; dev has no hashing, so the empty literal below
|
||||
// is correct there and the unhashed modules are simply cached on first use by
|
||||
// the runtime handler further down.
|
||||
//
|
||||
// This list used to be maintained by hand with the PRE-hash names, which the
|
||||
// build then renamed — so in production every entry 404'd and the silent
|
||||
// `.catch()` in install swallowed all of it. Measured against a running
|
||||
// instance: 15 of 23 entries failed. Offline still worked, because the fetch
|
||||
// handler caches every successful GET at runtime, but the precache warmed
|
||||
// nothing while looking like it did. Deriving it from the same manifest that
|
||||
// renames the files is the only thing that keeps the two from drifting again.
|
||||
const HASHED_ASSETS = [];
|
||||
|
||||
// Core app shell -- cached on install for instant startup
|
||||
const APP_SHELL = [
|
||||
'/',
|
||||
'/styles.css',
|
||||
'/mobile.css',
|
||||
'/constants.js',
|
||||
'/app.js',
|
||||
'/api-client.js',
|
||||
'/terminal-ui.js',
|
||||
'/session-ui.js',
|
||||
'/settings-ui.js',
|
||||
'/panels-ui.js',
|
||||
'/notification-manager.js',
|
||||
'/mobile-handlers.js',
|
||||
'/keyboard-accessory.js',
|
||||
'/voice-input.js',
|
||||
...HASHED_ASSETS.map((p) => '/' + p),
|
||||
'/vendor/xterm.min.js',
|
||||
'/vendor/xterm-addon-fit.min.js',
|
||||
'/vendor/xterm-addon-unicode11.min.js',
|
||||
'/vendor/xterm-zerolag-input.js',
|
||||
'/vendor/xterm-predictive-echo.js',
|
||||
'/vendor/xterm.css',
|
||||
'/icon-192.png',
|
||||
'/icon-512.png',
|
||||
@@ -102,7 +111,17 @@ self.addEventListener('fetch', (event) => {
|
||||
}
|
||||
return response;
|
||||
})
|
||||
.catch(() => caches.match(request))
|
||||
// ignoreSearch, or the precache can never be hit. `renderIndexHtml` runs
|
||||
// `cacheBustAssets`, which appends `?v=<mtime>` to EVERY same-origin
|
||||
// `.js`/`.css` reference — content-hashed names included, so the page asks
|
||||
// for `/app.556be563.js?v=1789423735875` while the precache stored
|
||||
// `/app.556be563.js`. `caches.match` is query-sensitive by default, so
|
||||
// every precached entry was unreachable and only `/`, the icons and the
|
||||
// manifest could ever be served offline.
|
||||
//
|
||||
// It also makes runtime-cached entries survive an mtime change: the same
|
||||
// file re-requested under a new `?v=` still matches the copy already held.
|
||||
.catch(() => caches.match(request, { ignoreSearch: true }))
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
@@ -154,7 +154,7 @@ Object.assign(CodemanApp.prototype, {
|
||||
this._persistTabRailWidth(preferred);
|
||||
try {
|
||||
if (this.activeSessionId && this.sendResize) await this.sendResize(this.activeSessionId);
|
||||
else this.fitAddon?.fit();
|
||||
else this.syncTerminalGeometry?.();
|
||||
this._updateConnectionLinesImmediate?.();
|
||||
} catch (error) {
|
||||
console.warn('Failed to resize terminal after rail resize:', error);
|
||||
|
||||
@@ -0,0 +1,815 @@
|
||||
// src/web/public/terminal-split.js
|
||||
|
||||
/**
|
||||
* @fileoverview SplitTerminalPane — a second, independent live terminal pane
|
||||
* ("Pane B") for split-view sessions. Deliberately plainer than the primary
|
||||
* pane (this.terminal/this._ws in terminal-ui.js): no local-echo overlay, no
|
||||
* CJK IME, no touch/mobile handlers, no keyboard accessory bar. Desktop-only
|
||||
* feature by nature — see docs/split-pane-sessions-plan.md.
|
||||
*
|
||||
* @dependency vendor/xterm.js, vendor/xterm-addon-fit.js
|
||||
* @dependency constants.js (window.CodemanTerminalFont, DEFAULT_SCROLLBACK, TERMINAL_TAIL_SIZE, TERMINAL_CHUNK_SIZE)
|
||||
* @dependency terminal-ui.js (codemanCurrentXtermTheme, codemanCurrentSkinIsLight)
|
||||
* @loadorder 7.5 of 16 — loaded after terminal-ui.js, before respawn-ui.js
|
||||
*/
|
||||
|
||||
(function (global) {
|
||||
/**
|
||||
* Minimal chunked write for Pane B's own xterm instance — write() in
|
||||
* TERMINAL_CHUNK_SIZE slices, yielding a frame between each, instead of one
|
||||
* giant synchronous write that blocks the main thread while parsing a long
|
||||
* scrollback. Deliberately NOT the primary pane's chunkedTerminalWrite
|
||||
* (terminal-ui.js): that one is wired into session-switch generation
|
||||
* counters and the live-output gate this simpler, independently
|
||||
* created/destroyed pane has no equivalent of.
|
||||
*/
|
||||
function writeChunked(terminal, buffer, isDestroyed) {
|
||||
if (!buffer) return Promise.resolve();
|
||||
if (buffer.length <= TERMINAL_CHUNK_SIZE) {
|
||||
terminal.write(buffer);
|
||||
return Promise.resolve();
|
||||
}
|
||||
// Resolves once the LAST chunk is written (or the pane was destroyed
|
||||
// mid-replay), so _loadBuffer() below can hold its single-flight flag
|
||||
// across the whole replay rather than just the fetch that precedes it.
|
||||
return new Promise((resolve) => {
|
||||
let offset = 0;
|
||||
const writeNext = () => {
|
||||
if (isDestroyed() || !terminal) {
|
||||
resolve();
|
||||
return;
|
||||
}
|
||||
const chunk = buffer.slice(offset, offset + TERMINAL_CHUNK_SIZE);
|
||||
offset += chunk.length;
|
||||
terminal.write(chunk);
|
||||
if (offset < buffer.length) {
|
||||
if (typeof requestAnimationFrame === 'function') requestAnimationFrame(writeNext);
|
||||
else setTimeout(writeNext, 16);
|
||||
} else {
|
||||
resolve();
|
||||
}
|
||||
};
|
||||
writeNext();
|
||||
});
|
||||
}
|
||||
|
||||
class SplitTerminalPane {
|
||||
constructor(sessionId, mountEl, opts = {}) {
|
||||
this.sessionId = sessionId;
|
||||
this.mountEl = mountEl;
|
||||
this.sessionMode = opts.mode;
|
||||
this.fontSettings = opts.fontSettings || {};
|
||||
// Live reference (not a snapshot) to the app's detachedSessions Set —
|
||||
// detaching this session AFTER the split is already open must still be
|
||||
// seen by _sendResize() below, or it re-creates the exact PTY-size
|
||||
// fight the split picker already refuses to open at pick time.
|
||||
this.detachedSessions = opts.detachedSessions;
|
||||
this.terminal = null;
|
||||
this.fitAddon = null;
|
||||
this.ws = null;
|
||||
this._wsReady = false;
|
||||
this._destroyed = false;
|
||||
// Single-flight state for _loadBuffer()/_refreshBuffer() below.
|
||||
this._bufferLoading = false;
|
||||
this._bufferRefreshPending = false;
|
||||
}
|
||||
|
||||
async connect() {
|
||||
const savedFontSize = parseInt(localStorage.getItem('codeman-font-size'), 10);
|
||||
this.terminal = new Terminal({
|
||||
theme: { ...global.codemanCurrentXtermTheme() },
|
||||
fontFamily: global.CodemanTerminalFont.resolve(this.fontSettings.terminalFontFamily),
|
||||
...global.CodemanTerminalFont.resolveWeights(this.fontSettings),
|
||||
fontSize: Number.isFinite(savedFontSize) ? savedFontSize : 14,
|
||||
lineHeight: 1.2,
|
||||
cursorBlink: false,
|
||||
cursorStyle: 'block',
|
||||
minimumContrastRatio: global.codemanCurrentSkinIsLight() ? 4.5 : 1,
|
||||
scrollback: DEFAULT_SCROLLBACK,
|
||||
allowTransparency: true,
|
||||
allowProposedApi: true,
|
||||
});
|
||||
|
||||
this.fitAddon = new FitAddon.FitAddon();
|
||||
this.terminal.loadAddon(this.fitAddon);
|
||||
this.terminal.open(this.mountEl);
|
||||
this.fitAddon.fit();
|
||||
|
||||
this.terminal.onData((data) => {
|
||||
if (this.ws && this.ws.readyState === WebSocket.OPEN) {
|
||||
this.ws.send(JSON.stringify({ t: 'i', d: data }));
|
||||
}
|
||||
});
|
||||
|
||||
// Pane B has no gates of its own by default, so every app-level chord
|
||||
// that the document capture-phase handler (app.js) only preventDefault()s
|
||||
// — never stopPropagation()s — reaches xterm here too and writes its raw
|
||||
// byte/escape sequence into THIS session's PTY on top of whatever the app
|
||||
// action already did to Pane A (COD-153; mirrors the primary pane's own
|
||||
// gates at terminal-ui.js's attachCustomKeyEventHandler: command palette,
|
||||
// Alt+1-9/[/] tab nav, Alt+B sidebar toggle, Ctrl+Z suspend, Shift/Ctrl+Enter
|
||||
// newline, and smart-copy Ctrl+C/Ctrl+Shift+C). Routed through the same
|
||||
// registry-aware predicates so a rebind or a disable restores plain
|
||||
// terminal behavior here too. Ctrl+V is deliberately left on xterm's own
|
||||
// default (plain-text paste): Pane B has no image-paste trap to route it
|
||||
// to, so intercepting it here would only break paste.
|
||||
this.terminal.attachCustomKeyEventHandler((ev) => {
|
||||
if (ev.isComposing || ev.key === 'Process' || ev.keyCode === 229) return true;
|
||||
if (
|
||||
ev.altKey &&
|
||||
!ev.ctrlKey &&
|
||||
!ev.shiftKey &&
|
||||
/^(Digit[1-9]|BracketLeft|BracketRight|KeyK)$/.test(ev.code || '')
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
if (ev.type === 'keydown' && global.app?.shouldOpenCommandPaletteFromShortcut?.(ev)) {
|
||||
return false;
|
||||
}
|
||||
if (ev.type === 'keydown' && global.app?.shouldToggleSessionSidebarFromShortcut?.(ev)) {
|
||||
return false;
|
||||
}
|
||||
// Ctrl+Z (SIGTSTP/job-control suspend): mirrors terminal-ui.js's own
|
||||
// swallow — in a plain shell session this is the user's own
|
||||
// job-control tool and must reach the PTY, but in every other mode
|
||||
// (claude/omp/pi/codex/...) it silently stops an unattended agent
|
||||
// loop dead. Pane B has its own PTY/session and must not send a
|
||||
// suspend into a non-shell one just because the primary pane's own
|
||||
// gate lives elsewhere.
|
||||
if (
|
||||
ev.type === 'keydown' &&
|
||||
ev.key.toLowerCase() === 'z' &&
|
||||
ev.ctrlKey &&
|
||||
!ev.altKey &&
|
||||
!ev.metaKey &&
|
||||
!ev.shiftKey &&
|
||||
this.sessionMode !== 'shell'
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
// Shift+Enter / Ctrl+Enter: insert a newline instead of submitting.
|
||||
// Mirrors terminal-ui.js's own handling — xterm sends plain \r for
|
||||
// every Enter variant, so an Ink app (Claude Code) can't tell a
|
||||
// newline from a submit. Without this gate, Pane B's onData would
|
||||
// send that bare \r straight over the WS and submit an incomplete
|
||||
// prompt instead of adding a line to it. Targets THIS pane's own
|
||||
// session (this.sessionId), never the primary pane's
|
||||
// activeSessionId, and has no local-echo overlay of its own to flush
|
||||
// first (Pane B is deliberately plainer — see the fileoverview).
|
||||
if (ev.key === 'Enter' && (ev.shiftKey || ev.ctrlKey) && ev.type === 'keydown') {
|
||||
fetch(`/api/sessions/${this.sessionId}/send-key`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ key: ev.ctrlKey ? 'C-Enter' : 'S-Enter' }),
|
||||
}).catch(() => {
|
||||
/* Best-effort, matching this pane's tolerance elsewhere. */
|
||||
});
|
||||
return false;
|
||||
}
|
||||
// Smart copy (mirrors terminal-ui.js's Ctrl+C gate, #211): with a
|
||||
// selection, Ctrl+C copies THIS pane's own selection instead of
|
||||
// sending ^C; with none, plain Ctrl+C must fall through unchanged or
|
||||
// the interrupt key is lost. Ctrl+Shift+C is different: it is the
|
||||
// explicit, never-falls-through copy chord, and the predicate above
|
||||
// does not distinguish it from plain Ctrl+C — ev.shiftKey does, below.
|
||||
// xterm's own evaluateKeyboardEvent routes a shifted ctrl-letter into
|
||||
// a branch that assigns c.key only for a couple of special cases
|
||||
// ("_"->US, "@"->NUL), neither of which is "c", so it emits NOTHING
|
||||
// for Ctrl+Shift+C either way — this is not about an accidental
|
||||
// interrupt byte reaching the PTY (verified live: it does not).
|
||||
// Gating this whole block on hasSelection() (an earlier draft) meant
|
||||
// that with no selection Ctrl+Shift+C skipped straight to `return
|
||||
// true`, silently ceding the keystroke to the BROWSER's own handling
|
||||
// (e.g. Chrome's Inspect-Element binding) with no feedback and no
|
||||
// attempt to copy, unlike Pane A, which always intercepts it.
|
||||
// Re-implemented against this.terminal rather than reusing
|
||||
// app.copyTerminalSelection(), which reads app.terminal — Pane A's —
|
||||
// and would copy the wrong pane's selection.
|
||||
if (ev.type === 'keydown' && global.app?.shouldCopyTerminalSelectionFromShortcut?.(ev)) {
|
||||
const raw = this.terminal?.getSelection?.() || '';
|
||||
const isColumnSelection = this.terminal?._core?._selectionService?._activeSelectionMode === 3;
|
||||
// Both clean options are read for THIS pane, never the primary one:
|
||||
// the gutter width comes from this.sessionId's own run mode, and the
|
||||
// partial-first-line flag from this terminal's own selection range.
|
||||
// Passing neither left Pane B keeping a margin Pane A dropped, on the
|
||||
// same split and the same keystroke.
|
||||
const range = global.app?._normalisedSelectionRange?.(this.terminal);
|
||||
const selection = isColumnSelection
|
||||
? raw
|
||||
: (global.CodemanCopySelection?.clean?.(raw, {
|
||||
margin: global.app?._cliGutterColumns?.(this.sessionId) ?? 0,
|
||||
firstLinePartial: !!range && range.start.x > 0,
|
||||
}) ?? raw);
|
||||
if (selection.trim()) {
|
||||
ev.preventDefault();
|
||||
void global.app._copyText?.(selection).then((ok) => {
|
||||
this.terminal?.clearSelection?.();
|
||||
global.app.showToast?.(ok ? 'Copied to clipboard' : 'Failed to copy', ok ? 'success' : 'error');
|
||||
});
|
||||
return false;
|
||||
}
|
||||
// Nothing worth copying — clear for feedback (a padding-only
|
||||
// selection cleans to '' and this press still falls through to the
|
||||
// PTY as 0x03, matching the primary pane's own rule).
|
||||
if (this.terminal?.hasSelection?.()) {
|
||||
this.terminal.clearSelection?.();
|
||||
global.app.showToast?.('Nothing to copy', 'warning');
|
||||
}
|
||||
// Ctrl+Shift+C never falls through, even with nothing to copy —
|
||||
// matches terminal-ui.js's own ev.shiftKey branch.
|
||||
if (ev.shiftKey) {
|
||||
ev.preventDefault();
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
});
|
||||
|
||||
// Load existing scrollback before going live. The WS below is
|
||||
// subscribe-only (ws-routes.ts sends nothing on connect, only future
|
||||
// 'terminal' events), so without this Pane B stays blank until the
|
||||
// target session happens to produce new output. It LOOKED
|
||||
// intermittent rather than always-broken because _sendResize() below
|
||||
// often nudges the shared session's real tmux window to a new size,
|
||||
// and tmux repaints its current screen on resize — that repaint was
|
||||
// getting captured and streamed here, incidentally populating the
|
||||
// pane. When Pane B's computed dimensions happened to already match
|
||||
// the session's last-known size, Session.resize() (session.ts) skips
|
||||
// the resize as a no-op, no repaint fires, and the pane stayed blank.
|
||||
// The await covers the whole chunked replay, not just the fetch, so a
|
||||
// live frame from the socket below can never land in the middle of it.
|
||||
await this._loadBuffer();
|
||||
if (this._destroyed) return;
|
||||
|
||||
const proto = location.protocol === 'https:' ? 'wss:' : 'ws:';
|
||||
const url = `${proto}//${location.host}${window.CodemanBase.base}/ws/sessions/${this.sessionId}/terminal`;
|
||||
this.ws = new WebSocket(url);
|
||||
|
||||
this.ws.onopen = () => {
|
||||
this._wsReady = true;
|
||||
this._sendResize();
|
||||
};
|
||||
|
||||
this.ws.onmessage = (event) => {
|
||||
try {
|
||||
const msg = JSON.parse(event.data);
|
||||
if (msg.t === 'o') {
|
||||
this.terminal.write(msg.d);
|
||||
} else if (msg.t === 'c') {
|
||||
this.terminal.clear();
|
||||
} else if (msg.t === 'r') {
|
||||
// Server-triggered refresh (SSE backpressure cleared, terminal
|
||||
// data was dropped). The primary pane routes this to
|
||||
// _onSessionNeedsRefresh (app.js:2990) — Pane B has its own
|
||||
// buffer loader for the same reason connect() does.
|
||||
this._refreshBuffer();
|
||||
}
|
||||
} catch {
|
||||
/* Malformed frame — ignore, matches primary pane's tolerance. */
|
||||
}
|
||||
};
|
||||
|
||||
// Mirror app.js's onclose/onerror pattern (app.js:2905-2964): _wsReady
|
||||
// must go false on a drop or fit()/_sendResize() silently no-ops on a
|
||||
// closed socket per the WebSocket spec (no exception, no log). No
|
||||
// reconnect logic here — Pane B is deliberately plainer than the
|
||||
// primary pane (see the fileoverview above); a drop just stops
|
||||
// resizing until the parent recreates the pane. But onData already
|
||||
// silently drops keystrokes while _wsReady is false (below), so
|
||||
// without a visible marker a dropped socket left Pane B looking
|
||||
// normal while it quietly ate everything typed into it. v1 scope is
|
||||
// "say so", not reconnect — collapsing the split would lose the
|
||||
// user's place in Pane B's scrollback for a transient blip.
|
||||
this.ws.onclose = () => {
|
||||
this._wsReady = false;
|
||||
this.terminal?.write('\r\n\x1b[2m[Pane B disconnected — close and reopen the split to reconnect]\x1b[0m\r\n');
|
||||
};
|
||||
|
||||
this.ws.onerror = () => {
|
||||
// onclose fires after onerror — cleanup happens there.
|
||||
};
|
||||
}
|
||||
|
||||
// Fetches and writes the session's current scrollback. Used both by
|
||||
// connect() (initial load) and by the `{t:'r'}` server-refresh frame
|
||||
// (below) — the primary pane's own _onSessionNeedsRefresh (app.js) is
|
||||
// scoped to `this.activeSessionId` and clears/rewrites the primary
|
||||
// terminal, neither of which applies to this independent pane, so this is
|
||||
// a standalone equivalent rather than a call into it.
|
||||
//
|
||||
// Mirrors the primary pane's own mode check (app.js's selectSession /
|
||||
// _onSessionNeedsRefresh): a shell session can retain hundreds of
|
||||
// thousands of plain scrollback lines, so pulling `?full=1` there parses
|
||||
// an unbounded, server-capped (up to terminalBufferMaxBytes, 32MB) body
|
||||
// into a 50000-line xterm on every load. Non-shell (TUI) sessions still
|
||||
// get one full replay. `fetch` here goes through the global wrapper
|
||||
// (constants.js), which already prefixes CodemanBase — unlike the raw
|
||||
// WebSocket URL above, which does not.
|
||||
//
|
||||
// Single-flight: the flag is held across the fetch AND the chunked write
|
||||
// (writeChunked resolves after its last chunk), so two replays can never
|
||||
// interleave their chunks into one terminal. A second call while one is
|
||||
// in flight is dropped here; _refreshBuffer() is the caller that queues
|
||||
// a trailing re-run instead.
|
||||
async _loadBuffer() {
|
||||
if (this._bufferLoading) return;
|
||||
this._bufferLoading = true;
|
||||
try {
|
||||
const query = this.sessionMode === 'shell' ? `tail=${TERMINAL_TAIL_SIZE}` : 'full=1';
|
||||
const res = await fetch(`/api/sessions/${this.sessionId}/terminal?${query}`);
|
||||
const payload = (await res.json())?.data ?? {};
|
||||
if (payload.terminalBuffer && this.terminal) {
|
||||
await writeChunked(this.terminal, payload.terminalBuffer, () => this._destroyed);
|
||||
}
|
||||
} catch {
|
||||
/* Best-effort — live output still arrives once the socket connects. */
|
||||
} finally {
|
||||
this._bufferLoading = false;
|
||||
}
|
||||
if (this._bufferRefreshPending && !this._destroyed) {
|
||||
this._bufferRefreshPending = false;
|
||||
this._refreshBuffer();
|
||||
}
|
||||
}
|
||||
|
||||
// The `{t:'r'}` server-refresh path: clear, then replay. Two refresh
|
||||
// frames in a row used to start two concurrent replays, each clearing
|
||||
// the terminal under the other's chunked write. A refresh that arrives
|
||||
// mid-replay is COALESCED into one trailing re-run rather than ignored:
|
||||
// the in-flight fetch may predate the drop the new frame is reporting,
|
||||
// and no further frame is coming to correct stale content.
|
||||
_refreshBuffer() {
|
||||
if (this._bufferLoading) {
|
||||
this._bufferRefreshPending = true;
|
||||
return;
|
||||
}
|
||||
this.terminal?.clear();
|
||||
void this._loadBuffer();
|
||||
}
|
||||
|
||||
// Local reflow only — no PTY resize frame. Split out so a divider drag
|
||||
// can reflow both panes at the browser's paint rate (rAF) while sending
|
||||
// the actual `{t:'z'}` resize once, at drag end, matching the primary
|
||||
// pane's own convention (throttledResize in terminal-ui.js).
|
||||
localFit() {
|
||||
if (!this.fitAddon) return;
|
||||
this.fitAddon.fit();
|
||||
}
|
||||
|
||||
fit() {
|
||||
this.localFit();
|
||||
this._sendResize();
|
||||
}
|
||||
|
||||
_sendResize() {
|
||||
if (!this._wsReady || !this.fitAddon) return;
|
||||
// One PTY cannot hold two sizes (mirrors sendResize's own
|
||||
// detachedElsewhere yield in terminal-ui.js): the session got detached
|
||||
// to its own window AFTER this split was opened, so its own window now
|
||||
// owns the PTY's size and Pane B must stand aside.
|
||||
if (this.detachedSessions?.has(this.sessionId)) return;
|
||||
const dims = this.fitAddon.proposeDimensions();
|
||||
if (!dims) return;
|
||||
// Send the real proposed dimensions unclamped, matching the primary
|
||||
// pane's convention (terminal-ui.js's getTerminalDimensions()) — the
|
||||
// server enforces its own valid range ([1,500]/[1,200] in ws-routes.ts).
|
||||
// A 40/10 floor here misreported Pane B's real width to the PTY at the
|
||||
// divider's own reachable 20% floor position, causing real
|
||||
// output-wrapping bugs.
|
||||
this.ws.send(JSON.stringify({ t: 'z', c: dims.cols, r: dims.rows, v: 'desktop' }));
|
||||
}
|
||||
|
||||
destroy() {
|
||||
this._destroyed = true;
|
||||
if (this.ws) {
|
||||
this.ws.onopen = null;
|
||||
this.ws.onmessage = null;
|
||||
// onclose fires asynchronously AFTER close(); without this it ran
|
||||
// its "disconnected" write against a pane already torn down.
|
||||
this.ws.onclose = null;
|
||||
this.ws.onerror = null;
|
||||
this.ws.close();
|
||||
this.ws = null;
|
||||
}
|
||||
if (this.terminal) {
|
||||
this.terminal.dispose();
|
||||
this.terminal = null;
|
||||
}
|
||||
this.fitAddon = null;
|
||||
}
|
||||
}
|
||||
|
||||
global.SplitTerminalPane = SplitTerminalPane;
|
||||
})(window);
|
||||
|
||||
Object.assign(CodemanApp.prototype, {
|
||||
/**
|
||||
* Desktop-only gate, same shape as home-sessions.js's shouldShowHomeSessions
|
||||
* + matchMedia backstop: a JS width check (so openSplitPane() below can
|
||||
* refuse even if a click somehow reaches the button) plus a live listener,
|
||||
* because a window narrowed WHILE the button is showing must hide it
|
||||
* without waiting for a settings save or reload. The CSS `@media
|
||||
* (max-width: 1179px)` rule in styles.css is the backstop for the reverse
|
||||
* direction: it hides the button even if this JS never runs at all.
|
||||
*/
|
||||
_applySplitButtonVisibility(enabled) {
|
||||
this._splitButtonSettingEnabled = enabled;
|
||||
const splitBtn = document.querySelector('.btn-split');
|
||||
const wide = window.innerWidth >= SPLIT_PANE_MIN_WIDTH;
|
||||
// Narrowing past the gate must not leave an open split on screen with no
|
||||
// way to reach the button that would close it — the two 240px min-widths
|
||||
// plus the divider overflow a narrow window and .main clips Pane B's edge.
|
||||
if (!wide && this._splitPane) this.closeSplitPane();
|
||||
if (!splitBtn) return;
|
||||
splitBtn.classList.toggle('btn-split--hidden', !enabled || !wide);
|
||||
if (!this._splitButtonWidthListenerInstalled && window.matchMedia) {
|
||||
this._splitButtonWidthListenerInstalled = true;
|
||||
const mq = window.matchMedia(`(min-width: ${SPLIT_PANE_MIN_WIDTH}px)`);
|
||||
mq.addEventListener('change', () => this._applySplitButtonVisibility(this._splitButtonSettingEnabled));
|
||||
}
|
||||
},
|
||||
|
||||
openSplitPicker(event) {
|
||||
// Mirrors toggleRunModeMenu (session-ui.js): stopPropagation on the
|
||||
// OPENING click so it never reaches the outside-click listener this
|
||||
// same call is about to register — without it, a click landing on the
|
||||
// button's own inner <svg> (matched by neither `menu.contains()` nor
|
||||
// the old exact-node check below) bubbled straight through to
|
||||
// `document` and self-closed the menu it just opened.
|
||||
event?.stopPropagation();
|
||||
if (this._splitPane) {
|
||||
this.closeSplitPane();
|
||||
return;
|
||||
}
|
||||
const candidates = window.CodemanSplitPane.buildSplitPickerSessions(
|
||||
this.sessions,
|
||||
this.sessionOrder,
|
||||
this.activeSessionId,
|
||||
this.detachedSessions
|
||||
);
|
||||
// Route a pre-existing menu through the SAME dismiss path used
|
||||
// everywhere else, instead of a raw `.remove()`: a genuinely still-open
|
||||
// menu has live document listeners (see below), and a raw removal left
|
||||
// them attached forever — only the single-slot field below got
|
||||
// overwritten, so every prior pair but the last was orphaned on
|
||||
// `document` with no way to ever find and remove it again.
|
||||
this._dismissSplitPicker();
|
||||
|
||||
const menu = document.createElement('div');
|
||||
menu.id = 'splitPickerMenu';
|
||||
menu.className = 'split-picker-menu';
|
||||
if (candidates.length === 0) {
|
||||
menu.innerHTML = '<div class="split-picker-empty">No other sessions to split with</div>';
|
||||
} else {
|
||||
menu.innerHTML = candidates
|
||||
.map(
|
||||
(c) =>
|
||||
// data-i18n-skip: the whole row's text IS a session name — i18n.js
|
||||
// does exact-string lookup over text nodes, and a session
|
||||
// literally named e.g. "Sessions" would otherwise get translated
|
||||
// on zh-CN (see the .session-name skip on the pane header below).
|
||||
`<button type="button" class="split-picker-item" data-i18n-skip onclick="app.openSplitPane(${escapeHtml(JSON.stringify(c.id))}); app._dismissSplitPicker();">${escapeHtml(c.label)}</button>`
|
||||
)
|
||||
.join('');
|
||||
}
|
||||
document.body.appendChild(menu);
|
||||
const splitBtn = document.querySelector('.btn-split');
|
||||
if (splitBtn) {
|
||||
const rect = splitBtn.getBoundingClientRect();
|
||||
menu.style.position = 'fixed';
|
||||
menu.style.top = `${rect.bottom + 4}px`;
|
||||
menu.style.right = `${window.innerWidth - rect.right}px`;
|
||||
}
|
||||
|
||||
// Dismiss on outside click or Escape — same one-shot listener pattern as
|
||||
// session-ui.js's other transient popovers (toggleCaseSettings(),
|
||||
// toggleRunModeMenu()). Deferred by a tick so the click that OPENED the
|
||||
// menu (still bubbling) doesn't immediately close it — reinforced by
|
||||
// the button's own stopPropagation() above, which is what actually
|
||||
// stops that same click reaching `document` at all. Picking an item
|
||||
// (above) calls the SAME dismiss method, so these listeners never
|
||||
// outlive the menu either way.
|
||||
//
|
||||
// Self-removing by identity: each handler removes ITSELF (and its
|
||||
// sibling) the moment it fires, rather than leaning solely on the
|
||||
// `this._splitPickerDismissHandlers` field. That field is still kept in
|
||||
// sync (so `_dismissSplitPicker()` called from elsewhere — the picker
|
||||
// item's onclick above, or a still-open menu at the top of this method
|
||||
// — can find and remove the CURRENT pair), but no path here can ever
|
||||
// again leave a pair attached to `document` with nothing referencing it.
|
||||
const closeOnOutsideClick = (e) => {
|
||||
if (menu.contains(e.target) || e.target.closest('.btn-split')) return;
|
||||
document.removeEventListener('click', closeOnOutsideClick);
|
||||
document.removeEventListener('keydown', closeOnEscape);
|
||||
this._splitPickerDismissHandlers = null;
|
||||
menu.remove();
|
||||
};
|
||||
const closeOnEscape = (e) => {
|
||||
if (e.key !== 'Escape') return;
|
||||
document.removeEventListener('click', closeOnOutsideClick);
|
||||
document.removeEventListener('keydown', closeOnEscape);
|
||||
this._splitPickerDismissHandlers = null;
|
||||
menu.remove();
|
||||
};
|
||||
this._splitPickerDismissHandlers = { closeOnOutsideClick, closeOnEscape };
|
||||
setTimeout(() => document.addEventListener('click', closeOnOutsideClick), 0);
|
||||
document.addEventListener('keydown', closeOnEscape);
|
||||
},
|
||||
|
||||
_dismissSplitPicker() {
|
||||
document.getElementById('splitPickerMenu')?.remove();
|
||||
if (this._splitPickerDismissHandlers) {
|
||||
document.removeEventListener('click', this._splitPickerDismissHandlers.closeOnOutsideClick);
|
||||
document.removeEventListener('keydown', this._splitPickerDismissHandlers.closeOnEscape);
|
||||
this._splitPickerDismissHandlers = null;
|
||||
}
|
||||
},
|
||||
|
||||
openSplitPane(sessionId) {
|
||||
// Desktop-only hard gate, independent of the button's own hidden state —
|
||||
// see _applySplitButtonVisibility's comment for why both a JS check and
|
||||
// a CSS backstop exist.
|
||||
if (window.innerWidth < SPLIT_PANE_MIN_WIDTH) return;
|
||||
// No active session means there is no `.terminal-wrap` to split against
|
||||
// (the welcome overlay is showing) — without this, a split opened from
|
||||
// the home screen still created the container and connected Pane B, just
|
||||
// behind the opaque overlay with nothing visible to show for it.
|
||||
if (!this.activeSessionId) return;
|
||||
// A web tab hides `.terminal-wrap`'s container via CSS with nothing
|
||||
// gating the button itself, and `activeSessionId` survives openWebview()
|
||||
// — without this, picking a session opens Pane B's socket behind a
|
||||
// hidden container with nothing on screen to show for it.
|
||||
if (this.activeWebviewId) return;
|
||||
// A stale picker click (opened before switching tabs) or clicking Pane
|
||||
// B's own session tab while split can otherwise land here with
|
||||
// sessionId === activeSessionId: two live WebSockets to the same
|
||||
// session, each independently claiming PTY dimensions via its own `{t:'z',...}`
|
||||
// resize frame. Refuse before creating any DOM or SplitTerminalPane.
|
||||
if (sessionId === this.activeSessionId) return;
|
||||
// The picker's own exclusions (buildSplitPickerSessions in constants.js),
|
||||
// re-applied here: the menu can sit open while a listed session's CLI
|
||||
// exits (pid → null) or gets popped out to its own window, and nothing
|
||||
// re-runs the picker filter for a row that already rendered. Same
|
||||
// outcome as the picker gives such a session (not offered, silently):
|
||||
// one with no PTY has nothing reading its pane, so Pane B would show
|
||||
// nothing and drop every keystroke behind a healthy-looking socket, and
|
||||
// a detached session's own window already owns its PTY size.
|
||||
const session = this.sessions.get(sessionId);
|
||||
if (!session || session.pid === null) return;
|
||||
if (this.detachedSessions?.has?.(sessionId)) return;
|
||||
if (this._splitPane) this.closeSplitPane();
|
||||
|
||||
const wrap = document.querySelector('.terminal-wrap');
|
||||
const parent = wrap.parentElement;
|
||||
|
||||
const container = document.createElement('div');
|
||||
container.className = 'terminal-split-container';
|
||||
|
||||
const divider = document.createElement('div');
|
||||
divider.className = 'split-divider';
|
||||
|
||||
const paneB = document.createElement('div');
|
||||
paneB.className = 'terminal-pane-b';
|
||||
paneB.innerHTML = `
|
||||
<div class="terminal-pane-b-header">
|
||||
<span class="session-name">${escapeHtml(session?.name || 'Session')}</span>
|
||||
<button type="button" class="terminal-pane-b-close" onclick="app.closeSplitPane()" aria-label="Close split">×</button>
|
||||
</div>
|
||||
<div class="terminal-pane-b-container"></div>
|
||||
`;
|
||||
|
||||
parent.insertBefore(container, wrap);
|
||||
container.appendChild(wrap);
|
||||
wrap.style.flexBasis = '50%';
|
||||
container.appendChild(divider);
|
||||
container.appendChild(paneB);
|
||||
paneB.style.flexBasis = '50%';
|
||||
|
||||
this._splitPane = new window.SplitTerminalPane(sessionId, paneB.querySelector('.terminal-pane-b-container'), {
|
||||
mode: session?.mode,
|
||||
fontSettings: this.loadAppSettingsFromStorage?.() || {},
|
||||
detachedSessions: this.detachedSessions,
|
||||
});
|
||||
this._splitPane.connect().catch(() => {
|
||||
/* Best-effort, matching the primary pane's own tolerance for a failed
|
||||
initial load — live output still arrives once/if the socket connects. */
|
||||
});
|
||||
this._splitSessionId = sessionId;
|
||||
|
||||
// Pane A just went from full width to 50%, but nothing has told its
|
||||
// session's PTY/tmux window about it yet — the passive ResizeObserver in
|
||||
// terminal-ui.js debounces 300ms and would eventually catch up, but
|
||||
// relying on that left the pane showing stale-width content (existing
|
||||
// box-drawing lines, banners) until the user hit "Redraw Terminal".
|
||||
// Force it immediately, mirroring closeSplitPane()'s symmetric call.
|
||||
this.sendResize?.(this.activeSessionId, { force: true })?.catch?.(() => {});
|
||||
|
||||
this._installSplitDividerDrag(divider, wrap, paneB);
|
||||
this._updateSplitButtonState(true);
|
||||
},
|
||||
|
||||
closeSplitPane(options = {}) {
|
||||
if (!this._splitPane) return;
|
||||
// A split can collapse MID-DRAG (either session ending, the window
|
||||
// narrowing past the gate, a click on Pane B's own tab). The drag's own
|
||||
// onUp is what normally clears `body.split-pane-resizing` (a col-resize
|
||||
// cursor plus user-select:none on EVERY element, styles.css), and it
|
||||
// relied on pointer capture routing pointerup back to a divider this
|
||||
// method detaches below, so a mid-drag collapse left the whole page
|
||||
// locked in resize mode until a reload. Tear the drag down first.
|
||||
this._splitDividerDragTeardown?.();
|
||||
this._splitDividerDragTeardown = null;
|
||||
this._splitPane.destroy();
|
||||
this._splitPane = null;
|
||||
this._splitSessionId = null;
|
||||
this._updateSplitButtonState(false);
|
||||
|
||||
const container = document.querySelector('.terminal-split-container');
|
||||
if (!container) return;
|
||||
const wrap = container.querySelector('.terminal-wrap');
|
||||
const parent = container.parentElement;
|
||||
wrap.style.flexBasis = '';
|
||||
parent.insertBefore(wrap, container);
|
||||
container.remove();
|
||||
|
||||
if (this.fitAddon) this.fitAddon.fit();
|
||||
// The Pane-A-ends branch of the _onSessionDeleted wrapper below collapses the split
|
||||
// while activeSessionId is still the id the server just removed, so a
|
||||
// resize from here would be aimed at a session that no longer exists;
|
||||
// the promoted session gets its own resize from selectSession().
|
||||
if (!options.skipPrimaryResize) {
|
||||
this.sendResize?.(this.activeSessionId, { force: true })?.catch?.(() => {});
|
||||
}
|
||||
},
|
||||
|
||||
// A click on .btn-split does one of two things — open the picker, or
|
||||
// (openSplitPicker's own early return) close an already-open split — and
|
||||
// nothing on the button said which. `.split-open` + aria-pressed give it
|
||||
// the same active-state language as the codebase's other toggle buttons
|
||||
// (keyboard-accessory's Ctrl key, the voice-input mic).
|
||||
_updateSplitButtonState(open) {
|
||||
const btn = document.querySelector('.btn-split');
|
||||
if (!btn) return;
|
||||
btn.classList.toggle('split-open', open);
|
||||
btn.setAttribute('aria-pressed', open ? 'true' : 'false');
|
||||
const title = open ? 'Split: close the second session' : 'Split: open a second session beside this one';
|
||||
btn.title = title;
|
||||
btn.setAttribute('aria-label', title);
|
||||
},
|
||||
|
||||
_installSplitDividerDrag(divider, wrap, paneB) {
|
||||
let dragging = false;
|
||||
let dragRaf = null;
|
||||
let pendingClientX = null;
|
||||
let capturedPointerId = null;
|
||||
|
||||
// Local-only reflow (flexBasis + both panes' xterm fit, no PTY resize
|
||||
// frame). Coalesced to one call per animation frame below — a raw
|
||||
// mousemove stream fires far faster than the browser repaints, and
|
||||
// without the rAF gate each event did a full xterm reflow on BOTH
|
||||
// panes AND sent Pane B a `{t:'z'}` resize frame (SplitTerminalPane has
|
||||
// no client-side "dims unchanged" skip), which fanned out into a
|
||||
// `tmux resize-window` child plus a SIGWINCH per frame — roughly fifty
|
||||
// of each dragging across half a wide viewport.
|
||||
const applyDragPercent = (clientX) => {
|
||||
const container = divider.parentElement;
|
||||
// The split can auto-collapse mid-drag (the other pane's session
|
||||
// ending, or the picker's own close button) — closeSplitPane() removes
|
||||
// `.terminal-split-container` from the DOM, which detaches `divider`
|
||||
// too, so `divider.parentElement` is null on the very next frame and
|
||||
// every drag threw here until mouseup finally removed the listener.
|
||||
if (!container) return;
|
||||
const rect = container.getBoundingClientRect();
|
||||
const rawPercent = ((clientX - rect.left) / rect.width) * 100;
|
||||
const percent = window.CodemanSplitPane.clampDividerPercent(rawPercent);
|
||||
wrap.style.flexBasis = `${percent}%`;
|
||||
paneB.style.flexBasis = `${100 - percent}%`;
|
||||
if (this.fitAddon) this.fitAddon.fit();
|
||||
this._splitPane?.localFit();
|
||||
};
|
||||
|
||||
const onMove = (e) => {
|
||||
if (!dragging) return;
|
||||
pendingClientX = e.clientX;
|
||||
if (dragRaf) return;
|
||||
dragRaf = requestAnimationFrame(() => {
|
||||
dragRaf = null;
|
||||
applyDragPercent(pendingClientX);
|
||||
});
|
||||
};
|
||||
|
||||
// Everything pointerdown ARMS, undone in one place: the body-level
|
||||
// cursor/selection lock, the divider's dragging class, pointer capture,
|
||||
// the move/up/cancel listeners and a queued reflow frame. Shared by onUp
|
||||
// (a normal drag end) and by closeSplitPane(), via the teardown handle
|
||||
// stored below, for a split that collapses mid-drag: the pointerup that
|
||||
// would have run onUp is routed by pointer capture to a divider
|
||||
// closeSplitPane() has detached, so it never arrives. Idempotent, since
|
||||
// the teardown runs whether or not a drag is in progress.
|
||||
const endDrag = () => {
|
||||
dragging = false;
|
||||
divider.classList.remove('dragging');
|
||||
document.body.classList.remove('split-pane-resizing');
|
||||
if (capturedPointerId !== null) {
|
||||
try {
|
||||
divider.releasePointerCapture(capturedPointerId);
|
||||
} catch {
|
||||
/* Already released (pointercancel/lostpointercapture beat us here). */
|
||||
}
|
||||
capturedPointerId = null;
|
||||
}
|
||||
divider.removeEventListener('pointermove', onMove);
|
||||
divider.removeEventListener('pointerup', onUp);
|
||||
divider.removeEventListener('pointercancel', onUp);
|
||||
if (dragRaf) {
|
||||
cancelAnimationFrame(dragRaf);
|
||||
dragRaf = null;
|
||||
}
|
||||
};
|
||||
|
||||
const onUp = () => {
|
||||
// A reflow frame still queued at release carries the final pointer
|
||||
// position; apply it once, synchronously, so the panes end where the
|
||||
// pointer did rather than one frame short.
|
||||
const hadQueuedFrame = dragRaf !== null;
|
||||
endDrag();
|
||||
if (hadQueuedFrame) applyDragPercent(pendingClientX);
|
||||
// Send the real PTY resize exactly once here, at drag end, for BOTH
|
||||
// panes — never per-move (matching the codebase's established
|
||||
// trailing-edge debounce convention, see throttledResize in
|
||||
// terminal-ui.js) so a fast drag doesn't flood dozens of intermediate
|
||||
// SIGWINCH/reflow states into scrollback or spawn a `tmux
|
||||
// resize-window` child per frame.
|
||||
this.sendResize?.(this.activeSessionId, { force: true })?.catch?.(() => {});
|
||||
this._splitPane?.fit();
|
||||
};
|
||||
|
||||
// Pointer events + setPointerCapture (mirrors tab-rail-resize.js) instead
|
||||
// of mousedown/document-level mousemove: a plain mousedown drag selects
|
||||
// the text under the cursor as it crosses both terminals, and pointer
|
||||
// capture routes move/up straight to `divider` regardless of what's under
|
||||
// the cursor mid-drag, so no document-level listener leak is possible if
|
||||
// the pointer is released off-window. `body.split-pane-resizing` (mirrors
|
||||
// `body.tab-rail-resizing`) locks the cursor/selection for the drag.
|
||||
divider.addEventListener('pointerdown', (e) => {
|
||||
if (e.button !== 0) return;
|
||||
e.preventDefault();
|
||||
dragging = true;
|
||||
divider.classList.add('dragging');
|
||||
document.body.classList.add('split-pane-resizing');
|
||||
try {
|
||||
divider.setPointerCapture(e.pointerId);
|
||||
capturedPointerId = e.pointerId;
|
||||
} catch {
|
||||
/* Capture failed — the drag still works via the listeners below. */
|
||||
}
|
||||
divider.addEventListener('pointermove', onMove);
|
||||
divider.addEventListener('pointerup', onUp);
|
||||
divider.addEventListener('pointercancel', onUp);
|
||||
});
|
||||
this._splitDividerDragTeardown = endDrag;
|
||||
},
|
||||
});
|
||||
|
||||
const _originalOnSessionDeleted = CodemanApp.prototype._onSessionDeleted;
|
||||
CodemanApp.prototype._onSessionDeleted = function (data) {
|
||||
if (this._splitSessionId === data.id) {
|
||||
this.closeSplitPane();
|
||||
} else if (this._splitPane && this.activeSessionId === data.id) {
|
||||
// Pane A's session ended: promote Pane B by closing the split and
|
||||
// selecting its session as the new (single) active pane. This is an
|
||||
// app-driven selection, not the user clicking a tab, so it must not
|
||||
// spend the promoted session's idle alert (see the Approvals Inbox
|
||||
// acknowledgement rule in CLAUDE.md — only a human opening a session
|
||||
// acknowledges it).
|
||||
const promoted = this._splitSessionId;
|
||||
// activeSessionId is still data.id here (the original handler below is
|
||||
// what retires it), so closeSplitPane()'s closing resize would be aimed
|
||||
// at the session the server just removed. Skip it; selectSession() sizes
|
||||
// the promoted session itself.
|
||||
this.closeSplitPane({ skipPrimaryResize: true });
|
||||
// Closing Pane A's own tab (closeSession(), app.js) adds data.id to
|
||||
// _closingSessions BEFORE awaiting the delete, then owns the follow-up
|
||||
// selection itself once the delete lands — same race _onSessionDeleted's
|
||||
// own active-session handoff guards against (see its comment). Selecting
|
||||
// here too would fight it for which tab wins.
|
||||
if (promoted && !this._closingSessions.has(data.id)) {
|
||||
this.selectSession(promoted, { auto: true });
|
||||
}
|
||||
}
|
||||
return _originalOnSessionDeleted.call(this, data);
|
||||
};
|
||||
|
||||
// I2: closes an active split BEFORE the primary pane rebinds to the same
|
||||
// session Pane B is showing (clicking Pane B's own session tab while split,
|
||||
// or any other selectSession() call that targets _splitSessionId). Without
|
||||
// this, Pane A rebinds to a session that Pane B's independent WebSocket is
|
||||
// still attached to — two live WebSockets to one session, each claiming PTY
|
||||
// dimensions via its own `{t:'z',...}` resize frame.
|
||||
const _originalSelectSession = CodemanApp.prototype.selectSession;
|
||||
CodemanApp.prototype.selectSession = function (sessionId, ...args) {
|
||||
if (this._splitPane && this._splitSessionId === sessionId) {
|
||||
this.closeSplitPane();
|
||||
}
|
||||
return _originalSelectSession.call(this, sessionId, ...args);
|
||||
};
|
||||
+675
-68
@@ -364,12 +364,35 @@ Object.assign(CodemanApp.prototype, {
|
||||
// this handler before its own cancel()), so preventDefault is explicit:
|
||||
// without it the browser runs its native copy on top of ours.
|
||||
if (this.shouldCopyTerminalSelectionFromShortcut?.(ev)) {
|
||||
const selection = this.terminal.hasSelection?.() ? this.terminal.getSelection() : '';
|
||||
if (selection) {
|
||||
// The CLEANED selection decides, not the raw one. A drag across the blank
|
||||
// part of a row selects real padding spaces, which are truthy, so testing
|
||||
// the raw text would spend this press on a copy of nothing and make the
|
||||
// user press again to interrupt.
|
||||
//
|
||||
// ⚠️ The gate cleans, and the copy is handed the RAW selection, because
|
||||
// copyTerminalSelection cleans again on its own. The margin strip is not
|
||||
// idempotent: a second pass takes up to `margin` more columns off what
|
||||
// the first pass left, so passing the cleaned string through dedented a
|
||||
// Claude or Codex copy twice. Every other copy path already hands over
|
||||
// the raw selection or reads it live.
|
||||
const raw = this.terminal?.hasSelection?.() ? this.terminal.getSelection() : '';
|
||||
const selection = this.cleanedTerminalSelection(raw);
|
||||
if (selection.trim()) {
|
||||
ev.preventDefault();
|
||||
void this.copyTerminalSelection(selection);
|
||||
void this.copyTerminalSelection(raw);
|
||||
return false;
|
||||
}
|
||||
// Nothing worth copying. The clear is for feedback, not for the
|
||||
// interrupt: the gate above tests the CLEANED selection, so a
|
||||
// padding-only selection left set cleans to '' on every later press and
|
||||
// falls through to the PTY anyway. What it buys is that a highlight
|
||||
// which copies nothing does not linger with no explanation, which is
|
||||
// also what the toast is for. Falls through exactly as an empty
|
||||
// selection does, so this press still reaches the PTY as 0x03.
|
||||
if (this.terminal?.hasSelection?.()) {
|
||||
this.terminal.clearSelection?.();
|
||||
this.showToast('Nothing to copy', 'warning');
|
||||
}
|
||||
if (ev.shiftKey) {
|
||||
ev.preventDefault();
|
||||
return false;
|
||||
@@ -528,6 +551,14 @@ Object.assign(CodemanApp.prototype, {
|
||||
this.terminal.onRender(() => this._syncMobileHelperTextareaToCursor());
|
||||
}
|
||||
|
||||
// Renderer liveness — see _startRenderLivenessWatchdog. Registered for every
|
||||
// device, not just touch: the rAF-discard behaviour is worst on an iOS PWA
|
||||
// but a stale handle wedges the debouncer identically anywhere it happens.
|
||||
this.terminal.onRender(() => {
|
||||
this._lastRenderAt = Date.now();
|
||||
});
|
||||
this._startRenderLivenessWatchdog();
|
||||
|
||||
// CJK IME input — textarea in index.html, just wire up send
|
||||
this._cjkInput = null;
|
||||
if (typeof CjkInput !== 'undefined') {
|
||||
@@ -564,12 +595,12 @@ Object.assign(CodemanApp.prototype, {
|
||||
if (isMobileSafari) {
|
||||
// Wait for layout, then fit multiple times to ensure proper sizing
|
||||
requestAnimationFrame(() => {
|
||||
this.fitAddon.fit();
|
||||
this.syncTerminalGeometry();
|
||||
// Double-check after another frame
|
||||
requestAnimationFrame(() => this.fitAddon.fit());
|
||||
requestAnimationFrame(() => this.syncTerminalGeometry());
|
||||
});
|
||||
} else {
|
||||
this.fitAddon.fit();
|
||||
this.syncTerminalGeometry();
|
||||
}
|
||||
// Whenever that first fit runs — on this line, or a frame or two later on
|
||||
// the mobile-Safari branch above — it measures whatever font the browser has
|
||||
@@ -741,6 +772,32 @@ Object.assign(CodemanApp.prototype, {
|
||||
let longPressStartX = 0;
|
||||
let longPressStartY = 0;
|
||||
let touchStartY = 0;
|
||||
let touchStartX = 0;
|
||||
// 'x' | 'y' | null — locked on the first travel past the tap slop, so a
|
||||
// diagonal drag cannot pan and scroll at the same time.
|
||||
let panAxis = null;
|
||||
/**
|
||||
* Can this gesture pan sideways? Only while the terminal is wider than
|
||||
* the box showing it (`.term-overflows-x`, set by
|
||||
* `_syncTerminalOverflowAffordance`).
|
||||
*
|
||||
* ⚠️ This has to be done in JS. `touch-action: pan-x` alone does nothing
|
||||
* for the sessions the affordance targets: `touchstart` calls
|
||||
* preventDefault() for every 'content' tap — the normal case for a
|
||||
* mouse-tracking TUI sitting at the bottom of its buffer — which cancels
|
||||
* the browser's pan before it starts. Measured under touch emulation, a
|
||||
* 140px horizontal swipe reached scrollLeft 141 without that
|
||||
* preventDefault and 0 with it. It only ever worked for shell sessions,
|
||||
* while scrolled up, or with a mouse.
|
||||
*/
|
||||
const canPanHorizontally = () =>
|
||||
// Both halves. The class is what makes the container a scroller at all
|
||||
// (`overflow-x: auto`); without it `scrollLeft` silently stays 0, and a
|
||||
// gesture locked to 'x' on that basis would do nothing AND suppress the
|
||||
// vertical scroll it should have been. The measurement is the second
|
||||
// half because sub-pixel cell widths can leave a stray pixel of
|
||||
// scrollWidth on a terminal that fits perfectly well.
|
||||
container.classList.contains('term-overflows-x') && container.scrollWidth - container.clientWidth > 1;
|
||||
let tapStartedWithTerminalFocus = false;
|
||||
let tapStartIntentCache = null;
|
||||
// px — ignore micro-drift to distinguish tap from scroll. Shared with the
|
||||
@@ -760,6 +817,8 @@ Object.assign(CodemanApp.prototype, {
|
||||
touchLastX = ev.touches[0].clientX;
|
||||
touchLastY = ev.touches[0].clientY;
|
||||
touchStartY = touchLastY;
|
||||
touchStartX = touchLastX;
|
||||
panAxis = null;
|
||||
velocity = 0;
|
||||
pixelAccum = 0;
|
||||
isTouching = true;
|
||||
@@ -828,8 +887,15 @@ Object.assign(CodemanApp.prototype, {
|
||||
}
|
||||
if (ev.touches.length === 1 && isTouching) {
|
||||
const touchY = ev.touches[0].clientY;
|
||||
if (!didScroll && Math.abs(touchY - touchStartY) >= TAP_THRESHOLD) {
|
||||
didScroll = true;
|
||||
const touchX = ev.touches[0].clientX;
|
||||
if (!didScroll) {
|
||||
const travelY = Math.abs(touchY - touchStartY);
|
||||
const travelX = Math.abs(touchX - touchStartX);
|
||||
const sideways = canPanHorizontally() && travelX >= TAP_THRESHOLD;
|
||||
if (travelY >= TAP_THRESHOLD || sideways) {
|
||||
didScroll = true;
|
||||
panAxis = sideways && travelX > travelY ? 'x' : 'y';
|
||||
}
|
||||
}
|
||||
// Below the tap threshold, treat the gesture as a potential tap:
|
||||
// don't preventDefault (iOS needs click synthesis to show the
|
||||
@@ -839,6 +905,15 @@ Object.assign(CodemanApp.prototype, {
|
||||
// fling, so a jittery tap would both position the cursor AND scroll.
|
||||
if (!didScroll) return;
|
||||
ev.preventDefault();
|
||||
if (panAxis === 'x') {
|
||||
// Pan the container, and touch nothing the vertical path owns —
|
||||
// no pixelAccum, no velocity, so touchend cannot turn a sideways
|
||||
// swipe into a momentum fling down the scrollback.
|
||||
container.scrollLeft -= touchX - touchLastX;
|
||||
touchLastX = touchX;
|
||||
touchLastY = touchY;
|
||||
return;
|
||||
}
|
||||
const delta = touchLastY - touchY; // positive = scroll down
|
||||
pixelAccum += delta;
|
||||
velocity = delta * 1.2;
|
||||
@@ -976,10 +1051,6 @@ Object.assign(CodemanApp.prototype, {
|
||||
this._resizeTimeout = null;
|
||||
this._lastResizeDims = null;
|
||||
|
||||
// Minimum terminal dimensions to prevent vertical text wrapping
|
||||
const MIN_COLS = 40;
|
||||
const MIN_ROWS = 10;
|
||||
|
||||
const throttledResize = () => {
|
||||
if (this._tabRailResizeOwnsObserver) return;
|
||||
// Trailing-edge debounce: ALL resize work (fit + clear + SIGWINCH) happens
|
||||
@@ -999,10 +1070,6 @@ Object.assign(CodemanApp.prototype, {
|
||||
}
|
||||
this._resizeTimeout = setTimeout(() => {
|
||||
this._resizeTimeout = null;
|
||||
// Fit xterm.js to final container dimensions
|
||||
if (this.fitAddon) {
|
||||
this.fitAddon.fit();
|
||||
}
|
||||
// Flush any stale flicker buffer before clearing viewport
|
||||
if (this.flickerFilterBuffer) {
|
||||
if (this.flickerFilterTimeout) {
|
||||
@@ -1011,32 +1078,47 @@ Object.assign(CodemanApp.prototype, {
|
||||
}
|
||||
this.flushFlickerBuffer();
|
||||
}
|
||||
// Skip server resize while mobile keyboard is visible — sending SIGWINCH
|
||||
// causes Ink to re-render at the new row count, garbling terminal output.
|
||||
// Local fit() still runs so xterm knows the viewport size for scrolling.
|
||||
// Hold the PTY's shape while the virtual keyboard is up: a SIGWINCH per
|
||||
// step of the OS animation makes Ink re-render at a row count that is
|
||||
// about to change again, and shifts the accessory toolbar mid-typing.
|
||||
// KeyboardHandler's settle timer sends ONE resize once the animation
|
||||
// stops (`_sendTerminalResize`), so the PTY is not left stale.
|
||||
const keyboardUp = typeof KeyboardHandler !== 'undefined' && KeyboardHandler.keyboardVisible;
|
||||
// Same yield as sendResize: never resize a PTY whose session is showing
|
||||
// in its own window. Dragging the dashboard's border must not reshape it.
|
||||
const detachedElsewhere = !this.isSoloWindow && this.detachedSessions?.has(this.activeSessionId);
|
||||
if (this.activeSessionId && !keyboardUp && !detachedElsewhere) {
|
||||
const dims = this.fitAddon.proposeDimensions();
|
||||
// Enforce minimum dimensions to prevent layout issues
|
||||
const cols = dims ? Math.max(dims.cols, MIN_COLS) : MIN_COLS;
|
||||
const rows = dims ? Math.max(dims.rows, MIN_ROWS) : MIN_ROWS;
|
||||
// ⚠️ Whether to fit is the SAME question as whether to send (issue #464).
|
||||
// This block used to fit unconditionally and skip only the SIGWINCH,
|
||||
// which is the one combination that cannot be right: it moves xterm to
|
||||
// a shape the PTY is never told about, and Claude Code computes its
|
||||
// repaints from the shape it was told. Withhold both, or neither —
|
||||
// a reflow nothing is rendering for buys nothing and costs correctness.
|
||||
const dims =
|
||||
this.activeSessionId && !keyboardUp && !detachedElsewhere ? this._geometryForResizeRequest() : null;
|
||||
// ⚠️ A null measurement is NOT a reason to report the floor. It used to
|
||||
// fall back to a bare 40x10, which tells the PTY a shape nothing measured
|
||||
// and xterm does not hold — the write-only guess this whole change exists
|
||||
// to remove. An unmeasurable terminal has nothing to say; the next
|
||||
// resize event says it.
|
||||
if (dims) {
|
||||
const { cols, rows } = dims;
|
||||
// Only send resize if dimensions actually changed
|
||||
if (!this._lastResizeDims || cols !== this._lastResizeDims.cols || rows !== this._lastResizeDims.rows) {
|
||||
// Clear viewport + scrollback ONLY when dimensions actually change.
|
||||
// fitAddon.fit() reflows content: lines at old width may wrap to more rows,
|
||||
// pushing overflow into scrollback. Ink's cursor-up count is based on the
|
||||
// pre-reflow line count, so ghost renders accumulate in scrollback.
|
||||
// syncTerminalGeometry() reflowed content: lines at old width may wrap to
|
||||
// more rows, pushing overflow into scrollback. Ink's cursor-up count is
|
||||
// based on the pre-reflow line count, so ghost renders accumulate there.
|
||||
// Fix: \x1b[3J (Erase Saved Lines) clears scrollback reflow debris,
|
||||
// then \x1b[H\x1b[2J clears the viewport for a clean Ink redraw.
|
||||
// IMPORTANT: Only clear when we're actually sending SIGWINCH (dims changed).
|
||||
// Clearing without a subsequent Ink redraw leaves the terminal blank.
|
||||
const activeResizeSession = this.activeSessionId ? this.sessions.get(this.activeSessionId) : null;
|
||||
// Not while another device holds the width: the columns were not
|
||||
// reflowed here, and a refused resize brings no redraw after it.
|
||||
if (
|
||||
activeResizeSession &&
|
||||
activeResizeSession.mode !== 'shell' &&
|
||||
!this._paneWidthRefused &&
|
||||
this.terminal &&
|
||||
this.isTerminalAtBottom()
|
||||
) {
|
||||
@@ -1061,11 +1143,24 @@ Object.assign(CodemanApp.prototype, {
|
||||
}
|
||||
}
|
||||
if (!sentViaWs) {
|
||||
// ⚠️ The reply carries the geometry that actually took, and this
|
||||
// is the path where a declined resize is LEAST likely to be
|
||||
// noticed: no socket means no `{"t":"zc"}` frame either, so
|
||||
// discarding it here left the one transport that cannot hear the
|
||||
// answer also not asking for it.
|
||||
const resizedSessionId = this.activeSessionId;
|
||||
fetch(`/api/sessions/${this.activeSessionId}/resize`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ cols, rows, viewportType }),
|
||||
}).catch(() => {});
|
||||
})
|
||||
.then(async (res) => {
|
||||
const applied = (await res.json())?.data ?? {};
|
||||
this._onPtyGeometryReport(resizedSessionId, applied.cols, applied.rows);
|
||||
})
|
||||
.catch(() => {
|
||||
/* a resize that never landed tells us nothing about the PTY */
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1077,6 +1172,12 @@ Object.assign(CodemanApp.prototype, {
|
||||
if (this._localEchoOverlay?.hasPending) {
|
||||
this._localEchoOverlay.rerender();
|
||||
}
|
||||
// Pane B (split view) has its own container and its own fit()/resize
|
||||
// frame — this observer only ever measured Pane A's container, so
|
||||
// without this call Pane B never learned about a window resize, an
|
||||
// Alt+B sidebar toggle, or a tab-rail drag, and its PTY silently
|
||||
// stayed at whatever size it was last dragged to.
|
||||
this._splitPane?.fit();
|
||||
}, 300); // Trailing-edge: only fire after 300ms of no resize events
|
||||
};
|
||||
|
||||
@@ -1913,24 +2014,22 @@ Object.assign(CodemanApp.prototype, {
|
||||
this.terminal?.select?.(index % cols, Math.floor(index / cols), length);
|
||||
},
|
||||
|
||||
/** Long-press fired: select the word under the finger and arm drag-to-extend. */
|
||||
/** Long-press fired: swallow the platform gesture, then select a word if one exists. */
|
||||
_beginTouchSelection(clientX, clientY) {
|
||||
// Reaching the 350ms threshold makes this a long press even when the finger
|
||||
// landed on blank space. Arm every guard before looking for a word so Chrome
|
||||
// cannot focus xterm's hidden textarea, and leave _touchSelecting set so the
|
||||
// touchend branch preventDefaults the compatibility mouse sequence.
|
||||
this._blurMobileTerminalInput();
|
||||
this._suppressTrustedTapMouseEvents();
|
||||
this._armTouchSelectionFocusGuard();
|
||||
this._touchSelecting = true;
|
||||
const cell = this._touchSelectionCellAt(clientX, clientY);
|
||||
if (!cell) return false;
|
||||
const word = this._touchSelectionWordAt(cell);
|
||||
if (!word) return false;
|
||||
// The keyboard must not sit on top of the thing being selected, and the
|
||||
// composer would eat the selection on its next keystroke anyway.
|
||||
this._blurMobileTerminalInput();
|
||||
this._touchSelectionAnchor = word;
|
||||
this._touchSelecting = true;
|
||||
this._touchSelectionActive = true;
|
||||
// From here until the gesture ends, no trusted mouse event may reach xterm —
|
||||
// see _endTouchSelectionGesture for why — and the terminal input may not take
|
||||
// focus. Both are re-armed as the gesture continues, since their windows are
|
||||
// short and a press can be held for much longer.
|
||||
this._suppressTrustedTapMouseEvents();
|
||||
this._armTouchSelectionFocusGuard();
|
||||
this._applyTouchSelection(word.index, word.length);
|
||||
// Android answers; iOS ignores it silently. Both are fine.
|
||||
try {
|
||||
@@ -3322,7 +3421,107 @@ Object.assign(CodemanApp.prototype, {
|
||||
return performance.now() - this._lastUserScrollUpAt < window.CodemanTerminalInput.USER_SCROLL_STICKY_SUPPRESS_MS;
|
||||
},
|
||||
|
||||
/**
|
||||
* Watchdog for a frozen renderer.
|
||||
*
|
||||
* iOS DISCARDS scheduled requestAnimationFrame callbacks when a PWA goes to
|
||||
* the background — not deferred, never delivered. xterm's RenderDebouncer
|
||||
* only clears its `_animationFrame` handle from INSIDE that callback, so once
|
||||
* one is dropped the handle stays permanently non-undefined and every later
|
||||
* `refresh()` returns on its first line. Parsing is decoupled from rendering,
|
||||
* so bytes keep filling the buffer correctly and nothing throws: the terminal
|
||||
* is simply frozen until the page is reloaded.
|
||||
*
|
||||
* Codeman is more exposed than an app that mounts a terminal per session —
|
||||
* there is exactly ONE xterm instance for the whole page load, so a single
|
||||
* backgrounding can wedge it for the rest of the session.
|
||||
*
|
||||
* The heal is what `_innerRefresh` would have done: cancel the stale handle,
|
||||
* clear the field, and request a full repaint (which schedules a fresh rAF).
|
||||
* Cancelling a genuinely pending handle is harmless — the full repaint that
|
||||
* follows covers whatever it was going to draw.
|
||||
*
|
||||
* Discipline for reaching into xterm privates, and it is not optional: every
|
||||
* access is optional-chained and the whole body is wrapped, so a shape change
|
||||
* upstream degrades to a no-op. A self-heal that can break the terminal it is
|
||||
* healing is worse than no self-heal.
|
||||
*
|
||||
* ⚠️ The field path (`_core._renderService._renderDebouncer._animationFrame`)
|
||||
* is validated against xterm 6.x and CANNOT be covered by the CI gate:
|
||||
* `_renderService` is only constructed by `Terminal.open()`, which needs a
|
||||
* real DOM, and the gate runs in node. `test/xterm-private-api.test.ts` pins
|
||||
* the RESOLVED lockfile version instead, so ANY bump fails there — not only a
|
||||
* major — and sends someone to re-check this by hand; the declared `^6.0.0`
|
||||
* range was the wrong assertion in both directions, since 6.4.0 could rename a
|
||||
* private field while resolving inside it. `test/terminal-resilience.test.ts`
|
||||
* covers the decision half. If the path ever goes stale the watchdog silently
|
||||
* stops healing — that is the failure mode to watch for, and why the version
|
||||
* guard exists at all.
|
||||
*/
|
||||
_startRenderLivenessWatchdog() {
|
||||
this._stopRenderLivenessWatchdog();
|
||||
this._lastRenderAt = Date.now();
|
||||
this._lastTerminalWriteAt = 0;
|
||||
this._renderLivenessTimer = setInterval(() => {
|
||||
try {
|
||||
if (typeof CodemanRenderLiveness === 'undefined') return;
|
||||
const kick = CodemanRenderLiveness.shouldKickRenderer({
|
||||
wroteAt: this._lastTerminalWriteAt || 0,
|
||||
renderedAt: this._lastRenderAt || 0,
|
||||
now: Date.now(),
|
||||
// A hidden terminal legitimately stops rendering (xterm pauses it),
|
||||
// so only a VISIBLE one that owes us a frame counts as frozen.
|
||||
visible: document.visibilityState === 'visible' && !!this.terminal?.element?.isConnected,
|
||||
});
|
||||
if (!kick) return;
|
||||
const kicked = this._kickRenderer();
|
||||
_crashDiag.log(`RENDER STALL: kick=${kicked}`);
|
||||
// Treat the kick as the render for accounting purposes either way, so a
|
||||
// terminal we cannot heal logs once per stall rather than every tick.
|
||||
this._lastRenderAt = Date.now();
|
||||
} catch {
|
||||
/* a watchdog must never throw into the interval */
|
||||
}
|
||||
}, RENDER_LIVENESS_POLL_MS);
|
||||
},
|
||||
|
||||
_stopRenderLivenessWatchdog() {
|
||||
if (this._renderLivenessTimer) {
|
||||
clearInterval(this._renderLivenessTimer);
|
||||
this._renderLivenessTimer = null;
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Do what xterm's dropped `_innerRefresh` would have done. Never throws.
|
||||
* @returns {boolean} true if a stale handle was found and cleared.
|
||||
*/
|
||||
_kickRenderer() {
|
||||
try {
|
||||
const renderService = this.terminal?._core?._renderService;
|
||||
const debouncer = renderService?._renderDebouncer;
|
||||
if (!debouncer || typeof renderService.refreshRows !== 'function') return false;
|
||||
const handle = debouncer._animationFrame;
|
||||
if (handle === undefined) return false; // not wedged — nothing to clear
|
||||
try {
|
||||
cancelAnimationFrame(handle);
|
||||
} catch {
|
||||
/* a stale handle may no longer be cancellable; clearing it is the point */
|
||||
}
|
||||
debouncer._animationFrame = undefined;
|
||||
renderService.refreshRows(0, Math.max(0, (this.terminal.rows || 1) - 1));
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
},
|
||||
|
||||
batchTerminalWrite(data) {
|
||||
// Feed the renderer watchdog. Recorded before the buffer-load early return
|
||||
// below: a write that is queued rather than written still means the pipeline
|
||||
// owes us a frame once it drains.
|
||||
this._lastTerminalWriteAt = Date.now();
|
||||
|
||||
// If a buffer load (chunkedTerminalWrite) is in progress, queue live events
|
||||
// to prevent interleaving historical buffer data with live SSE data.
|
||||
// This is critical: interleaving causes cursor position chaos with Ink redraws.
|
||||
@@ -4054,8 +4253,9 @@ Object.assign(CodemanApp.prototype, {
|
||||
this._localEchoOverlay.suppressBufferDetection();
|
||||
this._flushedOffsets?.delete(this.activeSessionId);
|
||||
this._flushedTexts?.delete(this.activeSessionId);
|
||||
if (flushed.count > 0) {
|
||||
this.sendInput('\x7f'.repeat(flushed.count)).catch(() => {});
|
||||
const flushedLength = Array.from(flushed.text || '').length;
|
||||
if (flushedLength > 0) {
|
||||
this.sendInput('\x7f'.repeat(flushedLength)).catch(() => {});
|
||||
}
|
||||
} else {
|
||||
// In non-local-echo mode the TUI already owns the editable buffer. Ctrl+U
|
||||
@@ -4141,12 +4341,125 @@ Object.assign(CodemanApp.prototype, {
|
||||
return !ev.altKey && (ev.key || '').toLowerCase() === 'c';
|
||||
},
|
||||
|
||||
/**
|
||||
* xterm's current selection, cleaned for the clipboard. The transform itself
|
||||
* is CodemanCopySelection.clean in constants.js, beside decideAutoCopy; this
|
||||
* is the half that needs the live terminal.
|
||||
*
|
||||
* `text` is for the callers that already read the selection to decide whether
|
||||
* to copy at all (the Ctrl+C gate and the right-click handler), so the read is
|
||||
* not repeated.
|
||||
*
|
||||
* ⚠️ **Pass the RAW selection, never an already-cleaned one.** The trailing
|
||||
* trim alone is a fixed point, because a CR is consumed by the parser as a
|
||||
* cursor move and never stored in a cell, so the only \r the selection can
|
||||
* carry is the Windows line join. The MARGIN strip is not: it takes the
|
||||
* narrower of the declared width and the run every line shares, so a second
|
||||
* pass over an already-stripped block takes up to `margin` columns more. A
|
||||
* caller that cleans to decide whether to copy must still hand the raw text
|
||||
* to copyTerminalSelection, which cleans once on its own.
|
||||
*
|
||||
* A COLUMN selection comes back untouched. Alt+drag makes one (xterm's
|
||||
* shouldColumnSelect keys on altKey alone, and Codeman sets neither of the
|
||||
* terminals it creates with the one option that would disable it), and a
|
||||
* rectangle's whole point is that its rows line up, which trimming each row
|
||||
* to its own last glyph would destroy. xterm exposes the mode nowhere public,
|
||||
* so this reads the private field the way this file already reads
|
||||
* terminal._core for cell dimensions, and falls back to cleaning normally if
|
||||
* a future xterm renames it. SelectionMode.COLUMN is 3.
|
||||
*/
|
||||
cleanedTerminalSelection(text) {
|
||||
const raw = text ?? (this.terminal?.hasSelection?.() ? this.terminal.getSelection() : '');
|
||||
if (!raw) return '';
|
||||
if (this.terminal?._core?._selectionService?._activeSelectionMode === 3) return raw;
|
||||
const clean = window.CodemanCopySelection?.clean;
|
||||
if (!clean) return raw;
|
||||
const range = this._normalisedSelectionRange();
|
||||
return clean(raw, {
|
||||
margin: this._cliGutterColumns(),
|
||||
firstLinePartial: !!range && range.start.x > 0,
|
||||
});
|
||||
},
|
||||
|
||||
/**
|
||||
* xterm's selection range with its two ends in reading order.
|
||||
*
|
||||
* `getSelectionPosition()` reports `start` and `end` as the two ends of the
|
||||
* drag, and on xterm 6.0 it already hands back the earlier one first: it
|
||||
* reads `_selectionService.selectionStart`, whose getter returns the model's
|
||||
* `finalSelectionStart`, and that swaps the pair for a reversed selection.
|
||||
* A real upward mouse drag through chromium confirms it. The ordering here
|
||||
* is a guard rather than a fix. One layer down the same model exposes the
|
||||
* UNNORMALISED fields under the same two names, and a reversed pair would
|
||||
* make the row window below run backwards and collapse, which would report
|
||||
* no margin at all for every upward drag in a deep buffer.
|
||||
*
|
||||
* `terminal` names which xterm to read, defaulting to the primary pane's.
|
||||
* Pane B of a split owns a second terminal and passes it, because this file's
|
||||
* `this` is always the primary pane.
|
||||
*/
|
||||
_normalisedSelectionRange(terminal) {
|
||||
const range = (terminal ?? this.terminal)?.getSelectionPosition?.();
|
||||
if (!range?.start || !range?.end) return null;
|
||||
const { start, end } = range;
|
||||
const reversed = end.y < start.y || (end.y === start.y && end.x < start.x);
|
||||
return reversed ? { start: end, end: start } : { start, end };
|
||||
},
|
||||
|
||||
/**
|
||||
* How many columns to take off a copy from one session's pane: the transcript
|
||||
* gutter its CLI declares, or 0 when it declares none. `sessionId` defaults to
|
||||
* the active session, and Pane B of a split passes its own, so both panes of a
|
||||
* split strip the width their own CLI declares rather than Pane A's.
|
||||
*
|
||||
* ⚠️ Read from `window.__codemanTranscriptGutter`, the map the server derives
|
||||
* from the `transcriptGutter` CAPABILITY at render time — never an id literal
|
||||
* here, which is the registry's standing rule and is also what lets a CLI that
|
||||
* declares a gutter later work with no change to this file.
|
||||
*
|
||||
* ⚠️ DECLARED rather than measured off the buffer, and two measured versions
|
||||
* are why. Asking whether the pane painted spaces across the unused part of
|
||||
* each row separates a TUI from a shell perfectly where it fires and never
|
||||
* over-stripped, but it is a function of pane WIDTH, since that padding exists
|
||||
* only while a rendered line stops short of the CLI's own layout width and
|
||||
* Claude Code's prose wraps to fill it: the share of padded rows on one live
|
||||
* transcript ran 44%, 6%, 6%, 7% and 87% at 123, 160, 198, 235 and 298
|
||||
* columns, so the strip did nothing at any ordinary window size. Taking the
|
||||
* narrowest indent on the rows around the selection instead fires at every
|
||||
* width and over-strips on about 1% of them, because a file listing inside the
|
||||
* transcript can be the narrowest thing on screen. A declared width does
|
||||
* neither, and it reads no buffer rows at all on a path that runs on every
|
||||
* Ctrl+C.
|
||||
*
|
||||
* A missing map means no session gets a strip, the same direction an
|
||||
* unmeasured CLI takes by declaring nothing.
|
||||
*/
|
||||
_cliGutterColumns(sessionId) {
|
||||
if (!this._copyStripMarginEnabled()) return 0;
|
||||
const byMode = window.__codemanTranscriptGutter;
|
||||
if (!byMode || typeof byMode !== 'object') return 0;
|
||||
const mode = this.sessions?.get(sessionId ?? this.activeSessionId)?.mode;
|
||||
const columns = mode ? byMode[mode] : 0;
|
||||
return Number.isInteger(columns) && columns > 0 ? columns : 0;
|
||||
},
|
||||
|
||||
// Copy the current terminal selection. Goes through _copyText (Clipboard API,
|
||||
// then a hidden-textarea + execCommand fallback) because install.sh's LAN
|
||||
// option serves plain HTTP, where navigator.clipboard is undefined.
|
||||
async copyTerminalSelection(text) {
|
||||
const selection = text ?? (this.terminal.hasSelection?.() ? this.terminal.getSelection() : '');
|
||||
if (!selection) return false;
|
||||
const selection = this.cleanedTerminalSelection(text);
|
||||
// trim(), not emptiness: a multi-row drag across padding cleans to newlines
|
||||
// alone, which are truthy, and a bare newline pasted into a chat composer
|
||||
// or a shell submits the line. decideAutoCopy applies the same rule.
|
||||
if (!selection.trim()) {
|
||||
// Clearing is feedback, not protection. The Ctrl+C gate tests the CLEANED
|
||||
// selection, so a padding-only selection left set can no longer swallow a
|
||||
// later interrupt; it cleans to '' and the press reaches the PTY. What the
|
||||
// clear avoids is a highlight that sits there having copied nothing.
|
||||
this.terminal?.clearSelection?.();
|
||||
this.showToast('Nothing to copy', 'warning');
|
||||
return false;
|
||||
}
|
||||
const ok = await this._copyText(selection);
|
||||
if (ok) {
|
||||
// Clearing is what makes a second Ctrl+C an interrupt (and xterm already
|
||||
@@ -4162,6 +4475,26 @@ Object.assign(CodemanApp.prototype, {
|
||||
return ok;
|
||||
},
|
||||
|
||||
/**
|
||||
* Whether this device wants the pane's left margin off the clipboard
|
||||
* (`copyStripMargin`, per-device, default ON).
|
||||
*
|
||||
* Read here rather than mirrored into a field, for the same reason
|
||||
* `_autoCopySelectionEnabled` is: there is then no apply-path a future
|
||||
* settings save can forget to call, and the toggle takes effect on the next
|
||||
* selection instead of the next reload. ⚠️ The test is `!== false`, not
|
||||
* `=== true`: this one defaults ON, and the desktop branch of
|
||||
* getDefaultSettings returns {} and leans on the read sites for defaults, so
|
||||
* a device that has never opened App Settings has no stored value at all.
|
||||
*/
|
||||
_copyStripMarginEnabled() {
|
||||
try {
|
||||
return this.loadAppSettingsFromStorage?.()?.copyStripMargin !== false;
|
||||
} catch {
|
||||
return true;
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Auto Copy's ON/OFF, read at flush time from the CACHED settings object
|
||||
* (loadAppSettingsFromStorage memoizes, so this is not a localStorage hit).
|
||||
@@ -4195,9 +4528,15 @@ Object.assign(CodemanApp.prototype, {
|
||||
async _flushAutoCopySelection() {
|
||||
const decide = window.CodemanAutoCopy?.decide;
|
||||
if (!decide || !this.terminal) return;
|
||||
const text = this.terminal.hasSelection?.() ? this.terminal.getSelection() : '';
|
||||
// The toggle is read FIRST because Auto Copy is off by default: reading and
|
||||
// cleaning a selection that can run to the 50 000-row scrollback ceiling
|
||||
// costs real time on a phone, and every mouseup would pay it for nothing.
|
||||
// Cleaning before decide() then means its dedupe and size cap both measure
|
||||
// the text that actually reaches the clipboard, not the padded rows behind.
|
||||
const enabled = this._autoCopySelectionEnabled();
|
||||
const text = enabled ? this.cleanedTerminalSelection() : '';
|
||||
const verdict = decide({
|
||||
enabled: this._autoCopySelectionEnabled(),
|
||||
enabled,
|
||||
text,
|
||||
lastCopied: this._autoCopyLastText,
|
||||
pending: !!this._autoCopyPending,
|
||||
@@ -5056,11 +5395,15 @@ Object.assign(CodemanApp.prototype, {
|
||||
setFontSize(size) {
|
||||
this.terminal.options.fontSize = size;
|
||||
document.getElementById('fontSizeDisplay').textContent = size;
|
||||
this.fitAddon.fit();
|
||||
this._refitAfterCellSizeChange();
|
||||
localStorage.setItem('codeman-font-size', size);
|
||||
// Update overlay font cache and re-render at new cell dimensions
|
||||
this._localEchoOverlay?.refreshFont();
|
||||
this._predictiveEcho?.refreshFont();
|
||||
if (this._splitPane?.terminal) {
|
||||
this._splitPane.terminal.options.fontSize = size;
|
||||
this._splitPane.fitAddon?.fit();
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
@@ -5081,11 +5424,15 @@ Object.assign(CodemanApp.prototype, {
|
||||
// without needing a tab switch. The fit below still runs, so the terminal
|
||||
// is never left unfitted if the wait is slow.
|
||||
this._terminalFontReady = this._awaitTerminalFont().then(() => {
|
||||
if (this.terminal?.options?.fontFamily === resolved) this.fitAddon?.fit();
|
||||
if (this.terminal?.options?.fontFamily === resolved) this._refitAfterCellSizeChange();
|
||||
});
|
||||
this.fitAddon?.fit();
|
||||
this._refitAfterCellSizeChange();
|
||||
this._localEchoOverlay?.refreshFont();
|
||||
this._predictiveEcho?.refreshFont();
|
||||
if (this._splitPane?.terminal) {
|
||||
this._splitPane.terminal.options.fontFamily = resolved;
|
||||
this._splitPane.fitAddon?.fit();
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
@@ -5122,9 +5469,9 @@ Object.assign(CodemanApp.prototype, {
|
||||
// rasterized yet. Re-arm the wait and fit again once it settles; the fit
|
||||
// below still runs, so the terminal is never left unfitted.
|
||||
this._terminalFontReady = this._awaitTerminalFont().then(() => {
|
||||
if (this.terminal?.options?.fontWeight === fontWeight) this.fitAddon?.fit();
|
||||
if (this.terminal?.options?.fontWeight === fontWeight) this._refitAfterCellSizeChange();
|
||||
});
|
||||
this.fitAddon?.fit();
|
||||
this._refitAfterCellSizeChange();
|
||||
this._localEchoOverlay?.refreshFont();
|
||||
this._predictiveEcho?.refreshFont();
|
||||
for (const [, entry] of this.teammateTerminals || []) {
|
||||
@@ -5137,6 +5484,11 @@ Object.assign(CodemanApp.prototype, {
|
||||
/* pane not laid out yet — its own resize observer refits it */
|
||||
}
|
||||
}
|
||||
if (this._splitPane?.terminal) {
|
||||
this._splitPane.terminal.options.fontWeight = fontWeight;
|
||||
this._splitPane.terminal.options.fontWeightBold = fontWeightBold;
|
||||
this._splitPane.fitAddon?.fit();
|
||||
}
|
||||
},
|
||||
|
||||
loadFontSize() {
|
||||
@@ -5210,19 +5562,126 @@ Object.assign(CodemanApp.prototype, {
|
||||
},
|
||||
|
||||
/**
|
||||
* Get terminal dimensions with minimum enforcement.
|
||||
* Prevents extremely narrow terminals that cause vertical text wrapping.
|
||||
* The geometry this terminal would report right now, floors applied.
|
||||
* Reads only — `syncTerminalGeometry()` is what makes it true of xterm.
|
||||
* @returns {{cols: number, rows: number}|null}
|
||||
*/
|
||||
getTerminalDimensions() {
|
||||
const MIN_COLS = 40;
|
||||
const MIN_ROWS = 10;
|
||||
const dims = this.fitAddon?.proposeDimensions();
|
||||
// Never throws. `proposeDimensions()` reads a rendered element and throws
|
||||
// on a terminal that has been disposed or detached mid-resize, which is an
|
||||
// ordinary outcome on a tab switch — and this is called from the settle
|
||||
// timer and the resize observer, where an exception takes the rest of the
|
||||
// callback (the padding fit, the scroll restore, the SIGWINCH) with it.
|
||||
try {
|
||||
return window.CodemanTerminalGeometry.clampTerminalDimensions(this.fitAddon?.proposeDimensions());
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Fit xterm to its container and return the geometry that was APPLIED.
|
||||
*
|
||||
* ⚠️ THE ONLY function that may change the terminal's size, and the only
|
||||
* source of the numbers sent to the server. `fitAddon.fit()` on its own is
|
||||
* not enough and the gap is issue #464: fit() resizes xterm to
|
||||
* `proposeDimensions()` RAW, while every server-facing path reported those
|
||||
* dimensions floored at 40x10. Whenever the floor bit — a phone with the
|
||||
* keyboard up routinely proposes under ten rows — the PTY was told one shape
|
||||
* and xterm held another, and Claude Code then computed every repaint for a
|
||||
* screen that did not exist. See the note in constants.js for what that
|
||||
* renders as, and why the floor is not negotiable at either end.
|
||||
*
|
||||
* Three call sites each used to do their own fit-then-clamp
|
||||
* (`throttledResize`, `sendResize`, KeyboardHandler's one-shot), which is
|
||||
* three chances to disagree; two of them also re-read `proposeDimensions()`
|
||||
* after the fit, so a container that moved in between — `_shrinkPaddingToFit`
|
||||
* runs exactly there — changed the answer without touching xterm.
|
||||
*
|
||||
* The second resize only happens when the floor actually bites, so the
|
||||
* ordinary path still reflows once, as before.
|
||||
*
|
||||
* @returns {{cols: number, rows: number}|null} null when the terminal cannot be measured
|
||||
*/
|
||||
syncTerminalGeometry() {
|
||||
if (!this.fitAddon || !this.terminal) return null;
|
||||
try {
|
||||
this.fitAddon.fit();
|
||||
} catch {
|
||||
/* a disposed or unattached terminal cannot be fitted; fall through to the read */
|
||||
}
|
||||
const dims = this.getTerminalDimensions();
|
||||
if (!dims) return null;
|
||||
return {
|
||||
cols: Math.max(dims.cols, MIN_COLS),
|
||||
rows: Math.max(dims.rows, MIN_ROWS),
|
||||
};
|
||||
if (!this._resizeTerminalTo(dims)) return null;
|
||||
// The floor can leave this terminal wider than the box that shows it, and
|
||||
// that clips columns with no gesture to reach them (issue #464, item 4).
|
||||
this._scheduleOverflowAffordanceSync();
|
||||
return dims;
|
||||
},
|
||||
|
||||
/**
|
||||
* The geometry to ASK the server for, applied locally only as far as the PTY
|
||||
* can follow it.
|
||||
*
|
||||
* Ordinarily that is all of it: `syncTerminalGeometry()`. While another
|
||||
* device holds the width (`_paneWidthRefused`, set by `_onPtyGeometryReport`)
|
||||
* it is not. Fitting then re-wraps xterm to the container's columns, the
|
||||
* request is refused, the report puts the PTY's columns back, and the whole
|
||||
* buffer re-wraps twice per ask, with the viewport pointing at a different
|
||||
* part of the scrollback in between. The mobile retry asks every 30 seconds,
|
||||
* so that happened on a timer for as long as the refusal lasted. So the
|
||||
* columns stay at the PTY's (the #464 invariant: the browser never draws at
|
||||
* a width the PTY does not have), the rows follow the container (they are
|
||||
* never adopted, see reconcilePtyGeometry), and the container's columns go
|
||||
* out as the request. An accepted request is adopted by the report.
|
||||
*
|
||||
* @returns {{cols: number, rows: number}|null} the geometry to request
|
||||
*/
|
||||
_geometryForResizeRequest() {
|
||||
if (!this._paneWidthRefused) return this.syncTerminalGeometry();
|
||||
const wanted = this.getTerminalDimensions();
|
||||
if (!wanted || !this.terminal) return null;
|
||||
if (!this._resizeTerminalTo({ cols: this.terminal.cols, rows: wanted.rows })) return null;
|
||||
this._scheduleOverflowAffordanceSync();
|
||||
return wanted;
|
||||
},
|
||||
|
||||
/**
|
||||
* Re-measure after something changed the CELL size, and tell the server.
|
||||
*
|
||||
* ⚠️ A font change is a geometry change. Bigger glyphs mean fewer columns in
|
||||
* the same box, and the PTY is drawing for a column count nobody updated:
|
||||
* `setFontSize`, `setFontFamily` and `setFontWeight` all refitted the terminal
|
||||
* and sent NOTHING, so raising the font on a phone could drop the browser
|
||||
* below the columns the CLI was still wrapping at until some unrelated resize
|
||||
* event happened along. That is issue #464 reached through the font menu.
|
||||
*
|
||||
* With no session there is no PTY to tell, and a session detached into its own
|
||||
* window is not this terminal's to resize — `sendResize` makes that call, and
|
||||
* fits as its first synchronous step, so this never fits twice.
|
||||
*/
|
||||
_refitAfterCellSizeChange() {
|
||||
if (this.activeSessionId) {
|
||||
this.sendResize(this.activeSessionId)?.catch?.(() => {});
|
||||
return;
|
||||
}
|
||||
this.syncTerminalGeometry();
|
||||
},
|
||||
|
||||
/**
|
||||
* Make xterm exactly `dims`. Idempotent, and never throws at a caller — a
|
||||
* terminal disposed mid-resize is an ordinary outcome on a tab switch.
|
||||
* @returns {{cols: number, rows: number}|null} the applied geometry
|
||||
*/
|
||||
_resizeTerminalTo(dims) {
|
||||
if (!this.terminal || !dims) return null;
|
||||
if (this.terminal.cols === dims.cols && this.terminal.rows === dims.rows) return dims;
|
||||
try {
|
||||
this.terminal.resize(dims.cols, dims.rows);
|
||||
return dims;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
@@ -5232,21 +5691,26 @@ Object.assign(CodemanApp.prototype, {
|
||||
* @returns {Promise<boolean>} Whether dimensions changed from the last send
|
||||
*/
|
||||
async sendResize(sessionId, options = {}) {
|
||||
// Fit terminal to container before reading dimensions — ensures local
|
||||
// terminal size matches what we report to the server PTY.
|
||||
if (this.fitAddon) this.fitAddon.fit();
|
||||
// One PTY cannot hold two sizes. A detached session is owned by its own
|
||||
// window, and the dashboard's terminal is narrower than that window because
|
||||
// the session rail takes width the popup does not have — so both sizing it
|
||||
// makes the CLI draw frames that fit neither, which garbles the popup. The
|
||||
// dashboard yields; the solo window sizes what it alone displays.
|
||||
// (_maybeRefetchFullHistory already stands aside for the same reason.)
|
||||
// ⚠️ AFTER the fit, never before: the local reflow keeps the dashboard's own
|
||||
// xterm right, and only the SERVER write is the dashboard's to withhold —
|
||||
// the mobile-keyboard guard below draws exactly this line. tab-rail-resize
|
||||
// performs its one settle-time refit through this call and has no fallback.
|
||||
// ⚠️ BEFORE the fit, never after. This used to fit first and withhold only
|
||||
// the server write, on the reasoning that the local reflow keeps the
|
||||
// dashboard's own xterm right. It does not: it leaves this xterm at a shape
|
||||
// the PTY was never told about, which is the #464 divergence exactly — and
|
||||
// the popup that DOES own the PTY is drawing for its own width, so the
|
||||
// dashboard's reflow is to a size nothing is rendering for. Withholding the
|
||||
// resize means withholding all of it. tab-rail-resize performs its one
|
||||
// settle-time refit through this call and has no fallback, which is correct:
|
||||
// a pane it does not own is not its to refit either.
|
||||
if (!this.isSoloWindow && this.detachedSessions?.has(sessionId)) return false;
|
||||
const dims = this.getTerminalDimensions();
|
||||
// Fit, floor, and apply in one step so the numbers below are the numbers
|
||||
// xterm is actually holding (or, while another device holds the width,
|
||||
// the numbers this container would hold if the PTY followed).
|
||||
const dims = this._geometryForResizeRequest();
|
||||
if (!dims) return false;
|
||||
// Did the dimensions actually change since the last resize we sent? Callers
|
||||
// use this to skip work (e.g. the post-resize TUI-redraw settle) when no
|
||||
@@ -5279,14 +5743,150 @@ Object.assign(CodemanApp.prototype, {
|
||||
}
|
||||
const body = { ...dims, viewportType };
|
||||
if (options.force) body.force = true;
|
||||
await fetch(`/api/sessions/${sessionId}/resize`, {
|
||||
const res = await fetch(`/api/sessions/${sessionId}/resize`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
// Same report the WS path gets as a {"t":"zc"} frame. An older server
|
||||
// answers `{}`, which reconciles to a no-op rather than throwing.
|
||||
try {
|
||||
const applied = (await res.json())?.data ?? {};
|
||||
this._onPtyGeometryReport(sessionId, applied.cols, applied.rows);
|
||||
} catch {
|
||||
/* a body that is not JSON tells us nothing about the PTY; keep our own geometry */
|
||||
}
|
||||
return changed;
|
||||
},
|
||||
|
||||
/**
|
||||
* Adopt the geometry the server says the PTY actually has.
|
||||
*
|
||||
* ⚠️ The server is the authority and this client is not always obeyed.
|
||||
* `Session.resize` declines a small-viewport request outright while a desktop
|
||||
* connection holds an active sizing claim, and says nothing — resize was
|
||||
* write-only until #464. A terminal that keeps its own shape after such a
|
||||
* refusal does not render "too narrow", it renders GARBLED: Claude Code wraps
|
||||
* its frame at the width it was told and walks the cursor up that many rows,
|
||||
* so a mismatch makes its erase count come out short and each repaint paints
|
||||
* over rows it never cleared. Measured against a real xterm — a PTY believing
|
||||
* 120 columns against a 62-column terminal draws every wrapped line twice.
|
||||
*
|
||||
* Adopting can leave the pane wider than the viewport, and the container is
|
||||
* `overflow: hidden`, so `.term-overflows-x` grants horizontal reach for exactly
|
||||
* as long as the mismatch lasts. Correct-and-reachable beats correct-and-
|
||||
* clipped beats garbled; nothing here is worth trapping content behind.
|
||||
*
|
||||
* Self-resolving: `_startMobileResizeRetry` re-sends this device's dimensions
|
||||
* on a timer, so the pane comes back to this screen once the desktop goes
|
||||
* idle, and the next report clears the class and the notice with it.
|
||||
*/
|
||||
_onPtyGeometryReport(sessionId, cols, rows) {
|
||||
if (!this.terminal || sessionId !== this.activeSessionId) return;
|
||||
const local = { cols: this.terminal.cols, rows: this.terminal.rows };
|
||||
const { adopt } = window.CodemanTerminalGeometry.reconcilePtyGeometry(local, { cols, rows });
|
||||
// Columns only, and the local row count is kept — see reconcilePtyGeometry
|
||||
// for why adopting rows put the CLI's input line below the container with
|
||||
// nothing able to scroll to it.
|
||||
if (adopt && this._resizeTerminalTo({ cols, rows: local.rows })) {
|
||||
// The numbers we would report next are now the PTY's, not the container's:
|
||||
// without this the dedupe in throttledResize/sendResize compares against a
|
||||
// request that was refused and suppresses the retry that recovers the pane.
|
||||
this._lastResizeDims = { cols, rows: local.rows };
|
||||
}
|
||||
// Is the PTY at a width this container did not ask for? Compared against
|
||||
// what we WOULD request, not against what the terminal currently holds:
|
||||
// once adopted those two are equal, so the second question answers itself
|
||||
// false and the condition would look resolved while it is still true.
|
||||
// The floor widens this terminal too, and that is the reader's own font
|
||||
// setting rather than another device — hence the comparison, not `>`.
|
||||
const wanted = this.getTerminalDimensions();
|
||||
this._paneWidthRefused = !!wanted && Number.isFinite(cols) && cols !== wanted.cols;
|
||||
this._scheduleOverflowAffordanceSync();
|
||||
},
|
||||
|
||||
/**
|
||||
* Measure on the NEXT frame, coalesced.
|
||||
*
|
||||
* `terminal.resize()` updates the buffer synchronously but the screen element
|
||||
* takes its new width with the render, so measuring in the same tick reads
|
||||
* the size the terminal just left. Coalesced because a settling container
|
||||
* fires several resizes and only the last one's measurement is the truth.
|
||||
*/
|
||||
_scheduleOverflowAffordanceSync() {
|
||||
if (typeof requestAnimationFrame !== 'function') {
|
||||
this._syncTerminalOverflowAffordance();
|
||||
return;
|
||||
}
|
||||
if (this._overflowAffordanceFrame) return;
|
||||
this._overflowAffordanceFrame = requestAnimationFrame(() => {
|
||||
this._overflowAffordanceFrame = null;
|
||||
this._syncTerminalOverflowAffordance();
|
||||
});
|
||||
},
|
||||
|
||||
/**
|
||||
* Let the reader reach a pane wider than the box that shows it.
|
||||
*
|
||||
* ⚠️ Keyed on what actually does not FIT, not on a PTY mismatch. Two
|
||||
* different causes put the terminal wider than its container and both leave
|
||||
* columns unreachable behind `.terminal-container`'s clip:
|
||||
*
|
||||
* - another device holds the sizing claim, so this terminal adopts a width
|
||||
* it did not ask for; and
|
||||
* - the 40-column floor. On a 360px phone, font 18 applies 40 columns and
|
||||
* paints 433px, and font 24 paints 578px — 218px, 38% of the pane, with no
|
||||
* gesture that could reach it. `increaseFontSize` goes to 24 and applies
|
||||
* immediately, so that is two taps away, and the PTY agrees with the
|
||||
* terminal throughout: a mismatch test would never fire.
|
||||
*
|
||||
* Measured rather than derived from cell arithmetic, because the cell width
|
||||
* is fractional and the container's padding is not ours to assume. One pixel
|
||||
* of slack keeps sub-pixel rounding from flapping the class.
|
||||
*/
|
||||
_syncTerminalOverflowAffordance() {
|
||||
// ⚠️ Nothing in here may throw. It runs off every geometry change, which is
|
||||
// the resize path, and the affordance is cosmetic: a terminal that cannot
|
||||
// be measured — disposed mid-resize, or a harness with no real DOM — must
|
||||
// lose the scroll affordance, never the resize.
|
||||
let container = null;
|
||||
let overflows = false;
|
||||
try {
|
||||
container = document.getElementById('terminalContainer');
|
||||
const screen = container?.querySelector('.xterm-screen');
|
||||
if (container && screen) {
|
||||
overflows = screen.getBoundingClientRect().width - container.clientWidth > 1;
|
||||
}
|
||||
} catch {
|
||||
/* unmeasurable; fall through with the affordance off */
|
||||
}
|
||||
container?.classList.toggle('term-overflows-x', overflows);
|
||||
// The notice tells the reader to scroll sideways, so it is only true advice
|
||||
// once there is something to scroll. A wide PTY on a screen wide enough to
|
||||
// show it needs no explanation and gets none.
|
||||
if (!overflows || !this._paneWidthRefused) {
|
||||
this._paneOwnedElsewhere = false;
|
||||
return;
|
||||
}
|
||||
this._notePaneOwnedElsewhere();
|
||||
},
|
||||
|
||||
/**
|
||||
* Say, once, that this pane's width belongs to another device.
|
||||
*
|
||||
* Once per transition, not per report: reports arrive on every resize, and a
|
||||
* toast that repeats is noise about a situation already on screen. Silent
|
||||
* when it resolves — the pane simply reflows back to this screen.
|
||||
*/
|
||||
_notePaneOwnedElsewhere() {
|
||||
if (this._paneOwnedElsewhere) return;
|
||||
this._paneOwnedElsewhere = true;
|
||||
// 53 characters: measured at one line on a 430px phone. The longer
|
||||
// wording wrapped to two, which is a lot of the terminal to cover for a
|
||||
// notice about a condition that resolves itself.
|
||||
this.showToast('Another device is setting the width — scroll sideways', 'info');
|
||||
},
|
||||
|
||||
/**
|
||||
* Send input to the active session.
|
||||
* @param {string} input - Text to send (include \r for Enter)
|
||||
@@ -5357,6 +5957,13 @@ Object.assign(CodemanApp.prototype, {
|
||||
}
|
||||
}
|
||||
}
|
||||
if (this._splitPane?.terminal) {
|
||||
this._splitPane.terminal.options.minimumContrastRatio = minimumContrastRatio;
|
||||
this._splitPane.terminal.options.theme = { ...theme };
|
||||
try {
|
||||
this._splitPane.terminal.refresh(0, this._splitPane.terminal.rows - 1);
|
||||
} catch {}
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -128,6 +128,18 @@ const APP_VERSION = (() => {
|
||||
const LOCAL_CLONE_ADMIN_ONLY =
|
||||
'Cloning from a local path is admin-only in multi-user mode. Use a repository URL instead.';
|
||||
|
||||
/**
|
||||
* Whether a clone or preflight must run with git's credential helpers cleared:
|
||||
* a non-admin in multi-user mode. Every user's git runs as the one server
|
||||
* account, so its helpers (the Docker image's opt-in `gh`/`az` ones, or any
|
||||
* `gh auth setup-git`) would otherwise read a private repository with the
|
||||
* signed-in admin's credentials, the same boundary the local-transport rule
|
||||
* above guards. Admins and single-user mode keep the account's own helpers.
|
||||
*/
|
||||
export function cloneWithoutCredentialHelpers(req: FastifyRequest): boolean {
|
||||
return isMultiUserMode() && !isAdmin(req);
|
||||
}
|
||||
|
||||
/**
|
||||
* The one line of git's stderr worth appending to an error message.
|
||||
*
|
||||
@@ -475,7 +487,9 @@ export function registerCaseRoutes(app: FastifyInstance, ctx: EventPort & Config
|
||||
if (!isGitAvailable()) {
|
||||
return { success: true, data: { parse: parsed, gitAvailable: false } };
|
||||
}
|
||||
const remote = await probeGitRemote(parsed.repository);
|
||||
const remote = await probeGitRemote(parsed.repository, undefined, {
|
||||
withoutCredentialHelpers: cloneWithoutCredentialHelpers(req),
|
||||
});
|
||||
return { success: true, data: { parse: parsed, remote, gitAvailable: true } };
|
||||
}
|
||||
);
|
||||
@@ -491,8 +505,10 @@ export function registerCaseRoutes(app: FastifyInstance, ctx: EventPort & Config
|
||||
* request died mid-clone still sees the case appear over SSE when git finishes.
|
||||
*
|
||||
* Deliberately NOT admin-gated in multi-user mode: unlike `/api/cases/link`,
|
||||
* this writes only inside the caller's own `resolveCasesDir`. The one exception
|
||||
* is a `local`-transport source, which would read through that boundary.
|
||||
* this writes only inside the caller's own `resolveCasesDir`. Two things would
|
||||
* otherwise read through that boundary: a `local`-transport source (refused for
|
||||
* non-admins) and the server account's git credential helpers, which every user
|
||||
* shares (cleared for non-admins, see `cloneWithoutCredentialHelpers`).
|
||||
*
|
||||
* Repository contents win over scaffolding: an existing CLAUDE.md is left
|
||||
* alone, and hooks are MERGED into whatever `.claude/settings.local.json` the
|
||||
@@ -562,6 +578,7 @@ export function registerCaseRoutes(app: FastifyInstance, ctx: EventPort & Config
|
||||
const clone = await cloneRepository({
|
||||
repository: parsed.repository,
|
||||
destination: casePath,
|
||||
withoutCredentialHelpers: cloneWithoutCredentialHelpers(req),
|
||||
...(ref ? { ref } : {}),
|
||||
...(shallow ? { shallow: true } : {}),
|
||||
});
|
||||
|
||||
@@ -16,16 +16,52 @@
|
||||
|
||||
import type { FastifyInstance, FastifyRequest } from 'fastify';
|
||||
import { ApiErrorCode, createErrorResponse, type ApiResponse } from '../../types.js';
|
||||
import { isAdmin, parseBody } from '../route-helpers.js';
|
||||
import { isAdmin, parseBody, readJsonConfig, SETTINGS_PATH } from '../route-helpers.js';
|
||||
import { isMultiUserMode } from '../../config/multiuser.js';
|
||||
import { getDataDir } from '../../config/instance.js';
|
||||
import { isBlockedWebviewUrl } from '../webview-egress-policy.js';
|
||||
import { egressBlockedReason, webviewFetch } from '../webview-egress.js';
|
||||
import { CustomModelHostSchema } from '../schemas.js';
|
||||
import { readCustomModelHosts, writeCustomModelHosts, type CustomModelHost } from '../../custom-model-hosts.js';
|
||||
import type { CliEntry } from '../../config/cli-registry/types.js';
|
||||
|
||||
const CODEMAN_CONFIG_DIR = getDataDir();
|
||||
const DISCOVER_TIMEOUT_MS = 8000;
|
||||
const PROPS_TIMEOUT_MS = 5000;
|
||||
|
||||
/**
|
||||
* Claude Code's own system prompt + tool schemas cost roughly this many tokens on EVERY
|
||||
* request, before a single character of conversation history — confirmed live, twice, on
|
||||
* requests reporting `in:0 out:0` (the very first exchange) failing at ~36.4K tokens. No
|
||||
* `CLAUDE_CODE_MAX_CONTEXT_TOKENS` value fixes this: that setting only changes when Claude
|
||||
* Code decides to COMPACT conversation history, and there is no history yet on the first
|
||||
* message for it to trim. A model whose real context is below this floor will refuse
|
||||
* Claude Code's very first message outright, unconditionally.
|
||||
*
|
||||
* Set well above the ~36.4K actually measured — CLAUDE.md size, active MCP servers, and
|
||||
* enabled skills all add to a project's real baseline, so the observed figure is a floor
|
||||
* for THAT one workspace, not a ceiling for every one. Erring conservative here means a
|
||||
* borderline-safe model still gets warned about (the user can launch anyway), rather than
|
||||
* this floor missing a genuinely-too-small one because a smaller test project happened to
|
||||
* fit.
|
||||
*/
|
||||
export const CLAUDE_MIN_SAFE_CONTEXT_TOKENS = 40000;
|
||||
|
||||
/**
|
||||
* True when applying this model to this CLI is heading for a guaranteed first-message
|
||||
* failure per `CLAUDE_MIN_SAFE_CONTEXT_TOKENS` above. Gated on `contextLengthVar` (today,
|
||||
* only claude's registry entry declares one) rather than a hardcoded mode check: a CLI
|
||||
* with a small enough baseline of its own to never trip this would have no reason to
|
||||
* declare the field in the first place, so the check simply never applies to it.
|
||||
*/
|
||||
export function exceedsSafeContextFloor(
|
||||
entry: Pick<CliEntry, 'capabilities'>,
|
||||
contextLength: number | undefined
|
||||
): boolean {
|
||||
const cap = entry.capabilities.customModelInjection;
|
||||
if (cap.kind !== 'env' || !cap.contextLengthVar) return false;
|
||||
return typeof contextLength === 'number' && contextLength < CLAUDE_MIN_SAFE_CONTEXT_TOKENS;
|
||||
}
|
||||
|
||||
function adminOnly(req: FastifyRequest, reply: { code: (n: number) => unknown }): ApiResponse<never> | null {
|
||||
if (!isMultiUserMode() || isAdmin(req)) return null;
|
||||
@@ -33,7 +69,58 @@ function adminOnly(req: FastifyRequest, reply: { code: (n: number) => unknown })
|
||||
return createErrorResponse(ApiErrorCode.FORBIDDEN, 'Admin only in multi-user mode');
|
||||
}
|
||||
|
||||
async function discoverModels(host: Pick<CustomModelHost, 'baseUrl' | 'apiKey' | 'authStyle'>): Promise<string[]> {
|
||||
/**
|
||||
* `defaultModelId` names the model the Run-menu picker applies for this endpoint with
|
||||
* no further choice, so it must actually be one of the discovered `models` — a schema
|
||||
* `.refine()` can't see across the two fields the way this can, and would also run on
|
||||
* every unrelated field edit rather than only when either of these two changes.
|
||||
*/
|
||||
function invalidDefaultModel(host: Pick<CustomModelHost, 'defaultModelId' | 'models'>): ApiResponse<never> | null {
|
||||
if (host.defaultModelId === undefined) return null;
|
||||
if ((host.models ?? []).includes(host.defaultModelId)) return null;
|
||||
return createErrorResponse(
|
||||
ApiErrorCode.INVALID_INPUT,
|
||||
'defaultModelId must be one of the endpoint’s discovered models'
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Never hand the stored credential back to the browser, on GET, POST or PUT
|
||||
* alike — the file is written 0600 precisely because it holds one. `apiKeySet`
|
||||
* is what lets the editor say "unchanged if left blank" without the client
|
||||
* ever holding the real value: `applyStoredApiKey()` below is the other half,
|
||||
* treating an absent key on PUT as "keep the stored one" rather than clearing
|
||||
* it, which is what makes never returning it survivable for the edit flow.
|
||||
*/
|
||||
function redactApiKey(host: CustomModelHost): Omit<CustomModelHost, 'apiKey'> & { apiKeySet: boolean } {
|
||||
const { apiKey, ...rest } = host;
|
||||
return { ...rest, apiKeySet: !!apiKey };
|
||||
}
|
||||
|
||||
/**
|
||||
* A PUT body with no `apiKey` (or a blank one) means "leave it alone", never
|
||||
* "clear it": the editor never receives the real value to resend deliberately
|
||||
* unchanged (see redactApiKey), so the only way it can tell the two apart is
|
||||
* by omission. There is deliberately no way to CLEAR a key back to unset this
|
||||
* way — a pre-existing limitation, not something this changes.
|
||||
*/
|
||||
function applyStoredApiKey(incoming: CustomModelHost, existing: CustomModelHost): CustomModelHost {
|
||||
return incoming.apiKey ? incoming : { ...incoming, apiKey: existing.apiKey };
|
||||
}
|
||||
|
||||
/**
|
||||
* `modelContextLengths`/`modelSizesGB` are server-populated by discovery, never
|
||||
* user-entered, and PUT replaces the whole record — so merge them back in from the
|
||||
* stored host rather than trust whatever the editor's body carried (or omitted).
|
||||
* The editor only ever sends `models`/`lastDiscoveredAt` verbatim from its cached
|
||||
* copy; requiring it to also round-trip these two is exactly the kind of thing a
|
||||
* future caller forgets, same class of bug `applyStoredApiKey` exists to prevent.
|
||||
*/
|
||||
function applyDiscoveredFields(incoming: CustomModelHost, existing: CustomModelHost): CustomModelHost {
|
||||
return { ...incoming, modelContextLengths: existing.modelContextLengths, modelSizesGB: existing.modelSizesGB };
|
||||
}
|
||||
|
||||
function authHeaders(host: Pick<CustomModelHost, 'apiKey' | 'authStyle'>): Record<string, string> {
|
||||
const headers: Record<string, string> = {};
|
||||
const apiKey = host.apiKey?.trim();
|
||||
// Exactly ONE header, never both — see custom-model-hosts.ts's CustomModelAuthStyle
|
||||
@@ -41,14 +128,137 @@ async function discoverModels(host: Pick<CustomModelHost, 'baseUrl' | 'apiKey' |
|
||||
const style = host.authStyle ?? 'bearer';
|
||||
if (apiKey && style === 'bearer') headers.Authorization = `Bearer ${apiKey}`;
|
||||
if (apiKey && style === 'api-key') headers['api-key'] = apiKey;
|
||||
return headers;
|
||||
}
|
||||
|
||||
export interface DiscoveryResult {
|
||||
models: string[];
|
||||
/** See `CustomModelHost.modelContextLengths` — only ever populated for models already loaded. */
|
||||
contextLengths: Record<string, number>;
|
||||
/** See `CustomModelHost.modelSizesGB` — populated for every model whose own listing states one. */
|
||||
sizesGB: Record<string, number>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Best-effort: pulls a file size in GB out of a model's own `description`, when the
|
||||
* server states one. llama-swap writes `"Auto-discovered 16.35 GB - parameters
|
||||
* auto-fitted by llama.cpp"` for a model it found on disk itself; a hand-configured
|
||||
* profile's own description (e.g. `"General-purpose reasoning model, MoE CPU-offloaded."`)
|
||||
* has no such figure and correctly yields no estimate rather than a guess — there is no
|
||||
* separate "give me the file size" endpoint to fall back on.
|
||||
*/
|
||||
function parseSizeGB(description: unknown): number | undefined {
|
||||
if (typeof description !== 'string') return undefined;
|
||||
const match = /(\d+(?:\.\d+)?)\s*GB\b/i.exec(description);
|
||||
if (!match) return undefined;
|
||||
const size = Number(match[1]);
|
||||
return Number.isFinite(size) && size > 0 ? size : undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Best-effort: fetches `GET /props?model=<id>` (llama.cpp-native, llama-swap-proxied) for
|
||||
* ONE already-loaded model and pulls its real `n_ctx` out. Never called for a model that
|
||||
* isn't already loaded — see the caller and `CustomModelHost.modelContextLengths` for why
|
||||
* that's a hard safety requirement, not just a nicety: llama-swap treats this endpoint's
|
||||
* `?model=` as a routing hint, and asking it about an unloaded model risks triggering an
|
||||
* actual (slow, GPU-swapping) load as a side effect of what should be read-only discovery.
|
||||
* Any failure (unreachable, non-2xx, missing/malformed field) is swallowed — one model's
|
||||
* context length is a nice-to-have, never worth failing the whole discovery pass over.
|
||||
*
|
||||
* ⚠️ FALLBACK ONLY — confirmed live to be actively WRONG for a `--fit-ctx`-launched llama-
|
||||
* swap backend: `/props`'s `n_ctx` read 154112 for a model llama-swap itself had launched
|
||||
* with `--fit-ctx 16384` (visible in `/running`'s own `cmd`), and the real server then
|
||||
* refused a request at the real 16384-token limit — `n_ctx` here appears to report the
|
||||
* model's theoretical/trained maximum, not the runtime-configured one. `parseCtxFromCmd`
|
||||
* (below), which reads the actual launch flag `/running` reports, is the primary source;
|
||||
* this is only used when that parse comes up empty (no recognized flag in `cmd`, or `cmd`
|
||||
* itself unavailable).
|
||||
*/
|
||||
async function fetchContextLength(
|
||||
host: Pick<CustomModelHost, 'baseUrl'>,
|
||||
modelId: string,
|
||||
headers: Record<string, string>
|
||||
): Promise<number | undefined> {
|
||||
try {
|
||||
const url = new URL(`${host.baseUrl.replace(/\/+$/, '')}/props`);
|
||||
url.searchParams.set('model', modelId);
|
||||
const res = await webviewFetch(url, { headers, signal: AbortSignal.timeout(PROPS_TIMEOUT_MS) });
|
||||
if (!res.ok) return undefined;
|
||||
const body = (await res.json()) as { n_ctx?: unknown; default_generation_settings?: { n_ctx?: unknown } };
|
||||
const nCtx = body.n_ctx ?? body.default_generation_settings?.n_ctx;
|
||||
return typeof nCtx === 'number' && Number.isFinite(nCtx) && nCtx > 0 ? nCtx : undefined;
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Parses the REAL configured context size out of llama-swap's own launch command for a
|
||||
* model (`/running`'s `cmd` field, e.g. `"llama-server -m ... --fit-ctx 16384 ..."`) —
|
||||
* the primary source for `modelContextLengths`, preferred over `/props`'s `n_ctx` (see
|
||||
* `fetchContextLength`'s own doc comment for why that field is unreliable here). Checks
|
||||
* `--fit-ctx` first (llama-swap's own auto-fit flag), then the plain llama.cpp
|
||||
* `-c`/`--ctx-size`/`--ctx_size` flags a hand-written launch command might use instead.
|
||||
* Returns `undefined` when `cmd` has none of these — not every launch command needs to
|
||||
* state one explicitly (llama.cpp has its own default), and guessing one would be worse
|
||||
* than the "no override applied" the caller already treats an unknown length as.
|
||||
*/
|
||||
function parseCtxFromCmd(cmd: unknown): number | undefined {
|
||||
if (typeof cmd !== 'string') return undefined;
|
||||
const match = /--fit-ctx\s+(\d+)/.exec(cmd) ?? /(?:^|\s)(?:-c|--ctx-size|--ctx_size)\s+(\d+)/.exec(cmd);
|
||||
if (!match) return undefined;
|
||||
const value = Number(match[1]);
|
||||
return Number.isFinite(value) && value > 0 ? value : undefined;
|
||||
}
|
||||
|
||||
async function discoverModels(
|
||||
host: Pick<CustomModelHost, 'baseUrl' | 'apiKey' | 'authStyle'>
|
||||
): Promise<DiscoveryResult> {
|
||||
const headers = authHeaders(host);
|
||||
const res = await webviewFetch(new URL(`${host.baseUrl.replace(/\/+$/, '')}/v1/models`), {
|
||||
headers,
|
||||
signal: AbortSignal.timeout(DISCOVER_TIMEOUT_MS),
|
||||
});
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||
const body = (await res.json()) as { data?: Array<{ id?: unknown }> };
|
||||
return (body.data ?? []).map((m) => m.id).filter((id): id is string => typeof id === 'string' && id.length > 0);
|
||||
const body = (await res.json()) as {
|
||||
data?: Array<{ id?: unknown; status?: { value?: unknown }; description?: unknown }>;
|
||||
};
|
||||
const entries = body.data ?? [];
|
||||
const models = entries.map((m) => m.id).filter((id): id is string => typeof id === 'string' && id.length > 0);
|
||||
|
||||
const sizesGB: Record<string, number> = {};
|
||||
for (const entry of entries) {
|
||||
if (typeof entry.id !== 'string' || !entry.id) continue;
|
||||
const size = parseSizeGB(entry.description);
|
||||
if (size !== undefined) sizesGB[entry.id] = size;
|
||||
}
|
||||
|
||||
// llama-swap-specific, feature-detected: a server that never mentions `status` on ANY
|
||||
// entry gets no context-length enrichment at all, rather than treating "no status field"
|
||||
// as "assume unloaded" — either reading is a guess, and skipping is the safe one, since
|
||||
// fetchContextLength must only ever run against a model this server itself calls loaded.
|
||||
const hasStatusField = entries.some((m) => m && typeof m === 'object' && 'status' in m);
|
||||
const contextLengths: Record<string, number> = {};
|
||||
if (hasStatusField) {
|
||||
const loadedIds = entries
|
||||
.filter((m) => m.status && typeof m.status === 'object' && (m.status as { value?: unknown }).value === 'loaded')
|
||||
.map((m) => m.id)
|
||||
.filter((id): id is string => typeof id === 'string' && id.length > 0);
|
||||
if (loadedIds.length > 0) {
|
||||
// Primary source: the REAL launch command (see parseCtxFromCmd's own doc comment
|
||||
// for why /props's n_ctx cannot be trusted here). One /running call covers every
|
||||
// loaded model, so this never costs more requests than the old /props-only path did
|
||||
// when the cmd parse succeeds, and exactly one extra when it has to fall back.
|
||||
const swapStatus = await getLlamaSwapStatus(host);
|
||||
const cmdById = new Map(swapStatus.running.map((r) => [r.model, r.cmd]));
|
||||
for (const id of loadedIds) {
|
||||
const fromCmd = parseCtxFromCmd(cmdById.get(id));
|
||||
const ctx = fromCmd ?? (await fetchContextLength(host, id, headers));
|
||||
if (ctx !== undefined) contextLengths[id] = ctx;
|
||||
}
|
||||
}
|
||||
}
|
||||
return { models, contextLengths, sizesGB };
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -66,41 +276,498 @@ function describeFetchError(err: unknown): string {
|
||||
return message;
|
||||
}
|
||||
|
||||
export function registerCustomModelRoutes(app: FastifyInstance): void {
|
||||
app.get('/api/model-endpoints', async (req) =>
|
||||
isMultiUserMode() && !isAdmin(req) ? [] : readCustomModelHosts(CODEMAN_CONFIG_DIR)
|
||||
);
|
||||
type RedactedHost = ReturnType<typeof redactApiKey>;
|
||||
|
||||
app.post('/api/model-endpoints', async (req, reply): Promise<ApiResponse<{ host: CustomModelHost }>> => {
|
||||
/**
|
||||
* Merges a fresh `GET /v1/models` result into a host record: stamps
|
||||
* `lastDiscoveredAt`, and drops `defaultModelId` if it no longer appears in
|
||||
* the fresh list (it would otherwise leave the Run-menu picker applying a
|
||||
* model id the endpoint just told us it doesn't serve). Pure — no IO, so the
|
||||
* manual route (which reports a fetch failure's *reason* to the caller) and
|
||||
* the periodic sweep below (which only cares whether it can move on) can
|
||||
* each do their own `discoverModels()` + error handling around one shared
|
||||
* "how to apply a successful result" step.
|
||||
*/
|
||||
const RUNNING_TIMEOUT_MS = 5000;
|
||||
|
||||
export interface LlamaSwapRunningModel {
|
||||
model: string;
|
||||
state: string;
|
||||
/** The actual launch command llama-swap started this backend with, when it says one —
|
||||
* see `parseCtxFromCmd`, which reads the real configured context size out of this. */
|
||||
cmd?: string;
|
||||
}
|
||||
|
||||
export interface LlamaSwapStatus {
|
||||
/**
|
||||
* Feature-detected via `GET /running`: true only when the server answered with
|
||||
* llama-swap's own shape (`{ running: [...] }`). Plain llama.cpp (and any other
|
||||
* OpenAI-compatible server) has no such endpoint and always runs the single model
|
||||
* it was started with, so there is no "current model" to conflict with — every
|
||||
* caller must treat `isLlamaSwap: false` as "nothing to check", never as an error.
|
||||
*/
|
||||
isLlamaSwap: boolean;
|
||||
running: LlamaSwapRunningModel[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Distinguishes llama-swap from a plain llama.cpp/OpenAI-compatible server, and reports
|
||||
* what llama-swap currently has loaded — llama.cpp only ever runs one GGUF at a time, and
|
||||
* llama-swap unloads/reloads it on demand when a request asks for a different one, which
|
||||
* can take anywhere from a few seconds to over a minute. Read-only: this never triggers a
|
||||
* swap itself (unlike `/props?model=`, `/running` takes no `model` parameter to route by).
|
||||
* Best-effort like `discoverModels()`'s siblings: any failure (unreachable, non-2xx,
|
||||
* unexpected shape) reads as "not llama-swap", never thrown.
|
||||
*/
|
||||
export async function getLlamaSwapStatus(
|
||||
host: Pick<CustomModelHost, 'baseUrl' | 'apiKey' | 'authStyle'>
|
||||
): Promise<LlamaSwapStatus> {
|
||||
try {
|
||||
const res = await webviewFetch(new URL(`${host.baseUrl.replace(/\/+$/, '')}/running`), {
|
||||
headers: authHeaders(host),
|
||||
signal: AbortSignal.timeout(RUNNING_TIMEOUT_MS),
|
||||
});
|
||||
if (!res.ok) return { isLlamaSwap: false, running: [] };
|
||||
const body = (await res.json()) as { running?: unknown };
|
||||
if (!Array.isArray(body.running)) return { isLlamaSwap: false, running: [] };
|
||||
const running = body.running
|
||||
.filter(
|
||||
(r): r is { model: string; state?: unknown; cmd?: unknown } =>
|
||||
!!r && typeof r === 'object' && typeof (r as { model?: unknown }).model === 'string'
|
||||
)
|
||||
.map((r) => ({
|
||||
model: r.model,
|
||||
state: typeof r.state === 'string' ? r.state : 'unknown',
|
||||
cmd: typeof r.cmd === 'string' ? r.cmd : undefined,
|
||||
}));
|
||||
return { isLlamaSwap: true, running };
|
||||
} catch {
|
||||
return { isLlamaSwap: false, running: [] };
|
||||
}
|
||||
}
|
||||
|
||||
interface LlamaSwapLogTail {
|
||||
latestLine?: string;
|
||||
lastAccessedAt: number;
|
||||
controller: AbortController;
|
||||
}
|
||||
|
||||
/** One open `/api/events` tail per endpoint, keyed by host id — see `getLatestLlamaSwapLogLine`. */
|
||||
const llamaSwapLogTails = new Map<string, LlamaSwapLogTail>();
|
||||
|
||||
/** A tail nothing has asked about in this long is closed by the next `pruneIdleLlamaSwapLogTails` sweep. */
|
||||
const LOG_TAIL_IDLE_MS = 30_000;
|
||||
|
||||
/**
|
||||
* Parses one `data: {...}` payload from llama-swap's `GET /api/events` SSE stream and
|
||||
* returns the backend (never llama-swap's own proxy) log text it carries, or `undefined`
|
||||
* for anything else (a different event `type`, a malformed frame, a proxy-sourced one).
|
||||
*
|
||||
* The real shape, confirmed live against a real llama-swap deployment — NOT documented
|
||||
* anywhere the plan doc's original research found, and genuinely surprising the first
|
||||
* time around: `GET /logs` (the endpoint that name suggests, and this feature's own
|
||||
* first cut was built against) turns out to carry ONLY llama-swap's own proxy
|
||||
* request-access log — it never once showed a single backend line even seconds after a
|
||||
* real, confirmed model swap. The backend llama-server process's actual stdout
|
||||
* (`load_model: ...`, `llama_server: model loaded`) only ever showed up in `/api/events`,
|
||||
* as `{"type":"logData","data":"<JSON-string>"}` whose OWN `data` field parses to a
|
||||
* second object, `{"data": "<newline-joined log text>", "source": "proxy" | "upstream"}`
|
||||
* — `source` is the exact, explicit distinguisher (`upstream` = the backend process,
|
||||
* `proxy` = llama-swap's own line), not a guessed regex against the text itself.
|
||||
*/
|
||||
function parseBackendLogDataEvent(dataLine: string): string | undefined {
|
||||
let outer: unknown;
|
||||
try {
|
||||
outer = JSON.parse(dataLine);
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
if (
|
||||
!outer ||
|
||||
typeof outer !== 'object' ||
|
||||
(outer as { type?: unknown }).type !== 'logData' ||
|
||||
typeof (outer as { data?: unknown }).data !== 'string'
|
||||
) {
|
||||
return undefined;
|
||||
}
|
||||
let inner: unknown;
|
||||
try {
|
||||
inner = JSON.parse((outer as { data: string }).data);
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
if (
|
||||
!inner ||
|
||||
typeof inner !== 'object' ||
|
||||
(inner as { source?: unknown }).source !== 'upstream' ||
|
||||
typeof (inner as { data?: unknown }).data !== 'string'
|
||||
) {
|
||||
return undefined;
|
||||
}
|
||||
return (inner as { data: string }).data;
|
||||
}
|
||||
|
||||
/**
|
||||
* Reads `GET /api/events` forever (until `entry.controller` aborts it), updating
|
||||
* `entry.latestLine` with the most recent BACKEND log line seen (see
|
||||
* `parseBackendLogDataEvent`). Fire-and-forget: the caller never awaits this — it runs
|
||||
* for the tail's whole lifetime in the background, and `getLatestLlamaSwapLogLine` just
|
||||
* reads whatever `entry.latestLine` currently holds. SSE frames are separated by a blank
|
||||
* line (`\n\n`), buffered the same way `/running`'s NDJSON-shaped siblings buffer partial
|
||||
* chunks — a frame split across two `reader.read()` calls must not be parsed early.
|
||||
*/
|
||||
/**
|
||||
* Cap on the unparsed remainder held between reads of the backend log stream. One
|
||||
* SSE frame is a status line, so this is orders of magnitude more than a real frame
|
||||
* needs; it exists so a server that never emits a frame boundary cannot grow the
|
||||
* buffer without bound for the life of the connection.
|
||||
*/
|
||||
const MAX_LOG_TAIL_BUFFER_CHARS = 64 * 1024;
|
||||
|
||||
async function pumpLlamaSwapLogTail(
|
||||
host: Pick<CustomModelHost, 'id' | 'baseUrl' | 'apiKey' | 'authStyle'>,
|
||||
entry: LlamaSwapLogTail
|
||||
): Promise<void> {
|
||||
try {
|
||||
const res = await webviewFetch(new URL(`${host.baseUrl.replace(/\/+$/, '')}/api/events`), {
|
||||
headers: authHeaders(host),
|
||||
signal: entry.controller.signal,
|
||||
});
|
||||
if (!res.ok || !res.body) return;
|
||||
const reader = res.body.getReader();
|
||||
const decoder = new TextDecoder();
|
||||
let buffer = '';
|
||||
for (;;) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) break;
|
||||
buffer += decoder.decode(value, { stream: true });
|
||||
const frames = buffer.split('\n\n');
|
||||
buffer = frames.pop() ?? '';
|
||||
// The remainder only shrinks at a frame boundary, so a server that streams
|
||||
// without `\n\n` (or one very long frame) would grow it for as long as the
|
||||
// connection is held, which is indefinitely by design. Past the cap the
|
||||
// partial frame cannot become a useful log line anyway, so drop it and
|
||||
// resynchronise on the next boundary rather than buffering forever.
|
||||
if (buffer.length > MAX_LOG_TAIL_BUFFER_CHARS) buffer = '';
|
||||
for (const frame of frames) {
|
||||
const dataLine = frame.split('\n').find((l) => l.startsWith('data:'));
|
||||
if (!dataLine) continue;
|
||||
const backendText = parseBackendLogDataEvent(dataLine.slice('data:'.length));
|
||||
if (!backendText) continue;
|
||||
const lines = backendText.split('\n').filter((l) => l.trim());
|
||||
if (lines.length > 0) entry.latestLine = lines[lines.length - 1]!.trim();
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// connection dropped / aborted / endpoint unreachable — a future access starts fresh
|
||||
} finally {
|
||||
// Delete by IDENTITY, not just by key: an aborted pump can finish after a NEWER
|
||||
// entry was already created for the same endpoint id (e.g. abort-then-immediately-
|
||||
// re-request), and deleting unconditionally would remove that newer entry and orphan
|
||||
// its connection — nothing would ever prune it, since pruneIdleLlamaSwapLogTails only
|
||||
// walks entries still present in the map.
|
||||
if (llamaSwapLogTails.get(host.id) === entry) {
|
||||
llamaSwapLogTails.delete(host.id);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Real-time "what is llama.cpp actually doing right now" for the loading banner
|
||||
* (docs/custom-model-endpoints-plan.md): llama-swap's `GET /api/events` SSE stream
|
||||
* carries the backend llama-server process's own stdout — `load_model: loading model
|
||||
* '<path>'`, `load_model: initializing, n_slots = N, n_ctx_slot = N`, `llama_server:
|
||||
* model loaded`, etc — tagged `source: "upstream"`, distinct from llama-swap's own
|
||||
* `source: "proxy"` request-access lines (see `parseBackendLogDataEvent`). Confirmed
|
||||
* live against a real llama-swap deployment, including through an actual forced model
|
||||
* swap end-to-end.
|
||||
*
|
||||
* Held OPEN per endpoint rather than re-opened on every 1s poll — confirmed live to stay
|
||||
* open indefinitely (read past 220KB over 8 seconds with no `done`), unlike `/logs`
|
||||
* (see `parseBackendLogDataEvent`'s doc comment), so reconnecting each poll would be
|
||||
* pure waste. One connection is reused across every session currently watching a load on
|
||||
* that endpoint; since llama.cpp/llama-swap only ever runs one model at a time, a line
|
||||
* seen while a load is in flight is safe to attribute to that load (a deployment that
|
||||
* could load several models concurrently would need a per-model tag this format doesn't
|
||||
* provide).
|
||||
*
|
||||
* Lazily started on first access and idle-closed rather than left open forever — see
|
||||
* `pruneIdleLlamaSwapLogTails`.
|
||||
*/
|
||||
export function getLatestLlamaSwapLogLine(
|
||||
host: Pick<CustomModelHost, 'id' | 'baseUrl' | 'apiKey' | 'authStyle'>
|
||||
): string | undefined {
|
||||
let entry = llamaSwapLogTails.get(host.id);
|
||||
if (!entry) {
|
||||
entry = { lastAccessedAt: Date.now(), controller: new AbortController() };
|
||||
llamaSwapLogTails.set(host.id, entry);
|
||||
void pumpLlamaSwapLogTail(host, entry);
|
||||
}
|
||||
entry.lastAccessedAt = Date.now();
|
||||
return entry.latestLine;
|
||||
}
|
||||
|
||||
/**
|
||||
* Closes EVERY open log tail. The idle sweep above only runs on server.ts's periodic
|
||||
* interval, and that interval is disposed on shutdown, so without this an outbound
|
||||
* stream outlives `WebServer.stop()` against CLAUDE.md's "clear Maps in stop()" rule.
|
||||
* Harmless today only because `cli.ts`'s shutdown handler reaches `process.exit(0)`,
|
||||
* which is not a property to rely on: tests and any in-process restart do not.
|
||||
*/
|
||||
export function closeAllLlamaSwapLogTails(): void {
|
||||
for (const entry of llamaSwapLogTails.values()) entry.controller.abort();
|
||||
llamaSwapLogTails.clear();
|
||||
}
|
||||
|
||||
/**
|
||||
* Closes any log tail nothing has called `getLatestLlamaSwapLogLine` about in
|
||||
* `LOG_TAIL_IDLE_MS` — a stream nobody is polling is an open connection with nothing to
|
||||
* show for it. Called from the same periodic sweep as `detectCustomModelSwapDisplacements`
|
||||
* in server.ts, not its own timer.
|
||||
*/
|
||||
export function pruneIdleLlamaSwapLogTails(now = Date.now()): void {
|
||||
for (const [id, entry] of llamaSwapLogTails) {
|
||||
if (now - entry.lastAccessedAt > LOG_TAIL_IDLE_MS) {
|
||||
entry.controller.abort();
|
||||
llamaSwapLogTails.delete(id);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Actually kicks off llama-swap's lazy model load, rather than waiting for the launched
|
||||
* CLI's own first prompt to do it. llama-swap has no separate "switch model" admin
|
||||
* endpoint — the ONLY thing that starts a swap is a real inference request naming the
|
||||
* model (confirmed live: applying a selection alone never appeared in the llama-swap
|
||||
* server's own logs; nothing had actually asked it to load anything). This sends the
|
||||
* smallest real request that will — `max_tokens: 1`, one throwaway user message — to
|
||||
* `${baseUrl}/v1/chat/completions`, the OpenAI-compatible endpoint every supported
|
||||
* harness already points at.
|
||||
*
|
||||
* Deliberately fire-and-forget: the caller (the apply/create routes) returns to the
|
||||
* client immediately, and the frontend's own polling (`GET .../running-status`) is what
|
||||
* actually confirms readiness — this call's response is never read, just its side
|
||||
* effect. No abort/timeout of its own either: a real load can take well over a minute for
|
||||
* a large model, and this is a normal long-running Node process, so there is nothing to
|
||||
* clean up by cutting it short. Errors are swallowed for the same reason `discoverModels`'s
|
||||
* siblings swallow theirs — one endpoint's hiccup here is a nice-to-have that failed, not
|
||||
* something worth surfacing as a request failure four layers up.
|
||||
*/
|
||||
export function triggerLlamaSwapLoad(
|
||||
host: Pick<CustomModelHost, 'baseUrl' | 'apiKey' | 'authStyle'>,
|
||||
modelId: string
|
||||
): void {
|
||||
const url = new URL(`${host.baseUrl.replace(/\/+$/, '')}/v1/chat/completions`);
|
||||
webviewFetch(url, {
|
||||
method: 'POST',
|
||||
headers: { ...authHeaders(host), 'content-type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
model: modelId,
|
||||
messages: [{ role: 'user', content: 'Hi' }],
|
||||
max_tokens: 1,
|
||||
stream: false,
|
||||
}),
|
||||
}).catch(() => {
|
||||
// best-effort — see the doc comment above
|
||||
});
|
||||
}
|
||||
|
||||
function applyDiscoveredModels(host: CustomModelHost, result: DiscoveryResult): CustomModelHost {
|
||||
const { models, contextLengths, sizesGB } = result;
|
||||
const defaultModelId = host.defaultModelId && models.includes(host.defaultModelId) ? host.defaultModelId : undefined;
|
||||
// Merge onto what's already known rather than replacing: a model not probed this round
|
||||
// (not currently loaded) keeps whatever context length an earlier round already learned
|
||||
// for it, and one no longer in the fresh list is dropped, same reasoning as defaultModelId.
|
||||
const merged = { ...host.modelContextLengths, ...contextLengths };
|
||||
const kept = Object.fromEntries(Object.entries(merged).filter(([id]) => models.includes(id)));
|
||||
const modelContextLengths = Object.keys(kept).length > 0 ? kept : undefined;
|
||||
// sizesGB, unlike contextLengths, is populated for every model in the SAME pass (no
|
||||
// loaded-only restriction — see parseSizeGB), so this is closer to a plain replace, but
|
||||
// still merges onto the previous round rather than dropping a size for a model whose
|
||||
// description happened to omit the figure on this particular pass.
|
||||
const mergedSizes = { ...host.modelSizesGB, ...sizesGB };
|
||||
const keptSizes = Object.fromEntries(Object.entries(mergedSizes).filter(([id]) => models.includes(id)));
|
||||
const modelSizesGB = Object.keys(keptSizes).length > 0 ? keptSizes : undefined;
|
||||
return {
|
||||
...host,
|
||||
models,
|
||||
defaultModelId,
|
||||
modelContextLengths,
|
||||
modelSizesGB,
|
||||
lastDiscoveredAt: new Date().toISOString(),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* `customModelEndpointsEnabled` defaults OFF (unlike `showPlanUsageLimits`'s
|
||||
* absent-means-on in `readPlanUsageTelemetryEnabled`), so mirror the frontend's
|
||||
* own gate (`session-ui.js`'s `!settings.customModelEndpointsEnabled`) rather
|
||||
* than that reader's default. Exists so the periodic re-discovery sweep in
|
||||
* server.ts can skip entirely while the feature is off, instead of polling
|
||||
* every saved endpoint forever regardless of the setting.
|
||||
*/
|
||||
export async function readCustomModelEndpointsEnabled(): Promise<boolean> {
|
||||
const settings = await readJsonConfig<Record<string, unknown>>(SETTINGS_PATH, 'settings.json', {});
|
||||
return settings.customModelEndpointsEnabled === true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Re-discovers every saved endpoint's models, best-effort. One endpoint being
|
||||
* unreachable (powered off, wrong network) must not stop the others from
|
||||
* refreshing, and a read-modify-write per host (rather than one batch write
|
||||
* at the end) means a crash or restart mid-sweep loses at most the endpoints
|
||||
* not yet reached, never a write already applied. Exported so both the
|
||||
* periodic timer (server.ts) and a test can drive it directly.
|
||||
*/
|
||||
export async function refreshAllCustomModelHosts(): Promise<void> {
|
||||
const dataDir = getDataDir();
|
||||
const hosts = await readCustomModelHosts(dataDir);
|
||||
for (const host of hosts) {
|
||||
if (isBlockedWebviewUrl(host.baseUrl)) continue;
|
||||
let result: DiscoveryResult;
|
||||
try {
|
||||
result = await discoverModels(host);
|
||||
} catch {
|
||||
continue; // unreachable this cycle — try again next tick, not fatal to the sweep
|
||||
}
|
||||
// Re-read + splice by id rather than reusing the array captured above: an
|
||||
// admin editing or deleting an endpoint via the API mid-sweep must win,
|
||||
// not be silently overwritten by a refresh that started before their change.
|
||||
const current = await readCustomModelHosts(dataDir);
|
||||
const index = current.findIndex((item) => item.id === host.id);
|
||||
if (index === -1) continue; // deleted mid-sweep
|
||||
current[index] = applyDiscoveredModels(current[index], result);
|
||||
await writeCustomModelHosts(dataDir, current);
|
||||
}
|
||||
}
|
||||
|
||||
/** The subset of `Session` this sweep needs — kept minimal so a test can pass a plain object. */
|
||||
export interface CustomModelSessionLike {
|
||||
id: string;
|
||||
name: string;
|
||||
customModel?: { endpointId: string; modelId: string; label?: string };
|
||||
}
|
||||
|
||||
/** One session whose model was just found evicted, ready to broadcast as `CustomModelSwappedOut`. */
|
||||
export interface CustomModelSwapDisplacement {
|
||||
sessionId: string;
|
||||
sessionName: string;
|
||||
endpointId: string;
|
||||
previousModel: string;
|
||||
currentlyLoadedModel: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Detects when a live session's own custom-model selection is no longer the model
|
||||
* llama-swap actually has loaded — evicted by ANOTHER session's activity on the same
|
||||
* endpoint, since llama.cpp/llama-swap runs one model at a time (the apply/create routes'
|
||||
* own swap-conflict check only ever runs at THAT session's own launch/apply moment, so it
|
||||
* cannot catch a later eviction triggered by a different session's normal use — confirmed
|
||||
* live: a session created while nothing else had a live conflict at that instant can still
|
||||
* get silently displaced afterward). Read-only, and best-effort per endpoint exactly like
|
||||
* `refreshAllCustomModelHosts`'s sibling sweep — one endpoint's hiccup here never blocks
|
||||
* checking the others.
|
||||
*
|
||||
* `notifiedSessionIds` is the caller's own de-dupe state (`server.ts` keeps one `Set` across
|
||||
* sweeps), mutated in place: a session id is added once displaced and removed again once its
|
||||
* own model is loaded and ready — so a LATER, genuinely new displacement can notify again
|
||||
* rather than the session staying silently un-notified forever after the first one.
|
||||
*/
|
||||
export async function detectCustomModelSwapDisplacements(
|
||||
sessions: Iterable<CustomModelSessionLike>,
|
||||
notifiedSessionIds: Set<string>
|
||||
): Promise<CustomModelSwapDisplacement[]> {
|
||||
const byEndpoint = new Map<string, CustomModelSessionLike[]>();
|
||||
for (const session of sessions) {
|
||||
if (!session.customModel) continue;
|
||||
const group = byEndpoint.get(session.customModel.endpointId);
|
||||
if (group) group.push(session);
|
||||
else byEndpoint.set(session.customModel.endpointId, [session]);
|
||||
}
|
||||
if (byEndpoint.size === 0) return [];
|
||||
|
||||
const hosts = await readCustomModelHosts(getDataDir());
|
||||
const displacements: CustomModelSwapDisplacement[] = [];
|
||||
|
||||
for (const [endpointId, group] of byEndpoint) {
|
||||
const host = hosts.find((h) => h.id === endpointId);
|
||||
if (!host) continue; // endpoint deleted since these sessions were created — nothing to check
|
||||
let status: LlamaSwapStatus;
|
||||
try {
|
||||
status = await getLlamaSwapStatus(host);
|
||||
} catch {
|
||||
continue; // unreachable this cycle — try again next tick, not fatal to the sweep
|
||||
}
|
||||
// Not llama-swap (feature-detected) or nothing loaded at all: nothing has been evicted,
|
||||
// by construction — a plain llama.cpp/OpenAI-compatible server only ever runs the one
|
||||
// model it was started with, so there is no "current model" to conflict with.
|
||||
if (!status.isLlamaSwap || status.running.length === 0) continue;
|
||||
const currentlyLoaded = status.running.find((r) => r.state === 'ready')?.model ?? status.running[0]?.model;
|
||||
if (!currentlyLoaded) continue;
|
||||
|
||||
for (const session of group) {
|
||||
const modelId = session.customModel!.modelId;
|
||||
const stillLoaded = status.running.some((r) => r.model === modelId);
|
||||
if (stillLoaded) {
|
||||
notifiedSessionIds.delete(session.id); // back to normal — a future eviction can notify again
|
||||
continue;
|
||||
}
|
||||
if (notifiedSessionIds.has(session.id)) continue; // already told them once for this displacement
|
||||
notifiedSessionIds.add(session.id);
|
||||
displacements.push({
|
||||
sessionId: session.id,
|
||||
sessionName: session.name,
|
||||
endpointId,
|
||||
previousModel: modelId,
|
||||
currentlyLoadedModel: currentlyLoaded,
|
||||
});
|
||||
}
|
||||
}
|
||||
return displacements;
|
||||
}
|
||||
|
||||
export function registerCustomModelRoutes(app: FastifyInstance): void {
|
||||
app.get('/api/model-endpoints', async (req): Promise<RedactedHost[]> => {
|
||||
if (isMultiUserMode() && !isAdmin(req)) return [];
|
||||
const hosts = await readCustomModelHosts(CODEMAN_CONFIG_DIR);
|
||||
return hosts.map(redactApiKey);
|
||||
});
|
||||
|
||||
app.post('/api/model-endpoints', async (req, reply): Promise<ApiResponse<{ host: RedactedHost }>> => {
|
||||
const denied = adminOnly(req, reply);
|
||||
if (denied) return denied;
|
||||
const host = parseBody(CustomModelHostSchema, req.body);
|
||||
if (isBlockedWebviewUrl(host.baseUrl)) {
|
||||
return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Endpoint base URL is not allowed');
|
||||
}
|
||||
const badDefault = invalidDefaultModel(host);
|
||||
if (badDefault) return badDefault;
|
||||
const hosts = await readCustomModelHosts(CODEMAN_CONFIG_DIR);
|
||||
if (hosts.some((item) => item.id === host.id)) {
|
||||
return createErrorResponse(ApiErrorCode.ALREADY_EXISTS, 'Model endpoint already exists');
|
||||
}
|
||||
await writeCustomModelHosts(CODEMAN_CONFIG_DIR, [...hosts, host]);
|
||||
return { success: true, data: { host } };
|
||||
return { success: true, data: { host: redactApiKey(host) } };
|
||||
});
|
||||
|
||||
app.put('/api/model-endpoints/:id', async (req, reply): Promise<ApiResponse<{ host: CustomModelHost }>> => {
|
||||
app.put('/api/model-endpoints/:id', async (req, reply): Promise<ApiResponse<{ host: RedactedHost }>> => {
|
||||
const denied = adminOnly(req, reply);
|
||||
if (denied) return denied;
|
||||
const { id } = req.params as { id: string };
|
||||
const host = parseBody(CustomModelHostSchema, { ...(req.body as object), id });
|
||||
if (isBlockedWebviewUrl(host.baseUrl)) {
|
||||
const incoming = parseBody(CustomModelHostSchema, { ...(req.body as object), id });
|
||||
if (isBlockedWebviewUrl(incoming.baseUrl)) {
|
||||
return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Endpoint base URL is not allowed');
|
||||
}
|
||||
const badDefault = invalidDefaultModel(incoming);
|
||||
if (badDefault) return badDefault;
|
||||
const hosts = await readCustomModelHosts(CODEMAN_CONFIG_DIR);
|
||||
const index = hosts.findIndex((item) => item.id === id);
|
||||
if (index === -1) return createErrorResponse(ApiErrorCode.NOT_FOUND, 'Model endpoint not found');
|
||||
const host = applyDiscoveredFields(applyStoredApiKey(incoming, hosts[index]), hosts[index]);
|
||||
const next = [...hosts];
|
||||
next[index] = host;
|
||||
await writeCustomModelHosts(CODEMAN_CONFIG_DIR, next);
|
||||
return { success: true, data: { host } };
|
||||
return { success: true, data: { host: redactApiKey(host) } };
|
||||
});
|
||||
|
||||
app.delete('/api/model-endpoints/:id', async (req, reply): Promise<ApiResponse<{ id: string }>> => {
|
||||
@@ -129,11 +796,11 @@ export function registerCustomModelRoutes(app: FastifyInstance): void {
|
||||
return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Endpoint base URL is not allowed');
|
||||
}
|
||||
try {
|
||||
const models = await discoverModels(host);
|
||||
const result = await discoverModels(host);
|
||||
const next = [...hosts];
|
||||
next[index] = { ...host, models, lastDiscoveredAt: new Date().toISOString() };
|
||||
next[index] = applyDiscoveredModels(host, result);
|
||||
await writeCustomModelHosts(CODEMAN_CONFIG_DIR, next);
|
||||
return { success: true, data: { models } };
|
||||
return { success: true, data: { models: result.models } };
|
||||
} catch (err) {
|
||||
const blocked = egressBlockedReason(err);
|
||||
return createErrorResponse(
|
||||
@@ -143,4 +810,38 @@ export function registerCustomModelRoutes(app: FastifyInstance): void {
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
// Read-only, no admin gate: any session owner who can already point their own session
|
||||
// at this endpoint (POST .../custom-model, ungated by design — see session-routes.ts)
|
||||
// can equally ask what it currently has loaded, before or while that apply is pending.
|
||||
app.get(
|
||||
'/api/model-endpoints/:id/running-status',
|
||||
async (
|
||||
req
|
||||
): Promise<
|
||||
ApiResponse<{
|
||||
isLlamaSwap: boolean;
|
||||
running: Array<Pick<LlamaSwapRunningModel, 'model' | 'state'>>;
|
||||
logLine?: string;
|
||||
}>
|
||||
> => {
|
||||
const { id } = req.params as { id: string };
|
||||
const hosts = await readCustomModelHosts(CODEMAN_CONFIG_DIR);
|
||||
const host = hosts.find((item) => item.id === id);
|
||||
if (!host) return createErrorResponse(ApiErrorCode.NOT_FOUND, 'Model endpoint not found');
|
||||
if (isBlockedWebviewUrl(host.baseUrl)) {
|
||||
return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Endpoint base URL is not allowed');
|
||||
}
|
||||
const status = await getLlamaSwapStatus(host);
|
||||
// Only worth tailing /api/events once llama-swap is actually confirmed — a plain
|
||||
// llama.cpp/OpenAI-compatible server has no such endpoint at all.
|
||||
const logLine = status.isLlamaSwap ? getLatestLlamaSwapLogLine(host) : undefined;
|
||||
// `cmd` (the literal llama-server launch line, which can carry model paths and
|
||||
// --api-key) exists only so parseCtxFromCmd() can read it server-side during
|
||||
// discovery — this un-gated, polled-every-second route has no reason to hand it
|
||||
// to the browser, which only ever reads `model`/`state`.
|
||||
const running = status.running.map(({ model, state }) => ({ model, state }));
|
||||
return { success: true, data: { isLlamaSwap: status.isLlamaSwap, running, logLine } };
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
@@ -176,6 +176,12 @@ export function registerHookEventRoutes(
|
||||
// identity beyond the shared per-instance secret, so a prompt claimed for a
|
||||
// session that can never show one must not create an answerable item).
|
||||
let approvalId: string | undefined;
|
||||
// Set when the item opened ALREADY acknowledged, which today means the session is
|
||||
// watching work it started itself. It rides the broadcast so a live page declines to
|
||||
// arm the alert (a reloading page learns the same thing from `acknowledgedAt` when it
|
||||
// seeds from /api/approvals), and it suppresses the push: an alert nobody can answer
|
||||
// is worth even less on a phone than in a tab.
|
||||
let acknowledgedReason: string | undefined;
|
||||
const approvalKind = APPROVAL_KIND_BY_EVENT[event];
|
||||
if (session && hooksAvailableForMode(session.mode, sessionHookOptions(session))) {
|
||||
if (approvalKind) {
|
||||
@@ -194,6 +200,10 @@ export function registerHookEventRoutes(
|
||||
toolSummary: typeof toolSummary === 'string' ? toolSummary : undefined,
|
||||
message: typeof safeData.message === 'string' ? safeData.message : undefined,
|
||||
cwd: typeof safeData.cwd === 'string' ? safeData.cwd : undefined,
|
||||
// What the pane says is still running in the background. An idle prompt from a
|
||||
// session that is watching its own work opens acknowledged, so it never arms an
|
||||
// alert nobody can answer; notePrompt() carries the whole reasoning.
|
||||
watching: session.watching,
|
||||
// Visible tmux frame first (it IS the dialog); raw byte-buffer tail as
|
||||
// the fallback for direct-PTY sessions and the no-op test mux.
|
||||
capture: () => {
|
||||
@@ -203,6 +213,7 @@ export function registerHookEventRoutes(
|
||||
},
|
||||
});
|
||||
approvalId = item.id;
|
||||
acknowledgedReason = item.acknowledgedReason;
|
||||
} else if (APPROVAL_RESOLVING_EVENTS.has(event)) {
|
||||
approvalInbox.resolveForSession(sessionId, 'resolved_in_terminal');
|
||||
}
|
||||
@@ -213,6 +224,7 @@ export function registerHookEventRoutes(
|
||||
timestamp: Date.now(),
|
||||
...safeData,
|
||||
...(approvalId && { approvalId }),
|
||||
...(acknowledgedReason && { acknowledgedReason }),
|
||||
});
|
||||
// Full state ride-along, same shape as the working/idle handlers: the home
|
||||
// screens rank the blocked group on lastActivityAt, and without this a
|
||||
@@ -224,12 +236,17 @@ export function registerHookEventRoutes(
|
||||
// on approvalId, and the answer route refuses keystrokes for dsh dialogs
|
||||
// (third-party TUI, unmeasured contract) — so a dsh push stays a plain
|
||||
// notification instead of offering buttons whose answer would be refused.
|
||||
ctx.sendPushNotifications(`hook:${event}`, {
|
||||
sessionId,
|
||||
sessionName,
|
||||
...safeData,
|
||||
...(approvalId && session?.mode !== 'deepseek' && { approvalId }),
|
||||
});
|
||||
// Nothing to push for a prompt that opened acknowledged: the agent is waiting for its
|
||||
// own monitor or backgrounded shell, and a phone buzzing about it is the same false
|
||||
// alarm as the tab alert, delivered where it is hardest to ignore.
|
||||
if (!acknowledgedReason) {
|
||||
ctx.sendPushNotifications(`hook:${event}`, {
|
||||
sessionId,
|
||||
sessionName,
|
||||
...safeData,
|
||||
...(approvalId && session?.mode !== 'deepseek' && { approvalId }),
|
||||
});
|
||||
}
|
||||
|
||||
// Track in run summary. `prompt_submitted` fires on EVERY prompt of every
|
||||
// Claude pane; only the ones where the conversation actually moved (a /clear
|
||||
|
||||
+10
-1
@@ -28,4 +28,13 @@ export { registerWsRoutes } from './ws-routes.js';
|
||||
export { registerVoiceRoutes } from './voice-routes.js';
|
||||
export { registerWebviewRoutes, tryWebviewRefererFallback } from './webview-routes.js';
|
||||
export { registerTabLayoutRoutes } from './tab-layout-routes.js';
|
||||
export { registerCustomModelRoutes } from './custom-model-routes.js';
|
||||
export {
|
||||
registerCustomModelRoutes,
|
||||
refreshAllCustomModelHosts,
|
||||
readCustomModelEndpointsEnabled,
|
||||
closeAllLlamaSwapLogTails,
|
||||
detectCustomModelSwapDisplacements,
|
||||
pruneIdleLlamaSwapLogTails,
|
||||
type CustomModelSessionLike,
|
||||
type CustomModelSwapDisplacement,
|
||||
} from './custom-model-routes.js';
|
||||
|
||||
@@ -11,7 +11,7 @@ import { homedir } from 'node:os';
|
||||
import { existsSync, statSync, mkdirSync, writeFileSync } from 'node:fs';
|
||||
import { execFile } from 'node:child_process';
|
||||
import fs from 'node:fs/promises';
|
||||
import { randomBytes } from 'node:crypto';
|
||||
import { randomBytes, randomUUID } from 'node:crypto';
|
||||
import { performance } from 'node:perf_hooks';
|
||||
import {
|
||||
ApiErrorCode,
|
||||
@@ -28,8 +28,10 @@ import {
|
||||
type GrokConfig,
|
||||
type DeepSeekConfig,
|
||||
type OmpConfig,
|
||||
type RemoteHost,
|
||||
} from '../../types.js';
|
||||
import { Session, isAltScreenStripMode, isExternalCliMode, isMuxAltScreenOnlyStripMode } from '../../session.js';
|
||||
import type { PaneCaptureOptions } from '../../mux-interface.js';
|
||||
import { SseEvent } from '../sse-events.js';
|
||||
import { webviewCapabilities } from '../../webview-capabilities.js';
|
||||
import {
|
||||
@@ -55,6 +57,12 @@ import {
|
||||
} from '../schemas.js';
|
||||
import { readCustomModelHosts } from '../../custom-model-hosts.js';
|
||||
import { applyCustomModelInjection, removeConfigDir } from '../../custom-model-injection-apply.js';
|
||||
import {
|
||||
getLlamaSwapStatus,
|
||||
triggerLlamaSwapLoad,
|
||||
exceedsSafeContextFloor,
|
||||
CLAUDE_MIN_SAFE_CONTEXT_TOKENS,
|
||||
} from './custom-model-routes.js';
|
||||
import { matchesPattern } from '../../config/cli-registry/patterns.js';
|
||||
import { ownerLayoutKey } from '../../tab-layout-persistence.js';
|
||||
import { TabLayoutValidationError } from '../../tab-layout.js';
|
||||
@@ -67,6 +75,13 @@ import {
|
||||
type WaitSignal,
|
||||
type SignalWaitResult,
|
||||
} from '../session-wait-registry.js';
|
||||
import {
|
||||
RemoteWakeRegistry,
|
||||
REMOTE_WAKE_REQUEST_READY_TIMEOUT_MS,
|
||||
createDefaultRemoteWakeDeps,
|
||||
isProbeable,
|
||||
type WakeableRemote,
|
||||
} from '../../remote-wake.js';
|
||||
import { clampWaitMs, MAX_BUFFER_SCAN_BYTES } from '../../config/agent-wait.js';
|
||||
import {
|
||||
autoConfigureRalph,
|
||||
@@ -134,6 +149,7 @@ import {
|
||||
checkRemoteTmuxAvailable,
|
||||
readRemoteCases,
|
||||
readRemoteHosts,
|
||||
rehydrateRemoteHostFields,
|
||||
toAttachedSessionRemote,
|
||||
toSessionRemote,
|
||||
} from '../../remote-hosts.js';
|
||||
@@ -749,10 +765,65 @@ export function resolveOmpConfigForCreate(
|
||||
return resolvedId ? { ...ompConfig, resumeSessionId: resolvedId } : ompConfig;
|
||||
}
|
||||
|
||||
/**
|
||||
* `RemoteHost` → the wake registry's host shape. They differ in one field name only
|
||||
* (`id` in host config vs `hostId` on a session's `remote`), but the rename is load-
|
||||
* bearing: the registry keys its per-host wake state on `hostId`. The proxy fields
|
||||
* travel too: they are what tells the registry its probe cannot reach this host.
|
||||
*/
|
||||
function wakeableHost(host: RemoteHost): WakeableRemote {
|
||||
return {
|
||||
hostId: host.id,
|
||||
label: host.label,
|
||||
host: host.host,
|
||||
port: host.port,
|
||||
wakeMac: host.wakeMac,
|
||||
wakeCommand: host.wakeCommand,
|
||||
jumpHost: host.jumpHost,
|
||||
socksProxy: host.socksProxy,
|
||||
extraSshOptions: host.extraSshOptions,
|
||||
};
|
||||
}
|
||||
|
||||
export function registerSessionRoutes(
|
||||
app: FastifyInstance,
|
||||
ctx: SessionPort & EventPort & ConfigPort & InfraPort & AuthPort & TabLayoutPort
|
||||
): void {
|
||||
ctx: SessionPort & EventPort & ConfigPort & InfraPort & AuthPort & TabLayoutPort,
|
||||
/** Test seam: inject a registry with fake IO instead of the real TCP/WoL probes. */
|
||||
options: { remoteWake?: RemoteWakeRegistry } = {}
|
||||
): RemoteWakeRegistry {
|
||||
// Wake-on-LAN for sleeping remote hosts (see remote-wake.ts). One registry per
|
||||
// route registration (= one web server) — the same shape as the process-wide
|
||||
// `sessionWaits` singleton, but without the global.
|
||||
//
|
||||
// ⚠️ The ONLY caller that may wake a host is the input route below. The
|
||||
// auto-reconnect watcher and boot recovery deliberately have no access to this
|
||||
// registry: waking there would re-wake the host seconds after every suspend, so
|
||||
// it could never stay asleep.
|
||||
const remoteWake =
|
||||
options.remoteWake ??
|
||||
new RemoteWakeRegistry(
|
||||
createDefaultRemoteWakeDeps({
|
||||
noteReconnected: (sessionId, success) => {
|
||||
// Duck-typed exactly like server.ts: TmuxManager owns the COD-108 backoff
|
||||
// state, and the port interface does not expose it.
|
||||
const mux = ctx.mux as unknown as { noteRemoteReconnect?: (id: string, ok: boolean) => void };
|
||||
mux.noteRemoteReconnect?.(sessionId, success);
|
||||
},
|
||||
broadcast: (event, payload) => ctx.broadcast(event, payload),
|
||||
log: (message) => console.log(message),
|
||||
// The session's `remote` block is a launch-time snapshot, so a wake target
|
||||
// configured later (banner's config dialog, or a hand-edited remote-hosts.json)
|
||||
// is resolved here — throttled by the registry, and the host config is
|
||||
// authoritative in BOTH directions (removing the field turns the feature off
|
||||
// for a live session too).
|
||||
resolveRemote: async (session) => {
|
||||
const remote = session.remote;
|
||||
if (!remote) return undefined;
|
||||
const hosts = await readRemoteHosts(CODEMAN_CONFIG_DIR);
|
||||
return rehydrateRemoteHostFields(remote, new Map(hosts.map((host) => [host.id, host])));
|
||||
},
|
||||
})
|
||||
);
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
// Auth
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
@@ -824,9 +895,33 @@ export function registerSessionRoutes(
|
||||
// creation (owned durable sessions) is handled by the dedicated case-create
|
||||
// endpoint below, which #145 consolidated remote-host resolution into.
|
||||
if (body.attachRemoteSession) {
|
||||
// Remote hosts are admin-only infrastructure everywhere else (the list answers
|
||||
// `[]` to a non-admin; write and discovery routes are `adminOnly`), and the wake
|
||||
// below spawns the host's `wakeCommand` or broadcasts a packet. So the gate comes
|
||||
// FIRST — before the host is even looked up — or an unprivileged account could
|
||||
// invoke that executable for any configured `hostId` and only then be told the
|
||||
// workingDir was outside its workspace (reproduced upstream: wake spy fired, 403).
|
||||
if (isMultiUserMode() && !isAdmin(req)) {
|
||||
return createErrorResponse(ApiErrorCode.FORBIDDEN, 'Remote hosts are admin-only in multi-user mode');
|
||||
}
|
||||
const { hostId, remoteSessionName } = body.attachRemoteSession;
|
||||
const host = (await readRemoteHosts(CODEMAN_CONFIG_DIR)).find((item) => item.id === hostId);
|
||||
if (!host) return createErrorResponse(ApiErrorCode.NOT_FOUND, 'Remote host not found');
|
||||
// An explicit wake request is the only thing that may wake a host, and the user
|
||||
// pressing Attach IS one (see quick-start for the same gate, and
|
||||
// `remote-wake.ts` for what must never call this). Without it a sleeping host
|
||||
// answers with an ssh failure that blames anything but the machine being asleep.
|
||||
const hostWake = await remoteWake.ensureHostAwake(wakeableHost(host), {
|
||||
timeoutMs: REMOTE_WAKE_REQUEST_READY_TIMEOUT_MS,
|
||||
// No session yet, so the wake events name their requester (multi-user routing).
|
||||
requestedBy: ownerFor(req),
|
||||
});
|
||||
if (hostWake === 'failed') {
|
||||
return createErrorResponse(
|
||||
ApiErrorCode.OPERATION_FAILED,
|
||||
`${host.label} did not come back after a wake-on-LAN request — nothing was attached`
|
||||
);
|
||||
}
|
||||
workingDir = `${host.username}@${host.host}:${remoteSessionName}`;
|
||||
remote = toAttachedSessionRemote(host, remoteSessionName, workingDir);
|
||||
}
|
||||
@@ -1143,13 +1238,83 @@ export function registerSessionRoutes(
|
||||
if (!endpoint) {
|
||||
return createErrorResponse(ApiErrorCode.NOT_FOUND, 'Model endpoint not found');
|
||||
}
|
||||
const contextLength = endpoint.modelContextLengths?.[body.modelId];
|
||||
|
||||
// Some CLIs (today: only claude) carry enough of their own fixed system-prompt/tool-
|
||||
// schema overhead that a small enough real context guarantees a first-message failure
|
||||
// no matter what CLAUDE_CODE_MAX_CONTEXT_TOKENS says — confirmed live at ~36.4K tokens
|
||||
// against a model configured with a real 16384-token context. Warn before committing
|
||||
// to a restart that's certain to fail, rather than letting the user discover it via a
|
||||
// cryptic 400 from the CLI itself. Answered by `confirmedContext` (or the legacy
|
||||
// `confirmed`, which still means both) — NOT by `confirmedSwap`: this warning is
|
||||
// about the caller's own session, and the swap warning below is about someone
|
||||
// else's, so an answer to one is not consent to the other.
|
||||
if (!(body.confirmed || body.confirmedContext) && exceedsSafeContextFloor(entry, contextLength)) {
|
||||
return {
|
||||
requiresContextWarning: true,
|
||||
modelId: body.modelId,
|
||||
contextLength,
|
||||
minSafeContextTokens: CLAUDE_MIN_SAFE_CONTEXT_TOKENS,
|
||||
};
|
||||
}
|
||||
|
||||
// llama.cpp runs exactly one model at a time; llama-swap unloads and reloads it on
|
||||
// demand, which can take anywhere from a few seconds to over a minute — long enough
|
||||
// that a session mid-swap looks indistinguishable from one that never left the native
|
||||
// backend. Feature-detected via llama-swap's own `GET /running` (a plain llama.cpp
|
||||
// server has no such endpoint and reads as `isLlamaSwap: false` — nothing to check).
|
||||
const swapStatus = await getLlamaSwapStatus(endpoint);
|
||||
const currentlyLoaded = swapStatus.running.find((r) => r.state === 'ready')?.model ?? swapStatus.running[0]?.model;
|
||||
// Distinct from targetReady below: this is ONLY about whether proceeding would evict a
|
||||
// model another session is actively using — true even if nothing is loaded at all yet
|
||||
// would be wrong here (nothing to evict), so this stays narrowly "a DIFFERENT model is
|
||||
// currently ready".
|
||||
const swapNeeded = swapStatus.isLlamaSwap && !!currentlyLoaded && currentlyLoaded !== body.modelId;
|
||||
// Whether the TARGET model itself is already the one loaded and ready — false whether
|
||||
// nothing is loaded yet, a different model is loaded, or this one is loaded but still
|
||||
// mid-load. Drives both the actual load trigger below and modelSwapInProgress in the
|
||||
// response; deliberately broader than swapNeeded, which only gates the confirmation ask.
|
||||
const targetReady = swapStatus.running.some((r) => r.model === body.modelId && r.state === 'ready');
|
||||
|
||||
// Only ask when switching would actually take the model away from another session
|
||||
// that is currently using it — never just because a swap is needed at all. Answered
|
||||
// by `confirmedSwap` (or the legacy `confirmed`). ⚠ It must NOT read
|
||||
// `confirmedContext`: this check runs second, and while the two shared one flag a
|
||||
// user who clicked past a too-small-context warning had already, silently, agreed to
|
||||
// evict another session's model.
|
||||
if (swapNeeded && !(body.confirmed || body.confirmedSwap)) {
|
||||
const conflicting = [...ctx.sessions.values()].filter(
|
||||
(s) =>
|
||||
s.id !== session.id && s.customModel?.endpointId === endpoint.id && s.customModel?.modelId === currentlyLoaded
|
||||
);
|
||||
if (conflicting.length > 0) {
|
||||
// Applying a custom model is ungated for any session owner, so in multi-user
|
||||
// mode a non-admin pointing their own session at a shared endpoint must not
|
||||
// learn another user's session names in the confirm dialog — with
|
||||
// autoNameSessions on, those names are that user's own prompts. The swap is
|
||||
// still blocked pending confirmation regardless of ownership (a foreign
|
||||
// session is just as real a disruption); only which ones get NAMED is scoped.
|
||||
const requestUser = getAuthUser(req);
|
||||
const affectedSessions = conflicting
|
||||
.filter((s) => canAccessOwned(requestUser, s.owner))
|
||||
.map((s) => ({ id: s.id, name: s.name }));
|
||||
return { requiresConfirmation: true, currentlyLoadedModel: currentlyLoaded, affectedSessions };
|
||||
}
|
||||
}
|
||||
|
||||
// A CLI whose config alone cannot select the model also gets its `model` launch param
|
||||
// forced (pi/omp `custom/<id>`, grok's block name). The argv engine DROPS a token that
|
||||
// fails its pattern rather than quoting it, which would silently launch the CLI on its
|
||||
// own default provider again, so refuse an id the pattern cannot carry up front.
|
||||
const modelSpec = entry.launch.params.model;
|
||||
const applied = applyCustomModelInjection(entry, endpoint, body.modelId, session.id);
|
||||
const applied = applyCustomModelInjection(
|
||||
entry,
|
||||
endpoint,
|
||||
body.modelId,
|
||||
session.id,
|
||||
contextLength,
|
||||
session.workingDir
|
||||
);
|
||||
if (!applied) {
|
||||
return createErrorResponse(ApiErrorCode.OPERATION_FAILED, `${session.mode} has no known custom-model mechanism`);
|
||||
}
|
||||
@@ -1182,9 +1347,17 @@ export function registerSessionRoutes(
|
||||
removeConfigDir(previousConfigDir);
|
||||
}
|
||||
|
||||
// Actually kick off llama-swap's load now, rather than waiting on the restarted CLI's
|
||||
// own first prompt to do it — confirmed live that applying a selection alone never
|
||||
// reached the llama-swap server at all (nothing in its own logs), since llama-swap has
|
||||
// no "switch model" admin call, only a real inference request naming the model.
|
||||
if (swapStatus.isLlamaSwap && !targetReady) {
|
||||
triggerLlamaSwapLoad(endpoint, body.modelId);
|
||||
}
|
||||
|
||||
const restarted = await session.restartCli();
|
||||
persistAndBroadcastSession(ctx, session);
|
||||
return { customModel: session.customModel, restarted };
|
||||
return { customModel: session.customModel, restarted, modelSwapInProgress: swapStatus.isLlamaSwap && !targetReady };
|
||||
});
|
||||
|
||||
// ========== Delete Session ==========
|
||||
@@ -1214,6 +1387,8 @@ export function registerSessionRoutes(
|
||||
}
|
||||
|
||||
const session = findSessionOrFail(ctx, id, req);
|
||||
// Wake state is dropped by `cleanupSession` itself (server.ts), on EVERY cleanup
|
||||
// path — not here: the scheduled-run and admin paths clean up without this route.
|
||||
await ctx.cleanupSession(session.id, killMux, 'user_delete');
|
||||
return {};
|
||||
});
|
||||
@@ -1408,6 +1583,12 @@ export function registerSessionRoutes(
|
||||
name: session.name,
|
||||
mode: session.mode,
|
||||
});
|
||||
// Persist, not just broadcast. Starting a command in the pane changes
|
||||
// `pid` and retracts any `paneExit` (Ark0N/Codeman#446), and the pane-exit
|
||||
// watcher cannot write that retraction to disk for us: its next tick finds
|
||||
// the in-memory field already cleared, reports no change and persists
|
||||
// nothing, so `state.json` would keep saying the agent had exited.
|
||||
ctx.persistSessionState(session);
|
||||
ctx.broadcast(SseEvent.SessionInteractive, { id });
|
||||
ctx.broadcast(SseEvent.SessionUpdated, { session: ctx.getSessionStateWithRespawn(session) });
|
||||
|
||||
@@ -1437,6 +1618,9 @@ export function registerSessionRoutes(
|
||||
name: session.name,
|
||||
mode: 'shell',
|
||||
});
|
||||
// Persist for the same reason /interactive does: a started pane retracts
|
||||
// `paneExit`, and the watcher's next tick cannot write that retraction.
|
||||
ctx.persistSessionState(session);
|
||||
ctx.broadcast(SseEvent.SessionInteractive, { id, mode: 'shell' });
|
||||
ctx.broadcast(SseEvent.SessionUpdated, { session: ctx.getSessionStateWithRespawn(session) });
|
||||
return {};
|
||||
@@ -1449,6 +1633,67 @@ export function registerSessionRoutes(
|
||||
// Terminal I/O (input, resize, buffer)
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
|
||||
// ========== Wake-on-LAN: state + manual trigger ==========
|
||||
//
|
||||
// Both routes are session-scoped (not host-scoped) because the wake flow needs the
|
||||
// SESSION: a woken host whose pane is not reattached is still a dead terminal, and an
|
||||
// exhausted COD-108 backoff never retries on its own. The probe in `/reachability` is
|
||||
// the same cheap TCP connect the input path uses and it NEVER wakes a host — the UI
|
||||
// decides that, with the button.
|
||||
|
||||
app.get('/api/sessions/:id/reachability', async (req) => {
|
||||
const { id } = req.params as { id: string };
|
||||
const session = findSessionOrFail(ctx, id, req);
|
||||
const remote = session.remote;
|
||||
if (!remote) {
|
||||
return { success: true, data: { reachable: true, probeable: true, wakeConfigured: 'none' as const } };
|
||||
}
|
||||
const force = (req.query as { force?: string })?.force === '1';
|
||||
// `reachable: null` + `probeable: false` for a host behind a jump host / SOCKS proxy:
|
||||
// the probe cannot reach it, so the UI shows no banner and stops polling.
|
||||
const reachable = await remoteWake.checkReachable(session, { force });
|
||||
return {
|
||||
success: true,
|
||||
data: {
|
||||
reachable,
|
||||
probeable: isProbeable(remote),
|
||||
wakeConfigured: await remoteWake.wakeConfigured(session),
|
||||
host: remote.host,
|
||||
label: remote.label,
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
app.post('/api/sessions/:id/wake', async (req) => {
|
||||
const { id } = req.params as { id: string };
|
||||
const session = findSessionOrFail(ctx, id, req);
|
||||
if (!session.remote) {
|
||||
return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Not a remote session');
|
||||
}
|
||||
// The UI uses this to route to the host config dialog instead of a dead button.
|
||||
if (!(await remoteWake.hasWakeTarget(session))) {
|
||||
return createErrorResponse(
|
||||
ApiErrorCode.INVALID_INPUT,
|
||||
'No wake-on-LAN target configured for this host (set a MAC address or a wake command)'
|
||||
);
|
||||
}
|
||||
// The button is pressed from the SAME dashboard the create/attach paths are, under
|
||||
// the same reverse proxy — so it holds the request open the same way and needs the
|
||||
// same request budget, not the 90 s session default (see remote-wake.ts).
|
||||
const woke = await remoteWake.ensureAwake(session, {
|
||||
force: true,
|
||||
timeoutMs: REMOTE_WAKE_REQUEST_READY_TIMEOUT_MS,
|
||||
});
|
||||
return {
|
||||
success: true,
|
||||
data: {
|
||||
woke,
|
||||
reachable: await remoteWake.checkReachable(session),
|
||||
wakeConfigured: await remoteWake.wakeConfigured(session),
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
// ========== Send Input ==========
|
||||
|
||||
app.post('/api/sessions/:id/input', async (req, reply) => {
|
||||
@@ -1490,6 +1735,42 @@ export function registerSessionRoutes(
|
||||
return {};
|
||||
}
|
||||
|
||||
// Wake-on-LAN (remote-wake.ts): a wake-enabled remote host that suspended leaves
|
||||
// the local ssh pane STALLED, and `send-keys` succeeds against it — the bytes
|
||||
// would vanish with no error anywhere. Give the registry the chance to probe the
|
||||
// host, wake it, reattach, and own delivery before we write into nothing.
|
||||
//
|
||||
// Costs nothing for non-wake hosts (the `wakeCommand` guard) or while the host is
|
||||
// known reachable inside the probe throttle window; the probe itself is a bare
|
||||
// TCP connect on wake-enabled hosts only, at most once per
|
||||
// REMOTE_WAKE_PROBE_MIN_INTERVAL_MS.
|
||||
if (!duplicate && (await remoteWake.hasWakeTarget(session))) {
|
||||
if (wantsWait) {
|
||||
// Send-and-wait keeps the response open anyway, so blocking on the wake is
|
||||
// simpler and more correct than buffering (buffering would break the wait).
|
||||
// A host that never comes back is an error here, as on the create/attach
|
||||
// paths: writing into the stalled pane would answer `delivered:true` plus a
|
||||
// timeout, which is the combination the API docs send callers to the wrong
|
||||
// recovery for.
|
||||
if (!(await remoteWake.ensureAwake(session))) {
|
||||
return createErrorResponse(
|
||||
ApiErrorCode.OPERATION_FAILED,
|
||||
`${session.remote?.label ?? 'the remote host'} did not come back after a wake-on-LAN request — nothing was sent`
|
||||
);
|
||||
}
|
||||
} else {
|
||||
const outcome = await remoteWake.handleInput(session, inputStr);
|
||||
// The registry holds the bytes and flushes them in order once the pane is
|
||||
// reattached. The client's ACK is this 200 — a tagged retry is deduped
|
||||
// (`shouldApplyInput` above already consumed the seq), so nothing is lost.
|
||||
// `buffered` is additive to the historical bare `{}`; `dropped` says the chunk
|
||||
// was over the wake buffer's cap and is GONE (a 200 with no field could not
|
||||
// tell delivered from buffered from dropped).
|
||||
if (outcome === 'buffered') return { buffered: true };
|
||||
if (outcome === 'dropped') return { buffered: true, dropped: true };
|
||||
}
|
||||
}
|
||||
|
||||
// Only a waiting request pays for the tmux probe: the browser's plain input path
|
||||
// (thousands of calls per session) must stay exec-free.
|
||||
const workerDead = wantsWait && workerIsDead(ctx.mux, session);
|
||||
@@ -1843,7 +2124,13 @@ export function registerSessionRoutes(
|
||||
const session = findSessionOrFail(ctx, id, req);
|
||||
|
||||
session.resize(cols, rows, { viewportType, force });
|
||||
return {};
|
||||
// Answer with the geometry the PTY ACTUALLY holds, which is not always the
|
||||
// one asked for: `Session.resize` declines small-viewport requests while a
|
||||
// desktop connection holds an active sizing claim. A browser terminal left
|
||||
// at a shape the PTY refused renders garbled output, not merely wrong-sized
|
||||
// output, so the client adopts this (issue #464). A session with no pane
|
||||
// reports nothing rather than the constructor defaults — see `ptyGeometry`.
|
||||
return session.ptyGeometry ?? {};
|
||||
});
|
||||
|
||||
// ========== Get Last Response (from transcript JSONL) ==========
|
||||
@@ -2632,14 +2919,16 @@ export function registerSessionRoutes(
|
||||
// returns null when unavailable, in which case we fall back to history.
|
||||
const muxName = session.muxName;
|
||||
const captureStartedAt = performance.now();
|
||||
// The visible path used to pass no options at all. It passes one now for a
|
||||
// single reason: `capturedGeometry` comes BACK on it, and the response has
|
||||
// to tell the client what size the frame it is about to render was built
|
||||
// for. See PaneCaptureOptions.capturedGeometry.
|
||||
const captureOpts: PaneCaptureOptions = isFullReload
|
||||
? { fullHistory: true, historyLimitLines: tmuxHistoryLimit, maxCaptureBytes: terminalBufferMaxBytes }
|
||||
: {};
|
||||
const liveMuxBuffer =
|
||||
muxName && typeof ctx.mux.captureActivePaneBuffer === 'function'
|
||||
? ctx.mux.captureActivePaneBuffer(
|
||||
muxName,
|
||||
isFullReload
|
||||
? { fullHistory: true, historyLimitLines: tmuxHistoryLimit, maxCaptureBytes: terminalBufferMaxBytes }
|
||||
: undefined
|
||||
)
|
||||
? ctx.mux.captureActivePaneBuffer(muxName, captureOpts)
|
||||
: null;
|
||||
const captureFinishedAt = performance.now();
|
||||
const hasLiveMuxBuffer = liveMuxBuffer !== null && liveMuxBuffer.length > 0;
|
||||
@@ -2785,6 +3074,25 @@ export function registerSessionRoutes(
|
||||
// what existed before the cut. The gap is what the indicator reports.
|
||||
retainedBytes: cleanBuffer.length,
|
||||
source,
|
||||
// The pane geometry this frame was drawn for. A visible-frame capture
|
||||
// positions every row absolutely, so a client whose terminal has fewer
|
||||
// rows than this overwrites its last line with the overflow and loses
|
||||
// the rows underneath. The client compares these against its own size.
|
||||
//
|
||||
// BOTH FIELDS ARE ABSENT unless this response really carries a capture,
|
||||
// and that is the honest answer rather than a gap to paper over. Two
|
||||
// separate things can leave a frame unpositioned. The cursor query is
|
||||
// what produces the absolute addressing in the first place, so a capture
|
||||
// that lost it returned a raw frame with no row positioning in it. And a
|
||||
// capture can report geometry and STILL hand back nothing: the
|
||||
// full-history path returns '' for a pane holding nothing visible, which
|
||||
// drops `source` to `history` while `capturedGeometry` is already
|
||||
// written, so the geometry has to be suppressed HERE rather than trusted
|
||||
// to be missing. Naming a size for a body that is the byte stream would
|
||||
// describe a frame that was never drawn and invite the client to repair
|
||||
// damage that does not exist.
|
||||
captureCols: hasLiveMuxBuffer ? captureOpts.capturedGeometry?.cols : undefined,
|
||||
captureRows: hasLiveMuxBuffer ? captureOpts.capturedGeometry?.rows : undefined,
|
||||
};
|
||||
});
|
||||
|
||||
@@ -3043,6 +3351,7 @@ export function registerSessionRoutes(
|
||||
effort,
|
||||
parentSessionId,
|
||||
agentOrigin,
|
||||
customModel,
|
||||
} = parseBody(QuickStartSchema, req.body);
|
||||
|
||||
// Resolved ONCE here: the same value labels a case directory this request creates
|
||||
@@ -3095,11 +3404,31 @@ export function registerSessionRoutes(
|
||||
grokConfig ||
|
||||
deepSeekConfig ||
|
||||
ompConfig ||
|
||||
openCodeConfig
|
||||
openCodeConfig ||
|
||||
customModel
|
||||
) {
|
||||
return createErrorResponse(
|
||||
ApiErrorCode.INVALID_INPUT,
|
||||
'envOverrides, effort, modelOverride, and per-CLI config are not supported for remote cases (they do not cross ssh). Configure the remote command via the host command override instead.'
|
||||
'envOverrides, effort, modelOverride, per-CLI config, and custom model endpoints are not supported for remote cases (they do not cross ssh). Configure the remote command via the host command override instead.'
|
||||
);
|
||||
}
|
||||
|
||||
// The user pressing "Run" on a case whose host is asleep IS an explicit wake
|
||||
// request (docs/remote-sessions.md §Wake-on-LAN), and the tmux probe below would
|
||||
// otherwise fail with "could not verify tmux on remote host …" — an ssh failure
|
||||
// that blames tmux for a machine that is merely suspended. Wired HERE, in the HTTP
|
||||
// route, and deliberately NOT in the shared session service: `cron-service.ts`
|
||||
// builds sessions through the service, and a wake down there would re-wake the
|
||||
// host on every schedule (the failure invariant #1 exists to prevent).
|
||||
const hostWake = await remoteWake.ensureHostAwake(wakeableHost(host), {
|
||||
timeoutMs: REMOTE_WAKE_REQUEST_READY_TIMEOUT_MS,
|
||||
// No session yet, so the wake events name their requester (multi-user routing).
|
||||
requestedBy: ownerFor(req),
|
||||
});
|
||||
if (hostWake === 'failed') {
|
||||
return createErrorResponse(
|
||||
ApiErrorCode.OPERATION_FAILED,
|
||||
`${host.label} did not come back after a wake-on-LAN request — the session was not started`
|
||||
);
|
||||
}
|
||||
|
||||
@@ -3108,6 +3437,20 @@ export function registerSessionRoutes(
|
||||
// surfaces a clear, structured error instead of a dead "tmux: command not found" pane.
|
||||
const tmuxCheck = await checkRemoteTmuxAvailable(host);
|
||||
if (!tmuxCheck.ok) {
|
||||
// An unreachable host and a host without tmux fail the same way over ssh, so the
|
||||
// probe's own message would send the user hunting for a tmux install. Ask the
|
||||
// registry (which just probed, when it woke the host) which of the two it is.
|
||||
// `=== false` on purpose: a proxied host answers `null` (the probe cannot reach
|
||||
// it), and an unknown verdict must not replace the real ssh error with
|
||||
// "not reachable" over a host that is fine.
|
||||
if ((await remoteWake.checkHostReachable(wakeableHost(host))) === false) {
|
||||
return createErrorResponse(
|
||||
ApiErrorCode.OPERATION_FAILED,
|
||||
hostWake === 'no-target'
|
||||
? `${host.label} (${host.host}) is not reachable, and this host has no wake-on-LAN target — configure a MAC address or a wake command first`
|
||||
: `${host.label} (${host.host}) is not reachable`
|
||||
);
|
||||
}
|
||||
return createErrorResponse(ApiErrorCode.OPERATION_FAILED, tmuxCheck.error || 'remote host is missing tmux');
|
||||
}
|
||||
|
||||
@@ -3130,11 +3473,12 @@ export function registerSessionRoutes(
|
||||
grokConfig ||
|
||||
deepSeekConfig ||
|
||||
ompConfig ||
|
||||
openCodeConfig
|
||||
openCodeConfig ||
|
||||
customModel
|
||||
) {
|
||||
return createErrorResponse(
|
||||
ApiErrorCode.INVALID_INPUT,
|
||||
'envOverrides, effort, and per-CLI config are not supported for docker cases (they do not cross into the container). Configure the container via the docker host command override instead.'
|
||||
'envOverrides, effort, per-CLI config, and custom model endpoints are not supported for docker cases (they do not cross into the container). Configure the container via the docker host command override instead.'
|
||||
);
|
||||
}
|
||||
|
||||
@@ -3422,7 +3766,153 @@ export function registerSessionRoutes(
|
||||
);
|
||||
const qsTerminalHistoryConfig = await ctx.getTerminalHistoryConfig();
|
||||
const qsGatedEnvOverrides = await clampEnvOverridesForOwner(owner, envOverrides);
|
||||
const session = new Session({
|
||||
const qsResolvedOmpConfig = resolveOmpConfigForCreate(mode, resolvedCasePath, ompConfig);
|
||||
|
||||
// Custom Model Endpoint Profiles, applied AT CREATE TIME (docs/custom-model-endpoints-plan.md)
|
||||
// rather than via the dedicated restart-in-place route (POST /api/sessions/:id/custom-
|
||||
// model, still what an ALREADY-RUNNING session uses to switch later): computing the
|
||||
// injection before the process exists and launching directly on it avoids the visible
|
||||
// native-boot-then-restart the restart-after-launch design otherwise shows on every
|
||||
// custom-model run — most jarring on a CLI like Codex whose TUI fully reinitializes.
|
||||
// Mirrors the dedicated route's own checks (llama-swap conflict, unsupported CLI,
|
||||
// unknown endpoint, a model id the CLI's argv pattern can't carry) rather than trusting
|
||||
// a lighter version of them, since this is the same server-side authority reached a
|
||||
// different way, not a separate, less-checked path.
|
||||
let qsCustomModelEnvOverrides = qsGatedEnvOverrides;
|
||||
// Only the INJECTED keys (never the caller's envOverrides merged in) — this is what
|
||||
// setCustomModel() bookkeeping must be given below. The Session constructor already
|
||||
// applies qsCustomModelEnvOverrides (the full merged set) directly; re-merging that
|
||||
// full set into setCustomModel() would put CLAUDE_CODE_EFFORT_LEVEL back after the
|
||||
// constructor stripped it (see setCustomModel()'s own doc comment in session.ts).
|
||||
let qsCustomModelAppliedEnvOverrides: Record<string, string> | undefined;
|
||||
let qsCustomModelLaunchModel: string | undefined;
|
||||
let qsCustomModelSessionId: string | undefined;
|
||||
let qsCustomModelSwapInProgress = false;
|
||||
let qsCustomModelBookkeeping:
|
||||
| {
|
||||
endpointId: string;
|
||||
modelId: string;
|
||||
label?: string;
|
||||
envKeys: string[];
|
||||
configDir?: string;
|
||||
launchModel?: string;
|
||||
}
|
||||
| undefined;
|
||||
if (customModel) {
|
||||
const cmEntry = getCli(mode);
|
||||
if (!cmEntry) return createErrorResponse(ApiErrorCode.INVALID_INPUT, `No CLI registry entry for mode ${mode}`);
|
||||
if (cmEntry.capabilities.customModelInjection.kind === 'unsupported') {
|
||||
return createErrorResponse(ApiErrorCode.OPERATION_FAILED, `${mode} has no known custom-model mechanism`);
|
||||
}
|
||||
const cmHosts = await readCustomModelHosts(CODEMAN_CONFIG_DIR);
|
||||
const cmEndpoint = cmHosts.find((h) => h.id === customModel.endpointId);
|
||||
if (!cmEndpoint) return createErrorResponse(ApiErrorCode.NOT_FOUND, 'Model endpoint not found');
|
||||
const cmContextLength = cmEndpoint.modelContextLengths?.[customModel.modelId];
|
||||
|
||||
// See the dedicated route's own comment for the full reasoning: some CLIs' own fixed
|
||||
// overhead can exceed a small enough real context on the very first message,
|
||||
// regardless of contextLengthVar. Warn before creating a session that's certain to
|
||||
// fail immediately.
|
||||
// See the dedicated route above for why this reads `confirmedContext` and never
|
||||
// `confirmedSwap`.
|
||||
if (
|
||||
!(customModel.confirmed || customModel.confirmedContext) &&
|
||||
exceedsSafeContextFloor(cmEntry, cmContextLength)
|
||||
) {
|
||||
return {
|
||||
requiresContextWarning: true,
|
||||
modelId: customModel.modelId,
|
||||
contextLength: cmContextLength,
|
||||
minSafeContextTokens: CLAUDE_MIN_SAFE_CONTEXT_TOKENS,
|
||||
};
|
||||
}
|
||||
|
||||
// See the dedicated route's own comment for the full reasoning: llama.cpp runs one
|
||||
// model at a time, llama-swap swaps on demand, and switching away from what another
|
||||
// live session is actively using deserves a warning, not a silent switch. There is no
|
||||
// "self" to exclude from the affected-sessions scan here — this session doesn't exist
|
||||
// yet.
|
||||
const cmSwapStatus = await getLlamaSwapStatus(cmEndpoint);
|
||||
const cmCurrentlyLoaded =
|
||||
cmSwapStatus.running.find((r) => r.state === 'ready')?.model ?? cmSwapStatus.running[0]?.model;
|
||||
const cmSwapNeeded = cmSwapStatus.isLlamaSwap && !!cmCurrentlyLoaded && cmCurrentlyLoaded !== customModel.modelId;
|
||||
// Broader than cmSwapNeeded (which only gates the confirmation ask above): true
|
||||
// whenever the TARGET model isn't already loaded and ready, including when nothing
|
||||
// is loaded at all yet. Drives the actual load trigger below.
|
||||
const cmTargetReady = cmSwapStatus.running.some((r) => r.model === customModel.modelId && r.state === 'ready');
|
||||
qsCustomModelSwapInProgress = cmSwapStatus.isLlamaSwap && !cmTargetReady;
|
||||
if (cmSwapNeeded && !(customModel.confirmed || customModel.confirmedSwap)) {
|
||||
const cmConflicting = [...ctx.sessions.values()].filter(
|
||||
(s) => s.customModel?.endpointId === cmEndpoint.id && s.customModel?.modelId === cmCurrentlyLoaded
|
||||
);
|
||||
if (cmConflicting.length > 0) {
|
||||
// Same reasoning as the dedicated /custom-model route above: the swap is
|
||||
// still blocked pending confirmation regardless of ownership, but a
|
||||
// non-admin caller only learns the names of sessions they can access.
|
||||
const cmRequestUser = getAuthUser(req);
|
||||
const cmAffectedSessions = cmConflicting
|
||||
.filter((s) => canAccessOwned(cmRequestUser, s.owner))
|
||||
.map((s) => ({ id: s.id, name: s.name }));
|
||||
return {
|
||||
requiresConfirmation: true,
|
||||
currentlyLoadedModel: cmCurrentlyLoaded,
|
||||
affectedSessions: cmAffectedSessions,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// Minted ourselves (rather than left to Session's own default) so the injection
|
||||
// below — and any configDir it writes — can target the REAL id the session launches
|
||||
// with, not a placeholder: `new Session({ id: ... })` accepts an explicit id for
|
||||
// exactly this reason.
|
||||
qsCustomModelSessionId = randomUUID();
|
||||
const cmApplied = applyCustomModelInjection(
|
||||
cmEntry,
|
||||
cmEndpoint,
|
||||
customModel.modelId,
|
||||
qsCustomModelSessionId,
|
||||
cmContextLength,
|
||||
resolvedCasePath
|
||||
);
|
||||
if (!cmApplied) {
|
||||
return createErrorResponse(ApiErrorCode.OPERATION_FAILED, `${mode} has no known custom-model mechanism`);
|
||||
}
|
||||
const cmModelSpec = cmEntry.launch.params.model;
|
||||
if (
|
||||
cmApplied.launchModel !== undefined &&
|
||||
cmModelSpec?.type === 'token' &&
|
||||
!matchesPattern(cmModelSpec.pattern, cmApplied.launchModel)
|
||||
) {
|
||||
removeConfigDir(cmApplied.configDir);
|
||||
return createErrorResponse(
|
||||
ApiErrorCode.INVALID_INPUT,
|
||||
`Model id ${JSON.stringify(customModel.modelId)} cannot be passed to ${mode} on its command line`
|
||||
);
|
||||
}
|
||||
|
||||
qsCustomModelEnvOverrides = { ...qsGatedEnvOverrides, ...cmApplied.envOverrides };
|
||||
qsCustomModelAppliedEnvOverrides = cmApplied.envOverrides;
|
||||
qsCustomModelLaunchModel = cmApplied.launchModel;
|
||||
qsCustomModelBookkeeping = {
|
||||
endpointId: cmEndpoint.id,
|
||||
modelId: customModel.modelId,
|
||||
label: cmEndpoint.label,
|
||||
envKeys: cmApplied.envKeys,
|
||||
configDir: cmApplied.configDir,
|
||||
launchModel: cmApplied.launchModel,
|
||||
};
|
||||
|
||||
// Actually kick off llama-swap's load now — see the dedicated apply route's own
|
||||
// comment on triggerLlamaSwapLoad for why this can't just wait on the launched CLI's
|
||||
// first prompt. Fired here, before the session is even created, so the load starts
|
||||
// concurrently with Claude/Codex/etc. booting rather than after.
|
||||
if (qsCustomModelSwapInProgress) {
|
||||
triggerLlamaSwapLoad(cmEndpoint, customModel.modelId);
|
||||
}
|
||||
}
|
||||
|
||||
const qsSessionOptions: ConstructorParameters<typeof Session>[0] = {
|
||||
id: qsCustomModelSessionId,
|
||||
workingDir: resolvedCasePath,
|
||||
name: sessionName ? sessionName.slice(0, MAX_SESSION_NAME_LENGTH) : '',
|
||||
mux: ctx.mux,
|
||||
@@ -3440,15 +3930,42 @@ export function registerSessionRoutes(
|
||||
piConfig: mode === 'pi' ? qsGatedPiConfig : undefined,
|
||||
grokConfig: mode === 'grok' ? qsGatedGrokConfig : undefined,
|
||||
deepSeekConfig: mode === 'deepseek' ? qsGatedDeepSeekConfig : undefined,
|
||||
ompConfig: resolveOmpConfigForCreate(mode, resolvedCasePath, ompConfig),
|
||||
envOverrides: qsGatedEnvOverrides,
|
||||
ompConfig: qsResolvedOmpConfig,
|
||||
envOverrides: qsCustomModelEnvOverrides,
|
||||
effort,
|
||||
remote,
|
||||
docker,
|
||||
resumeSessionId: dockerResumeId,
|
||||
tmuxHistoryLimit: qsTerminalHistoryConfig.tmuxHistoryLimit,
|
||||
parentSessionId: qsParentSessionId,
|
||||
});
|
||||
};
|
||||
// Force the custom-model selection's launchModel (pi/omp `custom/<id>`, grok's
|
||||
// `[model.<name>]` block name) onto whichever config field the registry says the
|
||||
// CLI's `model` launch param lives in — mirrors Session._withCustomModelLaunchModel,
|
||||
// which the restart-in-place path already uses, rather than a hardcoded per-CLI
|
||||
// branch here that a CLI landing its injection recipe later would silently miss.
|
||||
if (qsCustomModelLaunchModel !== undefined) {
|
||||
const qsCustomModelField = getCli(mode)?.launch.legacyConfigField;
|
||||
if (qsCustomModelField) {
|
||||
const qsSessionOptionsBag = qsSessionOptions as unknown as Record<string, unknown>;
|
||||
qsSessionOptionsBag[qsCustomModelField] = {
|
||||
...((qsSessionOptionsBag[qsCustomModelField] as Record<string, unknown>) ?? {}),
|
||||
model: qsCustomModelLaunchModel,
|
||||
};
|
||||
} else {
|
||||
qsSessionOptions.model = qsCustomModelLaunchModel;
|
||||
}
|
||||
}
|
||||
const session = new Session(qsSessionOptions);
|
||||
|
||||
// Records the selection for session.customModel/getCustomModelForPersist() and future
|
||||
// clear/switch calls — the actual env vars and launch-model config are already part of
|
||||
// the launch above (constructor envOverrides, piConfig/grokConfig/ompConfig.model), so
|
||||
// this is bookkeeping only, never a restart: setCustomModel() is synchronous state, no
|
||||
// tmux IO of its own (see its own doc comment in session.ts).
|
||||
if (qsCustomModelBookkeeping) {
|
||||
session.setCustomModel(qsCustomModelBookkeeping, qsCustomModelAppliedEnvOverrides);
|
||||
}
|
||||
|
||||
// Auto-detect completion phrase from CLAUDE.md BEFORE broadcasting
|
||||
// so the initial state already has the phrase configured (only if globally enabled)
|
||||
@@ -3544,6 +4061,7 @@ export function registerSessionRoutes(
|
||||
sessionId: session.id,
|
||||
casePath: resolvedCasePath,
|
||||
caseName,
|
||||
...(customModel ? { modelSwapInProgress: qsCustomModelSwapInProgress } : {}),
|
||||
};
|
||||
} catch (err) {
|
||||
// Clean up session on error to prevent orphaned resources
|
||||
@@ -4666,4 +5184,10 @@ export function registerSessionRoutes(
|
||||
|
||||
return { path: filepath, filename };
|
||||
});
|
||||
|
||||
// Returned so the server can own the registry's LIFETIME (drop state when a session is
|
||||
// cleaned up on any of its paths, resolve in-flight wakes on shutdown). The wake-CAPABLE
|
||||
// code stays here: `test/remote-wake.test.ts` pins that `server.ts` calls nothing but
|
||||
// `drop`/`stop` on this handle, so no timer path can reach a wake through it.
|
||||
return remoteWake;
|
||||
}
|
||||
|
||||
@@ -22,6 +22,9 @@
|
||||
* {"t":"c"} — clear terminal
|
||||
* {"t":"r"} — needs refresh (reload buffer)
|
||||
* {"t":"ia","seq":N} — input ACK (echoes the seq of an applied/deduped input frame)
|
||||
* {"t":"zc","c":N,"r":N} — resize confirm: the geometry the PTY now holds, which
|
||||
* is NOT always the one requested (see Session.resize
|
||||
* arbitration). Clients adopt it — issue #464.
|
||||
* Client -> Server:
|
||||
* {"t":"i","d":"...","seq":N,"cid":"..."} — input (keystroke or paste). seq+cid are
|
||||
* optional reliable-delivery tags: the server applies each
|
||||
@@ -240,6 +243,19 @@ export function registerWsRoutes(app: FastifyInstance, ctx: SessionPort, getHost
|
||||
}
|
||||
const force = msg.f === true;
|
||||
session.resize(msg.c, msg.r, { viewportType, force });
|
||||
// Report the geometry that actually took. Resize used to be
|
||||
// write-only, so a client whose request was declined by the
|
||||
// arbitration above — or floored, or overridden by another device
|
||||
// — had no way to find out, and went on rendering a CLI's repaints
|
||||
// against a screen shape that did not exist (issue #464). Sent
|
||||
// unconditionally: it is ~30 bytes on a debounced, rare message,
|
||||
// and always-send means the client needs no "did it take?" state.
|
||||
// A session with no pane sends nothing at all: its `_ptyCols`/
|
||||
// `_ptyRows` are constructor defaults no process was ever told.
|
||||
const applied = session.ptyGeometry;
|
||||
if (applied && socket.readyState === 1) {
|
||||
socket.send(`{"t":"zc","c":${applied.cols},"r":${applied.rows}}`);
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Ignore malformed messages
|
||||
|
||||
@@ -19,6 +19,7 @@ import {
|
||||
} from '../config/terminal-history.js';
|
||||
import { MAX_EDITABLE_BYTES } from '../config/file-editing.js';
|
||||
import { MIN_MATCH_LENGTH, MAX_MATCH_LENGTH } from '../config/agent-wait.js';
|
||||
import { MAX_WAKE_MACS } from '../config/remote-wake-limits.js';
|
||||
import { enabledCliIds, enabledClis } from '../config/cli-registry/registry.js';
|
||||
import type { SessionMode } from '../types.js';
|
||||
|
||||
@@ -737,6 +738,36 @@ export const RemoteHostSchema = z.object({
|
||||
.max(32)
|
||||
.optional(),
|
||||
commands: RemoteCommandOverridesSchema,
|
||||
// Wake-on-LAN: a single executable path (no arguments, no shell) run to power a
|
||||
// SLEEPING host back on, e.g. `/home/joe/bin/whuff`. Executed via spawn without
|
||||
// a shell, so there is no shell layer to escape; the regexes are belt-and-braces
|
||||
// (and the no-whitespace rule rejects an argument list before it can fail as a
|
||||
// confusing ENOENT at wake time). See docs/remote-sessions.md §Wake-on-LAN.
|
||||
wakeCommand: z
|
||||
.string()
|
||||
.min(1)
|
||||
.max(4096)
|
||||
.regex(/^\S+$/, 'Wake command must be a single executable path (no arguments)')
|
||||
.regex(NO_SHELL_META, 'Invalid characters in wake command')
|
||||
.optional(),
|
||||
// Wake-on-LAN MAC address(es), comma-separated. Structural: only hex pairs with
|
||||
// `:`/`-` separators, so nothing here can be a shell token even by accident (the
|
||||
// value never reaches a shell — Codeman builds the magic packet itself).
|
||||
wakeMac: z
|
||||
.string()
|
||||
.min(11)
|
||||
.max(128)
|
||||
.regex(
|
||||
/^[0-9a-fA-F]{2}([:-][0-9a-fA-F]{2}){5}(\s*,\s*[0-9a-fA-F]{2}([:-][0-9a-fA-F]{2}){5})*$/,
|
||||
'Wake MAC must be one or more MAC addresses, comma-separated'
|
||||
)
|
||||
// ⚠ The character cap admits seven MACs while parseMacList takes at most
|
||||
// MAX_WAKE_MACS, all-or-nothing. Without this the extra ones validated, persisted,
|
||||
// and then resolved to NO wake target, so the host read as unconfigured.
|
||||
.refine((value) => value.split(',').length <= MAX_WAKE_MACS, {
|
||||
message: `Wake MAC accepts at most ${MAX_WAKE_MACS} comma-separated addresses`,
|
||||
})
|
||||
.optional(),
|
||||
});
|
||||
|
||||
export const RemoteCaseLinkSchema = z.object({
|
||||
@@ -1033,6 +1064,27 @@ export const QuickStartSchema = z.object({
|
||||
* because it takes an existing `workingDir` and so never creates a directory to label.
|
||||
*/
|
||||
agentOrigin: z.string().max(64).optional(),
|
||||
/**
|
||||
* Custom Model Endpoint Profiles (docs/custom-model-endpoints-plan.md): launches directly
|
||||
* on this saved endpoint/model instead of the mode's native backend, computed server-side
|
||||
* from the admin-configured endpoint store the same way `POST /api/sessions/:id/custom-
|
||||
* model` does — never trusting raw env values from the client. One-shot, launch-time
|
||||
* equivalent of that route: no restart, so no visible relaunch (that route's restart-in-
|
||||
* place is still what an ALREADY-RUNNING session uses to switch later). Rejected for
|
||||
* remote/docker cases, same reasoning as `envOverrides` above. The three confirmation
|
||||
* flags mirror that route's fields; see `SessionCustomModelSchema` for why there are
|
||||
* two specific ones rather than the single legacy `confirmed`.
|
||||
*/
|
||||
customModel: z
|
||||
.object({
|
||||
endpointId: z.string().regex(/^[a-zA-Z0-9_-]+$/, 'Invalid endpoint id'),
|
||||
modelId: z.string().min(1).max(200),
|
||||
confirmed: z.boolean().optional(),
|
||||
confirmedContext: z.boolean().optional(),
|
||||
confirmedSwap: z.boolean().optional(),
|
||||
})
|
||||
.strict()
|
||||
.optional(),
|
||||
});
|
||||
|
||||
// ========== Hook Events ==========
|
||||
@@ -1932,6 +1984,17 @@ export const CustomModelHostSchema = z.object({
|
||||
authStyle: z.enum(['bearer', 'api-key']).optional(),
|
||||
models: z.array(z.string().max(200)).max(200).optional(),
|
||||
lastDiscoveredAt: z.string().max(64).optional(),
|
||||
// The Run-menu picker's per-endpoint default; validated against `models` at the
|
||||
// route layer (schema-level cross-field checks can't see the array narrowed the
|
||||
// same way a `.refine()` closure could, and the route already re-reads the stored
|
||||
// host to apply it, so the check belongs there once, not duplicated into a refine
|
||||
// that would run on every unrelated field edit too).
|
||||
defaultModelId: z.string().max(200).optional(),
|
||||
// Server-populated by discovery (custom-model-routes.ts); accepted here only so a client
|
||||
// round-tripping the GET response back through PUT (edit-save) doesn't drop it.
|
||||
modelContextLengths: z.record(z.string().max(200), z.number().int().positive().max(100_000_000)).optional(),
|
||||
// Same reasoning as modelContextLengths above.
|
||||
modelSizesGB: z.record(z.string().max(200), z.number().positive().max(100_000)).optional(),
|
||||
});
|
||||
|
||||
/** POST /api/sessions/:id/custom-model — apply or clear a session's custom-model selection. */
|
||||
@@ -1939,6 +2002,22 @@ export const CustomModelSelectionSchema = z.union([
|
||||
z.object({
|
||||
endpointId: z.string().regex(/^[a-zA-Z0-9_-]+$/, 'Invalid endpoint id'),
|
||||
modelId: z.string().min(1).max(200),
|
||||
/**
|
||||
* Two DIFFERENT questions can block a launch, and answering one is not consent to
|
||||
* the other: `confirmedContext` answers "this model's context window is below the
|
||||
* floor for this CLI", which affects only the caller, while `confirmedSwap` answers
|
||||
* "loading this will unload the model another session is using", which affects
|
||||
* someone else. They were one flag until the context check (which runs first)
|
||||
* silently spent the swap answer too, so a user clicking "launch anyway" past a
|
||||
* too-small context evicted another session's model without ever being asked.
|
||||
*
|
||||
* `confirmed` is the original single flag and still means BOTH, because it shipped
|
||||
* in the HTTP-API-only cut of this feature and an existing caller must keep working.
|
||||
* New callers should send the specific one they actually asked about.
|
||||
*/
|
||||
confirmed: z.boolean().optional(),
|
||||
confirmedContext: z.boolean().optional(),
|
||||
confirmedSwap: z.boolean().optional(),
|
||||
}),
|
||||
z.object({ clear: z.literal(true) }),
|
||||
]);
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user