mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-09-30 12:39:42 +02:00
Compare commits
69
Commits
@@ -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.32.0",
|
||||
"version": "1.33.1",
|
||||
"author": {
|
||||
"name": "Ark0N",
|
||||
"url": "https://github.com/Ark0N"
|
||||
|
||||
@@ -1,5 +1,80 @@
|
||||
# aicodeman
|
||||
|
||||
## 1.33.1
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- **Finished sessions close themselves (#486).** A session whose agent you ended with `/exit` is now closed the same way the X button closes it, so finished sessions stop piling up on the board; the conversation stays resumable from the Resume list and the lifecycle log records "agent exited cleanly (status 0)". Only an explicit exit status 0 with no signal, confirmed by two pane reads, qualifies: a crashed or OOM-killed agent keeps its row with the exit code on the tab. The phone overview and desktop home rail now say `exited` instead of `idle`, reboot restore no longer offers to rebuild a session whose agent had exited, and closing one session no longer deletes the `.claude-images` directory that a sibling session in the same case still uses. Thanks @irisitymichaelgrundberg.
|
||||
|
||||
**Search in the phone Select Case sheet (#488).** The bottom sheet gains a "Search cases" field that filters by name (every word must match, any order, ignoring case), Enter picks the case when exactly one row is left, and Escape clears then closes. Also fixes a dead band under Create New Case and a list shorter than the sheet could show.
|
||||
|
||||
**An oversized paste no longer jams a session's input (#484).** A single input over the 64 KiB frame limit used to be refused by both transports, retried every 2 s forever, block every later input for that session and come back from localStorage on each reload. Pastes over the limit are now split into in-limit frames delivered in order (up to 1 MiB; larger ones are refused with a toast and never queued), a refused frame is dropped instead of retried, frames persisted by an older build are pruned on load, and the WebSocket answers an oversized frame with an explicit `too_large` error instead of silence.
|
||||
|
||||
- 8841bcc: Add a search box to the Manage tab of the Add Case dialog. It filters the case list by name or path, and the reorder arrows are disabled while a filter is active so a swap cannot involve a hidden case.
|
||||
- 8841bcc: The case picker now refreshes its list from `/api/cases` when it opens and every 5 seconds while it stays open, so folders deleted or created on disk appear without a page reload. If the selected case has been removed, the picker falls back to another case without saving it as the last-used one.
|
||||
|
||||
Thanks @opticon454.
|
||||
|
||||
- 77ba41f: Install `uv` and `uvx` in the Compose server image and the agent image, so MCP servers launched with `uvx` (such as the Nginx Proxy Manager MCP) can be enabled by Codex instead of failing with `uvx` not found. Both images also install `libsecret-1-0`, the native library the `keytar` dependency of the Azure DevOps MCP (`@azure-devops/mcp`) needs; without it the server crashes before answering the MCP initialize handshake.
|
||||
|
||||
The Compose server image now also carries `pnpm`: `dsh plugin` spawns a literal `pnpm` with no npm fallback, so the Run menu's "DeepSeek - add a terminal profile" button failed with `dsh: pnpm not found on PATH` there. Because this release changes `server.Dockerfile`, the in-app updater asks Compose deployments to rebuild the image (`Update-Codeman.sh`) rather than applying it in place.
|
||||
|
||||
Thanks @opticon454 (#487, #485).
|
||||
|
||||
## 1.33.0
|
||||
|
||||
### Minor Changes
|
||||
|
||||
- CLI management from Settings (#476, finishing the CLI registry work from #343). `~/.codeman/clis.json` used to be hand-edit only; with the new opt-in `cliManagementEnabled` switch (synced, default OFF) App Settings → Agents & CLIs can enable or disable any CLI, install a missing stock CLI with its vetted install command, and add, edit or remove custom CLIs. Six new endpoints back it (`GET`/`POST /api/clis`, `PUT /api/clis/:id`, `POST /api/clis/:id/install`, `PUT /api/clis/custom/:id`, `DELETE /api/clis/:id`), documented in `docs/api-reference.md`. Every write is refused while the switch is off, is admin-only in multi-user mode, is serialized on one queue, and refuses to overwrite a `clis.json` that does not parse or has group/world permission bits. A custom entry is re-validated through the same schema as the stock ones and its install text is never executed. `shell` cannot be disabled. The Run menu and the welcome screen are now built from the enabled catalogue, so the welcome screen also offers Codex, Shell and any custom CLI, and the stock Claude entry is labelled "Claude Code".
|
||||
|
||||
Models: Opus 5.5 (`claude-opus-5-5`, 1M context capable) is offered in App Settings → Models and in task routing (#480).
|
||||
|
||||
Self-update: on a macOS `launchd-daemon` install, a Homebrew node upgrade could leave `update-status.json` stuck at `queued`, which made every later update fail with "An update is already in progress." The updater now falls back to `node` on PATH when the server's own node binary is gone, and an in-flight status that has not been written for 15 minutes is failed on the next read. A graceful shutdown that hangs is now force-exited after 10 s (and the launchd updater SIGKILLs a server that has not exited after 30 s), so launchd can start the new build instead of leaving the service down (#478). Both fixes protect updates that start FROM this release.
|
||||
|
||||
Session Manager (Cmd+K): rows keep their `mode`, `claudeSessionId` and `resumeId`, so the ⋯ menu's Resume session relaunches a Codex row as Codex on its own conversation, and the mode badge shows as it does on the home list (#477).
|
||||
|
||||
Maintainer fixes applied while landing #457: renaming a tab to the name it already has (the Session Options field saves on blur) is now a no-op, so it no longer pins the placeholder as the `/resume` title again; Docker sessions skip the transcript title sync, since their transcript lives in the container; and the agent skill's messaging examples no longer use a `w<N>-` name as the peer name.
|
||||
|
||||
Tests: the suite strips every inherited `CODEMAN_*` variable, so running it inside a Docker Compose deployment no longer writes into the deployment's real case root (#479).
|
||||
|
||||
### Thanks
|
||||
- @opticon454 for CLI management (#476), the last piece of the CLI registry, with every review item answered in one round, and for splitting the test isolation fix out into #479.
|
||||
- @shenlvkang-collab for the `/resume` title fix (#457) and the careful diagnosis behind it.
|
||||
- @julian3xl for the Session Manager row fix (#477), their first contribution.
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- 69a7128: fix(sessions): stop pinning the `w1-myapp` placeholder as Claude's session title. Local Claude spawns passed the tab name as `--name`, which is also the `/resume` picker entry and the terminal title, and a pinned title stops Claude generating its own, so every conversation of a case showed up in `/resume` as the same `w1-myapp` and none got a generated title. Only a name the user chose is pinned now; placeholder and auto-named tabs let Claude title the conversation again. Renaming a Claude tab also reaches `/resume`: the new name is appended to the conversation's transcript as the `custom-title` row `/rename` writes (a tab that was spawned with `--name` keeps re-appending its own title until its next respawn, so the rename wins from then on). Orchestrators that rely on a fixed peer name should give workers a descriptive `sessionName` rather than a `w<N>-` one.
|
||||
|
||||
## 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
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
[
|
||||
{
|
||||
"id": "claude",
|
||||
"label": "Claude",
|
||||
"label": "Claude Code",
|
||||
"shortBadge": "CC",
|
||||
"enabled": true,
|
||||
"order": 0,
|
||||
|
||||
@@ -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"
|
||||
@@ -17,6 +17,7 @@ FROM node:22-bookworm-slim
|
||||
RUN apt-get update \
|
||||
&& apt-get install -y --no-install-recommends \
|
||||
git \
|
||||
libsecret-1-0 \
|
||||
tmux \
|
||||
ripgrep \
|
||||
curl \
|
||||
@@ -26,6 +27,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.
|
||||
@@ -44,6 +127,10 @@ RUN apt-get update \
|
||||
# A different order is a different RUN string, which is a different layer hash and
|
||||
# so a needless cache miss between a bare `docker build` and a scripted one.
|
||||
ARG CLI_NPM_PACKAGES="@anthropic-ai/claude-code opencode-ai @openai/codex @google/gemini-cli"
|
||||
# uv/uvx: MCP servers are commonly launched with `uvx <package>` (e.g. the Nginx
|
||||
# Proxy Manager MCP), and Codex failed to enable them with "uvx not found". Copied
|
||||
# from the pinned upstream image into root-owned /usr/local/bin, never pip-installed.
|
||||
COPY --from=ghcr.io/astral-sh/uv:0.9 /uv /uvx /usr/local/bin/
|
||||
RUN npm install -g ${CLI_NPM_PACKAGES} \
|
||||
&& npm cache clean --force
|
||||
|
||||
|
||||
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"
|
||||
+119
-1
@@ -39,6 +39,7 @@ RUN apt-get update \
|
||||
curl \
|
||||
g++ \
|
||||
git \
|
||||
libsecret-1-0 \
|
||||
make \
|
||||
openssh-client \
|
||||
procps \
|
||||
@@ -68,6 +69,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.
|
||||
#
|
||||
@@ -107,13 +213,25 @@ COPY --from=docker:29-cli \
|
||||
# minimal image of this exact shape). The four CLIs live only in this prefix,
|
||||
# so they still resolve; entrypoint.sh additionally pins its own PATH to the
|
||||
# system directories for the root part of the start.
|
||||
# uv/uvx: MCP servers are commonly launched with `uvx <package>` (e.g. the Nginx
|
||||
# Proxy Manager MCP), and Codex failed to enable them with "uvx not found". Copied
|
||||
# from the pinned upstream image into root-owned /usr/local/bin, never pip-installed.
|
||||
COPY --from=ghcr.io/astral-sh/uv:0.9 /uv /uvx /usr/local/bin/
|
||||
ENV NPM_CONFIG_PREFIX=/opt/codeman-cli
|
||||
ENV PATH=$PATH:/opt/codeman-cli/bin
|
||||
# pnpm is not an agent CLI: it is here because `dsh plugin` (DeepSeek Harness, which
|
||||
# this image leaves to be installed at runtime, see SERVER_INTENTIONAL_OMISSIONS in
|
||||
# test/docker-agent-image-coverage.test.ts) spawns a literal `pnpm` with no npm
|
||||
# fallback, so the Run menu's "DeepSeek - add a terminal profile" button failed
|
||||
# with `dsh: pnpm not found on PATH` (exit 127) on this image. The agent image
|
||||
# already carries it for the same reason (#352). It lives in the same
|
||||
# runtime-writable prefix as the CLIs, so a session can update it in place.
|
||||
RUN npm install --global \
|
||||
@anthropic-ai/claude-code@2.1.258 \
|
||||
@google/gemini-cli@0.58.0 \
|
||||
@openai/codex@0.152.1 \
|
||||
opencode-ai@1.18.26 \
|
||||
pnpm@12.6.0 \
|
||||
&& npm cache clean --force
|
||||
|
||||
# Keep the web server and every local Codeman session unprivileged. PUID and
|
||||
@@ -153,7 +271,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
|
||||
|
||||
|
||||
@@ -757,3 +757,13 @@ works, and its replies arrive tagged `from-name="w9-msgtest"` (a derived-name
|
||||
worker's replies carry no `from-name`). A quick-start without `sessionName` has an
|
||||
empty Codeman name, so the peer name stays derived: agents should name their
|
||||
workers. Tests: `test/name-flag-injection.test.ts`.
|
||||
|
||||
Later narrowing: `--name` is not only the peer name but also the `/resume` picker
|
||||
entry and the terminal title, and a pinned title stops Claude generating its own, so
|
||||
pinning the `w1-myapp` placeholder listed every conversation of a case under the same
|
||||
name in `/resume`. Only a manual name is pinned now (`Session.cliPinnedName`,
|
||||
`nameSource === 'manual'`, carried to the builders as `cliName`); placeholder and auto
|
||||
names leave Claude to title the conversation. A rename in Codeman appends a
|
||||
`custom-title` row to the conversation's transcript (`claude-session-title.ts`), the
|
||||
row `/rename` writes. Tests: `test/claude-resume-title.test.ts`,
|
||||
`test/routes/session-name-routes.test.ts`.
|
||||
|
||||
@@ -691,6 +691,19 @@ normal `caseName`/`mode`/etc. body)
|
||||
jarring than a full relaunch, and folding it into the one-shot path is
|
||||
separate work — see `docs/custom-model-endpoints-plan.md`).
|
||||
|
||||
## CLI management
|
||||
|
||||
Read and write the CLI registry (`docs/cli-registry.md`). Every **write** route answers `403 FORBIDDEN` while `cliManagementEnabled` is off (the default), and for a non-admin in multi-user mode. A write that would overwrite a `clis.json` which does not parse, or which has group/world permission bits, is refused with `409 CONFLICT` and a message naming the fix; the file is left untouched.
|
||||
|
||||
| Method | Path | Body | Notes |
|
||||
| -------- | ----------------------------- | ------------------------------------------------------- | ------------------------------------------------------------------------------------------------------- |
|
||||
| `GET` | `/api/clis` | none | Every entry, disabled ones included: `id`, `label`, `shortBadge`, `order`, `kind`, `enabled`, `stock`, `installed`, and `installCommand` for a stock entry. Not gated; a non-admin in multi-user mode gets `[]`. |
|
||||
| `PUT` | `/api/clis/:id` | `{ enabled }` | Toggle an existing entry, stock or custom. `404` for an unknown id; `400 INVALID_INPUT` when disabling a `kind: 'shell'` entry. |
|
||||
| `POST` | `/api/clis/:id/install` | none | Run a **stock** entry's install command (never a custom one: `400`). `409 CONFLICT` while an install for the same id is running; `422 OPERATION_FAILED` with the output tail when it fails. Never enables the entry. |
|
||||
| `POST` | `/api/clis` | `{ id, label, shortBadge, binaries, argv, enabled? }` | Create a custom entry. `409 ALREADY_EXISTS` for a stock id or an existing custom id. `enabled` defaults to `true`. |
|
||||
| `PUT` | `/api/clis/custom/:id` | `{ label, shortBadge, binaries, argv, enabled? }` | Replace an existing custom entry. An absent `enabled` keeps the entry's current state. `400` for a stock id, `404` for an unknown one. |
|
||||
| `DELETE` | `/api/clis/:id` | none | Delete a custom entry. `400` for a stock id, `404` for an unknown one. |
|
||||
|
||||
## 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
@@ -0,0 +1,314 @@
|
||||
# CLI management Settings UI + write API — plan
|
||||
|
||||
> Tracked separately from `DEPLOYMENT_PLAN.md` (PR B2, merged) and `docs/copilot-integration-plan.md`
|
||||
> (parked). This is "PR C" from the original #343 review: *"settings UI + write endpoints +
|
||||
> auto-install, once we've settled the trust model... I want to make that call on its own, not
|
||||
> inside a 100-file diff."*
|
||||
>
|
||||
> **Phase 0 is CLOSED as of 2026-09-21** — all three original pieces are IN SCOPE (expanded from
|
||||
> this plan's first draft, which recommended #2/#3 as separate/out-of-scope; the user chose full
|
||||
> scope instead, with the risk called out explicitly for #3 before confirming). See "Decisions"
|
||||
> below for the full record.
|
||||
|
||||
## Status as of 2026-09-22
|
||||
|
||||
**Phases 1–6 are ALL IMPLEMENTED** (commits `da07b38c` "add cliManagementEnabled flag and GET
|
||||
/api/clis" and `db4557d9` "Phases 3-6 - write API + custom entries + Settings UI", both on this
|
||||
branch, `feat/cli-management`). Confirmed present in the tree: `cliManagementEnabled` in
|
||||
`SettingsUpdateSchema`; `GET /api/clis`, `PUT /api/clis/:id`, `POST /api/clis/:id/install`,
|
||||
`POST /api/clis`, `PUT /api/clis/custom/:id`, `DELETE /api/clis/:id` in
|
||||
`src/web/routes/cli-registry-routes.ts`; the `shell`/`claude` `UNDISABLEABLE_IDS` backend guard;
|
||||
`isAdmin(req)` gating on both the list and write routes; `appendAdminAudit` wired into the install
|
||||
route; tmp+rename+`0o600` writes in `registry-writer.ts`; the full Settings UI (row list, toggle,
|
||||
Install button, custom-entry create/edit/delete form) in `settings-ui.js` + `index.html`.
|
||||
`test/routes/cli-registry-routes.test.ts` (425 lines) and `test/cli-registry-no-id-branching.test.ts`
|
||||
cover it. This status section, plus the fix and gap below, is the one piece of that work done in
|
||||
a *different* session from the one that wrote Phases 1–6 — reviewed by reading the diff and
|
||||
verifying each claim against the actual routes/tests, not by re-implementing anything.
|
||||
|
||||
### Gotcha found and fixed (commit `0c77dd0a`)
|
||||
|
||||
**Toggling a CLI off in Settings had no effect anywhere except the Settings row itself.**
|
||||
`window.__codemanCliAvailable` — the flag `isCliAvailable()` reads client-side to gate the
|
||||
welcome-screen buttons, the Run-menu dropdown and the mobile overview — is injected **once**, at
|
||||
initial page render (`server.ts`), built purely from each CLI's own installed-on-PATH resolver
|
||||
(`isClaudeAvailable()` etc.), with **no reference to the registry's `enabled` flag at all**. So
|
||||
disabling a CLI here updated its own row and nothing else — every launch surface kept offering it,
|
||||
both live and after a full page reload, since even a *fresh* render never consulted the registry.
|
||||
Root-caused and reported by the user testing the live feature ("toggle those off, they still
|
||||
appear in that menu and on the front main screen").
|
||||
|
||||
Fixed two places:
|
||||
- `server.ts`: after building `available`, intersect the nine real `SessionMode` ids against
|
||||
`enabledClis()`. `git`/`cloudflared` (utility binaries, not CLI registry entries) and
|
||||
`deepseekBinary` (a secondary installed-only flag for the "add a profile" affordance) are
|
||||
deliberately left alone — they were never registry-gated to begin with.
|
||||
- `settings-ui.js`: `toggleCliEnabled()` now patches `window.__codemanCliAvailable` in place and
|
||||
refreshes the welcome screen, the mobile overview and an already-open Run menu, mirroring the
|
||||
existing `installDeepSeekProfile()` pattern for the same "injected once, needs an explicit
|
||||
patch" reason — the server-side fix alone still left every surface stale until the next reload.
|
||||
|
||||
New test in `test/render-index-html.test.ts`: an installed-but-disabled CLI (codex, forced via
|
||||
`clis.json` + `reloadCliRegistry()`) reads as unavailable, while an installed-and-enabled one
|
||||
(claude) is unaffected by the override.
|
||||
|
||||
**Verified on the Debian devbox** (`codeman-devbox`, real tmux — this sandbox has none and
|
||||
`WebServer`'s constructor hard-requires it): typecheck clean, the new test passes (17/17 in
|
||||
`render-index-html.test.ts`), the CLI-registry suites pass (86/86), and the **full CI gate is
|
||||
green — 415 test files, 7855 tests, 0 failures**.
|
||||
|
||||
### Launch-surface registry integration — completed
|
||||
|
||||
The welcome screen, desktop Run menu and mobile Run picker now use the same injected CLI catalog.
|
||||
Every enabled registry entry is rendered; unavailable binaries remain hidden as before. Settings
|
||||
updates the catalog and availability flags in place after enable/disable, create, edit or delete,
|
||||
so the launch surfaces update without a page reload. A custom entry uses the generic quick-start
|
||||
path, while stock entries retain their existing per-CLI launch settings.
|
||||
|
||||
Not otherwise re-verified line-by-line against every Phase 1–6 checklist item below (e.g. the
|
||||
exact wording of toasts, the "same PR" sequencing notes) — the checklists are left as originally
|
||||
written; treat the **Status** section above as authoritative for what exists.
|
||||
|
||||
---
|
||||
|
||||
## Background
|
||||
|
||||
`src/config/cli-registry/registry.ts` is READ-ONLY today, and says so in its own header comment:
|
||||
|
||||
> "⚠️ READ-ONLY. Nothing in this module writes, creates or migrates the file... there is no
|
||||
> settings UI and no write API yet... A `seededStockIds` ratchet belongs with the write API that
|
||||
> needs it."
|
||||
|
||||
Confirmed on `master` (2026-09-21): no `/api/clis` route exists at all (read or write);
|
||||
`~/.codeman/clis.json` is hand-edit-only; `resolveInstallCommandForPlatform()` is documented
|
||||
"Display text only — never executed" — nothing runs an install command server-side today. The
|
||||
original #343 review flagged the opposite (`spawn(command, {shell: true})`, `env.allowedPrefixes`
|
||||
contributed from a write) as needing its own trust-model decision; that decision was never made
|
||||
after the split, just dropped. This plan makes it.
|
||||
|
||||
**Closest existing precedent, and the template this plan follows for the read/write API**:
|
||||
`src/web/routes/custom-model-routes.ts` + `src/custom-model-hosts.ts` (#393/#430/#459) — a small
|
||||
per-item JSON store, Settings-UI-driven, admin-gated in multi-user mode, tmp+rename+0600 writes.
|
||||
|
||||
**Precedent for the new master feature flag (Phase 1)**: `customModelEndpointsEnabled` —
|
||||
`z.boolean().optional()` in `SettingsUpdateSchema` (`schemas.ts:1319`), a checkbox read/written by
|
||||
id in `openAppSettings()`/`saveAppSettings()` (`settings-ui.js:401`/`:2120`). SYNCED, not
|
||||
per-device (present in the schema, absent from `displayKeys`), default OFF.
|
||||
|
||||
**Spec refs for the whole plan:**
|
||||
- `src/config/cli-registry/registry.ts` — the read path; `resolveRegistry()`'s merge semantics
|
||||
(`deepMerge`, `UNMERGEABLE_KEYS`) apply unchanged to whatever this plan writes
|
||||
- `docs/cli-registry.md` — registry shape, "The override file", "Arg-template safety" (the four
|
||||
layers Phase 5's custom-entry validation must not weaken), "Adding a CLI" (the 5-step recipe a
|
||||
custom entry does NOT get to skip just because it arrives via UI instead of a stock.ts edit)
|
||||
- `src/web/routes/custom-model-routes.ts` + `src/custom-model-hosts.ts` — read/write API template
|
||||
- `docs/multi-user-plan.md`, `docs/security-architecture.md` — admin-gating conventions
|
||||
- `CLAUDE.md` §Multi-user mode, §"Settings surface", §"Per-device vs synced settings"
|
||||
|
||||
---
|
||||
|
||||
## Decisions (Phase 0, closed 2026-09-21)
|
||||
|
||||
1. **Enable/disable a stock CLI's `enabled` flag** — IN SCOPE. Plus a **master feature flag**
|
||||
(`cliManagementEnabled`, synced, default OFF) gating the whole Settings UI section's visibility,
|
||||
matching this codebase's standing convention for new admin-facing surfaces.
|
||||
2. **Auto-install** (stock CLIs' already-shipped, already-vetted install commands) — IN SCOPE,
|
||||
same PR.
|
||||
3. **Custom CLI entries via the UI** — IN SCOPE, **typed-argv only**: a custom entry goes through
|
||||
the exact same schema/argv-safety path stock entries do (named token patterns, no raw shell-text
|
||||
field). Its install command stays **display-only text**, same as every stock entry today — Phase
|
||||
4's auto-install NEVER executes a custom entry's install command, only a stock one's. This is
|
||||
the one place scope was deliberately narrowed relative to what was agreed in principle, because
|
||||
`docs/cli-registry.md`'s arg-template-safety section exists specifically to keep config free of
|
||||
shell text, and a free-text install command for a user-defined entry would reopen exactly that.
|
||||
4. **`shell`/`claude` un-disableable** — enforced at the **backend**, not just the UI (a
|
||||
frontend-only guard is bypassable with curl).
|
||||
5. **Non-admin visibility in multi-user mode** — the CLI-management Settings section is **hidden
|
||||
entirely** for a non-admin, not shown-empty.
|
||||
6. **`seededStockIds` ratchet** — not needed. `deepMerge()` only overrides a key the file actually
|
||||
sets, so a CLI absent from `clis.json.clis` always falls through to its stock `enabled` value
|
||||
with no special-casing. (Carried over from the first draft, not re-litigated.)
|
||||
|
||||
---
|
||||
|
||||
## Phase 1 — Master feature flag: `cliManagementEnabled`
|
||||
|
||||
**Status:** DONE (commit `da07b38c`) — verified present in `SettingsUpdateSchema`, `index.html`,
|
||||
`openAppSettings()`/`saveAppSettings()`.
|
||||
|
||||
**Spec refs:**
|
||||
- `schemas.ts:1319` (`customModelEndpointsEnabled`) — the exact pattern to mirror: `z.boolean().optional()`
|
||||
in `SettingsUpdateSchema`
|
||||
- `settings-ui.js:401`/`:2120` — checkbox read/write by id in `openAppSettings()`/`saveAppSettings()`
|
||||
- `CLAUDE.md` §"Adding Features" → "App setting" — decide per-device vs synced FIRST (this one is
|
||||
synced: a feature toggle, not a display preference) and add to `displayKeys` NEVER for a synced
|
||||
setting
|
||||
|
||||
**Checklist:**
|
||||
- [x] Add `cliManagementEnabled: z.boolean().optional()` to `SettingsUpdateSchema`
|
||||
- [x] Add the checkbox to `index.html`'s `#settings-clis` section, above where Phase 6's per-CLI
|
||||
list will render — reads/writes via `openAppSettings()`/`saveAppSettings()` by id, same as
|
||||
`customModelEndpointsEnabled`
|
||||
- [x] `readCliManagementEnabled()` helper (mirrors `readCustomModelEndpointsEnabled()` in
|
||||
`custom-model-routes.ts:609`) for the route file(s) in Phases 2-5 to gate on
|
||||
- [x] When OFF: `GET /api/clis` still exists but the Settings UI section stays hidden
|
||||
(`applyCliManagementVisibility()`); the write endpoints reject (see Phase 3)
|
||||
|
||||
**Verify:** `npm run typecheck` passes; a unit test confirms `SettingsUpdateSchema` accepts/rejects
|
||||
the field correctly; toggling it in a fresh browser profile shows/hides the Settings section with
|
||||
no server restart.
|
||||
|
||||
---
|
||||
|
||||
## Phase 2 — Read endpoint: `GET /api/clis`
|
||||
|
||||
**Status:** DONE (commit `da07b38c`) — verified present in `src/web/routes/cli-registry-routes.ts`.
|
||||
|
||||
**Spec refs:**
|
||||
- `src/web/routes/custom-model-routes.ts:730` (`GET /api/model-endpoints`) — multi-user read
|
||||
gating: empty list for a non-admin, never a 403
|
||||
- `src/config/cli-registry/registry.ts` — `listClis()` (every entry, including disabled stock
|
||||
ones — this is an admin/settings surface, unlike `enabledClis()`)
|
||||
- `window.__codemanCliAvailable`'s resolvers (`isClaudeAvailable()` etc.) — candidate `installed`
|
||||
source; confirm whether to reuse directly or the response needs its own probe (Open Question 4,
|
||||
carried from the first draft — still genuinely open, decide during this phase not before)
|
||||
|
||||
**Checklist:**
|
||||
- [x] New route file `cli-registry-routes.ts`
|
||||
- [x] Response excludes `launch`/`env`/`capabilities`/`overlays`/`discovery`
|
||||
- [x] `isMultiUserMode() && !isAdmin(req)` → `[]`
|
||||
- [x] Unit tests in `test/routes/cli-registry-routes.test.ts` (admin/non-admin/single-user,
|
||||
disabled stock CLI still present)
|
||||
|
||||
**Verify:** `npm test -- test/routes/cli-registry-routes.test.ts` passes; `curl localhost:3000/api/clis | jq`
|
||||
shows every stock CLI including disabled ones.
|
||||
|
||||
---
|
||||
|
||||
## Phase 3 — Write endpoint: `PUT /api/clis/:id` (stock enable/disable)
|
||||
|
||||
**Status:** DONE (commit `db4557d9`) — `UNDISABLEABLE_IDS`, admin gate, tmp+rename+0600 all
|
||||
confirmed present.
|
||||
|
||||
**Spec refs:**
|
||||
- `src/web/routes/custom-model-routes.ts:753` + `src/custom-model-hosts.ts:91` — write-path
|
||||
template: `adminOnly` gate, read-modify-write the WHOLE file, tmp+rename+0600
|
||||
- `registry.ts:47` (`filePath()` = `dataPath(...)`) and `reloadCliRegistry()` — write to the same
|
||||
resolved path, invalidate the cache on every successful write or the change is invisible until
|
||||
restart
|
||||
|
||||
**Checklist:**
|
||||
- [x] Body: `{ enabled: boolean }`. Zod schema in `schemas.ts`
|
||||
- [x] Gate order: `cliManagementEnabled` → `adminOnly` → shell/claude guard → stock-only guard
|
||||
- [x] Rejects disabling `shell` or `claude` (`UNDISABLEABLE_IDS`)
|
||||
- [x] Rejects a write for an id that isn't a stock CLI
|
||||
- [x] Deep-merges `{ clis: { [id]: { enabled } } }`, preserving other override keys
|
||||
- [x] tmp+rename+0600 write, `reloadCliRegistry()` on success
|
||||
- [x] Unit tests (`test/routes/cli-registry-routes.test.ts`)
|
||||
|
||||
**Verify:** `npm test` full gate green; `curl -X PUT localhost:3000/api/clis/grok -d '{"enabled":false}'`
|
||||
then `GET /api/clis` shows the change with no restart; same against `shell`/`claude` returns an
|
||||
error and changes nothing; `ls -la ~/.codeman/clis.json` shows mode 0600.
|
||||
|
||||
---
|
||||
|
||||
## Phase 4 — Auto-install: `POST /api/clis/:id/install` (stock CLIs only)
|
||||
|
||||
**Status:** DONE (commit `db4557d9`) — route present, `appendAdminAudit` wired in.
|
||||
|
||||
**Spec refs:**
|
||||
- `registry.ts:231` (`resolveInstallCommandForPlatform`) — currently "Display text only — never
|
||||
executed"; this phase is what changes that, for stock entries only, with Decision 2's sign-off
|
||||
- Original #343 review's exact concern re: `env.allowedPrefixes` contributed from a write — stays
|
||||
out of scope; this phase only ever runs a command, never touches the env allowlist
|
||||
|
||||
**Checklist:**
|
||||
- [x] Separate endpoint from Phase 3's toggle
|
||||
- [x] Gate order: `cliManagementEnabled` → `adminOnly` → stock-entry-only guard
|
||||
- [x] `resolveInstallCommandForPlatform(entry)` for the target
|
||||
- [x] Bounded execution (timeout, captured stdout/stderr)
|
||||
- [x] Does NOT auto-enable on successful install
|
||||
- [x] Audit-logged via `appendAdminAudit`
|
||||
- [x] Unit tests
|
||||
|
||||
**Verify:** a real install triggered via the endpoint against a CLI not currently installed,
|
||||
`GET /api/clis`'s `installed` field flips true with no restart; audit log entry present; attempting
|
||||
install against a custom entry's id fails with a clear error; full CI gate green.
|
||||
|
||||
---
|
||||
|
||||
## Phase 5 — Custom CLI entries: create / update / delete via API
|
||||
|
||||
**Status:** DONE (commit `db4557d9`) — `POST /api/clis`, `PUT /api/clis/custom/:id`,
|
||||
`DELETE /api/clis/:id` all present. Open Question 2 resolved: a **separate** endpoint
|
||||
(`PUT /api/clis/custom/:id`), not Phase 3's `PUT /api/clis/:id` widened.
|
||||
|
||||
**Spec refs:**
|
||||
- `docs/cli-registry.md` §"Arg-template safety" (all four layers), §"Adding a CLI" (the 5-step
|
||||
recipe) — a custom entry created via this API must satisfy the SAME schema (`CliEntrySchema`)
|
||||
every stock entry does; there is no relaxed path for UI-originated entries
|
||||
- `registry.ts`'s `resolveRegistry()` — the custom-entry branch (`stock: false`, dropped with a
|
||||
warning on validation failure, never falls back silently) already exists and is unchanged by
|
||||
this phase; this phase only adds a way to WRITE what that branch reads
|
||||
|
||||
**Checklist:**
|
||||
- [x] `POST /api/clis` (create), full `CliEntrySchema` validation
|
||||
- [x] `PUT /api/clis/custom/:id` (update) — separate endpoint from Phase 3's stock toggle
|
||||
- [x] `DELETE /api/clis/:id` refuses for any stock id
|
||||
- [x] `id` collision check against existing stock ids
|
||||
- [x] `discovery.install.command` on a custom entry stays DISPLAY-ONLY
|
||||
- [x] Same tmp+rename+0600 write pattern, `reloadCliRegistry()` on every successful mutation
|
||||
- [x] Unit tests
|
||||
|
||||
**Verify:** `npm test` full gate green; create a custom entry via curl, confirm it appears in
|
||||
`GET /api/clis` — **confirm it appears in the Run menu is UNVERIFIED and currently FALSE, see
|
||||
"Outstanding" above**; delete it, confirm it's gone and `clis.json` no longer references it.
|
||||
|
||||
---
|
||||
|
||||
## Phase 6 — Settings UI
|
||||
|
||||
**Status:** DONE (commit `db4557d9`) — `#cliListGroup`, row rendering, toggle, Install button,
|
||||
custom-entry create/edit/delete form all present in `settings-ui.js`/`index.html`. Manual browser
|
||||
verification per the phase's own "Verify" step (flag on/off, non-admin hidden, toggle stops the
|
||||
Run menu offering a CLI, create/enable/launch a custom entry, delete it, shell/claude undisableable)
|
||||
has **not** been re-run in this session — the toggle→Run-menu leg specifically was BROKEN until the
|
||||
gotcha fix above, and the create→launch leg for a custom entry is the confirmed gap in
|
||||
"Outstanding".
|
||||
|
||||
**Spec refs:**
|
||||
- `index.html:2357` (`#settings-clis`) — the existing home; Phase 1's master toggle at the top,
|
||||
then the per-CLI list, then (if `cliManagementEnabled`) a "custom CLI" creation form, all above
|
||||
the existing Codex-only groups
|
||||
- `CLAUDE.md` §"Settings surface" — App Settings scrolls, it does not tab-switch
|
||||
- `admin-ui.js` — pattern for an admin-only-VISIBLE section (not just admin-only-writable),
|
||||
needed here per Decision 5
|
||||
|
||||
**Checklist:**
|
||||
- [x] Whole section hidden when `cliManagementEnabled` is OFF, and separately hidden for a
|
||||
non-admin in multi-user mode (`_applyCliManagementAdminGate`)
|
||||
- [x] Fetches `GET /api/clis` when the section becomes visible; renders one row per CLI
|
||||
- [x] Stock rows: enabled toggle only; `shell`/`claude` rows show the toggle disabled/greyed
|
||||
- [x] Custom rows: enabled toggle plus edit/delete affordances
|
||||
- [x] "Add custom CLI" form (id/label/badge/binary/argv)
|
||||
- [x] Toggle/edit/delete update the row in place
|
||||
|
||||
**Verify:** manual browser test per `CLAUDE.md`'s "Always Test Before Deploying" rule — **not yet
|
||||
re-run end-to-end in this session**; do this before considering the feature ready to ship, and
|
||||
expect the custom-entry-launch step to fail until the Outstanding gap above is closed.
|
||||
|
||||
---
|
||||
|
||||
## Remaining Open Questions
|
||||
|
||||
1. **Phase 2's `installed` source** — resolved: reuses `window.__codemanCliAvailable`'s existing
|
||||
resolvers via `GET /api/clis`'s own probe (confirmed by reading the route).
|
||||
2. **Phase 5's `PUT` endpoint shape** — resolved: a **separate** endpoint
|
||||
(`PUT /api/clis/custom/:id`), not Phase 3's toggle route widened.
|
||||
3. **Sequencing against the parked Copilot plan** — unchanged, still not blocking.
|
||||
4. **NEW: custom-CLI Run-menu integration** — see "Outstanding" above. Not decided or started.
|
||||
|
||||
---
|
||||
|
||||
Implementation is underway (see Status above); this line is left for history rather than removed —
|
||||
the plan was originally approved before Phases 1–6 landed.
|
||||
+52
-5
@@ -18,7 +18,17 @@ Every run mode Codeman can launch — Claude Code, Terminal/Shell, OpenCode, Cod
|
||||
|
||||
## The override file
|
||||
|
||||
`~/.codeman/clis.json` (instance-scoped through `dataPath()`) holds overrides and custom entries only, never a copy of the stock catalog: `{ "clis": { "<id>": { ...partial entry... } } }`. Objects merge key-wise onto the stock entry, arrays replace wholesale. **The file must be mode 0600**; the loader refuses any group/world permission bit, read bits included, so a file created with a normal umask (0644) is ignored until you `chmod 600` it. Every reason a file was ignored or an entry dropped is logged once, prefixed `[cli-registry]`, on the first load. A stock entry whose override fails validation falls back to the shipped definition; a custom entry that fails is dropped. The file is read once per process and re-read only on restart.
|
||||
`~/.codeman/clis.json` (instance-scoped through `dataPath()`) holds overrides and custom entries only, never a copy of the stock catalog: `{ "clis": { "<id>": { ...partial entry... } } }`. Objects merge key-wise onto the stock entry, arrays replace wholesale. **The file must be mode 0600**; the loader refuses any group/world permission bit, read bits included, so a file created with a normal umask (0644) is ignored until you `chmod 600` it. Every reason a file was ignored or an entry dropped is logged once, prefixed `[cli-registry]`, on the first load. A stock entry whose override fails validation falls back to the shipped definition; a custom entry that fails is dropped. The file is read once per process and re-read after a change made through CLI management (below).
|
||||
|
||||
## Managing CLIs from Settings
|
||||
|
||||
App Settings → Agents & CLIs → **CLI management** (`cliManagementEnabled`, default OFF; admin-only in multi-user mode) lists every entry with an installed/not-installed badge and:
|
||||
|
||||
- toggles any entry on or off. A `kind: 'shell'` entry cannot be disabled, and the row shows no switch for it. A disabled CLI disappears from the Run menu, the welcome screen and the phone overview, and new session requests for it are rejected.
|
||||
- installs a missing **stock** CLI by running its shipped install command, after a confirm that names the exact command. Only one install per CLI runs at a time, and the command runs without any `CODEMAN_*` variable in its environment. A custom entry's install command is never executed.
|
||||
- adds, edits and deletes **custom** entries (id, label, badge, binaries, launch argv). The server re-validates the whole assembled entry through `CliEntrySchema`, so the form cannot bypass the load-time rules.
|
||||
|
||||
These are the only writes to `clis.json`. They are serialized, and a file that does not parse or has unsafe permissions is refused rather than overwritten; fix it (or `chmod 600` it) and retry. The HTTP routes are listed in `docs/api-reference.md` under *CLI management*.
|
||||
|
||||
## The shape of an entry
|
||||
|
||||
@@ -36,7 +46,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 +56,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.
|
||||
@@ -112,9 +159,9 @@ This matters because it is invisible when it is wrong. `capabilities.privilegedP
|
||||
|
||||
## Fields declared for later
|
||||
|
||||
`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.
|
||||
`accent`, `capabilities.echo`, `capabilities.wheelForward`, `capabilities.keyboardAccessory` and `capabilities.maxFrameBytes` are **declared but not yet read**. (`shortBadge` was on this list until the CLI management list in Settings started showing it.) 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.
|
||||
|
||||
|
||||
@@ -53,7 +53,9 @@ that spawns a literal `pnpm` with no npm fallback, so without one it exits 127 w
|
||||
surfaces that same line as the install error. `npm install -g pnpm` (or
|
||||
`corepack enable pnpm`) is the fix. This is what broke the Docker agent image in
|
||||
[#352](https://github.com/Ark0N/Codeman/issues/352); the image now installs pnpm
|
||||
alongside `dsh`.
|
||||
alongside `dsh`. The Compose server image (`docker/server.Dockerfile`) does not
|
||||
ship `dsh`, since it is installed at runtime, but it does ship pnpm so the UI
|
||||
button works there too.
|
||||
|
||||
Codeman's default is `@deepseek-harness-tui/dsh-tui` because it is by a wide
|
||||
margin the most used community TUI, it is MIT, and it implements the status
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -66,6 +66,31 @@ each `(clientId, seq)` at most once, so a resend can't type the prompt twice.
|
||||
(the 200 is the client's ACK). `curl`/legacy callers omit the fields and always
|
||||
apply.
|
||||
|
||||
## Oversized input (issue #484)
|
||||
|
||||
Delivery has a third outcome besides "applied" and "retry": **refused for good**.
|
||||
Both transports refuse a frame longer than `MAX_INPUT_LENGTH` (64 KiB,
|
||||
`src/config/terminal-limits.ts`; the POST schema uses the same constant). Before
|
||||
#484 the client treated that like a transient failure, so an oversized paste sat
|
||||
at the head of the queue, was re-sent every 2 s forever, blocked every later
|
||||
input for the session, and came back from localStorage on each reload.
|
||||
|
||||
- `_sendInputAsync()` splits a paste over the frame limit into in-limit frames
|
||||
(`CodemanInputLimit.split`, constants.js, never cutting a surrogate pair). They
|
||||
go out in seq order, so the PTY sees one contiguous stream. A paste over
|
||||
`PASTE_MAX_CHARS` (1 MiB), or an oversized `useMux` write (line-oriented, never
|
||||
split), is refused with a toast and never queued.
|
||||
- The WebSocket answers an oversized sequenced frame with
|
||||
`{t:'ia', seq, err:'too_large', max}`; the client drops it with a toast. A
|
||||
client that predates `err` reads it as a plain ACK and drops it too.
|
||||
- The POST drain drops a frame answered `400`/`413` (`401`/`403` stay transient:
|
||||
an expired login delivers once the user signs in again).
|
||||
- `_loadReliableState()` prunes persisted frames over the limit, so a queue
|
||||
poisoned by an older build heals on the first load after upgrading.
|
||||
- ⚠️ The frontend limit (`INPUT_FRAME_MAX_CHARS`) and the composer's
|
||||
`COMPOSER_INPUT_FRAME_LIMIT` must equal `MAX_INPUT_LENGTH`; pinned by
|
||||
`test/input-size-limit.test.ts`.
|
||||
|
||||
## Known limitation
|
||||
|
||||
Dedup state is in-memory on the server. A **server restart** between a write and
|
||||
@@ -79,3 +104,5 @@ across the narrow restart window.
|
||||
semantics (monotonic, per-client, gap-tolerant, eviction-safe).
|
||||
- `test/routes/session-routes.test.ts` — POST `/input` applies a tagged
|
||||
`(clientId, seq)` once on redelivery; untagged input always applies.
|
||||
- `test/input-size-limit.test.ts`: one input limit on both sides, frame
|
||||
splitting, and dropping (never retrying) a frame refused for good (#484).
|
||||
|
||||
@@ -497,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.
|
||||
@@ -516,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).
|
||||
|
||||
---
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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. |
|
||||
|
||||
@@ -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. |
|
||||
|
||||

|
||||
|
||||
|
||||
+1
-1
@@ -169,7 +169,7 @@ export PUPPETEER_SKIP_DOWNLOAD="${PUPPETEER_SKIP_DOWNLOAD:-1}"
|
||||
# commit as the script itself. Nothing fetched at install time is ever executed; there is
|
||||
# no network refresh of these arrays. See cli_catalog_select_platform below.
|
||||
CLI_IDS=('claude' 'shell' 'opencode' 'codex' 'gemini' 'antigravity' 'pi' 'grok' 'deepseek' 'omp')
|
||||
CLI_LABELS=('Claude' 'Shell' 'OpenCode' 'Codex' 'Gemini' 'Antigravity' 'Pi' 'Grok' 'DeepSeek' 'OMP')
|
||||
CLI_LABELS=('Claude Code' 'Shell' 'OpenCode' 'Codex' 'Gemini' 'Antigravity' 'Pi' 'Grok' 'DeepSeek' 'OMP')
|
||||
CLI_ENABLED=(1 1 1 1 1 1 1 1 1 1)
|
||||
CLI_LAUNCHER_ONLY=(0 0 0 0 0 0 0 0 1 0)
|
||||
CLI_DOCS=('https://docs.claude.com/claude-code' '' 'https://opencode.ai/docs' 'https://developers.openai.com/codex/cli' 'https://github.com/google-gemini/gemini-cli' 'https://antigravity.google/cli' 'https://pi.dev' 'https://github.com/xai-org/grok-build' 'https://github.com/deepseek-ai/deepseek-harness' 'https://omp.sh')
|
||||
|
||||
Generated
+3
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "aicodeman",
|
||||
"version": "1.32.0",
|
||||
"version": "1.33.1",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "aicodeman",
|
||||
"version": "1.32.0",
|
||||
"version": "1.33.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.32.0",
|
||||
"version": "1.33.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.32.0",
|
||||
"version": "1.33.1",
|
||||
"author": {
|
||||
"name": "Ark0N",
|
||||
"url": "https://github.com/Ark0N"
|
||||
|
||||
@@ -340,7 +340,7 @@ ESC=$(printf '\033')
|
||||
### Starting a worker
|
||||
|
||||
`POST /api/v1/quick-start` body (all optional):
|
||||
`{"caseName":"worker-1","mode":"claude","sessionName":"w9-worker","effort":"high"}`
|
||||
`{"caseName":"worker-1","mode":"claude","sessionName":"auth-worker","effort":"high"}`
|
||||
, `mode` ∈ `claude|shell|opencode|codex|gemini|antigravity|pi|grok|deepseek|omp`; response is
|
||||
`.data.{sessionId, caseName, casePath}`. Creates the case directory (a real directory
|
||||
on the user's disk) if missing, do not retry it in a loop, and remember the name.
|
||||
|
||||
@@ -101,11 +101,16 @@ the case name, read it from the listing.
|
||||
From Codeman 1.16 a LOCAL claude spawn passes `--name <session name>` when the local
|
||||
CLI is 2.1.224+ (`buildNameCliArgs`, `session-cli-builder.ts:97-101`, wired in at
|
||||
`tmux-manager.ts:797`), so a worker's peer name usually IS its Codeman session name
|
||||
(verified live: quick-start with `sessionName: "w9-msgtest"` listed as `w9-msgtest`,
|
||||
and its messages arrive tagged `from-name="w9-msgtest"`; a derived-name worker's
|
||||
(verified live: a quick-start `sessionName` is listed as that exact peer name, and
|
||||
the worker's messages arrive tagged `from-name="<that name>"`; a derived-name worker's
|
||||
messages carry no `from-name`). Name your workers: a quick-start WITHOUT
|
||||
`sessionName` leaves the Codeman name empty, so there is nothing to pass and the
|
||||
peer name stays derived. The flag is fail-closed (older/unknown CLI omits it, because an
|
||||
peer name stays derived. ⚠️ Give them a DESCRIPTIVE name: only a name the user chose
|
||||
is pinned (`Session.cliPinnedName`), because `--name` is also the conversation's
|
||||
`/resume` title and terminal title and suppresses Claude's own generated title. A
|
||||
placeholder-shaped name (`w9-msgtest`, anything matching `isGeneratedSessionName`)
|
||||
and an auto name are NOT passed, so such a worker's peer name is derived; use
|
||||
`msgtest-worker` rather than `w9-msgtest`. The flag is fail-closed (older/unknown CLI omits it, because an
|
||||
unknown flag aborts startup and would kill every spawn) and allowlist-sanitized (a name of
|
||||
only unsafe characters is dropped), and the docker/remote builders never see it at all
|
||||
(`tmux-manager.ts:782-789`), which is why the `tmux` column stays the canonical join key
|
||||
@@ -196,8 +201,8 @@ idle:
|
||||
The contract an orchestrator follows for any fleet of two or more messaging workers.
|
||||
Every topology in the next section is this protocol plus a wiring diagram.
|
||||
|
||||
1. **Spawn with a name, and confirm hooks.** Use `quick-start` with `sessionName` (the
|
||||
`--name` gate above). Session create installs the hooks block into the workspace
|
||||
1. **Spawn with a name, and confirm hooks.** Use `quick-start` with a descriptive,
|
||||
non-`w<N>-` `sessionName` (the `--name` gate above). Session create installs the hooks block into the workspace
|
||||
whatever kind it is, so a linked case and a raw `POST /api/sessions` path both get
|
||||
`stop`/`blocked` by default. ⚠️ Not unconditionally: the operator can turn
|
||||
`workspaceHooksEnabled` off, remote SSH sessions never get hooks, and a session from
|
||||
|
||||
@@ -692,8 +692,9 @@ The shape, each step verified live (probes, failure modes and safety detail in
|
||||
[§5.2](#52-readiness)).
|
||||
2. `ListAgents`: find the worker's row by its `tmux codeman-<first 8 of session id>`
|
||||
column; the row's `name [ref]` is the address. On Codeman 1.16+ with claude
|
||||
2.1.224+ a worker's peer name is its Codeman session name, so pass `sessionName`
|
||||
in quick-start to pick it; older setups list a name derived from the case folder.
|
||||
2.1.224+ a worker's peer name is its Codeman session name, so pass a DESCRIPTIVE
|
||||
`sessionName` in quick-start to pick it (a `w<N>-` placeholder-shaped name is not
|
||||
pinned, so it lists derived); older setups list a name derived from the case folder.
|
||||
No row = messaging is off for that worker (it is feature-flagged even on matching
|
||||
CLI versions, observed live): fall back to the HTTP recipes without complaint.
|
||||
3. `SendMessage` the task; first contact must use the `name [ref]` form copied from
|
||||
|
||||
@@ -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."
|
||||
|
||||
@@ -340,7 +340,7 @@ ESC=$(printf '\033')
|
||||
### Starting a worker
|
||||
|
||||
`POST /api/v1/quick-start` body (all optional):
|
||||
`{"caseName":"worker-1","mode":"claude","sessionName":"w9-worker","effort":"high"}`
|
||||
`{"caseName":"worker-1","mode":"claude","sessionName":"auth-worker","effort":"high"}`
|
||||
, `mode` ∈ `claude|shell|opencode|codex|gemini|antigravity|pi|grok|deepseek|omp`; response is
|
||||
`.data.{sessionId, caseName, casePath}`. Creates the case directory (a real directory
|
||||
on the user's disk) if missing, do not retry it in a loop, and remember the name.
|
||||
|
||||
@@ -101,11 +101,16 @@ the case name, read it from the listing.
|
||||
From Codeman 1.16 a LOCAL claude spawn passes `--name <session name>` when the local
|
||||
CLI is 2.1.224+ (`buildNameCliArgs`, `session-cli-builder.ts:97-101`, wired in at
|
||||
`tmux-manager.ts:797`), so a worker's peer name usually IS its Codeman session name
|
||||
(verified live: quick-start with `sessionName: "w9-msgtest"` listed as `w9-msgtest`,
|
||||
and its messages arrive tagged `from-name="w9-msgtest"`; a derived-name worker's
|
||||
(verified live: a quick-start `sessionName` is listed as that exact peer name, and
|
||||
the worker's messages arrive tagged `from-name="<that name>"`; a derived-name worker's
|
||||
messages carry no `from-name`). Name your workers: a quick-start WITHOUT
|
||||
`sessionName` leaves the Codeman name empty, so there is nothing to pass and the
|
||||
peer name stays derived. The flag is fail-closed (older/unknown CLI omits it, because an
|
||||
peer name stays derived. ⚠️ Give them a DESCRIPTIVE name: only a name the user chose
|
||||
is pinned (`Session.cliPinnedName`), because `--name` is also the conversation's
|
||||
`/resume` title and terminal title and suppresses Claude's own generated title. A
|
||||
placeholder-shaped name (`w9-msgtest`, anything matching `isGeneratedSessionName`)
|
||||
and an auto name are NOT passed, so such a worker's peer name is derived; use
|
||||
`msgtest-worker` rather than `w9-msgtest`. The flag is fail-closed (older/unknown CLI omits it, because an
|
||||
unknown flag aborts startup and would kill every spawn) and allowlist-sanitized (a name of
|
||||
only unsafe characters is dropped), and the docker/remote builders never see it at all
|
||||
(`tmux-manager.ts:782-789`), which is why the `tmux` column stays the canonical join key
|
||||
@@ -196,8 +201,8 @@ idle:
|
||||
The contract an orchestrator follows for any fleet of two or more messaging workers.
|
||||
Every topology in the next section is this protocol plus a wiring diagram.
|
||||
|
||||
1. **Spawn with a name, and confirm hooks.** Use `quick-start` with `sessionName` (the
|
||||
`--name` gate above). Session create installs the hooks block into the workspace
|
||||
1. **Spawn with a name, and confirm hooks.** Use `quick-start` with a descriptive,
|
||||
non-`w<N>-` `sessionName` (the `--name` gate above). Session create installs the hooks block into the workspace
|
||||
whatever kind it is, so a linked case and a raw `POST /api/sessions` path both get
|
||||
`stop`/`blocked` by default. ⚠️ Not unconditionally: the operator can turn
|
||||
`workspaceHooksEnabled` off, remote SSH sessions never get hooks, and a session from
|
||||
|
||||
@@ -692,8 +692,9 @@ The shape, each step verified live (probes, failure modes and safety detail in
|
||||
[§5.2](#52-readiness)).
|
||||
2. `ListAgents`: find the worker's row by its `tmux codeman-<first 8 of session id>`
|
||||
column; the row's `name [ref]` is the address. On Codeman 1.16+ with claude
|
||||
2.1.224+ a worker's peer name is its Codeman session name, so pass `sessionName`
|
||||
in quick-start to pick it; older setups list a name derived from the case folder.
|
||||
2.1.224+ a worker's peer name is its Codeman session name, so pass a DESCRIPTIVE
|
||||
`sessionName` in quick-start to pick it (a `w<N>-` placeholder-shaped name is not
|
||||
pinned, so it lists derived); older setups list a name derived from the case folder.
|
||||
No row = messaging is off for that worker (it is feature-flagged even on matching
|
||||
CLI versions, observed live): fall back to the HTTP recipes without complaint.
|
||||
3. `SendMessage` the task; first contact must use the `name [ref]` form copied from
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
/**
|
||||
* @fileoverview Carry a Codeman rename into Claude Code's own session title.
|
||||
*
|
||||
* Claude Code keeps a conversation's title in its transcript as a
|
||||
* `{"type":"custom-title"}` row (what `/rename` writes), last row wins, and the
|
||||
* `/resume` picker shows `customTitle ?? aiTitle`. Renaming a tab in Codeman
|
||||
* used to change only the tab, so `/resume` kept listing the old name.
|
||||
*
|
||||
* Appending the row is enough for a pane that was spawned WITHOUT `--name`
|
||||
* (every placeholder- or auto-named tab, see `Session.cliPinnedName`): that
|
||||
* process holds no title of its own and never writes one back. A process that
|
||||
* WAS spawned with `--name` re-appends its in-memory title after each turn, so
|
||||
* there the new title holds from the next spawn, which pins the new name.
|
||||
*
|
||||
* @module claude-session-title
|
||||
*/
|
||||
|
||||
import fs from 'node:fs/promises';
|
||||
|
||||
/**
|
||||
* Append a `custom-title` row for `conversationId` to an existing transcript.
|
||||
* Never creates the file: a missing transcript means the conversation has not
|
||||
* been written yet, and a file of only a title row would show up in `/resume`
|
||||
* as an empty conversation. Returns whether a row was written.
|
||||
*/
|
||||
export async function appendClaudeCustomTitle(
|
||||
transcriptPath: string,
|
||||
conversationId: string,
|
||||
title: string
|
||||
): Promise<boolean> {
|
||||
const customTitle = title.trim();
|
||||
// Claude reads the row through `customTitle ?? aiTitle`, so an empty string
|
||||
// would blank the picker entry rather than fall back to the generated title.
|
||||
if (!customTitle) return false;
|
||||
try {
|
||||
if (!(await fs.stat(transcriptPath)).isFile()) return false;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
// One O_APPEND write of one line, the same way Claude appends its own rows,
|
||||
// so it cannot interleave with a row the live process is writing.
|
||||
const row = JSON.stringify({ type: 'custom-title', customTitle, sessionId: conversationId });
|
||||
await fs.appendFile(transcriptPath, `${row}\n`);
|
||||
return true;
|
||||
}
|
||||
+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) {
|
||||
|
||||
@@ -0,0 +1,115 @@
|
||||
/**
|
||||
* @fileoverview Write side of the CLI registry (docs/cli-enable-disable-plan.md, Phases 3/5).
|
||||
*
|
||||
* Kept deliberately SEPARATE from `registry.ts`, whose reading path does no writes on import
|
||||
* (`schemas.ts` imports it, transitively). Only `cli-registry-routes.ts` imports this module,
|
||||
* so that property still holds for every OTHER importer of the registry.
|
||||
*
|
||||
* Every mutation goes through `mutateRegistryFile()`, which does three things the #476 review
|
||||
* found missing:
|
||||
*
|
||||
* - **Serialized.** Mutations run one at a time on a single promise chain, and each one
|
||||
* reads, changes, writes and reloads before the next starts. Unserialized read-modify-write
|
||||
* lost toggles when three `PUT /api/clis/:id` calls ran in parallel.
|
||||
* - **Refuses a file it must not trust.** The reader ignores a `clis.json` with any
|
||||
* group/world permission bit and quarantines one that does not parse. The writer used to
|
||||
* treat both as "start fresh", so one Settings click replaced a hand-edited file with a
|
||||
* one-key file, or rewrote a refused file as 0600 and so trusted it. It now starts fresh
|
||||
* ONLY on ENOENT and otherwise throws `RegistryWriteRefusedError`, leaving the file alone.
|
||||
* - **Unique temp file.** Every write gets its own tmp name before the rename, so two writes
|
||||
* can never rename each other's temp file away (the ENOENT-on-rename 500s).
|
||||
*
|
||||
* Same tmp+rename+0600 shape as `custom-model-hosts.ts`. The file is hand-editable, so a
|
||||
* write must never leave it half-written, and 0600 is the mode `isUnsafePermissions()`
|
||||
* requires on the next read.
|
||||
*/
|
||||
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import { existsSync, mkdirSync } from 'node:fs';
|
||||
import fs from 'node:fs/promises';
|
||||
import { dirname } from 'node:path';
|
||||
import { isUnsafePermissions, registryFilePath, reloadCliRegistry } from './registry.js';
|
||||
import type { CliRegistryFile } from './types.js';
|
||||
|
||||
/** A write refused because the existing `clis.json` must not be overwritten. The message is user-facing. */
|
||||
export class RegistryWriteRefusedError extends Error {
|
||||
constructor(message: string) {
|
||||
super(message);
|
||||
this.name = 'RegistryWriteRefusedError';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Read the raw override file for mutation. Only a MISSING file starts fresh. A file with
|
||||
* unsafe permissions, one that cannot be read, or one that does not parse is refused rather
|
||||
* than overwritten, because the user's hand-edit is worth more than one toggle.
|
||||
*/
|
||||
export async function readRegistryFileForWrite(): Promise<CliRegistryFile> {
|
||||
const path = registryFilePath();
|
||||
let raw: string;
|
||||
try {
|
||||
raw = await fs.readFile(path, 'utf-8');
|
||||
} catch (err) {
|
||||
if ((err as NodeJS.ErrnoException).code === 'ENOENT') return { schemaVersion: 1, clis: {} };
|
||||
throw new RegistryWriteRefusedError(`Cannot read ${path} (${(err as Error).message}); not changing it.`);
|
||||
}
|
||||
if (isUnsafePermissions(path)) {
|
||||
throw new RegistryWriteRefusedError(
|
||||
`${path} has group/world permission bits, so Codeman ignores it. Run \`chmod 600 ${path}\` and check its contents before changing CLIs here.`
|
||||
);
|
||||
}
|
||||
let parsed: unknown;
|
||||
try {
|
||||
parsed = JSON.parse(raw);
|
||||
} catch (err) {
|
||||
throw new RegistryWriteRefusedError(
|
||||
`${path} is not valid JSON (${(err as Error).message}). Fix or remove it before changing CLIs here.`
|
||||
);
|
||||
}
|
||||
const clis = (parsed as { clis?: unknown } | null)?.clis;
|
||||
if (typeof parsed !== 'object' || parsed === null || typeof clis !== 'object' || clis === null) {
|
||||
throw new RegistryWriteRefusedError(`${path} has no "clis" object. Fix or remove it before changing CLIs here.`);
|
||||
}
|
||||
return parsed as CliRegistryFile;
|
||||
}
|
||||
|
||||
export async function writeRegistryFile(file: CliRegistryFile): Promise<void> {
|
||||
const target = registryFilePath();
|
||||
const dir = dirname(target);
|
||||
if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
|
||||
const tmp = `${target}.${process.pid}.${randomUUID()}.tmp`;
|
||||
try {
|
||||
await fs.writeFile(tmp, JSON.stringify(file, null, 2), { mode: 0o600 });
|
||||
await fs.rename(tmp, target);
|
||||
} catch (err) {
|
||||
await fs.rm(tmp, { force: true }).catch(() => {});
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
let mutationChain: Promise<unknown> = Promise.resolve();
|
||||
|
||||
/**
|
||||
* Run one registry mutation. The chain holds exactly one at a time: `fn` receives the
|
||||
* current file and returns `{ file, result }`. If `file` is set it is written and the
|
||||
* registry reloaded before the next mutation starts; if not, nothing is written, which is
|
||||
* how a validation failure returns early. Checks made inside `fn` (does this id exist,
|
||||
* is it a duplicate) therefore see every earlier mutation's result.
|
||||
*
|
||||
* A failed mutation rejects its own caller only. The chain keeps going.
|
||||
*/
|
||||
export function mutateRegistryFile<T>(
|
||||
fn: (file: CliRegistryFile) => Promise<{ file?: CliRegistryFile; result: T }> | { file?: CliRegistryFile; result: T }
|
||||
): Promise<T> {
|
||||
const run = mutationChain.then(async () => {
|
||||
const current = await readRegistryFileForWrite();
|
||||
const { file, result } = await fn(current);
|
||||
if (file) {
|
||||
await writeRegistryFile(file);
|
||||
reloadCliRegistry();
|
||||
}
|
||||
return result;
|
||||
});
|
||||
mutationChain = run.catch(() => {});
|
||||
return run;
|
||||
}
|
||||
@@ -48,6 +48,16 @@ function filePath(): string {
|
||||
return dataPath('clis.json');
|
||||
}
|
||||
|
||||
/**
|
||||
* The resolved path of `~/.codeman/clis.json`, exported for the write API
|
||||
* (`cli-registry-writer.ts`, docs/cli-enable-disable-plan.md Phases 3/5) so both the read and
|
||||
* write sides resolve the SAME path through the SAME instance-scoped helper — never a second
|
||||
* `dataPath('clis.json')` call that could drift from this one under a future `dataPath()` change.
|
||||
*/
|
||||
export function registryFilePath(): string {
|
||||
return filePath();
|
||||
}
|
||||
|
||||
/**
|
||||
* Keys that must never be merged out of a hand-editable JSON file.
|
||||
*
|
||||
@@ -90,8 +100,11 @@ export interface LoadResult {
|
||||
* as mode 0o666 there regardless of its actual ACL), so this check would flag every file on
|
||||
* Windows and silently ignore all user config. `win32` relies on NTFS ACLs instead, which
|
||||
* this check cannot see and does not attempt to.
|
||||
*
|
||||
* Exported for `registry-writer.ts`, which must refuse the same files: rewriting a refused
|
||||
* file as 0600 would silently turn it into trusted config.
|
||||
*/
|
||||
function isUnsafePermissions(path: string): boolean {
|
||||
export function isUnsafePermissions(path: string): boolean {
|
||||
if (process.platform === 'win32') return false;
|
||||
try {
|
||||
const mode = statSync(path).mode & 0o777;
|
||||
|
||||
@@ -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() })
|
||||
|
||||
@@ -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',
|
||||
label: 'Claude Code',
|
||||
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
|
||||
@@ -368,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,
|
||||
@@ -454,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,
|
||||
@@ -518,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' },
|
||||
@@ -565,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,
|
||||
@@ -657,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,
|
||||
@@ -727,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,
|
||||
@@ -851,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,
|
||||
@@ -986,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,
|
||||
@@ -1151,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;
|
||||
/**
|
||||
@@ -629,12 +670,14 @@ export interface CliOverlays {
|
||||
/**
|
||||
* ⚠️ DECLARED-FOR-LATER: fields no code reads yet.
|
||||
*
|
||||
* `shortBadge`, `accent`, `overlays.credStore`, `capabilities.echo`, `capabilities.wheelForward`,
|
||||
* `accent`, `overlays.credStore`, `capabilities.echo`, `capabilities.wheelForward`,
|
||||
* `capabilities.keyboardAccessory` and `capabilities.maxFrameBytes` all describe FRONTEND
|
||||
* behaviour, and the frontend is deliberately untouched by the change that introduced this
|
||||
* registry — `app.js`, `terminal-ui.js`, `styles.css` and friends keep their own
|
||||
* behaviour, and most of the frontend is deliberately untouched by the change that introduced
|
||||
* this registry — `app.js`, `terminal-ui.js`, `styles.css` and friends keep their own
|
||||
* hand-authored per-CLI rules, and moving them is its own piece of work with its own way of
|
||||
* being verified (a mobile/browser suite the CI gate cannot see).
|
||||
* being verified (a mobile/browser suite the CI gate cannot see). `shortBadge` graduated out of
|
||||
* this list (docs/cli-enable-disable-plan.md, Phase 2): `GET /api/clis` reads it for the
|
||||
* CLI-management Settings list.
|
||||
*
|
||||
* They are declared now because each entry should describe its CLI completely, and because
|
||||
* transcribing them while the hand-written source is still on screen is when the values are
|
||||
@@ -651,7 +694,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. */
|
||||
|
||||
@@ -69,7 +69,9 @@ const ALL: ProbeEnvironment[] = ['linux', 'darwin', 'wsl', 'win32'];
|
||||
* shown to the user and claude's does not follow the pattern.
|
||||
*/
|
||||
const DOCTOR_ROW_OVERRIDES: Record<string, { id?: string; label?: string; usedBy: string[] }> = {
|
||||
claude: { usedBy: ['Claude Code sessions (default backend)'] },
|
||||
// The label override keeps the doctor row's historical "Claude CLI" spelling now that
|
||||
// the registry label is the product name, "Claude Code".
|
||||
claude: { label: 'Claude CLI', usedBy: ['Claude Code sessions (default backend)'] },
|
||||
opencode: { usedBy: ['OpenCode sessions'] },
|
||||
codex: { usedBy: ['Codex sessions'] },
|
||||
gemini: { usedBy: ['Gemini sessions'] },
|
||||
|
||||
+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,
|
||||
|
||||
+60
-1
@@ -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;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -72,6 +90,13 @@ export interface CreateSessionOptions {
|
||||
workingDir: string;
|
||||
mode: SessionMode;
|
||||
name?: string;
|
||||
/**
|
||||
* Name pinned on a claude spawn as `--name` (version-gated, sanitized, local only).
|
||||
* Deliberately NOT `name`: `--name` owns the prompt-box label, the `/resume` picker
|
||||
* entry and the terminal title, and a pinned title stops Claude generating its own,
|
||||
* so only a user-chosen name belongs here (see `Session.cliPinnedName`).
|
||||
*/
|
||||
cliName?: string;
|
||||
niceConfig?: NiceConfig;
|
||||
model?: string;
|
||||
claudeMode?: ClaudeMode;
|
||||
@@ -105,8 +130,15 @@ export interface RespawnPaneOptions {
|
||||
sessionId: string;
|
||||
workingDir: string;
|
||||
mode: SessionMode;
|
||||
/** Session display name; a respawned claude keeps its `--name` peer name (version-gated, local only). */
|
||||
/** Session display name (tab name). */
|
||||
name?: string;
|
||||
/**
|
||||
* Name pinned on a respawned claude as `--name` (version-gated, sanitized, local only).
|
||||
* Deliberately NOT `name`: `--name` owns the prompt-box label, the `/resume` picker
|
||||
* entry and the terminal title, and a pinned title stops Claude generating its own,
|
||||
* so only a user-chosen name belongs here (see `Session.cliPinnedName`).
|
||||
*/
|
||||
cliName?: string;
|
||||
niceConfig?: NiceConfig;
|
||||
model?: string;
|
||||
claudeMode?: ClaudeMode;
|
||||
@@ -180,6 +212,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 */
|
||||
@@ -294,6 +327,32 @@ 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;
|
||||
|
||||
/**
|
||||
* How many authoritative pane reads have agreed on the exit `getPaneExit()`
|
||||
* reports, or 0 when it reports none. The exited-agent sweep closes a session
|
||||
* only once this reaches `CLEAN_EXIT_CONFIRMING_READS` (`pane-exit-sweep.ts`),
|
||||
* and a multiplexer without this method never has a session closed by it.
|
||||
*/
|
||||
getPaneExitReadCount?(muxName: string): number;
|
||||
|
||||
/** 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>;
|
||||
|
||||
|
||||
@@ -0,0 +1,99 @@
|
||||
/**
|
||||
* @fileoverview The exited-agent sweep's decision rule (Ark0N/Codeman#446).
|
||||
*
|
||||
* Codeman creates every tmux pane with `remain-on-exit on`, so `/exit` ends the
|
||||
* CLI while the pane, the tmux session and the `tmux attach-session` process
|
||||
* all live on. Part 1 of #446 records that as `SessionState.paneExit`. This
|
||||
* module decides when such a session is closed, the way the X button closes
|
||||
* it, so finished sessions stop piling up on the board.
|
||||
*
|
||||
* The rule closes a session only on a POSITIVE observation of a clean exit:
|
||||
*
|
||||
* - The exit status must be an explicit numeric 0 with no signal. An absent
|
||||
* status is UNKNOWN, never 0: on tmux 3.2a a SIGKILLed pane reports neither a
|
||||
* status nor a signal, so reading absence as clean would sweep an agent the
|
||||
* OOM killer took. A non-zero status or any signal keeps the row, marked with
|
||||
* the exit, as the crash evidence #210 was filed to keep.
|
||||
* - At least {@link CLEAN_EXIT_CONFIRMING_READS} authoritative pane reads must
|
||||
* have agreed on that exit. A failed, empty or skipped read counts for
|
||||
* nothing, because unknown never closes anything.
|
||||
* - No start, attach or relaunch may be in flight for the session. The
|
||||
* dead-pane branch of `Session._setupOrAttachMuxSession()` respawns an exited
|
||||
* pane on purpose, and for a few seconds that pane still reads as dead.
|
||||
* - The exit must land at least {@link CLEAN_EXIT_MIN_PANE_LIFETIME_MS} after
|
||||
* the last start, attach or relaunch finished. A CLI that prints a startup
|
||||
* error ("not logged in", a bad profile, a config error) and exits 0 would
|
||||
* otherwise lose its tab, and the error with it, seconds after launch. Its
|
||||
* row stays, marked `exited (0)`, for the user to read and close.
|
||||
*
|
||||
* Scoping to local mux-backed sessions happens before this rule runs:
|
||||
* `Session.setPaneExit()` forces the field to UNKNOWN for direct-PTY, remote,
|
||||
* docker and discovered sessions, so their `paneExit` never reaches here.
|
||||
*
|
||||
* Pure, so the rule is unit-tested without a server (test/pane-exit-sweep.test.ts).
|
||||
*/
|
||||
import type { PaneExit } from './types/index.js';
|
||||
|
||||
/**
|
||||
* How many authoritative pane reads must agree on a clean exit before the
|
||||
* session is closed. At the watcher's 2 s cadence two reads mean a finished
|
||||
* session disappears within about four seconds of its agent exiting.
|
||||
*/
|
||||
export const CLEAN_EXIT_CONFIRMING_READS = 2;
|
||||
|
||||
/**
|
||||
* How long a pane must have been up before a clean exit closes its session.
|
||||
* An exit sooner than this after the last pane start is read as a startup
|
||||
* failure rather than a user ending the agent, and the row is kept.
|
||||
*/
|
||||
export const CLEAN_EXIT_MIN_PANE_LIFETIME_MS = 10_000;
|
||||
|
||||
/** The lifecycle-log reason recorded when the sweep closes a session. */
|
||||
export const CLEAN_EXIT_CLOSE_REASON = 'agent exited cleanly (status 0)';
|
||||
|
||||
/**
|
||||
* Is this exit a clean one? True only for an explicit numeric status of 0 with
|
||||
* no signal reported.
|
||||
*
|
||||
* ⚠ Never widen this to `(exit.status ?? 0) === 0` or to "no signal, so it was
|
||||
* clean". An absent status is how a signal death presents on tmux 3.2a, and
|
||||
* that shortcut would close crashed agents with nothing failing to warn you.
|
||||
*/
|
||||
export function isCleanPaneExit(exit: PaneExit | undefined): boolean {
|
||||
if (!exit) return false;
|
||||
if (exit.signal !== undefined) return false;
|
||||
return exit.status === 0;
|
||||
}
|
||||
|
||||
/** Everything the sweep needs to know about one session. */
|
||||
export interface CleanExitSweepCandidate {
|
||||
/** The session's published exit, already scoped by `Session.setPaneExit()`. */
|
||||
paneExit: PaneExit | undefined;
|
||||
/** Authoritative pane reads that agreed on that exit (`getPaneExitReadCount()`). */
|
||||
confirmingReads: number;
|
||||
/** A start, attach or relaunch is running for this session's pane. */
|
||||
paneLifecycleInFlight: boolean;
|
||||
/** The session is already being closed or detached. */
|
||||
closing: boolean;
|
||||
/**
|
||||
* When the last start, attach or relaunch of this pane finished
|
||||
* (`Session.paneStartedAt`), or 0 when none has run in this process.
|
||||
*/
|
||||
paneStartedAt: number;
|
||||
}
|
||||
|
||||
/** Should the sweep close this session now? See the file overview for the rule. */
|
||||
export function shouldCloseCleanlyExitedSession(candidate: CleanExitSweepCandidate): boolean {
|
||||
if (candidate.closing) return false;
|
||||
if (candidate.paneLifecycleInFlight) return false;
|
||||
if (!isCleanPaneExit(candidate.paneExit)) return false;
|
||||
// `at` is when this server first read the pane dead, so an exit during the
|
||||
// start itself lands BEFORE `paneStartedAt` and is kept too.
|
||||
if (
|
||||
candidate.paneStartedAt > 0 &&
|
||||
candidate.paneExit!.at - candidate.paneStartedAt < CLEAN_EXIT_MIN_PANE_LIFETIME_MS
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
return candidate.confirmingReads >= CLEAN_EXIT_CONFIRMING_READS;
|
||||
}
|
||||
+27
-14
@@ -19,19 +19,22 @@
|
||||
* touching its status, so a pinned session a reboot killed still reads `idle` or
|
||||
* `busy` and stays eligible.
|
||||
*
|
||||
* ⚠️ Ending the AGENT rather than the session is a shape this module CANNOT
|
||||
* recognise today, and a reboot restores it. `/exit` ends the CLI inside the
|
||||
* pane, `remain-on-exit` keeps the pane, and the PTY Codeman owns is the
|
||||
* `tmux attach-session` process, which stays alive throughout — so no exit
|
||||
* handler runs, no lifecycle `exit` is logged, and the record keeps both its pid
|
||||
* and `status: 'idle'`. Nothing durable distinguishes it from a session that was
|
||||
* simply idle when the power went. Ark0N/Codeman#446 covers making Codeman
|
||||
* notice the dead pane; until a record can say the agent is gone, this pass will
|
||||
* offer those sessions back, and the user dismisses or closes them.
|
||||
* ⚠️ Ending the AGENT rather than the session leaves no trace in `status` or
|
||||
* `pid`. `/exit` ends the CLI inside the pane, `remain-on-exit` keeps the pane,
|
||||
* and the PTY Codeman owns is the `tmux attach-session` process, which stays
|
||||
* alive throughout — so no exit handler runs, no lifecycle `exit` is logged,
|
||||
* and the record keeps both its pid and `status: 'idle'`. Ark0N/Codeman#446
|
||||
* handles it in two steps. The pane-exit watcher persists `paneExit`, and the
|
||||
* clean-exit sweep (`pane-exit-sweep.ts`) closes a session whose agent exited
|
||||
* with status 0 through `cleanupSession()`, which leaves the durable record
|
||||
* described above. This module also refuses a record whose persisted
|
||||
* `paneExit` is a clean exit, which covers a session that exited moments
|
||||
* before the power went, before the sweep reached it. A crashed agent's record
|
||||
* stays eligible, like the row the sweep leaves on the board for it.
|
||||
*
|
||||
* The `pid` check below is therefore NOT that rule. It refuses a record whose
|
||||
* attach process was already gone, which is a session that never started or
|
||||
* whose pane died outright.
|
||||
* The `pid` check below is NOT that rule. It refuses a record whose attach
|
||||
* process was already gone, which is a session that never started or whose
|
||||
* pane died outright.
|
||||
*
|
||||
* @dependencies types (SessionState), config/cli-registry
|
||||
* @consumedby web/server (plan build at boot), web/routes/reboot-restore-routes
|
||||
@@ -41,6 +44,7 @@
|
||||
|
||||
import type { SessionState } from './types.js';
|
||||
import { getCli } from './config/cli-registry/registry.js';
|
||||
import { isCleanPaneExit } from './pane-exit-sweep.js';
|
||||
|
||||
/** Session statuses a reboot restore may rebuild. `stopped` is the kill marker. */
|
||||
const RESTORABLE_STATUSES: ReadonlySet<string> = new Set(['idle', 'busy', 'error']);
|
||||
@@ -109,7 +113,7 @@ export function resolveResumeConversationId(state: SessionState): string {
|
||||
/**
|
||||
* Why one session was passed over. Reported for logging and shown to the user.
|
||||
*
|
||||
* The first seven are decided before anything is built. `capacity-reached` and
|
||||
* All but the last two are decided before anything is built. `capacity-reached` and
|
||||
* `rebuild-failed` can only happen once a click is spending the plan, and they
|
||||
* are the two the banner must not confuse with a missing workspace: one means
|
||||
* "try again after closing something", the other means the CLI would not start.
|
||||
@@ -120,6 +124,7 @@ export interface RebootRestoreRejection {
|
||||
| 'no-persisted-record'
|
||||
| 'intentionally-ended'
|
||||
| 'not-running'
|
||||
| 'agent-exited'
|
||||
| 'respawn-blocked'
|
||||
| 'remote-or-docker'
|
||||
| 'unsupported-mode'
|
||||
@@ -191,7 +196,8 @@ export function planRebootRestore(
|
||||
//
|
||||
// ⚠️ This does NOT catch a session the user ended with `/exit`. See the
|
||||
// module header: that leaves the pid in place, because the pid is the tmux
|
||||
// attach process and `remain-on-exit` keeps it alive.
|
||||
// attach process and `remain-on-exit` keeps it alive. The `paneExit` check
|
||||
// below catches it instead.
|
||||
//
|
||||
// Conservative on purpose. A session that somehow persisted no pid while
|
||||
// genuinely running is not offered, and its conversation stays reachable
|
||||
@@ -200,6 +206,13 @@ export function planRebootRestore(
|
||||
skipped.push({ sessionId, reason: 'not-running' });
|
||||
continue;
|
||||
}
|
||||
if (isCleanPaneExit(state.paneExit)) {
|
||||
// The user ended the agent, and the clean-exit sweep would have closed the
|
||||
// session had the power not gone first (Ark0N/Codeman#446). The same
|
||||
// explicit-0 rule applies: an absent status is unknown, not clean.
|
||||
skipped.push({ sessionId, reason: 'agent-exited' });
|
||||
continue;
|
||||
}
|
||||
if (state.respawnBlocked === true) {
|
||||
// The crash-loop breaker tripped on this pane. Re-creating it restarts the loop.
|
||||
skipped.push({ sessionId, reason: 'respawn-blocked' });
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
+508
-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,35 @@ 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;
|
||||
/**
|
||||
* How many starts, attaches or relaunches are running for this session's
|
||||
* pane. While one is, a dead-pane reading may describe a pane that is being
|
||||
* revived on purpose, so the exited-agent sweep leaves the session alone
|
||||
* (Ark0N/Codeman#446). A counter rather than a flag, so two overlapping
|
||||
* operations cannot clear each other's mark.
|
||||
*/
|
||||
private _paneLifecycleOps = 0;
|
||||
/** When the last pane start, attach or relaunch finished (ms), 0 when none has run. */
|
||||
private _paneStartedAt = 0;
|
||||
/**
|
||||
* The server has started closing this session, so no start or attach may
|
||||
* begin (see {@link markClosing}).
|
||||
*/
|
||||
private _closing = false;
|
||||
/**
|
||||
* 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 +779,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 +935,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 +1172,118 @@ 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;
|
||||
}
|
||||
|
||||
/**
|
||||
* True while a start, attach or relaunch is running for this session's pane.
|
||||
* The exited-agent sweep reads it (see `pane-exit-sweep.ts`): the dead-pane
|
||||
* branch of {@link _setupOrAttachMuxSession} respawns an exited pane, and
|
||||
* until it finishes and clears the exit, the pane still reads as dead.
|
||||
*/
|
||||
get paneLifecycleInFlight(): boolean {
|
||||
return this._paneLifecycleOps > 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* When the last start, attach or relaunch of this pane finished, or 0 when
|
||||
* none has run in this process. The exited-agent sweep keeps an exit that
|
||||
* lands within `CLEAN_EXIT_MIN_PANE_LIFETIME_MS` of it, since that reads as a
|
||||
* CLI failing at startup rather than a user ending it. An attach to a pane
|
||||
* that was already running stamps it too, which only costs a user who
|
||||
* `/exit`s within seconds of a server restart a row to close by hand.
|
||||
*/
|
||||
get paneStartedAt(): number {
|
||||
return this._paneStartedAt;
|
||||
}
|
||||
|
||||
/**
|
||||
* Mark this session as being closed, or clear the mark after a close that
|
||||
* failed. While it is set, {@link startInteractive} and {@link startShell}
|
||||
* refuse to run. A start that raced a close would otherwise launch a CLI in a
|
||||
* tmux session whose record is about to be deleted (Ark0N/Codeman#446).
|
||||
*/
|
||||
markClosing(closing: boolean): void {
|
||||
this._closing = closing;
|
||||
}
|
||||
|
||||
/** Run one pane start, attach or relaunch with {@link paneLifecycleInFlight} raised. */
|
||||
private async _withPaneLifecycle<T>(op: () => Promise<T>): Promise<T> {
|
||||
this._paneLifecycleOps++;
|
||||
try {
|
||||
return await op();
|
||||
} finally {
|
||||
this._paneLifecycleOps--;
|
||||
this._paneStartedAt = Date.now();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 +1305,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.
|
||||
@@ -1421,6 +1618,19 @@ export class Session extends EventEmitter {
|
||||
return this._nameSource;
|
||||
}
|
||||
|
||||
/**
|
||||
* The name to pin on the Claude CLI as `--name`, or undefined to let Claude
|
||||
* title the conversation itself. `--name` is the prompt-box label, the
|
||||
* `/resume` picker entry and the terminal title all at once, and a pinned
|
||||
* title stops Claude generating its own, so only a name the user chose is
|
||||
* worth pinning. Pinning the `w1-myapp` placeholder gave every conversation
|
||||
* in a case the same `/resume` entry; an auto name is a cut of the first
|
||||
* prompt, which Claude's own generated title already beats.
|
||||
*/
|
||||
get cliPinnedName(): string | undefined {
|
||||
return this._nameSource === 'manual' ? this._name : undefined;
|
||||
}
|
||||
|
||||
setAutoClear(enabled: boolean, threshold?: number): void {
|
||||
this._autoOps.setAutoClear(enabled, threshold);
|
||||
}
|
||||
@@ -1620,6 +1830,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 +1889,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 +1946,90 @@ 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 }> {
|
||||
return this._withPaneLifecycle(() => this._doSetupOrAttachMuxSession(options));
|
||||
}
|
||||
|
||||
private async _doSetupOrAttachMuxSession(options: {
|
||||
respawnPaneOptions: import('./mux-interface.js').RespawnPaneOptions;
|
||||
createSessionOptions: import('./mux-interface.js').CreateSessionOptions;
|
||||
spawnErrLabel: string;
|
||||
}): 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 +2064,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 +2109,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;
|
||||
}
|
||||
@@ -1869,6 +2137,10 @@ export class Session extends EventEmitter {
|
||||
* the mux session is gone — see {@link reattachRemote} for that reasoning).
|
||||
*/
|
||||
async restartCli(): Promise<boolean> {
|
||||
return this._withPaneLifecycle(() => this._doRestartCli());
|
||||
}
|
||||
|
||||
private async _doRestartCli(): Promise<boolean> {
|
||||
if (!this._useMux || !this._mux || !this._muxSession) return false;
|
||||
const mux = this._mux;
|
||||
|
||||
@@ -1878,26 +2150,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;
|
||||
}
|
||||
@@ -1914,6 +2176,7 @@ export class Session extends EventEmitter {
|
||||
workingDir: this.workingDir,
|
||||
mode: this.mode,
|
||||
name: this._name,
|
||||
cliName: this.cliPinnedName,
|
||||
niceConfig: this._niceConfig,
|
||||
model: this._model,
|
||||
claudeMode: this._claudeMode,
|
||||
@@ -1947,6 +2210,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
|
||||
@@ -2160,6 +2529,9 @@ export class Session extends EventEmitter {
|
||||
if (this.ptyProcess) {
|
||||
throw new Error('Session already has a running process');
|
||||
}
|
||||
if (this._closing) {
|
||||
throw new Error('Session is being closed');
|
||||
}
|
||||
|
||||
// Bounds the workspace-trust scan (see _maybeAcceptTrustDialog). Stamped here
|
||||
// rather than at PTY spawn so a slow mux attach still counts as startup.
|
||||
@@ -2268,7 +2640,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: {
|
||||
@@ -2276,6 +2648,7 @@ export class Session extends EventEmitter {
|
||||
workingDir: this.workingDir,
|
||||
mode: this.mode,
|
||||
name: this._name,
|
||||
cliName: this.cliPinnedName,
|
||||
niceConfig: this._niceConfig,
|
||||
model: this._model,
|
||||
claudeMode: this._claudeMode,
|
||||
@@ -2315,7 +2688,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 ||
|
||||
@@ -2403,7 +2787,7 @@ export class Session extends EventEmitter {
|
||||
this._model,
|
||||
this._allowedTools,
|
||||
this._effort,
|
||||
this._name,
|
||||
this.cliPinnedName,
|
||||
getClaudeCliVersion()
|
||||
);
|
||||
this.ptyProcess = spawnPtyWithHelperRepair(() =>
|
||||
@@ -2416,6 +2800,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 +3094,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.
|
||||
*
|
||||
@@ -2914,6 +3353,9 @@ export class Session extends EventEmitter {
|
||||
if (this.ptyProcess) {
|
||||
throw new Error('Session already has a running process');
|
||||
}
|
||||
if (this._closing) {
|
||||
throw new Error('Session is being closed');
|
||||
}
|
||||
|
||||
this._resetBuffers();
|
||||
|
||||
@@ -2984,6 +3426,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 +3536,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(
|
||||
@@ -3779,6 +4223,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)
|
||||
@@ -3864,6 +4342,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;
|
||||
@@ -3963,6 +4445,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();
|
||||
|
||||
|
||||
+396
-19
@@ -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,154 @@ 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;
|
||||
}
|
||||
|
||||
/**
|
||||
* A {@link PaneExitObservation} as the manager stores it, with a count of the
|
||||
* authoritative reads that have seen this same exit. The count is what lets
|
||||
* the exited-agent sweep act only on a death that more than one read agreed on
|
||||
* (`CLEAN_EXIT_CONFIRMING_READS` in `pane-exit-sweep.ts`). A failed or skipped
|
||||
* read never reaches {@link TmuxManager.applyPaneExits}, so it neither raises
|
||||
* the count nor resets it.
|
||||
*/
|
||||
interface TrackedPaneExit extends PaneExitObservation {
|
||||
/** Authoritative reads that saw this exit, counting the first. */
|
||||
reads: number;
|
||||
}
|
||||
|
||||
/** 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;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -735,7 +884,7 @@ export function buildSpawnCommand(options: {
|
||||
effort?: EffortLevel;
|
||||
/** Resolved by resolveStatusLineCliCommand (hooks-config.ts) — undefined skips the exporter. Claude only. */
|
||||
statusLineCommand?: string;
|
||||
/** Codeman session name, passed to claude as `--name` (version-gated, sanitized; local spawns only). */
|
||||
/** Name pinned on claude as `--name` (version-gated, sanitized; local spawns only). Only a user-chosen name: see `Session.cliPinnedName`. */
|
||||
sessionName?: string;
|
||||
/**
|
||||
* Claude CLI version for the `--name` gate. Omitted = probe the local CLI
|
||||
@@ -1527,6 +1676,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, TrackedPaneExit> = 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. */
|
||||
@@ -1894,6 +2067,7 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
|
||||
workingDir,
|
||||
mode,
|
||||
name,
|
||||
cliName,
|
||||
niceConfig,
|
||||
model,
|
||||
claudeMode,
|
||||
@@ -1997,7 +2171,7 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
|
||||
resumeSessionId,
|
||||
effort,
|
||||
statusLineCommand,
|
||||
sessionName: name,
|
||||
sessionName: cliName,
|
||||
});
|
||||
|
||||
const config = niceConfig || DEFAULT_NICE_CONFIG;
|
||||
@@ -2225,7 +2399,7 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
|
||||
effort,
|
||||
remote,
|
||||
docker,
|
||||
name,
|
||||
cliName,
|
||||
} = options;
|
||||
const session = this.sessions.get(sessionId);
|
||||
if (!session) return null;
|
||||
@@ -2262,7 +2436,7 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
|
||||
resumeSessionId,
|
||||
effort,
|
||||
statusLineCommand,
|
||||
sessionName: name,
|
||||
sessionName: cliName,
|
||||
});
|
||||
const config = niceConfig || DEFAULT_NICE_CONFIG;
|
||||
const cmd = wrapWithNice(baseCmd, config);
|
||||
@@ -2297,6 +2471,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 +2687,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 +2798,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 +2852,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 +2874,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 +2911,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 +3078,185 @@ 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;
|
||||
}
|
||||
|
||||
/**
|
||||
* How many authoritative pane reads have agreed on the exit that
|
||||
* {@link getPaneExit} reports, or 0 when it reports none. A new observation
|
||||
* starts at 1, and every later read that sees the same pane with the same
|
||||
* status and signal adds one.
|
||||
*/
|
||||
getPaneExitReadCount(muxName: string): number {
|
||||
return this.paneExits.get(muxName)?.reads ?? 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.
|
||||
*
|
||||
* The same rule decides the read count: a repeat of the stored exit adds one,
|
||||
* and anything that starts a new observation starts the count again at 1.
|
||||
*/
|
||||
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, reads: prev.reads + 1 } : { ...next, reads: 1 });
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 +3476,8 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
|
||||
|
||||
destroy(): void {
|
||||
this.stopStatsCollection();
|
||||
this.stopPaneExitWatcher();
|
||||
this.paneExits.clear();
|
||||
this.stopMouseModeSync();
|
||||
this.stopRemoteReconnectWatcher();
|
||||
this.reconnectState.clear();
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -625,6 +625,55 @@ 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 the clean-exit sweep (`pane-exit-sweep.ts`) away
|
||||
* from crashed agents: an agent SIGKILLed by the OOM killer would otherwise
|
||||
* read as a user typing `/exit` and be closed. Nothing here fails when
|
||||
* somebody adds that `??` — the types allow it and the label still renders.
|
||||
* The rule is enforced in `derivePaneExits()` (`tmux-manager.ts`), which omits
|
||||
* the key rather than defaulting it, and again in `isCleanPaneExit()`, which
|
||||
* accepts only an explicit 0.
|
||||
*/
|
||||
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;
|
||||
@@ -780,6 +829,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;
|
||||
}
|
||||
@@ -204,6 +204,28 @@ export function createProductionCliResolverHost(options: ProductionCliResolverHo
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Per-binary invalidation generation, bumped by `invalidateCliExecutableResolvers()`.
|
||||
*
|
||||
* Every resolver instance (each per-CLI module's private one AND the generic registry
|
||||
* resolver in cli-resolver.ts) is built by the factory below and caches in its own
|
||||
* closure, so there is no instance to reach from outside. Keying on the BINARY name is
|
||||
* what lets one call reach all of them: the CLI-management install/update routes know
|
||||
* which binaries just changed, and every resolver knows its own.
|
||||
*/
|
||||
const binaryGenerations = new Map<string, number>();
|
||||
|
||||
/**
|
||||
* Forget every cached result — success and negative-cache backoff alike — for these
|
||||
* binaries, so the next `resolve()` re-runs the chain immediately. For an action that
|
||||
* just changed what is on disk (an install) or what a CLI's binary IS (editing a custom
|
||||
* entry): without it a CLI installed from Settings kept reading as missing for up to the
|
||||
* 5-minute backoff, and an edited entry kept launching its old binary until a restart.
|
||||
*/
|
||||
export function invalidateCliExecutableResolvers(binaries: readonly string[]): void {
|
||||
for (const binary of binaries) binaryGenerations.set(binary, (binaryGenerations.get(binary) ?? 0) + 1);
|
||||
}
|
||||
|
||||
export function createCliExecutableResolver<T = undefined>(
|
||||
options: {
|
||||
binary: string;
|
||||
@@ -235,6 +257,8 @@ export function createCliExecutableResolver<T = undefined>(
|
||||
let failures = 0;
|
||||
/** Timestamp of the most recent miss. */
|
||||
let lastFailureAt = 0;
|
||||
/** The invalidation generation the cached state above belongs to. */
|
||||
let generation = binaryGenerations.get(options.binary) ?? 0;
|
||||
const accept = (path: string | null, source: CliResolutionSource): CliResolution<T> | null => {
|
||||
if (!path || !isAbsolute(path) || !host.exists(path)) return null;
|
||||
const validation = options.validateCandidate?.(path) ?? ({ accepted: true } as CandidateValidation<T>);
|
||||
@@ -249,6 +273,13 @@ export function createCliExecutableResolver<T = undefined>(
|
||||
|
||||
return {
|
||||
resolve() {
|
||||
const current = binaryGenerations.get(options.binary) ?? 0;
|
||||
if (current !== generation) {
|
||||
generation = current;
|
||||
cached = null;
|
||||
failures = 0;
|
||||
lastFailureAt = 0;
|
||||
}
|
||||
if (cached) return cached;
|
||||
// Negative cache: a miss is remembered and the chain — whose login-shell
|
||||
// tail is a synchronous 5s-bounded spawn — is not re-run until the
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
/**
|
||||
* @fileoverview One answer to "is this CLI installed here?", shared by the page render
|
||||
* (`renderIndexHtml` in server.ts, which injects `window.__codemanCliAvailable` and
|
||||
* `window.__codemanCliCatalog`) and `GET /api/clis` (the Settings list's badge).
|
||||
*
|
||||
* The two used to keep their own copies of the per-CLI probe map, so they could drift apart
|
||||
* and the badge could disagree with the Run menu.
|
||||
*
|
||||
* Every probe is a memoized resolver, so this is cheap to call per request. Dynamic imports
|
||||
* keep the nine resolvers out of any module that never asks.
|
||||
*/
|
||||
|
||||
import type { CliEntry } from '../config/cli-registry/types.js';
|
||||
import { isCliAvailable as isRegistryCliAvailable } from './cli-resolver.js';
|
||||
|
||||
/**
|
||||
* The stock CLIs whose own resolver answers availability. It keeps the resolver's specific
|
||||
* semantics (pi/grok/deepseek identity probes). DeepSeek reports RUNNABLE here, not merely
|
||||
* installed: `dsh` is a profile launcher, and a dsh with no pane-capable profile would
|
||||
* offer a Run button that spawns a pane which dies on arrival.
|
||||
*/
|
||||
export async function probeStockCliAvailability(): Promise<Record<string, boolean>> {
|
||||
const [
|
||||
{ isClaudeAvailable },
|
||||
{ isOpenCodeAvailable },
|
||||
{ isCodexAvailable },
|
||||
{ isGeminiAvailable },
|
||||
{ isAntigravityAvailable },
|
||||
{ isPiAvailable },
|
||||
{ isGrokAvailable },
|
||||
{ isDeepSeekRunnable },
|
||||
{ isOmpAvailable },
|
||||
] = await Promise.all([
|
||||
import('./claude-cli-resolver.js'),
|
||||
import('./opencode-cli-resolver.js'),
|
||||
import('./codex-cli-resolver.js'),
|
||||
import('./gemini-cli-resolver.js'),
|
||||
import('./antigravity-cli-resolver.js'),
|
||||
import('./pi-cli-resolver.js'),
|
||||
import('./grok-cli-resolver.js'),
|
||||
import('./deepseek-cli-resolver.js'),
|
||||
import('./omp-cli-resolver.js'),
|
||||
]);
|
||||
return {
|
||||
claude: isClaudeAvailable(),
|
||||
opencode: isOpenCodeAvailable(),
|
||||
codex: isCodexAvailable(),
|
||||
gemini: isGeminiAvailable(),
|
||||
antigravity: isAntigravityAvailable(),
|
||||
pi: isPiAvailable(),
|
||||
grok: isGrokAvailable(),
|
||||
deepseek: isDeepSeekRunnable(),
|
||||
omp: isOmpAvailable(),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Is `entry` installed? A shell entry has no binary to probe, since it is the server's own
|
||||
* login shell. A stock entry with a dedicated resolver uses `stockAvailability`. Anything
|
||||
* else, custom entries included, uses the registry's GENERIC resolver. That is the one a
|
||||
* session spawn uses, and it understands the entry's declared binaries and search dirs.
|
||||
*/
|
||||
export function isCliEntryInstalled(entry: CliEntry, stockAvailability: Record<string, boolean>): boolean {
|
||||
if (entry.kind === 'shell') return true;
|
||||
const id = entry.id as string;
|
||||
return Object.prototype.hasOwnProperty.call(stockAvailability, id)
|
||||
? stockAvailability[id]
|
||||
: isRegistryCliAvailable(id);
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -12,7 +12,8 @@
|
||||
* image dir.
|
||||
*/
|
||||
import fs from 'node:fs/promises';
|
||||
import { join } from 'node:path';
|
||||
import { realpathSync } from 'node:fs';
|
||||
import { join, resolve } from 'node:path';
|
||||
import type { SessionPort } from './ports/index.js';
|
||||
|
||||
const MAX_AGE_MS = 7 * 24 * 60 * 60 * 1000; // 7 days
|
||||
@@ -53,6 +54,73 @@ export async function sweepPasteImagesOnce(
|
||||
return { scanned, deleted };
|
||||
}
|
||||
|
||||
/**
|
||||
* The path two sessions must share to share a paste-image dir: the canonical
|
||||
* path when it can be resolved, so a sibling that reaches the same directory
|
||||
* through a symlink matches, and the normalised path otherwise (a directory
|
||||
* that no longer exists has nothing left to protect).
|
||||
*/
|
||||
function canonicalDir(dir: string): string {
|
||||
try {
|
||||
return realpathSync(dir);
|
||||
} catch {
|
||||
return resolve(dir);
|
||||
}
|
||||
}
|
||||
|
||||
/** One session the paste-image guard weighs: its id, directory and, for a persisted record, its status. */
|
||||
export interface PasteImageDirUser {
|
||||
id: string;
|
||||
workingDir: string;
|
||||
status?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Does another live session still use this working directory's paste-image
|
||||
* dir? Deleting a session removes `{workingDir}/.claude-images` recursively,
|
||||
* and several sessions routinely share one case directory, so without this
|
||||
* check closing one session deletes the pasted images a sibling in the same
|
||||
* case still refers to.
|
||||
*
|
||||
* Two kinds of sibling count as live:
|
||||
*
|
||||
* - a session in the server's map, unless it is itself being killed;
|
||||
* - a persisted record whose status is not `stopped`. That covers a session
|
||||
* detached with `killMux=false`, which leaves the server's map while its
|
||||
* tmux pane keeps running, and a session whose detach is still in progress.
|
||||
*
|
||||
* A session being KILLED does not count. Without that exemption, killing two
|
||||
* sessions of one case concurrently (a bulk delete, or the exited-agent sweep
|
||||
* closing two panes on one tick) would have each defer to the other, and
|
||||
* neither would remove the dir.
|
||||
*
|
||||
* Erring toward "in use" only costs a missed deletion, which the periodic
|
||||
* sweep above ages out. A pinned record whose tmux session is gone keeps its
|
||||
* status through boot pruning, so it holds the dir this way until unpinned.
|
||||
*/
|
||||
export function pasteImageDirInUseByOtherSession(input: {
|
||||
live: Iterable<PasteImageDirUser>;
|
||||
persisted: Iterable<PasteImageDirUser>;
|
||||
closingId: string;
|
||||
workingDir: string;
|
||||
killing: ReadonlySet<string>;
|
||||
}): boolean {
|
||||
const target = canonicalDir(input.workingDir);
|
||||
const matches = (user: PasteImageDirUser): boolean =>
|
||||
user.id !== input.closingId &&
|
||||
!input.killing.has(user.id) &&
|
||||
!!user.workingDir &&
|
||||
canonicalDir(user.workingDir) === target;
|
||||
for (const user of input.live) {
|
||||
if (matches(user)) return true;
|
||||
}
|
||||
for (const user of input.persisted) {
|
||||
if (user.status === 'stopped') continue;
|
||||
if (matches(user)) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
export function startPasteImageGc(ctx: Pick<SessionPort, 'sessions'>): () => void {
|
||||
const initial = setTimeout(() => {
|
||||
void sweepPasteImagesOnce(ctx);
|
||||
|
||||
+554
-79
@@ -66,7 +66,23 @@ const _crashDiag = {
|
||||
// concurrent clients (desktop + phone) don't clobber each other.
|
||||
_pageId: Date.now().toString(36) + '-' + Math.random().toString(36).slice(2, 8),
|
||||
log(msg) {
|
||||
const entry = `${new Date().toISOString().slice(11,23)} ${msg}`;
|
||||
// Entries are joined with '\n' into ONE localStorage value and beaconed to
|
||||
// the server, and some call sites interpolate text this client does not
|
||||
// control (a WebSocket close `reason` arrives from the server). A newline
|
||||
// in there forges extra entries in the trail; an unbounded string can fill
|
||||
// the storage quota and silently kill every later breadcrumb. Flatten and
|
||||
// cap. CodemanDiag is loaded before app.js, but guard anyway — a
|
||||
// diagnostic that can throw is worse than no diagnostic.
|
||||
// Bound to a local FIRST: `CodemanDiag?.x` still throws a ReferenceError
|
||||
// when the identifier was never declared, and this is the one function in
|
||||
// the app that must never throw.
|
||||
const diag = typeof CodemanDiag !== 'undefined' ? CodemanDiag : null;
|
||||
const flat = diag?.sanitizeDiagEntry
|
||||
? diag.sanitizeDiagEntry(msg)
|
||||
: String(msg == null ? '' : msg)
|
||||
.replace(/[\r\n\u2028\u2029]+/g, ' ')
|
||||
.slice(0, diag?.DIAG_ENTRY_MAX_CHARS ?? 300);
|
||||
const entry = `${new Date().toISOString().slice(11,23)} ${flat}`;
|
||||
this._entries.push(entry);
|
||||
if (this._entries.length > this._maxEntries) this._entries.shift();
|
||||
try { localStorage.setItem('codeman-crash-diag', this._entries.join('\n')); } catch {}
|
||||
@@ -325,6 +341,66 @@ function parseSessionPrefix(name) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
// Exited-agent tab label (Ark0N/Codeman#446)
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
// The server publishes session.paneExit when the agent inside a local tmux
|
||||
// pane has exited while remain-on-exit kept the pane. The field is tri-state
|
||||
// and its third state is absence, which means Codeman does not know — that
|
||||
// renders as nothing here and must never read as alive.
|
||||
//
|
||||
// status and signal are each optional, because tmux can know the pane died
|
||||
// without reporting how (a SIGKILLed pane on tmux 3.2a reports neither). So an
|
||||
// absent status shows a bare "exited" rather than "exited (0)": a clean exit
|
||||
// and an unexplained one must not look the same.
|
||||
function paneExitLabel(paneExit) {
|
||||
if (!paneExit || typeof paneExit !== 'object') return '';
|
||||
if (typeof paneExit.signal === 'number' && paneExit.signal > 0) return `exited (signal ${paneExit.signal})`;
|
||||
if (typeof paneExit.status === 'number') return `exited (${paneExit.status})`;
|
||||
return 'exited';
|
||||
}
|
||||
|
||||
// The tab's accessible name, with the exit appended when there is one. Shared
|
||||
// by the full render and applyPaneExitBadge() so the two cannot disagree.
|
||||
function paneExitAriaLabel(name, label) {
|
||||
return label ? `${name} session, agent ${label}` : `${name} session`;
|
||||
}
|
||||
|
||||
// Add, update or remove one tab's exited-agent badge in place. Separate from
|
||||
// the render loop so it can be exercised directly: this is the only path a
|
||||
// session going live-to-exited ever takes, since that transition adds and
|
||||
// removes no tab and so never reaches the full rebuild.
|
||||
function applyPaneExitBadge(tab, paneExit) {
|
||||
const label = paneExitLabel(paneExit);
|
||||
const existing = tab.querySelector('.tab-exited-badge');
|
||||
// Quiets the status dot too. That dot reports `status`, which stays `idle` or
|
||||
// `busy` for an exited pane by design, so without this a green or pulsing dot
|
||||
// sits next to a badge saying the agent is gone.
|
||||
tab.classList.toggle('tab-agent-exited', !!label);
|
||||
// The tab's aria-label overrides its contents for the accessible name, and the
|
||||
// badge is aria-hidden like its siblings, so the exit has to ride the label.
|
||||
const name = tab.querySelector('.tab-name')?.dataset?.fullName;
|
||||
if (name) tab.setAttribute('aria-label', paneExitAriaLabel(name, label));
|
||||
if (!label) {
|
||||
existing?.remove();
|
||||
return;
|
||||
}
|
||||
if (!existing) {
|
||||
const badge = document.createElement('span');
|
||||
badge.className = 'tab-exited-badge';
|
||||
badge.setAttribute('aria-hidden', 'true');
|
||||
// Generated status text, like the status pills: it carries data-i18n-skip
|
||||
// rather than a dictionary entry. Without it the translator would rewrite
|
||||
// the badge and the next render pass would rewrite it back, because the
|
||||
// comparison below is against the English string.
|
||||
badge.setAttribute('data-i18n-skip', '');
|
||||
badge.textContent = label;
|
||||
tab.querySelector('.tab-name')?.insertAdjacentElement('afterend', badge);
|
||||
return;
|
||||
}
|
||||
if (existing.textContent !== label) existing.textContent = label;
|
||||
}
|
||||
|
||||
const DEFAULT_SHORTCUTS = [
|
||||
{
|
||||
id: 'show-shortcuts',
|
||||
@@ -682,6 +758,10 @@ class CodemanApp {
|
||||
this._wsReady = false; // True when WS is open and ready for I/O
|
||||
this._wsState = 'disconnected'; // connecting | connected | reconnecting | fallback | disconnected
|
||||
this._wsLastRecvAt = 0; // ms timestamp of the last frame received on the active WS
|
||||
// Session whose socket dropped unintentionally, so output produced during
|
||||
// the outage is missing from its buffer. Output frames carry no sequence
|
||||
// number, so the only recovery is to refetch on the next successful open.
|
||||
this._wsOutputGapSession = null;
|
||||
|
||||
// Terminal write batching with DEC 2026 sync support
|
||||
this.pendingWrites = [];
|
||||
@@ -1998,14 +2078,12 @@ class CodemanApp {
|
||||
+ (this._loadBufferQueue?.reduce((s, w) => s + w.data.length, 0) || 0)
|
||||
+ (this._terminalWriteInFlightBytes || 0);
|
||||
if (queued + data.data.length > 131072) { // 128KB — drop to prevent accumulation
|
||||
// Schedule a self-recovery once the
|
||||
// queue drains (debounced to avoid hammering the API during sustained bursts).
|
||||
if (!this._clientDropRecoveryTimer) {
|
||||
this._clientDropRecoveryTimer = setTimeout(() => {
|
||||
this._clientDropRecoveryTimer = null;
|
||||
this._onSessionNeedsRefresh();
|
||||
}, 2000);
|
||||
}
|
||||
// The bytes are gone from the stream now, so the recovery is the only
|
||||
// thing that puts this terminal back in step with the PTY. It also
|
||||
// writes the crash-trail line, once per debounce window: logged here,
|
||||
// one line per dropped frame evicted the whole 50-entry trail in
|
||||
// under a second.
|
||||
this._scheduleDroppedOutputRecovery(data.id, 0, queued);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -2013,6 +2091,61 @@ class CodemanApp {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Put the terminal back in step after a dropped frame, and keep trying until
|
||||
* something actually repaints.
|
||||
*
|
||||
* ⚠️ A fire-and-forget timer is not a recovery, which is what this used to be:
|
||||
* it nulled its own handle and then called `_onSessionNeedsRefresh()`, whose
|
||||
* early returns are most likely to fire during the very burst that caused the
|
||||
* drop. A skipped refresh lost the recovery with nothing left to retry it, so
|
||||
* the hole stayed in the stream — and a hole in a TUI byte stream is a
|
||||
* desynced cursor, which is muffled text (issue #464).
|
||||
*
|
||||
* Debounced by the same 2s as before, so a sustained burst still collapses
|
||||
* into one attempt rather than hammering the API; bounded by
|
||||
* `DROP_RECOVERY_MAX_ATTEMPTS`, because the early returns it retries past
|
||||
* are transient contention. A refresh that died at the fetch DEADLINE is
|
||||
* not retried: that is a stalled link, not contention, and each retry would
|
||||
* be another `?full=1` capture waiting out a deadline of up to two minutes.
|
||||
* Giving up leaves exactly what the old code left, so the floor is no worse.
|
||||
*
|
||||
* @param {string} sessionId - the session whose output was dropped
|
||||
* @param {number} [attempt] - zero-based, for the bound
|
||||
* @param {number} [queuedBytes] - render-queue bytes at the drop, for the crash trail
|
||||
*/
|
||||
_scheduleDroppedOutputRecovery(sessionId, attempt = 0, queuedBytes) {
|
||||
if (!sessionId || this._clientDropRecoveryTimer) return;
|
||||
// Behind the debounce guard: one line per window, not per dropped frame.
|
||||
if (Number.isFinite(queuedBytes)) _crashDiag.log(`TERMINAL DROP: ${(queuedBytes / 1024).toFixed(0)}KB queued`);
|
||||
this._clientDropRecoveryTimer = setTimeout(async () => {
|
||||
this._clientDropRecoveryTimer = null;
|
||||
let repainted = false;
|
||||
let timedOut = false;
|
||||
try {
|
||||
const result = await this._onSessionNeedsRefresh({ id: sessionId });
|
||||
repainted = result === true;
|
||||
timedOut = result === 'deadline';
|
||||
} catch {
|
||||
// Treated as "did not repaint" — retrying is the entire point of this.
|
||||
}
|
||||
const retry = window.CodemanDroppedOutput.shouldRetryDroppedOutputRecovery({
|
||||
repainted,
|
||||
timedOut,
|
||||
attempt,
|
||||
stillActive: this.activeSessionId === sessionId,
|
||||
});
|
||||
if (retry) {
|
||||
_crashDiag.log(`DROP RECOVERY: attempt ${attempt + 1} did not repaint, retrying`);
|
||||
this._scheduleDroppedOutputRecovery(sessionId, attempt + 1);
|
||||
}
|
||||
// Read through `window.` like terminal-ui.js does with its own constants:
|
||||
// a bare global resolves in a browser but not in the vm harnesses the gate
|
||||
// runs app.js under, and this body executes inside a timer where a
|
||||
// ReferenceError would be swallowed — taking the recovery with it.
|
||||
}, window.CodemanDroppedOutput.DROP_RECOVERY_DELAY_MS);
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
// Response Viewer — native-scroll panel for reading full Claude responses
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
@@ -2436,8 +2569,14 @@ class CodemanApp {
|
||||
// placeholder beats a messy screen dump there.
|
||||
const sessionMode = this.sessions.get(this.activeSessionId)?.mode || 'claude';
|
||||
if (!lastResponse && (sessionMode === 'claude' || sessionMode === 'shell')) {
|
||||
const termRes = await fetch(`/api/sessions/${this.activeSessionId}/terminal`);
|
||||
const termData = (await termRes.json())?.data ?? {};
|
||||
// The no-param form is capped only by `terminalBufferMaxBytes` (32MB by
|
||||
// default), so it is the largest body the frontend asks for anywhere —
|
||||
// it gets the full-history budget, not the tail one.
|
||||
const termCapture = await this._fetchTerminalCapture(
|
||||
`/api/sessions/${this.activeSessionId}/terminal`,
|
||||
{ full: true }
|
||||
);
|
||||
const termData = termCapture.json?.data ?? {};
|
||||
if (termData.terminalBuffer) {
|
||||
lastResponse = this._cleanTerminalBuffer(termData.terminalBuffer);
|
||||
}
|
||||
@@ -2555,15 +2694,104 @@ class CodemanApp {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch a terminal capture under a deadline.
|
||||
*
|
||||
* Every terminal fetch used to run with no timeout at all, including
|
||||
* `?full=1`, which _maybeRefetchFullHistory itself calls "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 the load-state
|
||||
* machinery stays armed behind it.
|
||||
*
|
||||
* The budget scales with what is being asked for and with how many captures
|
||||
* are already running (see CodemanFetchDeadline): a full scrollback on a slow
|
||||
* uplink legitimately needs longer than a tail, and eight tabs resuming must
|
||||
* not all expire together because each assumed it had the link to itself.
|
||||
*
|
||||
* An abort surfaces as a rejected promise, which every caller already handles —
|
||||
* they wrap these in try/catch and log. That is the point: a timeout becomes a
|
||||
* recoverable error instead of an indefinite hang.
|
||||
*
|
||||
* ⚠️ **The body is read HERE, and that is the whole point.** `await fetch()`
|
||||
* settles on response HEADERS, not the body, so clearing the deadline when it
|
||||
* resolves leaves the body — the multi-megabyte `?full=1` capture this exists
|
||||
* for — completely unbounded. Measured against a server that sends headers
|
||||
* immediately and stalls the body: `fetch()` resolved at 30ms, the timer was
|
||||
* cleared there, and the body completed at 4026ms unaborted under a 1000ms
|
||||
* deadline. Reading the body inside the helper is what makes the deadline
|
||||
* cover the transfer rather than just the handshake. `_terminalCaptureInflight`
|
||||
* is scoped the same way, so a body still streaming counts toward the budget
|
||||
* of a capture starting beside it.
|
||||
*
|
||||
* Returns the PARSED envelope plus the response headers, because two callers
|
||||
* read `server-timing`, and `headersAt` because those same callers measure
|
||||
* header-vs-body time and can no longer observe that moment themselves.
|
||||
*
|
||||
* @param {string} url
|
||||
* @param {{full?: boolean}} [opts]
|
||||
* @returns {Promise<{json: unknown, headers: Headers|undefined, headersAt: number}>}
|
||||
*/
|
||||
async _fetchTerminalCapture(url, opts = {}) {
|
||||
const deadlineMs =
|
||||
typeof CodemanFetchDeadline !== 'undefined'
|
||||
? CodemanFetchDeadline.terminalFetchDeadlineMs({
|
||||
full: !!opts.full,
|
||||
inflight: this._terminalCaptureInflight || 0,
|
||||
})
|
||||
: 45000;
|
||||
// AbortSignal.timeout() is not on every browser Codeman supports, so drive
|
||||
// it from a controller and always clear the timer — an uncancelled one
|
||||
// would abort a LATER request that reused this controller's signal.
|
||||
//
|
||||
// Degrade to a plain fetch where AbortController is missing rather than
|
||||
// throwing: a capture with no deadline is the behaviour every caller had
|
||||
// before this helper existed, while a ReferenceError here would take out
|
||||
// terminal replay entirely. The deadline is a safety net, not a dependency.
|
||||
const canAbort = typeof AbortController === 'function';
|
||||
const controller = canAbort ? new AbortController() : null;
|
||||
const timer = controller ? setTimeout(() => controller.abort(), deadlineMs) : null;
|
||||
this._terminalCaptureInflight = (this._terminalCaptureInflight || 0) + 1;
|
||||
try {
|
||||
const res = await (controller ? fetch(url, { signal: controller.signal }) : fetch(url));
|
||||
const headersAt = performance.now();
|
||||
// Still inside the deadline: an abort here rejects the body stream, which
|
||||
// is exactly the case a header-only timeout could not reach.
|
||||
const json = await res.json();
|
||||
return { json, headers: res.headers, headersAt };
|
||||
} catch (err) {
|
||||
if (err?.name === 'AbortError') {
|
||||
_crashDiag.log(`TERMINAL FETCH TIMEOUT after ${deadlineMs}ms`);
|
||||
}
|
||||
throw err;
|
||||
} finally {
|
||||
if (timer !== null) clearTimeout(timer);
|
||||
this._terminalCaptureInflight = Math.max(0, (this._terminalCaptureInflight || 1) - 1);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Reload this session's buffer from the server.
|
||||
*
|
||||
* ⚠️ Returns whether it ACTUALLY reloaded. Four of the paths out of here are
|
||||
* 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 makes a caller need this. A caller that treats "called" as
|
||||
* "recovered" silently loses the recovery; `_scheduleDroppedOutputRecovery`
|
||||
* is the one that cannot afford to.
|
||||
*
|
||||
* @returns {Promise<boolean|'deadline'>} true only once a response has been
|
||||
* applied; 'deadline' when the capture fetch hit its deadline (a stalled
|
||||
* link, which the dropped-output scheduler does not retry); false otherwise.
|
||||
*/
|
||||
async _onSessionNeedsRefresh(event = {}) {
|
||||
// Server sends this after SSE backpressure clears — terminal data was dropped,
|
||||
// so reload the buffer to recover from any display corruption.
|
||||
const sessionId = this.activeSessionId;
|
||||
if (event?.id && event.id !== sessionId) return;
|
||||
if (!sessionId || !this.terminal) return;
|
||||
if (event?.id && event.id !== sessionId) return false;
|
||||
if (!sessionId || !this.terminal) return false;
|
||||
// Skip if buffer load already in progress — avoids competing clear+rewrite cycles
|
||||
if (this._isLoadingBuffer) return;
|
||||
if (this._terminalRefreshOwner?.sessionId === sessionId) return;
|
||||
if (this._isLoadingBuffer) return false;
|
||||
if (this._terminalRefreshOwner?.sessionId === sessionId) return false;
|
||||
const refreshOwner = { sessionId };
|
||||
this._terminalRefreshOwner = refreshOwner;
|
||||
try {
|
||||
@@ -2573,22 +2801,23 @@ class CodemanApp {
|
||||
// TUI modes still recover the whole picture, with the downgrade guard for
|
||||
// repaint-mode panes whose tmux capture can be smaller than xterm's buffer.
|
||||
const useFullHistory = this.sessions.get(sessionId)?.mode !== 'shell';
|
||||
let res = await fetch(
|
||||
let capture = await this._fetchTerminalCapture(
|
||||
useFullHistory
|
||||
? `/api/sessions/${sessionId}/terminal?full=1`
|
||||
: `/api/sessions/${sessionId}/terminal?tail=${TERMINAL_TAIL_SIZE}`
|
||||
: `/api/sessions/${sessionId}/terminal?tail=${TERMINAL_TAIL_SIZE}`,
|
||||
{ full: useFullHistory }
|
||||
);
|
||||
let headersReceivedAt = performance.now();
|
||||
let data = (await res.json())?.data ?? {};
|
||||
let headersReceivedAt = capture.headersAt;
|
||||
let data = capture.json?.data ?? {};
|
||||
if (useFullHistory && data.terminalBuffer && this._replayWouldShrinkBuffer(data.terminalBuffer)) {
|
||||
res = await fetch(`/api/sessions/${sessionId}/terminal?tail=${TERMINAL_TAIL_SIZE}`);
|
||||
headersReceivedAt = performance.now();
|
||||
data = (await res.json())?.data ?? {};
|
||||
capture = await this._fetchTerminalCapture(`/api/sessions/${sessionId}/terminal?tail=${TERMINAL_TAIL_SIZE}`);
|
||||
headersReceivedAt = capture.headersAt;
|
||||
data = capture.json?.data ?? {};
|
||||
}
|
||||
// Bail on a tab switch mid-fetch: writing here would paint this session's
|
||||
// history into the terminal the user is now looking at. The window is two
|
||||
// fetches wide in the fallback case, so this guard is not optional.
|
||||
if (this.activeSessionId !== sessionId || this._terminalRefreshOwner !== refreshOwner) return;
|
||||
if (this.activeSessionId !== sessionId || this._terminalRefreshOwner !== refreshOwner) return false;
|
||||
if (data.terminalBuffer) {
|
||||
// This refresh is SERVER-triggered, so a user quietly reading scrollback
|
||||
// did not ask for it and must not be dragged to the bottom by it (#259).
|
||||
@@ -2596,8 +2825,11 @@ class CodemanApp {
|
||||
// meaningless across it — distance from the bottom is what survives.
|
||||
const before = this.terminal.buffer?.active;
|
||||
const linesFromBottom = before ? Math.max(0, (before.baseY || 0) - (before.viewportY || 0)) : 0;
|
||||
this.terminal.clear();
|
||||
this.terminal.reset();
|
||||
// One queued clear, not clear()+reset(): both of those are synchronous
|
||||
// and skip xterm's write queue, so live bytes still parsing would land
|
||||
// after them and fuse into the buffer written below. See
|
||||
// _resetTerminalForReplay.
|
||||
this._resetTerminalForReplay();
|
||||
await this.chunkedTerminalWrite(
|
||||
data.terminalBuffer,
|
||||
TERMINAL_CHUNK_SIZE,
|
||||
@@ -2625,13 +2857,36 @@ class CodemanApp {
|
||||
this.sendResize(this.activeSessionId);
|
||||
}
|
||||
}
|
||||
// ⚠️ HERE: after a response arrived, and NOT in the `finally`. The marker
|
||||
// means "this session lost output", and only a reconcile that actually
|
||||
// completed settles it. Clearing on every exit meant one that threw — or
|
||||
// hit the fetch deadline, which is the flaky-link case the marker exists
|
||||
// for — dropped the gap silently with nothing to retry it.
|
||||
// ⚠️ Outside the `if (data.terminalBuffer)` too: a server that answers
|
||||
// with an empty capture HAS reconciled us, there was simply nothing to
|
||||
// replay. Leaving the marker set there refetched on every reconnect for
|
||||
// the life of the page.
|
||||
this._markTerminalBufferReconciled(sessionId);
|
||||
return true;
|
||||
} catch (err) {
|
||||
console.error('needsRefresh reload failed:', err);
|
||||
return err?.name === 'AbortError' ? 'deadline' : false;
|
||||
} finally {
|
||||
if (this._terminalRefreshOwner === refreshOwner) this._terminalRefreshOwner = null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Drop the "this session lost output" marker.
|
||||
*
|
||||
* Called from every path that repaints a session's buffer from the server, so
|
||||
* the ws.onopen reconcile fires once and only when nothing else already did
|
||||
* the work. See the ws.onclose note for what the marker means.
|
||||
*/
|
||||
_markTerminalBufferReconciled(sessionId) {
|
||||
if (sessionId && this._wsOutputGapSession === sessionId) this._wsOutputGapSession = null;
|
||||
}
|
||||
|
||||
async _onSessionClearTerminal(data) {
|
||||
if (data.id === this.activeSessionId) {
|
||||
// Skip if selectSession is already loading the buffer — clearTerminal arriving
|
||||
@@ -2642,12 +2897,16 @@ class CodemanApp {
|
||||
|
||||
// Fetch buffer, clear terminal, write buffer, resize (no Ctrl+L needed)
|
||||
try {
|
||||
const res = await fetch(`/api/sessions/${data.id}/terminal`);
|
||||
const headersReceivedAt = performance.now();
|
||||
const termData = (await res.json())?.data ?? {};
|
||||
// No-param capture: `terminalBufferMaxBytes` (32MB) is its only ceiling,
|
||||
// so it needs the full-history budget. Defaulting to the tail budget
|
||||
// gave the largest payload the smallest deadline.
|
||||
const capture = await this._fetchTerminalCapture(`/api/sessions/${data.id}/terminal`, { full: true });
|
||||
const headersReceivedAt = capture.headersAt;
|
||||
const termData = capture.json?.data ?? {};
|
||||
|
||||
this.terminal.clear();
|
||||
this.terminal.reset();
|
||||
// Queued clear — see _resetTerminalForReplay for why clear()+reset()
|
||||
// cannot do this job.
|
||||
this._resetTerminalForReplay();
|
||||
if (termData.terminalBuffer) {
|
||||
// Strip any DEC 2026 markers and write raw content
|
||||
// (markers don't help here - this is a static buffer reload, not live Ink redraws)
|
||||
@@ -2972,6 +3231,22 @@ class CodemanApp {
|
||||
// Flush any durably-queued input over the fresh socket (covers frames a
|
||||
// prior half-open socket silently dropped, and input typed while offline).
|
||||
this._onWsReady(sessionId);
|
||||
// Reconcile the output hole this drop left (see the ws.onclose note).
|
||||
// Only after an unintentional close — a first connect has no gap, and
|
||||
// refetching there would duplicate the buffer selectSession just wrote.
|
||||
if (this._wsOutputGapSession === sessionId) {
|
||||
// NOT cleared here. `_onSessionNeedsRefresh` clears it once it has
|
||||
// actually repainted; a reconcile that fails or is skipped (a buffer
|
||||
// load already in flight, a tab switch) leaves the marker set so the
|
||||
// next open retries. Re-entry is safe: `_terminalRefreshOwner` makes
|
||||
// a second reconcile for the same session a no-op.
|
||||
_crashDiag.log(`WS REOPEN: reconciling output gap for ${sessionId}`);
|
||||
// Fire-and-forget: this is recovery, and a failure here must not stop
|
||||
// the socket coming up. _onSessionNeedsRefresh already guards against
|
||||
// running while a buffer load is in flight and against a tab switch
|
||||
// landing this session's history in another session's terminal.
|
||||
void this._onSessionNeedsRefresh({ id: sessionId });
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
@@ -2993,6 +3268,10 @@ class CodemanApp {
|
||||
// Input ACK — the server applied (or deduped) this seq; drop it from
|
||||
// the durable queue so it can never be re-delivered/lost.
|
||||
this._onWsInputAck(msg.seq, msg);
|
||||
} else if (msg.t === 'zc') {
|
||||
// Resize confirm — the geometry the PTY actually holds, which is not
|
||||
// always the one this client asked for (issue #464).
|
||||
this._onPtyGeometryReport(sessionId, msg.c, msg.r);
|
||||
}
|
||||
} catch {
|
||||
// Ignore malformed messages
|
||||
@@ -3019,6 +3298,30 @@ class CodemanApp {
|
||||
`WS CLOSE code=${event.code} reason=${event.reason || ''} action=${plan.action} attempts=${this._wsReconnectAttempts || 0}`
|
||||
);
|
||||
|
||||
// Output frames carry no sequence number, so a dropped socket leaves a
|
||||
// hole with nothing to replay it. ws.onopen re-sends dims and flushes
|
||||
// queued INPUT; `needsRefresh` fires only on external-CLI startup and on
|
||||
// SSE backpressure drain, never here.
|
||||
//
|
||||
// ⚠️ The gap this closes is NARROWER than "the device went offline". If
|
||||
// the network drops, SSE drops with it and `handleInit`'s keepTerminal
|
||||
// branch already reconciles on reconnect. The uncovered case is the WS
|
||||
// dying while SSE stays up — a half-open socket, a proxy idle-timeout,
|
||||
// a ping timeout — because `_onSSETerminal` discards every SSE terminal
|
||||
// frame while `_wsReady` is true, and `_wsReady` only flips here, in
|
||||
// onclose. Detecting a half-open socket takes up to the ping+pong window,
|
||||
// and that whole span produces output nothing writes to the terminal.
|
||||
//
|
||||
// Reaching onclose at all means the drop was NOT intentional
|
||||
// (_disconnectWs nulls this handler first), so mark the gap and let the
|
||||
// next successful open reconcile from the server's buffer.
|
||||
//
|
||||
// Scoped to the session that actually lost bytes: a user who switches
|
||||
// sessions during an outage gets a clean intentional disconnect for the
|
||||
// new one, and its freshly-loaded buffer must not be refetched because a
|
||||
// DIFFERENT session's socket dropped.
|
||||
this._wsOutputGapSession = sessionId;
|
||||
|
||||
const stillActive = this.activeSessionId === sessionId;
|
||||
if (plan.action === 'give-up') {
|
||||
this._wsState = stillActive ? 'fallback' : 'disconnected';
|
||||
@@ -3124,7 +3427,26 @@ class CodemanApp {
|
||||
*/
|
||||
_sendInputAsync(sessionId, input, opts) {
|
||||
if (!sessionId || !input) return;
|
||||
this._reliableSend(sessionId, input, opts?.useMux === true);
|
||||
const useMux = opts?.useMux === true;
|
||||
// Both transports refuse a frame over the server's limit (issue #484), and a
|
||||
// refused frame used to sit at the head of the durable queue for good. So an
|
||||
// oversized paste goes out as several in-limit frames, delivered in seq order
|
||||
// as one contiguous stream. A mux write is line-oriented (it strips newlines
|
||||
// and sends Enter on its own), so it is never split: refuse it instead.
|
||||
const limit = window.CodemanInputLimit;
|
||||
if (limit && input.length > limit.FRAME_MAX_CHARS) {
|
||||
if (useMux || input.length > limit.PASTE_MAX_CHARS) {
|
||||
const max = useMux ? limit.FRAME_MAX_CHARS : limit.PASTE_MAX_CHARS;
|
||||
this.showToast?.(
|
||||
`Input too large (${Math.ceil(input.length / 1024)} KB, limit ${Math.floor(max / 1024)} KB); not sent`,
|
||||
'error'
|
||||
);
|
||||
return;
|
||||
}
|
||||
for (const frame of limit.split(input)) this._reliableSend(sessionId, frame, false);
|
||||
return;
|
||||
}
|
||||
this._reliableSend(sessionId, input, useMux);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -3244,6 +3566,12 @@ class CodemanApp {
|
||||
// Session no longer exists — the input can never land. Drop it
|
||||
// rather than retry forever (not a "lost" prompt: the target is gone).
|
||||
this._ackDelivery(sessionId, rec.seq);
|
||||
} else if (resp && (resp.status === 400 || resp.status === 413)) {
|
||||
// The frame itself was refused, so a retry gets the same answer. Kept
|
||||
// queued, it was re-POSTed every 2 s forever and blocked every later
|
||||
// input for this session behind it (issue #484). 401/403 stay
|
||||
// transient: an expired login delivers fine once the user signs in.
|
||||
this._dropRejectedInput(sessionId, rec);
|
||||
} else {
|
||||
break; // offline / 5xx — leave queued; sweep + reconnect retry later
|
||||
}
|
||||
@@ -3254,6 +3582,12 @@ class CodemanApp {
|
||||
})();
|
||||
}
|
||||
|
||||
/** Drop a frame the server refused for good, and say so once. */
|
||||
_dropRejectedInput(sessionId, rec) {
|
||||
this._ackDelivery(sessionId, rec.seq);
|
||||
this.showToast?.(`Input refused by the server (${Math.ceil(rec.data.length / 1024)} KB); not sent`, 'error');
|
||||
}
|
||||
|
||||
/** Drop an ACKed record (by exact seq) and persist. */
|
||||
_ackDelivery(sessionId, seq) {
|
||||
const list = this._pendingDeliveries.get(sessionId);
|
||||
@@ -3298,6 +3632,13 @@ class CodemanApp {
|
||||
_onWsInputAck(seq, msg) {
|
||||
const sessionId = this._wsSessionId;
|
||||
if (!sessionId || !Number.isInteger(seq)) return;
|
||||
if (msg && msg.err) {
|
||||
// Refused for good (e.g. over the size limit): retrying cannot help.
|
||||
const rec = (this._pendingDeliveries.get(sessionId) || []).find((r) => r.seq === seq);
|
||||
if (rec) this._dropRejectedInput(sessionId, rec);
|
||||
else this._ackDelivery(sessionId, seq);
|
||||
return;
|
||||
}
|
||||
if (msg && msg.dup) {
|
||||
const list = this._pendingDeliveries.get(sessionId);
|
||||
const rec = list && list.find((r) => r.seq === seq);
|
||||
@@ -3411,20 +3752,22 @@ class CodemanApp {
|
||||
if (saved && saved.pending) {
|
||||
for (const [s, recs] of Object.entries(saved.pending)) {
|
||||
if (Array.isArray(recs) && recs.length) {
|
||||
// Reset sentAt so they re-deliver promptly on this fresh load.
|
||||
this._pendingDeliveries.set(
|
||||
s,
|
||||
recs
|
||||
.filter((r) => r && typeof r.data === 'string' && Number.isInteger(r.seq))
|
||||
.map((r) => ({
|
||||
seq: r.seq,
|
||||
data: r.data,
|
||||
useMux: !!r.useMux,
|
||||
ts: r.ts || Date.now(),
|
||||
tries: 0,
|
||||
sentAt: 0,
|
||||
}))
|
||||
);
|
||||
const frameMax = window.CodemanInputLimit?.FRAME_MAX_CHARS ?? Infinity;
|
||||
const kept = recs
|
||||
.filter((r) => r && typeof r.data === 'string' && Number.isInteger(r.seq))
|
||||
// A frame over the server's limit can never be ACKed; one persisted
|
||||
// by an older build would otherwise come back on every load (#484).
|
||||
.filter((r) => r.data.length <= frameMax)
|
||||
// Reset sentAt so they re-deliver promptly on this fresh load.
|
||||
.map((r) => ({
|
||||
seq: r.seq,
|
||||
data: r.data,
|
||||
useMux: !!r.useMux,
|
||||
ts: r.ts || Date.now(),
|
||||
tries: 0,
|
||||
sentAt: 0,
|
||||
}));
|
||||
if (kept.length) this._pendingDeliveries.set(s, kept);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -4571,11 +4914,25 @@ class CodemanApp {
|
||||
_sidebarRichRow(id, session) {
|
||||
if (typeof this._mobileOverviewState !== 'function') return null;
|
||||
const state = this._mobileOverviewState(session, this.pendingHooks?.get(id));
|
||||
// An exited agent (Ark0N/Codeman#446) overrides the LABEL, never the state:
|
||||
// `state` keys SESSION_ACTIVITY_RANK and the sort, while `status` stays idle
|
||||
// or busy for an exited pane by design, so without this the muted dot sits
|
||||
// beside a pill saying "idle". A pending alert still wins, exactly as it
|
||||
// does for the dot. The rule is `_mobileOverviewExit()`, shared with both
|
||||
// home screens so the three surfaces agree on which sessions have exited.
|
||||
const exit = this._mobileOverviewExit ? this._mobileOverviewExit(state, session) : null;
|
||||
const exited = !!exit;
|
||||
return {
|
||||
state,
|
||||
pill: this._sidebarRichPillLabel(state),
|
||||
exited,
|
||||
pill: exited ? 'exited' : this._sidebarRichPillLabel(state),
|
||||
// What the pane's own footer says is still running in the background ("1 monitor",
|
||||
// "2 shells"). A row that has one went quiet because the agent is waiting for that,
|
||||
// which is a different thing from waiting for the user — so it rides BESIDE the
|
||||
// state pill and never replaces it.
|
||||
watching: typeof session.watching === 'string' ? session.watching : '',
|
||||
createdAt: Number(session.createdAt) || 0,
|
||||
since: this._mobileOverviewSince ? this._mobileOverviewSince(state, session) : null,
|
||||
since: exit ? exit.since : this._mobileOverviewSince ? this._mobileOverviewSince(state, session) : null,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -4608,7 +4965,19 @@ class CodemanApp {
|
||||
parts.push('<span class="tab-meta-sep" aria-hidden="true">\u00B7</span>');
|
||||
parts.push(stamp(row.since.key, row.since.at, 'for', 'tab-meta-since'));
|
||||
}
|
||||
parts.push(`<span class="tab-pill tab-pill--${escapeHtml(row.state)}">${escapeHtml(row.pill)}</span>`);
|
||||
const pillMod = row.exited ? 'exited' : row.state;
|
||||
parts.push(`<span class="tab-pill tab-pill--${escapeHtml(pillMod)}">${escapeHtml(row.pill)}</span>`);
|
||||
// The word is duplicated from mobile-overview.js for the same reason the pill labels
|
||||
// above are: it is one word, and this file must render a complete row even when a
|
||||
// stale cached mobile-overview.js has arrived without it.
|
||||
// The visible text is that constant. The pane-derived label appears only in the
|
||||
// tooltip, where escapeHtml() (which escapes both quote characters) is what this file
|
||||
// already relies on for every untrusted string it puts in an attribute, and where the
|
||||
// source caps it at MAX_WATCHING_LABEL_CHARS before it ever gets here.
|
||||
if (row.watching) {
|
||||
const title = escapeHtml(`Still running in the background: ${row.watching}`);
|
||||
parts.push(`<span class="tab-pill tab-pill--watching" title="${title}">watching</span>`);
|
||||
}
|
||||
// Both absolute stamps ALSO on the line itself, not only on the two items.
|
||||
// Below 288px the rail hides `.tab-meta-created` (the `tab-rail-tight`
|
||||
// rule), and a tooltip on a `display: none` element has no hover target —
|
||||
@@ -4646,7 +5015,11 @@ class CodemanApp {
|
||||
const prev = tab.dataset.tabState;
|
||||
// The since ANCHOR moves without the state changing (each new turn re-stamps
|
||||
// lastSubmitAt), so key the compare on both.
|
||||
const sig = `${row.state}:${row.since ? row.since.at : 0}:${row.createdAt}`;
|
||||
// Unescaped on purpose, and it still matches the attribute the initial render wrote:
|
||||
// that one goes through escapeHtml() because it is interpolated into markup, and the
|
||||
// browser hands the decoded string back through `dataset`. `watching` is the only
|
||||
// pane-derived value in this signature, which is why it is the only one escaped there.
|
||||
const sig = `${row.state}${row.exited ? '+exited' : ''}:${row.since ? row.since.at : 0}:${row.createdAt}:${row.watching}`;
|
||||
if (tab.dataset.tabMetaSig === sig) return;
|
||||
tab.dataset.tabMetaSig = sig;
|
||||
tab.dataset.tabState = row.state;
|
||||
@@ -4968,6 +5341,11 @@ class CodemanApp {
|
||||
statusEl.className = `tab-status ${status}`;
|
||||
}
|
||||
|
||||
// The exited-agent badge (Ark0N/Codeman#446). A session going from live
|
||||
// to exited changes no tab count, so the full rebuild below never runs
|
||||
// for it and this is the only path that ever draws the badge.
|
||||
applyPaneExitBadge(tab, session.paneExit);
|
||||
|
||||
// Rich sidebar meta ("created 3d ago · working 12m" + pill). The stamps
|
||||
// themselves move on _tickSidebarRichTimes(); this is here for the parts
|
||||
// a tick cannot see — the state flipping, and with it the pill, the row
|
||||
@@ -5265,13 +5643,18 @@ class CodemanApp {
|
||||
const richMeta = this._sidebarRichMetaHTML(richRow);
|
||||
const richClass = richRow ? ` tab-state-${richRow.state}` : '';
|
||||
const richData = richRow
|
||||
? ` data-tab-state="${richRow.state}" data-tab-meta-sig="${richRow.state}:${richRow.since ? richRow.since.at : 0}:${richRow.createdAt}"`
|
||||
? ` data-tab-state="${richRow.state}" data-tab-meta-sig="${richRow.state}${richRow.exited ? '+exited' : ''}:${richRow.since ? richRow.since.at : 0}:${richRow.createdAt}:${escapeHtml(richRow.watching)}"`
|
||||
: '';
|
||||
|
||||
// '' whenever the server said nothing about this pane's agent, which covers
|
||||
// a running pane and every session shape the field never applies to
|
||||
// (direct-PTY, remote SSH, docker). See paneExitLabel().
|
||||
const paneExitBadge = paneExitLabel(session.paneExit);
|
||||
|
||||
const inlineSessionActions = this.shouldInlineSessionActions();
|
||||
const tabActionsHtml = `<span class="tab-actions"><span class="tab-gear" onclick="event.stopPropagation(); app.openSessionOptions(${escapeHtml(JSON.stringify(id))})" title="Session options" aria-label="Session options" tabindex="0">⚙</span><span class="tab-detach" onclick="event.stopPropagation(); app.detachSession(${escapeHtml(JSON.stringify(id))})" title="Open in a new window" aria-label="Open session in a new window" tabindex="0">⧉</span><span class="tab-close" onclick="event.stopPropagation(); app.requestCloseSession(${escapeHtml(JSON.stringify(id))})" title="Close session" aria-label="Close session" tabindex="0">×</span><button type="button" class="tab-more" onclick="event.stopPropagation(); app.openTabRailActionMenu(event, ${escapeHtml(JSON.stringify(id))})" title="Session actions" aria-label="Session actions">⋯</button></span>`;
|
||||
|
||||
parts.push(`<div class="session-tab ${isActive ? 'active' : ''}${alertClass}${richClass}${loadState ? ' tab-loading' : ''}${this.hasTabDetachOverride(id) ? ' tab-show-detach' : ''}"${richData}${railOrderStyle} data-id="${id}" data-color="${color}" ${loadState ? `data-load-phase="${escapeHtml(loadState.phase)}"` : ''} onclick="app.handleSessionTabClick(event, ${escapeHtml(JSON.stringify(id))})" oncontextmenu="event.preventDefault(); app.startInlineRename(${escapeHtml(JSON.stringify(id))})" tabindex="0" role="tab" aria-selected="${isActive ? 'true' : 'false'}" aria-busy="${loadState ? 'true' : 'false'}" aria-label="${escapeHtml(name)} session" ${tabTooltip ? `title="${escapeHtml(tabTooltip)}"` : ''}>
|
||||
parts.push(`<div class="session-tab ${isActive ? 'active' : ''}${alertClass}${richClass}${paneExitBadge ? ' tab-agent-exited' : ''}${loadState ? ' tab-loading' : ''}${this.hasTabDetachOverride(id) ? ' tab-show-detach' : ''}"${richData}${railOrderStyle} data-id="${id}" data-color="${color}" ${loadState ? `data-load-phase="${escapeHtml(loadState.phase)}"` : ''} onclick="app.handleSessionTabClick(event, ${escapeHtml(JSON.stringify(id))})" oncontextmenu="event.preventDefault(); app.startInlineRename(${escapeHtml(JSON.stringify(id))})" tabindex="0" role="tab" aria-selected="${isActive ? 'true' : 'false'}" aria-busy="${loadState ? 'true' : 'false'}" aria-label="${escapeHtml(paneExitAriaLabel(name, paneExitBadge))}" ${tabTooltip ? `title="${escapeHtml(tabTooltip)}"` : ''}>
|
||||
${_tabIdx < 9 ? '<span class="tab-number">' + (_tabIdx + 1) + '</span>' : ''}
|
||||
${loadState ? '<span class="tab-load-spinner" aria-hidden="true"></span>' : ''}
|
||||
<span class="tab-status ${status}" aria-hidden="true"></span>
|
||||
@@ -5279,6 +5662,7 @@ class CodemanApp {
|
||||
<span class="tab-name-row">
|
||||
${mode === 'shell' ? '<span class="tab-mode shell" aria-hidden="true">sh</span>' : mode === 'opencode' ? '<span class="tab-mode opencode" aria-hidden="true">oc</span>' : mode === 'codex' ? '<span class="tab-mode codex" aria-hidden="true">cx</span>' : mode === 'gemini' ? '<span class="tab-mode gemini" aria-hidden="true">gm</span>' : mode === 'antigravity' ? '<span class="tab-mode antigravity" aria-hidden="true">ag</span>' : mode === 'pi' ? '<span class="tab-mode pi" aria-hidden="true">pi</span>' : mode === 'grok' ? '<span class="tab-mode grok" aria-hidden="true">gk</span>' : mode === 'deepseek' ? '<span class="tab-mode deepseek" aria-hidden="true">ds</span>' : mode === 'omp' ? '<span class="tab-mode omp" aria-hidden="true">om</span>' : ''}
|
||||
<span class="tab-name" data-session-id="${id}" data-full-name="${escapeHtml(name)}">${tabLabel}</span>
|
||||
${paneExitBadge ? `<span class="tab-exited-badge" data-i18n-skip aria-hidden="true">${escapeHtml(paneExitBadge)}</span>` : ''}
|
||||
${inlineSessionActions ? tabActionsHtml : ''}
|
||||
<span class="tab-detached-badge" aria-hidden="true">detached</span>
|
||||
</span>
|
||||
@@ -5866,9 +6250,29 @@ class CodemanApp {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear the terminal for a replay, IN STREAM.
|
||||
*
|
||||
* xterm's `write()` is asynchronously queued (the WriteBuffer parses in ~12ms
|
||||
* slices) while `Terminal.reset()` is synchronous and, by upstream's own
|
||||
* documentation, "does not clear input buffers and does not reset the parser,
|
||||
* thus the terminal will continue to apply pending input data". So bytes
|
||||
* queued just before a `reset()` are parsed AFTER it and fuse into whatever
|
||||
* snapshot is written next — measured upstream as `p8rmissions` rendered
|
||||
* where `bypass permissions` belonged.
|
||||
*
|
||||
* A queued clear cannot race that way: it lands after the leftovers and
|
||||
* before the snapshot, whatever the queue held. This function used to follow
|
||||
* the sync `reset()` with a queued `\x1b[3J\x1b[H\x1b[2J`, which already got
|
||||
* that right for CONTENT. RIS (`\x1bc`) additionally resets modes, charsets,
|
||||
* scroll regions and SGR state, so leftover bytes cannot park the terminal in
|
||||
* alt-screen or an odd scroll region and survive the clear.
|
||||
*
|
||||
* Callers may write the replacement content in as many chunks as they like —
|
||||
* ordering within the queue is what matters, not writing it all at once.
|
||||
*/
|
||||
_resetTerminalForReplay() {
|
||||
this.terminal.reset();
|
||||
this.terminal.write('\x1b[3J\x1b[H\x1b[2J');
|
||||
this.terminal.write('\x1bc');
|
||||
}
|
||||
|
||||
_recordTerminalLoadTiming(timing) {
|
||||
@@ -5932,9 +6336,9 @@ class CodemanApp {
|
||||
this._fullHistoryRepullInFlight = true;
|
||||
try {
|
||||
const requestStartedAt = performance.now();
|
||||
const res = await fetch(`/api/sessions/${sessionId}/terminal?full=1`);
|
||||
const headersReceivedAt = performance.now();
|
||||
const payload = (await res.json())?.data ?? {};
|
||||
const capture = await this._fetchTerminalCapture(`/api/sessions/${sessionId}/terminal?full=1`, { full: true });
|
||||
const headersReceivedAt = capture.headersAt;
|
||||
const payload = capture.json?.data ?? {};
|
||||
const bodyParsedAt = performance.now();
|
||||
const buffer = payload.terminalBuffer;
|
||||
const timing = {
|
||||
@@ -5947,7 +6351,7 @@ class CodemanApp {
|
||||
bodyAndJsonMs: bodyParsedAt - headersReceivedAt,
|
||||
resetAndParseMs: 0,
|
||||
totalMs: 0,
|
||||
serverTiming: res.headers?.get?.('server-timing') || '',
|
||||
serverTiming: capture.headers?.get?.('server-timing') || '',
|
||||
refused: false,
|
||||
};
|
||||
// Bail on a tab switch mid-fetch: writing here would paint another session's
|
||||
@@ -6315,11 +6719,18 @@ class CodemanApp {
|
||||
// For that just-created-session case we flush (not discard) queued SSE events.
|
||||
let bufferWasEmpty = false;
|
||||
let cacheResetAndParseMs = 0;
|
||||
// Hoisted out of the try: the catch needs to know whether the pane was
|
||||
// blanked before the fetch, because only then is there nothing on screen.
|
||||
let clearedBeforeFresh = false;
|
||||
try {
|
||||
// Fit terminal to container BEFORE writing any buffer data.
|
||||
// If the browser was resized while viewing another session, the terminal
|
||||
// canvas may be at stale dimensions — content would render at wrong width.
|
||||
if (this.fitAddon) this.fitAddon.fit();
|
||||
// A width refusal belonged to the PREVIOUS session's pane, and while it
|
||||
// stands sendResize keeps the columns it last adopted; this pane's own
|
||||
// report re-establishes it if another device holds this one too.
|
||||
this._paneWidthRefused = false;
|
||||
this.syncTerminalGeometry();
|
||||
|
||||
// Also push the new dimensions to the PTY. Without this, codex/codeman
|
||||
// sees the size that was set the last time the throttled resize handler
|
||||
@@ -6396,7 +6807,8 @@ class CodemanApp {
|
||||
// blank and rewrites with fresh data. Skip the cache and write the fresh
|
||||
// buffer once for a single clean transition.
|
||||
const cachedBuffer = this.terminalBufferCache.get(sessionId);
|
||||
let clearedBeforeFresh = false;
|
||||
// `clearedBeforeFresh` is declared above the try, because the catch reads
|
||||
// it — re-declaring it here would shadow that and silently break it.
|
||||
if (cachedBuffer && !sessionIsBusy && !restoredSnapshot && session?.mode !== 'shell') {
|
||||
_crashDiag.log(`CACHE_WRITE: ${(cachedBuffer.length/1024).toFixed(0)}KB`);
|
||||
this._setTerminalLoadState(sessionId, selectGen, 'replaying');
|
||||
@@ -6445,17 +6857,32 @@ class CodemanApp {
|
||||
const useFullHistory = session?.mode !== 'shell' && !this._fullHistoryLoaded.has(sessionId);
|
||||
if (useFullHistory) this._fullHistoryLoaded.add(sessionId);
|
||||
const fetchStartedAt = performance.now();
|
||||
const res = await fetch(
|
||||
useFullHistory
|
||||
? `/api/sessions/${sessionId}/terminal?full=1`
|
||||
: `/api/sessions/${sessionId}/terminal?tail=${TERMINAL_TAIL_SIZE}`
|
||||
);
|
||||
const headersReceivedAt = performance.now();
|
||||
const tailUrl = `/api/sessions/${sessionId}/terminal?tail=${TERMINAL_TAIL_SIZE}`;
|
||||
let capture;
|
||||
try {
|
||||
capture = await this._fetchTerminalCapture(
|
||||
useFullHistory ? `/api/sessions/${sessionId}/terminal?full=1` : tailUrl,
|
||||
{ full: useFullHistory }
|
||||
);
|
||||
} catch (err) {
|
||||
// The deadline made a slow link reachable for the first time, and the
|
||||
// pane was already blanked above — so an abort here used to leave a
|
||||
// black rectangle, discard the queued live output, and never reach
|
||||
// `_connectWs`. Degrade to the bounded tail instead: less history, but
|
||||
// a working tab. Only for the full-history pull; the tail has nothing
|
||||
// smaller to fall back to, and a second failure is the honest floor.
|
||||
if (err?.name !== 'AbortError' || !useFullHistory) throw err;
|
||||
_crashDiag.log('FULL CAPTURE ABORTED → tail');
|
||||
// It never loaded, so the next select must be allowed to try again.
|
||||
this._fullHistoryLoaded.delete(sessionId);
|
||||
capture = await this._fetchTerminalCapture(tailUrl);
|
||||
}
|
||||
const headersReceivedAt = capture.headersAt;
|
||||
if (this._isStaleSelect(selectGen)) {
|
||||
this._clearTerminalLoadState(sessionId, selectGen);
|
||||
return;
|
||||
}
|
||||
const data = (await res.json())?.data ?? {};
|
||||
const data = capture.json?.data ?? {};
|
||||
const bodyParsedAt = performance.now();
|
||||
// How this load must end, decided here because `chunkedTerminalWrite` is
|
||||
// what actually ends it for a non-empty buffer. A tmux pane capture is a
|
||||
@@ -6535,7 +6962,7 @@ class CodemanApp {
|
||||
cacheResetAndParseMs,
|
||||
freshResetAndParseMs,
|
||||
selectToReplayCompleteMs: performance.now() - _selStart,
|
||||
serverTiming: res.headers?.get?.('server-timing') || '',
|
||||
serverTiming: capture.headers?.get?.('server-timing') || '',
|
||||
};
|
||||
// Buffer load complete — unblock live SSE writes. chunkedTerminalWrite calls
|
||||
// _finishBufferLoad after ordering the fetched snapshot in xterm; if we skipped
|
||||
@@ -6552,6 +6979,11 @@ class CodemanApp {
|
||||
bufferWasEmpty ? { flushQueued: true, since: 0 } : finishOpts
|
||||
);
|
||||
}
|
||||
// This load repainted the session from the server, so any pending
|
||||
// output-gap marker is already satisfied. Selecting a session runs BEFORE
|
||||
// _connectWs, so without this the socket opening afterwards would replay
|
||||
// the whole buffer again on top of the one just written.
|
||||
this._markTerminalBufferReconciled(sessionId);
|
||||
// Drop the guard so user input clears state normally
|
||||
this._restoringFlushedState = false;
|
||||
|
||||
@@ -6646,17 +7078,28 @@ class CodemanApp {
|
||||
// and, because it goes through `forceReload`, a dropped and reopened
|
||||
// WebSocket plus a deleted xterm snapshot.
|
||||
//
|
||||
// That equality is the signature of a CLAMP rather than a race.
|
||||
// `getTerminalDimensions()` floors at 40x10 while `fitAddon.fit()` does
|
||||
// not, so a terminal narrower than 40 columns or shorter than 10 rows
|
||||
// reports a pane permanently bigger than itself, and every select would
|
||||
// retry without ever converging. A race never produces this equality: its
|
||||
// whole premise is that the pane was still at the size we asked it to
|
||||
// leave. The other non-converging case, `Session.resize` declining a
|
||||
// small viewport while a desktop claim is live, does not produce it
|
||||
// either — that pane sits at the DESKTOP's size — so it still costs the
|
||||
// one capped attempt, and stopping it needs the pane-ownership policy
|
||||
// this does not touch.
|
||||
// A race never produces this equality: its whole premise is that the pane
|
||||
// was still at the size we asked it to leave. So the equality means the
|
||||
// pane already IS what we asked for and a retry would capture the same
|
||||
// frame twice.
|
||||
//
|
||||
// ⚠️ This used to also be the signature of a CLAMP, and that is now fixed
|
||||
// at the source rather than worked around here (issue #464).
|
||||
// `getTerminalDimensions()` floors at 40x10 while `fitAddon.fit()` did
|
||||
// not, so a terminal under 40 columns or 10 rows reported a pane
|
||||
// permanently bigger than itself and every select retried without ever
|
||||
// converging. `syncTerminalGeometry()` now applies that floor to xterm as
|
||||
// well, so the browser terminal IS the size it reports and the clamp can
|
||||
// no longer manufacture a mismatch — which also means the repair below is
|
||||
// reached only by cases it can actually repair.
|
||||
//
|
||||
// The other non-converging case, `Session.resize` declining a small
|
||||
// viewport while a desktop claim is live, does not produce this equality
|
||||
// either — that pane sits at the DESKTOP's size. It no longer needs
|
||||
// repairing from here: the server reports the geometry the PTY actually
|
||||
// holds ({"t":"zc"} / the resize response) and `_onPtyGeometryReport`
|
||||
// adopts it, so the terminal matches the pane that is being drawn instead
|
||||
// of replaying against one that never existed.
|
||||
const captureMatchesRequestedSize =
|
||||
!!dimsAfterLoad && data.captureCols === dimsAfterLoad.cols && data.captureRows === dimsAfterLoad.rows;
|
||||
|
||||
@@ -6823,14 +7266,46 @@ class CodemanApp {
|
||||
} catch (err) {
|
||||
if (this._isLoadingBuffer) this._finishBufferLoad(bufferLoadOwner);
|
||||
this._restoringFlushedState = false;
|
||||
this._setTerminalLoadState(sessionId, selectGen, 'failed');
|
||||
console.error('Failed to load session terminal:', err);
|
||||
if (this._isStaleSelect(selectGen)) {
|
||||
this._clearTerminalLoadState(sessionId, selectGen);
|
||||
return;
|
||||
}
|
||||
// The history did not load. That is not a reason to leave the tab dead:
|
||||
// ⚠️ the socket is what carries LIVE output, and it is opened at the end
|
||||
// of the happy path, so bailing here left the session mute until the user
|
||||
// switched away and back.
|
||||
this._connectWs(sessionId);
|
||||
// Only when the pane was blanked for a replay that never came. A pane
|
||||
// still holding its previous content is stale, not empty, and stacking a
|
||||
// notice on top of readable output is worse than the staleness.
|
||||
if (clearedBeforeFresh && this.terminal) {
|
||||
// Three short lines, none over 25 columns, because the narrowest
|
||||
// terminal this app will render is the 40-column floor and a notice
|
||||
// that wraps there leaves a lone '.' on a line of its own — measured at
|
||||
// 320px, where a single 52-character sentence did exactly that.
|
||||
// Each line is one fact: what failed, that the session is still alive,
|
||||
// and what to do. The last says RELOAD rather than "reopen the tab",
|
||||
// because `selectSession` early-returns when the session is already
|
||||
// active, so clicking the tab you are already on retries nothing.
|
||||
this.terminal.write(
|
||||
'\r\n\x1b[2m History did not load.\r\n Live output continues.\r\n Reload to try again.\x1b[0m\r\n'
|
||||
);
|
||||
}
|
||||
// ⚠️ CLEAR, not 'failed'. `_setTerminalLoadState` only marks the TAB, and
|
||||
// nothing ever cleared it on this path — so the tab kept its spinner and
|
||||
// `aria-busy="true"` forever, telling every reader and every screen reader
|
||||
// that a load was still running when it had already given up.
|
||||
this._clearTerminalLoadState(sessionId, selectGen);
|
||||
}
|
||||
}
|
||||
|
||||
// Shared cleanup for all session data — called from both closeSession() and session:deleted handler
|
||||
_cleanupSessionData(sessionId) {
|
||||
this.closeTabRailActionMenu?.();
|
||||
// A dead session has no buffer to reconcile; leaving the marker set would
|
||||
// make a later socket for a REUSED id reconcile against nothing.
|
||||
this._markTerminalBufferReconciled(sessionId);
|
||||
// If the deleted session is currently being renamed, abort the rename
|
||||
// so the inline <input> doesn't ghost as a stale tab on screen.
|
||||
if (this._activeRename?.sessionId === sessionId) {
|
||||
|
||||
@@ -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>` +
|
||||
|
||||
+336
-24
@@ -762,6 +762,49 @@ function resolveTerminalFontWeights(settings) {
|
||||
// without a terminal, a clipboard, or a browser.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Largest single input frame the server accepts, in UTF-16 code units.
|
||||
* ⚠️ Must equal MAX_INPUT_LENGTH in src/config/terminal-limits.ts (pinned by
|
||||
* test/input-size-limit.test.ts). Both transports reject a longer frame, and
|
||||
* before issue #484 the durable input queue retried such a frame forever.
|
||||
*/
|
||||
const INPUT_FRAME_MAX_CHARS = 64 * 1024;
|
||||
|
||||
/**
|
||||
* Largest paste the client will deliver at all. Anything up to this is split
|
||||
* into INPUT_FRAME_MAX_CHARS frames that go out in seq order, so the PTY sees
|
||||
* one contiguous byte stream (bracketed-paste markers included). Past it the
|
||||
* input is refused with a toast rather than queued: every frame is persisted
|
||||
* and retried until ACKed, so a multi-megabyte paste would pin the queue.
|
||||
*/
|
||||
const INPUT_PASTE_MAX_CHARS = 1024 * 1024;
|
||||
|
||||
/**
|
||||
* Split input into frames no longer than `max` code units, never cutting a
|
||||
* surrogate pair in half (a lone surrogate reaches the PTY as U+FFFD).
|
||||
*
|
||||
* @param {string} data
|
||||
* @param {number} [max]
|
||||
* @returns {string[]}
|
||||
*/
|
||||
function splitInputFrames(data, max = INPUT_FRAME_MAX_CHARS) {
|
||||
if (typeof data !== 'string' || data.length === 0) return [];
|
||||
if (!(max >= 2)) max = 2;
|
||||
if (data.length <= max) return [data];
|
||||
const frames = [];
|
||||
let start = 0;
|
||||
while (start < data.length) {
|
||||
let end = Math.min(start + max, data.length);
|
||||
if (end < data.length) {
|
||||
const code = data.charCodeAt(end - 1);
|
||||
if (code >= 0xd800 && code <= 0xdbff) end--; // keep the pair together
|
||||
}
|
||||
frames.push(data.slice(start, end));
|
||||
start = end;
|
||||
}
|
||||
return frames;
|
||||
}
|
||||
|
||||
/**
|
||||
* Upper bound on an AUTO-copied selection.
|
||||
*
|
||||
@@ -819,36 +862,32 @@ function decideAutoCopy({ enabled, text, lastCopied, pending } = {}) {
|
||||
// _selectTouchSelectionLine already treats those cells as padding. This is that
|
||||
// same rule for the mouse and keyboard paths, which never had it.
|
||||
//
|
||||
// ⚠ Trailing padding ONLY. A shared LEADING indent is deliberately left alone,
|
||||
// and this note is here so the idea is not re-derived: it was built, measured
|
||||
// and dropped before merge. Removing the longest leading run every selected row
|
||||
// shares looks like the mirror image of the trailing trim and is not, because
|
||||
// no native terminal does it and the transform cannot tell a TUI's margin from
|
||||
// content that is genuinely indented. Measured over 401 445 three-row windows
|
||||
// across 1 010 tracked files in this repo, it fired on 73% of them: 92% inside
|
||||
// a YAML workflow, 76% over `git log` output, 48% in a TypeScript source file.
|
||||
// No width threshold separates the two, because they are the same widths: a
|
||||
// live Claude Code pane's own margins measure 2 and 5 columns while the most
|
||||
// common non-TUI shared run is 4, sitting between them.
|
||||
// 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 asymmetry that settles it is in the failure modes. A wrong trailing trim
|
||||
// ⚠ 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.
|
||||
//
|
||||
// ⚠ It also cannot be made consistent cheaply. Whether the first row joins the
|
||||
// measurement depended on the mousedown COLUMN, which the user never sees, so
|
||||
// one block of three rows produced three different clipboard results; and the
|
||||
// flag read `getSelectionPosition().start`, which is the mousedown anchor that
|
||||
// xterm never normalises, so dragging UP through a block read it off the bottom
|
||||
// row. If it is ever revisited, the one qualification that measured clean is
|
||||
// painted trailing padding (a full-screen TUI writes real spaces across every
|
||||
// row; a shell pane leaves those cells never-written, so xterm trims them):
|
||||
// zero false positives over all 401 445 windows. It still mangles a `git log`
|
||||
// body sitting inside an agent's own gutter, which is why it was not taken now.
|
||||
function cleanCopiedSelection(text) {
|
||||
// ⚠ 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.
|
||||
@@ -868,7 +907,36 @@ function cleanCopiedSelection(text) {
|
||||
while (cut > 0 && (line[cut - 1] === ' ' || line[cut - 1] === '\t')) cut--;
|
||||
return cut === end ? line : line.slice(0, cut) + line.slice(end);
|
||||
};
|
||||
return text.split('\n').map(trimEnd).join('\n');
|
||||
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') {
|
||||
@@ -915,6 +983,11 @@ if (typeof window !== 'undefined') {
|
||||
compare: compareSessionActivity,
|
||||
sort: sortSessionsByActivity,
|
||||
};
|
||||
window.CodemanInputLimit = {
|
||||
FRAME_MAX_CHARS: INPUT_FRAME_MAX_CHARS,
|
||||
PASTE_MAX_CHARS: INPUT_PASTE_MAX_CHARS,
|
||||
split: splitInputFrames,
|
||||
};
|
||||
window.CodemanAutoCopy = {
|
||||
decide: decideAutoCopy,
|
||||
MAX_CHARS: AUTO_COPY_MAX_CHARS,
|
||||
@@ -1560,6 +1633,226 @@ function buildSplitPickerSessions(sessions, sessionOrder, excludeId, detachedIds
|
||||
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 };
|
||||
@@ -1569,4 +1862,23 @@ if (typeof window !== 'undefined') {
|
||||
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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -201,6 +201,8 @@ Object.assign(CodemanApp.prototype, {
|
||||
const session = this.sessions.get(id);
|
||||
const matched = this._mobileOverviewCaseFor(session.workingDir, cases);
|
||||
const state = this._mobileOverviewState(session, this.pendingHooks?.get(id));
|
||||
// Guarded: a stale cached mobile-overview.js may predate the helper.
|
||||
const exit = this._mobileOverviewExit ? this._mobileOverviewExit(state, session) : null;
|
||||
const mode = session.mode || 'claude';
|
||||
return {
|
||||
id,
|
||||
@@ -211,7 +213,13 @@ Object.assign(CodemanApp.prototype, {
|
||||
caseName: matched ? matched.name : '',
|
||||
dir: this._shortenHomePath ? this._shortenHomePath(session.workingDir) : session.workingDir || '',
|
||||
state,
|
||||
pill: HOME_SESSIONS_PILL_LABEL[state] || state,
|
||||
// What the row's dot, accent and pill show. It differs from `state` only
|
||||
// for an exited agent (Ark0N/Codeman#446), whose state still sorts it.
|
||||
display: exit ? 'exited' : state,
|
||||
pill: exit ? 'exited' : 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,
|
||||
@@ -221,7 +229,7 @@ Object.assign(CodemanApp.prototype, {
|
||||
lastSubmitAt: Number(session.lastSubmitAt) || 0,
|
||||
// "how long has it been like this", resolved by the phone overview's
|
||||
// helper so both home screens label the same stamp with the same word.
|
||||
since: this._mobileOverviewSince(state, session),
|
||||
since: exit ? exit.since : this._mobileOverviewSince(state, session),
|
||||
};
|
||||
});
|
||||
|
||||
@@ -389,7 +397,8 @@ Object.assign(CodemanApp.prototype, {
|
||||
_buildHomeSessionRow(row) {
|
||||
const item = document.createElement('button');
|
||||
item.type = 'button';
|
||||
item.className = 'home-sessions-row home-sessions-row--' + row.state;
|
||||
const display = row.display || row.state;
|
||||
item.className = 'home-sessions-row home-sessions-row--' + display;
|
||||
item.dataset.hsAction = 'session';
|
||||
item.dataset.hsSession = row.id;
|
||||
item.title = row.dir ? `${row.name} (${row.dir})` : row.name;
|
||||
@@ -406,7 +415,7 @@ Object.assign(CodemanApp.prototype, {
|
||||
}
|
||||
|
||||
const dot = document.createElement('span');
|
||||
dot.className = 'home-sessions-dot home-sessions-dot--' + row.state;
|
||||
dot.className = 'home-sessions-dot home-sessions-dot--' + display;
|
||||
dot.setAttribute('aria-hidden', 'true');
|
||||
item.appendChild(dot);
|
||||
|
||||
@@ -438,7 +447,7 @@ Object.assign(CodemanApp.prototype, {
|
||||
item.appendChild(body);
|
||||
|
||||
const pill = document.createElement('span');
|
||||
pill.className = 'home-sessions-pill home-sessions-pill--' + row.state;
|
||||
pill.className = 'home-sessions-pill home-sessions-pill--' + display;
|
||||
// Skipped by i18n on purpose: generic single words ("idle", "done", "error")
|
||||
// that collide with state strings on other surfaces.
|
||||
pill.setAttribute('data-i18n-skip', '');
|
||||
@@ -450,6 +459,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;
|
||||
|
||||
@@ -106,16 +106,20 @@
|
||||
'Manage AI Coding tools in persistent tmux sessions.': '在持久化 tmux 会话中管理 AI 编程工具。',
|
||||
'Select case': '选择案例',
|
||||
'Select Case': '选择案例',
|
||||
'Search cases': '搜索案例',
|
||||
'No matching cases': '没有匹配的案例',
|
||||
'All cases': '全部案例',
|
||||
'No directory': '未选择目录',
|
||||
Run: '运行',
|
||||
'Run Claude Code': '运行 Claude Code',
|
||||
'Run OpenCode': '运行 OpenCode',
|
||||
'Run Codex': '运行 Codex',
|
||||
'Run Gemini': '运行 Gemini',
|
||||
'Run Antigravity': '运行 Antigravity',
|
||||
'Run Pi': '运行 Pi',
|
||||
'Run Grok': '运行 Grok',
|
||||
'Run DeepSeek': '运行 DeepSeek',
|
||||
'Run OMP': '运行 OMP',
|
||||
'Run Shell': '运行 Shell',
|
||||
'Select AI backend': '选择 AI 后端',
|
||||
'Create New Case': '新建案例',
|
||||
|
||||
+81
-60
@@ -451,42 +451,11 @@
|
||||
<h1 class="welcome-title">Codeman</h1>
|
||||
<p class="welcome-desc">Manage AI Coding tools in persistent tmux sessions.</p>
|
||||
<div class="welcome-actions">
|
||||
<button class="welcome-btn welcome-btn-claude" id="welcomeClaudeBtn" style="display: none;" onclick="app.setRunMode('claude'); app.runClaude()">
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polygon points="5 3 19 12 5 21 5 3"/></svg>
|
||||
Run Claude Code
|
||||
</button>
|
||||
<div class="welcome-cli-actions" id="welcomeCliActions"></div>
|
||||
<button class="welcome-btn welcome-btn-tunnel" id="welcomeTunnelBtn" style="display: none;" onclick="app.toggleTunnelFromWelcome()">
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M12 2L2 7l10 5 10-5-10-5z"/><path d="M2 17l10 5 10-5"/><path d="M2 12l10 5 10-5"/></svg>
|
||||
Cloudflare Tunnel
|
||||
</button>
|
||||
<button class="welcome-btn welcome-btn-opencode" id="welcomeOpencodeBtn" style="display: none;" onclick="app.setRunMode('opencode'); app.runOpenCode()">
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polygon points="5 3 19 12 5 21 5 3"/></svg>
|
||||
Run OpenCode
|
||||
</button>
|
||||
<button class="welcome-btn welcome-btn-antigravity" id="welcomeAntigravityBtn" style="display: none;" onclick="app.setRunMode('antigravity'); app.runAntigravity()">
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polygon points="5 3 19 12 5 21 5 3"/></svg>
|
||||
Run Antigravity
|
||||
</button>
|
||||
<button class="welcome-btn welcome-btn-gemini" id="welcomeGeminiBtn" style="display: none;" onclick="app.setRunMode('gemini'); app.runGemini()">
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polygon points="5 3 19 12 5 21 5 3"/></svg>
|
||||
Run Gemini
|
||||
</button>
|
||||
<button class="welcome-btn welcome-btn-pi" id="welcomePiBtn" style="display: none;" onclick="app.setRunMode('pi'); app.runPi()">
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polygon points="5 3 19 12 5 21 5 3"/></svg>
|
||||
Run Pi
|
||||
</button>
|
||||
<button class="welcome-btn welcome-btn-grok" id="welcomeGrokBtn" style="display: none;" onclick="app.setRunMode('grok'); app.runGrok()">
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polygon points="5 3 19 12 5 21 5 3"/></svg>
|
||||
Run Grok
|
||||
</button>
|
||||
<button class="welcome-btn welcome-btn-deepseek" id="welcomeDeepSeekBtn" style="display: none;" onclick="app.setRunMode('deepseek'); app.runDeepSeek()">
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polygon points="5 3 19 12 5 21 5 3"/></svg>
|
||||
Run DeepSeek
|
||||
</button>
|
||||
<button class="welcome-btn welcome-btn-omp" id="welcomeOmpBtn" style="display: none;" onclick="app.setRunMode('omp'); app.runOmp()">
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polygon points="5 3 19 12 5 21 5 3"/></svg>
|
||||
Run OMP
|
||||
</button>
|
||||
</div>
|
||||
<div class="welcome-qr" id="welcomeQr" onclick="app.toggleWelcomeQrSize()">
|
||||
<div class="welcome-qr-inner" id="welcomeQrInner"></div>
|
||||
@@ -648,39 +617,13 @@
|
||||
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M6 9l6 6 6-6"/></svg>
|
||||
</button>
|
||||
<div class="run-mode-menu" id="runModeMenu">
|
||||
<button class="run-mode-option" data-mode="claude" onclick="app.setRunMode('claude')">
|
||||
<span class="run-mode-dot claude"></span>Claude Code
|
||||
</button>
|
||||
<button class="run-mode-option" data-mode="opencode" onclick="app.setRunMode('opencode')">
|
||||
<span class="run-mode-dot opencode"></span>OpenCode
|
||||
</button>
|
||||
<button class="run-mode-option" data-mode="codex" onclick="app.setRunMode('codex')">
|
||||
<span class="run-mode-dot codex"></span>Codex
|
||||
</button>
|
||||
<button class="run-mode-option" data-mode="gemini" onclick="app.setRunMode('gemini')">
|
||||
<span class="run-mode-dot gemini"></span>Gemini
|
||||
</button>
|
||||
<button class="run-mode-option" data-mode="antigravity" onclick="app.setRunMode('antigravity')">
|
||||
<span class="run-mode-dot antigravity"></span>Antigravity
|
||||
</button>
|
||||
<button class="run-mode-option" data-mode="pi" onclick="app.setRunMode('pi')">
|
||||
<span class="run-mode-dot pi"></span>Pi
|
||||
</button>
|
||||
<button class="run-mode-option" data-mode="grok" onclick="app.setRunMode('grok')">
|
||||
<span class="run-mode-dot grok"></span>Grok
|
||||
</button>
|
||||
<button class="run-mode-option" data-mode="deepseek" onclick="app.setRunMode('deepseek')">
|
||||
<span class="run-mode-dot deepseek"></span>DeepSeek
|
||||
</button>
|
||||
<div class="run-mode-cli-options" id="runModeCliOptions"></div>
|
||||
<!-- Shown only when `dsh` is installed but no pane-capable profile is:
|
||||
DeepSeek ships no terminal front door, so the fix is an install,
|
||||
not a greyed-out entry the user cannot act on. -->
|
||||
<button class="run-mode-option run-mode-option-install" data-action="deepseek-install" id="runModeDeepSeekInstall" style="display: none;" onclick="app.installDeepSeekProfile()">
|
||||
<span class="run-mode-dot deepseek"></span>DeepSeek — add a terminal profile…
|
||||
</button>
|
||||
<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
|
||||
@@ -1791,6 +1734,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>
|
||||
|
||||
@@ -2187,6 +2137,8 @@
|
||||
<option value="claude-fable-5-1[1m]" data-variant="1m" data-base="claude-fable-5-1">Fable 5.1 (1M context)</option>
|
||||
<option value="claude-fable-5" data-meta="Most powerful" data-base="claude-fable-5" data-ctx="1">Fable 5</option>
|
||||
<option value="claude-fable-5[1m]" data-variant="1m" data-base="claude-fable-5">Fable 5 (1M context)</option>
|
||||
<option value="claude-opus-5-5" data-meta="Latest Opus" data-base="claude-opus-5-5" data-ctx="1">Opus 5.5</option>
|
||||
<option value="claude-opus-5-5[1m]" data-variant="1m" data-base="claude-opus-5-5">Opus 5.5 (1M context)</option>
|
||||
<option value="opus" data-meta="Most capable" data-base="opus" data-ctx="1">Opus</option>
|
||||
<option value="opus[1m]" data-variant="1m" data-base="opus">Opus (1M context)</option>
|
||||
<option value="claude-opus-4-6" data-meta="Previous generation" data-base="claude-opus-4-6" data-ctx="1">Opus 4.6</option>
|
||||
@@ -2197,7 +2149,7 @@
|
||||
<div class="set-row" id="appSettingsContextRow" data-search="1m context window opus long">
|
||||
<div class="set-row-text">
|
||||
<span class="set-row-label">1M context window</span>
|
||||
<span class="set-row-desc" id="appSettingsContextDesc">Available for Fable 5.1, Fable 5, Opus and Opus 4.6.</span>
|
||||
<span class="set-row-desc" id="appSettingsContextDesc">Available for Fable 5.1, Fable 5, Opus 5.5, Opus and Opus 4.6.</span>
|
||||
</div>
|
||||
<label class="switch switch-sm"><input type="checkbox" id="appSettingsOpusContext1m"><span class="slider"></span></label>
|
||||
</div>
|
||||
@@ -2239,6 +2191,7 @@
|
||||
<option value="">Default (CLI default)</option>
|
||||
<option value="claude-fable-5-1">Fable 5.1 (Latest)</option>
|
||||
<option value="claude-fable-5">Fable 5 (Most powerful)</option>
|
||||
<option value="claude-opus-5-5">Opus 5.5 (Latest Opus)</option>
|
||||
<option value="opus">Opus (Most capable)</option>
|
||||
<option value="sonnet">Sonnet (Balanced)</option>
|
||||
<option value="haiku">Haiku (Fast & cheap)</option>
|
||||
@@ -2254,6 +2207,7 @@
|
||||
<option value="opus">Opus</option>
|
||||
<option value="claude-fable-5-1">Fable 5.1</option>
|
||||
<option value="claude-fable-5">Fable 5</option>
|
||||
<option value="claude-opus-5-5">Opus 5.5</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="set-mini">
|
||||
@@ -2265,6 +2219,7 @@
|
||||
<option value="opus">Opus</option>
|
||||
<option value="claude-fable-5-1">Fable 5.1</option>
|
||||
<option value="claude-fable-5">Fable 5</option>
|
||||
<option value="claude-opus-5-5">Opus 5.5</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="set-mini">
|
||||
@@ -2276,6 +2231,7 @@
|
||||
<option value="opus">Opus</option>
|
||||
<option value="claude-fable-5-1">Fable 5.1</option>
|
||||
<option value="claude-fable-5">Fable 5</option>
|
||||
<option value="claude-opus-5-5">Opus 5.5</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="set-mini">
|
||||
@@ -2287,6 +2243,7 @@
|
||||
<option value="opus">Opus</option>
|
||||
<option value="claude-fable-5-1">Fable 5.1</option>
|
||||
<option value="claude-fable-5">Fable 5</option>
|
||||
<option value="claude-opus-5-5">Opus 5.5</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
@@ -2363,6 +2320,64 @@
|
||||
</div>
|
||||
<p class="set-section-blurb">Launch flags for the CLIs Codeman spawns.</p>
|
||||
|
||||
<div class="set-group" id="cliManagementGroup">
|
||||
<div class="set-group-head"><h4>CLI management</h4><span class="set-scope">synced</span></div>
|
||||
<p class="set-group-hint">Enable/disable a CLI, install one that's missing, or add your own — without hand-editing ~/.codeman/clis.json.</p>
|
||||
<div class="set-group-body">
|
||||
<div class="set-row" data-search="cli management enable disable install custom">
|
||||
<div class="set-row-text">
|
||||
<span class="set-row-label">Enable CLI management</span>
|
||||
<span class="set-row-desc">Adds the list below and its write endpoints. Off by default: this changes machine configuration, not just what you see.</span>
|
||||
</div>
|
||||
<label class="switch switch-sm"><input type="checkbox" id="appSettingsCliManagement" onchange="app.applyCliManagementVisibility()"><span class="slider"></span></label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="set-group" id="cliListGroup" style="display: none;">
|
||||
<div class="set-group-head"><h4>Installed CLIs</h4></div>
|
||||
<div class="set-group-body">
|
||||
<div id="cliListRows"></div>
|
||||
<div class="set-row" data-search="add custom cli">
|
||||
<div class="set-row-text">
|
||||
<span class="set-row-label">Add a custom CLI</span>
|
||||
<span class="set-row-desc">A launch command Codeman doesn't ship — id, label, badge, binary and its bare argv.</span>
|
||||
</div>
|
||||
<button type="button" class="btn btn-xs" id="cliCustomAddToggle" onclick="app.openCliCustomForm()">Add</button>
|
||||
</div>
|
||||
<form id="cliCustomForm" style="display: none;" onsubmit="app.submitCliCustomForm(event)">
|
||||
<div class="set-row has-field">
|
||||
<div class="set-row-text"><span class="set-row-label">Id</span></div>
|
||||
<input type="text" id="cliCustomId" class="set-input" placeholder="my-cli" maxlength="24">
|
||||
</div>
|
||||
<div class="set-row has-field">
|
||||
<div class="set-row-text"><span class="set-row-label">Label</span></div>
|
||||
<input type="text" id="cliCustomLabel" class="set-input" placeholder="My CLI" maxlength="60">
|
||||
</div>
|
||||
<div class="set-row has-field">
|
||||
<div class="set-row-text"><span class="set-row-label">Badge</span></div>
|
||||
<input type="text" id="cliCustomBadge" class="set-input" placeholder="MC" maxlength="6">
|
||||
</div>
|
||||
<div class="set-row has-field">
|
||||
<div class="set-row-text"><span class="set-row-label">Binary</span></div>
|
||||
<input type="text" id="cliCustomBinary" class="set-input" placeholder="my-cli">
|
||||
</div>
|
||||
<div class="set-row has-field">
|
||||
<div class="set-row-text">
|
||||
<span class="set-row-label">Launch argv</span>
|
||||
<span class="set-row-desc">Space-separated bare words, e.g. "my-cli --flag". No quoting or shell syntax.</span>
|
||||
</div>
|
||||
<input type="text" id="cliCustomArgv" class="set-input" placeholder="my-cli --flag">
|
||||
</div>
|
||||
<div class="set-row">
|
||||
<button type="submit" class="btn btn-xs" id="cliCustomSubmit">Create</button>
|
||||
<button type="button" class="btn btn-xs" id="cliCustomCancel" onclick="app.closeCliCustomForm()">Cancel</button>
|
||||
</div>
|
||||
<div id="cliCustomFormError" class="set-row-desc" style="color: var(--error, #e5484d); display: none;"></div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="set-group">
|
||||
<div class="set-group-head"><h4>Claude</h4><span class="set-scope">synced</span></div>
|
||||
<div class="set-group-body">
|
||||
@@ -3160,6 +3175,7 @@
|
||||
<h2>Manage</h2>
|
||||
</div>
|
||||
<p class="set-section-blurb">Reorder or remove cases, and pick up anything exported from a docker case.</p>
|
||||
<input type="search" id="caseManageSearch" class="set-input" placeholder="Search cases by name or path" autocomplete="off" spellcheck="false" aria-label="Search cases" oninput="app.setCaseManageFilter(this.value)" style="margin-bottom: 8px;">
|
||||
<div class="case-manage-list" id="caseManageList">
|
||||
<!-- Populated by JS -->
|
||||
</div>
|
||||
@@ -3189,7 +3205,12 @@
|
||||
<h3>Select Case</h3>
|
||||
<button class="modal-close" onclick="app.closeMobileCasePicker()" aria-label="Close case picker">×</button>
|
||||
</div>
|
||||
<div class="mobile-case-picker-search">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true"><circle cx="11" cy="11" r="7"/><line x1="21" y1="21" x2="16.65" y2="16.65"/></svg>
|
||||
<input type="search" id="mobileCaseSearch" placeholder="Search cases" aria-label="Search cases" autocomplete="off" autocapitalize="off" autocorrect="off" spellcheck="false" enterkeyhint="go" oninput="app.filterMobileCases()" onkeydown="app.onMobileCaseSearchKey(event)">
|
||||
</div>
|
||||
<div class="mobile-case-picker-body">
|
||||
<div class="mobile-case-empty" id="mobileCaseEmpty" hidden>No matching cases</div>
|
||||
<div class="mobile-case-list" id="mobileCaseList">
|
||||
<!-- Cases populated by JS -->
|
||||
</div>
|
||||
|
||||
@@ -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 {}
|
||||
},
|
||||
|
||||
@@ -42,13 +42,14 @@ const MOBILE_OVERVIEW_PHONE_QUERY = '(max-width: 599px)';
|
||||
|
||||
/** How many past conversations show before the "Show all" toggle. */
|
||||
const MOBILE_OVERVIEW_PAST_LIMIT = 8;
|
||||
const SHELL_KIND = 'shell';
|
||||
|
||||
/**
|
||||
* Backends offered by the Run picker, mirroring the toolbar's run-mode menu
|
||||
* (`#runModeMenu` in index.html). `short` is the badge on the Run button itself.
|
||||
*/
|
||||
const MOBILE_OVERVIEW_RUN_MODES = [
|
||||
{ mode: 'claude', label: 'Claude Code', short: 'Claude' },
|
||||
{ mode: 'claude', label: 'Claude Code', short: 'Claude Code' },
|
||||
{ mode: 'opencode', label: 'OpenCode', short: 'OpenCode' },
|
||||
{ mode: 'codex', label: 'Codex', short: 'Codex' },
|
||||
{ mode: 'gemini', label: 'Gemini', short: 'Gemini' },
|
||||
@@ -60,6 +61,31 @@ 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;
|
||||
|
||||
function mobileOverviewRunModes() {
|
||||
const catalog =
|
||||
typeof window !== 'undefined' && Array.isArray(window.__codemanCliCatalog) ? window.__codemanCliCatalog : [];
|
||||
if (catalog.length === 0) return MOBILE_OVERVIEW_RUN_MODES;
|
||||
return catalog
|
||||
.filter((entry) => entry.enabled)
|
||||
.map((entry) => ({
|
||||
mode: entry.id,
|
||||
label: entry.kind === SHELL_KIND ? 'Terminal / Shell' : entry.label,
|
||||
// The registry `label`, not `shortBadge`: the Run button has always shown a word
|
||||
// ("Claude", "Codex", "Shell"), and every stock label IS that word, so this stays
|
||||
// identical to MOBILE_OVERVIEW_RUN_MODES above. `shortBadge` is the two-letter tab
|
||||
// code ("CC", "CX"), which read as a regression on the button.
|
||||
short: entry.label,
|
||||
}));
|
||||
}
|
||||
|
||||
/** Pill copy per state. Kept short: a phone row has ~90px for it. */
|
||||
const MOBILE_OVERVIEW_PILL_LABEL = {
|
||||
needs: 'needs you',
|
||||
@@ -135,6 +161,36 @@ Object.assign(CodemanApp.prototype, {
|
||||
return { key: MOBILE_OVERVIEW_SINCE_LABEL[state] || state, at };
|
||||
},
|
||||
|
||||
/**
|
||||
* The exited-agent override for one row (Ark0N/Codeman#446), or null when
|
||||
* the row shows its state as usual.
|
||||
*
|
||||
* The server publishes `session.paneExit` once the agent inside a local tmux
|
||||
* pane has exited, while `status` stays `idle` or `busy` by design. So a row
|
||||
* classified as idle or working may really be a pane with nothing running
|
||||
* in it. This overrides what the row SHOWS, never its `state`: `state` still
|
||||
* picks the section and the sort, the way `_sidebarRichRow()` (app.js) does
|
||||
* for the detailed sidebar and rail. A pending alert still wins, because a
|
||||
* human being blocked outranks the agent having exited.
|
||||
*
|
||||
* Shared by the phone overview, the desktop home rail and the rich tab rows,
|
||||
* so the three cannot disagree about which sessions have exited.
|
||||
*
|
||||
* Guarded like every other cross-file call: `paneExitLabel()` lives in
|
||||
* app.js, and a stale cached app.js must degrade to no override, not throw.
|
||||
*
|
||||
* @returns {{since: {key: string, at: number}|null}|null}
|
||||
*/
|
||||
_mobileOverviewExit(state, session) {
|
||||
if (state !== 'idle' && state !== 'working') return null;
|
||||
if (typeof paneExitLabel !== 'function' || !paneExitLabel(session.paneExit)) return null;
|
||||
// `at` is when this server first saw the pane dead, which is what "exited
|
||||
// 2m" should measure. A row without it shows no duration at all rather
|
||||
// than a working or idle stamp that no longer describes the pane.
|
||||
const at = Number(session.paneExit.at) || 0;
|
||||
return { since: at ? { key: 'exited', at } : null };
|
||||
},
|
||||
|
||||
/**
|
||||
* Longest-prefix match of a workingDir against the case list, so a session
|
||||
* started in a subdirectory still belongs to its case. Mirrors the matching in
|
||||
@@ -176,6 +232,7 @@ Object.assign(CodemanApp.prototype, {
|
||||
const rows = sessions.map((session) => {
|
||||
const matched = this._mobileOverviewCaseFor(session.workingDir, cases);
|
||||
const state = this._mobileOverviewState(session, pendingHooks.get && pendingHooks.get(session.id));
|
||||
const exit = this._mobileOverviewExit(state, session);
|
||||
const orderIndex = order.indexOf(session.id);
|
||||
return {
|
||||
id: session.id,
|
||||
@@ -184,7 +241,14 @@ Object.assign(CodemanApp.prototype, {
|
||||
caseName: matched ? matched.name : '',
|
||||
dir: this._shortenHomePath ? this._shortenHomePath(session.workingDir) : session.workingDir || '',
|
||||
state,
|
||||
pill: MOBILE_OVERVIEW_PILL_LABEL[state] || state,
|
||||
// What the row's dot, accent and pill show. It differs from `state` only
|
||||
// for an exited agent, whose state still decides the section and sort.
|
||||
display: exit ? 'exited' : state,
|
||||
pill: exit ? 'exited' : 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,
|
||||
@@ -192,7 +256,7 @@ Object.assign(CodemanApp.prototype, {
|
||||
// pair resolved for DISPLAY, and the two must not drift apart.
|
||||
lastActivityAt: Number(session.lastActivityAt) || 0,
|
||||
lastSubmitAt: Number(session.lastSubmitAt) || 0,
|
||||
since: this._mobileOverviewSince(state, session),
|
||||
since: exit ? exit.since : this._mobileOverviewSince(state, session),
|
||||
orderIndex: orderIndex === -1 ? Number.MAX_SAFE_INTEGER : orderIndex,
|
||||
};
|
||||
});
|
||||
@@ -515,7 +579,7 @@ Object.assign(CodemanApp.prototype, {
|
||||
const runMode = document.createElement('span');
|
||||
runMode.className = 'mobile-overview-run-mode';
|
||||
runMode.setAttribute('data-i18n-skip', '');
|
||||
runMode.textContent = MOBILE_OVERVIEW_RUN_MODES.find((m) => m.mode === mode)?.short || mode;
|
||||
runMode.textContent = mobileOverviewRunModes().find((m) => m.mode === mode)?.short || mode;
|
||||
run.appendChild(runMode);
|
||||
group.appendChild(run);
|
||||
|
||||
@@ -570,7 +634,7 @@ Object.assign(CodemanApp.prototype, {
|
||||
menu.className = 'mobile-overview-run-menu';
|
||||
const current = this.runMode || 'claude';
|
||||
|
||||
for (const entry of MOBILE_OVERVIEW_RUN_MODES) {
|
||||
for (const entry of mobileOverviewRunModes()) {
|
||||
if (entry.mode !== 'shell' && !this.isCliAvailable(entry.mode)) continue;
|
||||
const option = document.createElement('button');
|
||||
option.type = 'button';
|
||||
@@ -668,12 +732,13 @@ Object.assign(CodemanApp.prototype, {
|
||||
_buildMobileOverviewRow(row) {
|
||||
const item = document.createElement('button');
|
||||
item.type = 'button';
|
||||
item.className = 'mobile-overview-row mobile-overview-row--' + row.state;
|
||||
const display = row.display || row.state;
|
||||
item.className = 'mobile-overview-row mobile-overview-row--' + display;
|
||||
item.dataset.moAction = 'session';
|
||||
item.dataset.moSession = row.id;
|
||||
|
||||
const dot = document.createElement('span');
|
||||
dot.className = 'mobile-overview-dot mobile-overview-dot--' + row.state;
|
||||
dot.className = 'mobile-overview-dot mobile-overview-dot--' + display;
|
||||
dot.setAttribute('aria-hidden', 'true');
|
||||
item.appendChild(dot);
|
||||
|
||||
@@ -706,13 +771,15 @@ Object.assign(CodemanApp.prototype, {
|
||||
item.appendChild(body);
|
||||
|
||||
const pill = document.createElement('span');
|
||||
pill.className = 'mobile-overview-pill mobile-overview-pill--' + row.state;
|
||||
pill.className = 'mobile-overview-pill mobile-overview-pill--' + display;
|
||||
// Skipped by i18n on purpose: the labels are generic single words ("idle",
|
||||
// "done", "error") that collide with state strings on other surfaces.
|
||||
pill.setAttribute('data-i18n-skip', '');
|
||||
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 +801,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
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
|
||||
+51
-18
@@ -694,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;
|
||||
@@ -933,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;
|
||||
@@ -2148,9 +2165,11 @@ html.mobile-init .file-browser-panel {
|
||||
background: rgba(0, 0, 0, 0.5);
|
||||
}
|
||||
|
||||
/* The footer already reserves the home-indicator inset; padding the sheet too
|
||||
counted it twice and left a dead band under Create New Case. */
|
||||
.mobile-case-picker-sheet {
|
||||
max-height: 60vh;
|
||||
padding-bottom: var(--safe-area-bottom);
|
||||
max-height: 80vh;
|
||||
max-height: 80dvh;
|
||||
animation: slideUp 0.2s ease-out;
|
||||
}
|
||||
|
||||
@@ -2961,6 +2980,20 @@ html.mobile-init .file-browser-panel {
|
||||
color: var(--green);
|
||||
}
|
||||
|
||||
/* An exited agent (Ark0N/Codeman#446): neutral, since nothing is running behind
|
||||
the row. The dot and the row take `--exited` too and keep their base rules. */
|
||||
.mobile-overview-pill--exited {
|
||||
border-color: var(--text-muted);
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
/* 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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -672,6 +672,9 @@ Object.assign(CodemanApp.prototype, {
|
||||
gitBranch: s.gitBranch,
|
||||
worktreeName: s.worktreeName,
|
||||
worktreeRepo: s.worktreeRepo,
|
||||
mode: s.mode,
|
||||
claudeSessionId: s.claudeSessionId,
|
||||
resumeId: s.resumeId,
|
||||
};
|
||||
const isLive = !!this.sessions?.has?.(s.sessionId);
|
||||
const item = this._buildHistoryItem(record, this.cases, {
|
||||
@@ -5276,8 +5279,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 {
|
||||
@@ -5286,8 +5291,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) {
|
||||
|
||||
@@ -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?.();
|
||||
});
|
||||
},
|
||||
|
||||
|
||||
+217
-19
@@ -122,7 +122,23 @@ const RUN_MODE_LAUNCH = {
|
||||
* (`openSessionOptions`), guaranteed to drift from each other the moment a
|
||||
* ninth CLI landed in one and not the other.
|
||||
*/
|
||||
/** How often the OPEN case picker re-reads /api/cases (it also refreshes once on open). */
|
||||
const CASE_PICKER_REFRESH_MS = 5000;
|
||||
|
||||
const EXTERNAL_CLI_MODES = new Set(Object.keys(RUN_MODE_LAUNCH));
|
||||
const BUILT_IN_RUN_MODES = new Set(['claude', 'shell', ...Object.keys(RUN_MODE_LAUNCH)]);
|
||||
|
||||
function registryCliCatalog() {
|
||||
return typeof window !== 'undefined' && Array.isArray(window.__codemanCliCatalog) ? window.__codemanCliCatalog : [];
|
||||
}
|
||||
|
||||
function registryCliById(id) {
|
||||
return registryCliCatalog().find((entry) => entry.id === id);
|
||||
}
|
||||
|
||||
function isExternalCliRunMode(mode) {
|
||||
return EXTERNAL_CLI_MODES.has(mode) || registryCliById(mode)?.kind === 'agent';
|
||||
}
|
||||
|
||||
Object.assign(CodemanApp.prototype, {
|
||||
/**
|
||||
@@ -233,16 +249,74 @@ Object.assign(CodemanApp.prototype, {
|
||||
const input = document.getElementById('quickStartCaseSearch');
|
||||
const list = document.getElementById('quickStartCaseList');
|
||||
if (!input || !list) return;
|
||||
const wasOpen = this._casePickerOpen === true;
|
||||
this._casePickerOpen = true;
|
||||
this._casePickerFilter = filter;
|
||||
this._casePickerActiveIndex = 0;
|
||||
input.setAttribute('aria-expanded', 'true');
|
||||
this.renderCasePickerList();
|
||||
// Every keystroke re-enters here, so only the closed -> open transition
|
||||
// refreshes and arms the timer; typing must not fire a fetch per key.
|
||||
if (!wasOpen) this._startCasePickerRefresh();
|
||||
},
|
||||
|
||||
/** Re-read the case list while the picker is open, so folders deleted or created on disk show up without a page reload. */
|
||||
_startCasePickerRefresh() {
|
||||
void this.refreshCasePickerCases();
|
||||
if (this._casePickerRefreshTimer) return;
|
||||
this._casePickerRefreshTimer = setInterval(() => void this.refreshCasePickerCases(), CASE_PICKER_REFRESH_MS);
|
||||
},
|
||||
|
||||
_stopCasePickerRefresh() {
|
||||
if (this._casePickerRefreshTimer) clearInterval(this._casePickerRefreshTimer);
|
||||
this._casePickerRefreshTimer = null;
|
||||
},
|
||||
|
||||
/**
|
||||
* Lighter than loadQuickStartCases(): that one closes the picker and re-picks a
|
||||
* selection, which would yank the list away from someone mid-browse. This only
|
||||
* swaps the data and repaints, and does nothing when the list is unchanged.
|
||||
*/
|
||||
async refreshCasePickerCases() {
|
||||
if (this._casePickerRefreshInFlight) return;
|
||||
this._casePickerRefreshInFlight = true;
|
||||
try {
|
||||
const res = await fetch('/api/cases');
|
||||
if (!res.ok) return;
|
||||
const cases = (await res.json()).data;
|
||||
if (!Array.isArray(cases) || !this._casePickerOpen) return;
|
||||
const signature = list => JSON.stringify((list || []).map(c => [c.name, c.path, c.location]));
|
||||
if (signature(cases) === signature(this.cases)) return;
|
||||
this.cases = cases;
|
||||
|
||||
const select = document.getElementById('quickStartCase');
|
||||
if (select) {
|
||||
const previous = select.value;
|
||||
this.renderQuickStartCaseSelectOptions(select, this.getCasePickerOptions());
|
||||
if (cases.some(c => c.name === previous)) {
|
||||
select.value = previous;
|
||||
} else if (cases.length > 0) {
|
||||
// The selected case was removed on disk: fall back the way the initial
|
||||
// load does, without saving it as the user's last-used case.
|
||||
const fallback = cases.find(c => c.name === 'testcase') || cases[0];
|
||||
select.value = fallback.name;
|
||||
this.updateDirDisplayForCase(fallback.name);
|
||||
this.updateMobileCaseLabel(fallback.name);
|
||||
this.updateCasePickerInput(fallback.name);
|
||||
}
|
||||
}
|
||||
this.renderCasePickerList();
|
||||
} catch {
|
||||
// A failed poll leaves the list as it was; the next tick retries.
|
||||
} finally {
|
||||
this._casePickerRefreshInFlight = false;
|
||||
}
|
||||
},
|
||||
|
||||
closeCasePicker() {
|
||||
const input = document.getElementById('quickStartCaseSearch');
|
||||
const list = document.getElementById('quickStartCaseList');
|
||||
this._stopCasePickerRefresh();
|
||||
this._casePickerOpen = false;
|
||||
this._casePickerFilter = '';
|
||||
input?.setAttribute('aria-expanded', 'false');
|
||||
@@ -511,7 +585,7 @@ Object.assign(CodemanApp.prototype, {
|
||||
if (mode === 'shell') {
|
||||
return await this.runShell();
|
||||
}
|
||||
if (mode === 'claude' || !EXTERNAL_CLI_MODES.has(mode)) {
|
||||
if (mode === 'claude' || !isExternalCliRunMode(mode)) {
|
||||
return await this.runClaude();
|
||||
}
|
||||
return await this._runCliMode(mode);
|
||||
@@ -544,6 +618,7 @@ Object.assign(CodemanApp.prototype, {
|
||||
e?.stopPropagation();
|
||||
const menu = document.getElementById('runModeMenu');
|
||||
if (!menu) return;
|
||||
this.renderRegistryRunOptions();
|
||||
menu.classList.toggle('active');
|
||||
// Update selected state
|
||||
menu.querySelectorAll('.run-mode-option').forEach(btn => {
|
||||
@@ -602,13 +677,13 @@ Object.assign(CodemanApp.prototype, {
|
||||
// reporting it as a fault hid every agent mode on a freshly linked Docker case
|
||||
// behind "start it yourself first", for a container Codeman was about to create.
|
||||
const probeError = isDocker ? this._dockerCaseProbeError?.[caseName] : null;
|
||||
for (const mode of ['claude', 'opencode', 'codex', 'gemini', 'antigravity', 'pi', 'grok', 'deepseek', 'omp']) {
|
||||
const btn = menu.querySelector(`.run-mode-option[data-mode="${mode}"]`);
|
||||
if (!btn) continue;
|
||||
for (const option of menu.querySelectorAll('.run-mode-option[data-mode]')) {
|
||||
const mode = option.dataset.mode;
|
||||
if (!mode || mode === 'shell') continue;
|
||||
let available;
|
||||
if (isDocker) available = probeError ? false : containerModes ? containerModes.includes(mode) : true;
|
||||
else available = this.isCliAvailable(mode);
|
||||
btn.style.display = available ? 'flex' : 'none';
|
||||
option.style.display = available ? 'flex' : 'none';
|
||||
}
|
||||
this._renderRunModeNotice(menu, probeError);
|
||||
// DeepSeek is the one mode whose availability has two halves: `dsh` can be
|
||||
@@ -627,6 +702,29 @@ Object.assign(CodemanApp.prototype, {
|
||||
if (dsWeb) dsWeb.style.display = avail.deepseekBinary ? 'flex' : 'none';
|
||||
},
|
||||
|
||||
/** Render every enabled agent entry from the server's registry projection. */
|
||||
renderRegistryRunOptions() {
|
||||
const container = document.getElementById('runModeCliOptions');
|
||||
if (!container) return;
|
||||
const catalog = registryCliCatalog();
|
||||
if (catalog.length === 0) return; // cached pages from before the catalog keep their static fallback.
|
||||
container.replaceChildren();
|
||||
for (const cli of catalog) {
|
||||
if (cli.kind !== 'agent' || !cli.enabled) continue;
|
||||
const option = document.createElement('button');
|
||||
option.type = 'button';
|
||||
option.className = 'run-mode-option';
|
||||
option.dataset.mode = cli.id;
|
||||
option.onclick = () => this.setRunMode(cli.id);
|
||||
const dot = document.createElement('span');
|
||||
dot.className = `run-mode-dot ${cli.id}`;
|
||||
dot.setAttribute('aria-hidden', 'true');
|
||||
option.appendChild(dot);
|
||||
option.append(cli.label);
|
||||
container.appendChild(option);
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Generates the Run menu's Custom Model Endpoint entries
|
||||
* (docs/custom-model-endpoints-plan.md): one button per (capable harness, saved
|
||||
@@ -1640,7 +1738,8 @@ Object.assign(CodemanApp.prototype, {
|
||||
gearBtn.className = `btn-toolbar btn-run-gear mode-${mode}`;
|
||||
}
|
||||
if (label) {
|
||||
label.textContent = mode === 'opencode' ? 'Run OC' : mode === 'codex' ? 'Run CX' : mode === 'gemini' ? 'Run GM' : mode === 'antigravity' ? 'Run AG' : mode === 'pi' ? 'Run PI' : mode === 'grok' ? 'Run GK' : mode === 'deepseek' ? 'Run DS' : mode === 'omp' ? 'Run OMP' : mode === 'shell' ? 'Run SH' : 'Run';
|
||||
const registryEntry = registryCliById(mode);
|
||||
label.textContent = mode === 'opencode' ? 'Run OC' : mode === 'codex' ? 'Run CX' : mode === 'gemini' ? 'Run GM' : mode === 'antigravity' ? 'Run AG' : mode === 'pi' ? 'Run PI' : mode === 'grok' ? 'Run GK' : mode === 'deepseek' ? 'Run DS' : mode === 'omp' ? 'Run OMP' : mode === 'shell' ? 'Run SH' : registryEntry ? `Run ${registryEntry.shortBadge}` : 'Run';
|
||||
}
|
||||
},
|
||||
|
||||
@@ -1667,7 +1766,12 @@ Object.assign(CodemanApp.prototype, {
|
||||
},
|
||||
|
||||
_initRunMode() {
|
||||
try { this._runMode = localStorage.getItem('codeman_runMode') || 'claude'; } catch { this._runMode = 'claude'; }
|
||||
this.renderRegistryRunOptions();
|
||||
let savedMode = 'claude';
|
||||
try { savedMode = localStorage.getItem('codeman_runMode') || 'claude'; } catch { /* localStorage unavailable */ }
|
||||
// Go through the setter so a CLI disabled after the previous visit, or a
|
||||
// removed custom CLI, cannot survive in localStorage as a runnable mode.
|
||||
this.runMode = savedMode;
|
||||
this._applyRunMode();
|
||||
},
|
||||
|
||||
@@ -2131,7 +2235,15 @@ Object.assign(CodemanApp.prototype, {
|
||||
* tests assert on that name directly too.
|
||||
*/
|
||||
async _runCliMode(mode) {
|
||||
const entry = RUN_MODE_LAUNCH[mode];
|
||||
const catalogEntry = registryCliById(mode);
|
||||
const entry = RUN_MODE_LAUNCH[mode] ||
|
||||
(catalogEntry && {
|
||||
label: catalogEntry.label,
|
||||
installHint: `${catalogEntry.label} is not available on this host.`,
|
||||
supportsCustomModel: false,
|
||||
buildConfig: () => null,
|
||||
});
|
||||
if (!entry) throw new Error(`Unknown run mode: ${mode}`);
|
||||
const caseName = document.getElementById('quickStartCase').value || 'testcase';
|
||||
// Remote/docker cases run the CLI on the OTHER side — the local status
|
||||
// probe and the local-only config/env below don't apply (quick-start
|
||||
@@ -2147,7 +2259,7 @@ Object.assign(CodemanApp.prototype, {
|
||||
this.terminal.focus();
|
||||
|
||||
try {
|
||||
if (!isRemote) {
|
||||
if (!isRemote && RUN_MODE_LAUNCH[mode]) {
|
||||
const statusRes = await fetch(`/api/${mode}/status`);
|
||||
const status = (await statusRes.json()).data;
|
||||
if (!status.available) {
|
||||
@@ -2158,6 +2270,9 @@ Object.assign(CodemanApp.prototype, {
|
||||
this._reportSessionLaunchError(ownsLaunchTerminal, entry.unrunnableHint);
|
||||
return;
|
||||
}
|
||||
} else if (!isRemote && !this.isCliAvailable(mode)) {
|
||||
this._reportSessionLaunchError(ownsLaunchTerminal, entry.installHint);
|
||||
return;
|
||||
}
|
||||
|
||||
const globalSettings = this.loadAppSettingsFromStorage();
|
||||
@@ -2294,7 +2409,7 @@ Object.assign(CodemanApp.prototype, {
|
||||
if (detachToggle) detachToggle.checked = this.hasTabDetachOverride(sessionId);
|
||||
|
||||
// Reset to an appropriate tab — Summary for external CLIs (Respawn/Ralph are Claude-only)
|
||||
const isAltMode = EXTERNAL_CLI_MODES.has(session.mode);
|
||||
const isAltMode = isExternalCliRunMode(session.mode);
|
||||
this.switchOptionsTab(isAltMode ? 'summary' : 'respawn');
|
||||
|
||||
// Update respawn status display and buttons
|
||||
@@ -4232,6 +4347,11 @@ Object.assign(CodemanApp.prototype, {
|
||||
// Case Management (reorder + delete)
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
|
||||
setCaseManageFilter(value) {
|
||||
this._caseManageFilter = String(value || '');
|
||||
this.renderCaseManageList();
|
||||
},
|
||||
|
||||
renderCaseManageList() {
|
||||
const container = document.getElementById('caseManageList');
|
||||
const cases = this.cases || [];
|
||||
@@ -4240,6 +4360,18 @@ Object.assign(CodemanApp.prototype, {
|
||||
return;
|
||||
}
|
||||
|
||||
// Every term must appear in the name or path (same rule as the Run picker's
|
||||
// filter). Reordering stays on the FULL list, so the arrows are disabled while
|
||||
// a filter is active: a swap with a neighbour the user cannot see is a surprise.
|
||||
const terms = (this._caseManageFilter || '').trim().toLowerCase().split(/\s+/).filter(Boolean);
|
||||
const filtering = terms.length > 0;
|
||||
const visible = filtering
|
||||
? cases.filter(c => {
|
||||
const haystack = `${c.name} ${c.path || ''}`.toLowerCase();
|
||||
return terms.every(term => haystack.includes(term));
|
||||
})
|
||||
: cases;
|
||||
|
||||
// Cases an agent worker created (server-side marker file, see agent-case-marker.ts).
|
||||
// A long orchestration leaves one scratch directory per worker behind, so they get
|
||||
// a badge and a bulk cleanup entry point rather than having to be recognised by name.
|
||||
@@ -4251,9 +4383,14 @@ Object.assign(CodemanApp.prototype, {
|
||||
title="Review and delete the scratch cases agent workers left behind">Clean up</button>
|
||||
</div>`
|
||||
: '';
|
||||
cases.forEach((c, idx) => {
|
||||
const isFirst = idx === 0;
|
||||
const isLast = idx === cases.length - 1;
|
||||
if (filtering && visible.length === 0) {
|
||||
html += '<div class="form-hint" style="text-align: center; padding: 2rem 0;">No cases match</div>';
|
||||
}
|
||||
visible.forEach(c => {
|
||||
const idx = cases.indexOf(c);
|
||||
const isFirst = filtering || idx === 0;
|
||||
const isLast = filtering || idx === cases.length - 1;
|
||||
const reorderTitle = filtering ? 'Clear the search to reorder' : null;
|
||||
// Was `/Users/<user>` only, the mirror image of the Run menu's bug: every
|
||||
// case path on a Linux host rendered in full, unabbreviated.
|
||||
const pathDisplay = c.path ? this._shortenHomePath(c.path) : '';
|
||||
@@ -4277,9 +4414,9 @@ Object.assign(CodemanApp.prototype, {
|
||||
: ''
|
||||
}
|
||||
<button class="case-manage-btn" onclick="app.moveCaseUp(${escapeHtml(JSON.stringify(c.name))})"
|
||||
title="Move up" ${isFirst ? 'disabled' : ''}>▲</button>
|
||||
title="${reorderTitle || 'Move up'}" ${isFirst ? 'disabled' : ''}>▲</button>
|
||||
<button class="case-manage-btn" onclick="app.moveCaseDown(${escapeHtml(JSON.stringify(c.name))})"
|
||||
title="Move down" ${isLast ? 'disabled' : ''}>▼</button>
|
||||
title="${reorderTitle || 'Move down'}" ${isLast ? 'disabled' : ''}>▼</button>
|
||||
<button class="case-manage-btn case-manage-btn-delete" onclick="app.deleteCase(${escapeHtml(JSON.stringify(c.name))})"
|
||||
title="Delete case">✕</button>
|
||||
</div>
|
||||
@@ -4452,6 +4589,7 @@ Object.assign(CodemanApp.prototype, {
|
||||
const isSelected = c.name === currentCase;
|
||||
html += `
|
||||
<button class="mobile-case-item ${isSelected ? 'selected' : ''}"
|
||||
data-search="${escapeHtml(`${c.label} ${c.name}`.toLowerCase())}"
|
||||
onclick="app.selectMobileCase(${escapeHtml(JSON.stringify(c.name))})">
|
||||
<span class="mobile-case-item-icon">
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
@@ -4474,7 +4612,61 @@ Object.assign(CodemanApp.prototype, {
|
||||
}
|
||||
|
||||
listContainer.innerHTML = html;
|
||||
// Every open starts unfiltered. The search box is not focused on purpose:
|
||||
// that would raise the phone keyboard over a list most opens just tap.
|
||||
const search = document.getElementById('mobileCaseSearch');
|
||||
if (search) search.value = '';
|
||||
listContainer.parentElement.style.minHeight = '';
|
||||
this.filterMobileCases();
|
||||
modal.classList.add('active');
|
||||
// Bring the current case into view when the list is longer than the sheet.
|
||||
// Scroll the list's own box, never scrollIntoView(), which can also scroll
|
||||
// the document under the fixed header.
|
||||
const body = listContainer.parentElement;
|
||||
const selected = listContainer.querySelector('.mobile-case-item.selected');
|
||||
if (body && selected) {
|
||||
const top = selected.offsetTop - body.offsetTop;
|
||||
if (top + selected.offsetHeight > body.scrollTop + body.clientHeight) {
|
||||
body.scrollTop = top - (body.clientHeight - selected.offsetHeight) / 2;
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
/** Hide case rows whose name does not contain every word typed in the search box. */
|
||||
filterMobileCases() {
|
||||
const search = document.getElementById('mobileCaseSearch');
|
||||
const words = (search?.value || '').toLowerCase().split(/\s+/).filter(Boolean);
|
||||
// Hold the list at its unfiltered height while searching, so the sheet (and
|
||||
// the input under the thumb) does not jump as rows disappear.
|
||||
const body = document.querySelector('.mobile-case-picker-body');
|
||||
if (body && words.length && !body.style.minHeight) body.style.minHeight = `${body.offsetHeight}px`;
|
||||
let shown = 0;
|
||||
for (const item of document.querySelectorAll('#mobileCaseList .mobile-case-item')) {
|
||||
const hay = item.dataset.search || '';
|
||||
const match = words.every((w) => hay.includes(w));
|
||||
item.hidden = !match;
|
||||
if (match) shown++;
|
||||
}
|
||||
const empty = document.getElementById('mobileCaseEmpty');
|
||||
if (empty) empty.hidden = shown > 0;
|
||||
},
|
||||
|
||||
/** Enter picks the case when the search narrows the list to exactly one; Escape clears, then closes. */
|
||||
onMobileCaseSearchKey(event) {
|
||||
if (event.key === 'Enter') {
|
||||
event.preventDefault();
|
||||
const visible = [...document.querySelectorAll('#mobileCaseList .mobile-case-item:not([hidden])')];
|
||||
if (visible.length === 1) visible[0].click();
|
||||
} else if (event.key === 'Escape') {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
if (event.target.value) {
|
||||
event.target.value = '';
|
||||
this.filterMobileCases();
|
||||
} else {
|
||||
this.closeMobileCasePicker();
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
closeMobileCasePicker() {
|
||||
@@ -4547,9 +4739,15 @@ Object.defineProperty(CodemanApp.prototype, 'runMode', {
|
||||
return this._runMode || 'claude';
|
||||
},
|
||||
set(mode) {
|
||||
this._runMode =
|
||||
mode === 'opencode' || mode === 'codex' || mode === 'gemini' || mode === 'antigravity' || mode === 'pi' || mode === 'grok' || mode === 'deepseek' || mode === 'omp' || mode === 'claude'
|
||||
? mode
|
||||
: 'claude';
|
||||
const entry = registryCliById(mode);
|
||||
if ((entry && entry.enabled) || (!entry && BUILT_IN_RUN_MODES.has(mode))) {
|
||||
this._runMode = mode;
|
||||
return;
|
||||
}
|
||||
// A disabled (or unknown) mode falls back to the first ENABLED catalogue entry, never a
|
||||
// hardcoded 'claude': claude can be disabled too, and the server rejects a disabled mode.
|
||||
const catalog = registryCliCatalog();
|
||||
const firstEnabled = catalog.find((cli) => cli.enabled && cli.kind === 'agent') || catalog.find((cli) => cli.enabled);
|
||||
this._runMode = firstEnabled ? firstEnabled.id : 'claude';
|
||||
},
|
||||
});
|
||||
|
||||
+346
-23
@@ -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');
|
||||
@@ -402,6 +410,12 @@ Object.assign(CodemanApp.prototype, {
|
||||
// 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();
|
||||
// CLI management (docs/cli-enable-disable-plan.md): synced, default OFF.
|
||||
document.getElementById('appSettingsCliManagement').checked = settings.cliManagementEnabled === true;
|
||||
// Same reasoning as applyCustomModelEndpointsVisibility above: assigning
|
||||
// .checked fires no onchange, so the list's visibility (and lazy load)
|
||||
// needs an explicit sync on every open, not just a save.
|
||||
this.applyCliManagementVisibility();
|
||||
// Read My Mind: synced, default OFF (opt-in; capture + prediction cost real tokens).
|
||||
document.getElementById('appSettingsReadMyMind').checked = settings.readMyMindEnabled === true;
|
||||
document.getElementById('appSettingsUltracodeFloatingWindows').checked =
|
||||
@@ -446,6 +460,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(
|
||||
@@ -971,7 +987,7 @@ Object.assign(CodemanApp.prototype, {
|
||||
desc.textContent = inert
|
||||
? 'The selected model has no 1M variant.'
|
||||
: base
|
||||
? 'Available for Fable 5.1, Fable 5, Opus and Opus 4.6.'
|
||||
? 'Available for Fable 5.1, Fable 5, Opus 5.5, Opus and Opus 4.6.'
|
||||
: 'With no model pinned, this starts new sessions on Opus with a 1M window.';
|
||||
}
|
||||
},
|
||||
@@ -1297,29 +1313,53 @@ Object.assign(CodemanApp.prototype, {
|
||||
return flags[tool] !== false;
|
||||
},
|
||||
|
||||
/** Render the registry's enabled, available CLIs as welcome-screen actions. */
|
||||
renderWelcomeCliActions() {
|
||||
const container = document.getElementById('welcomeCliActions');
|
||||
if (!container) return;
|
||||
const catalog = Array.isArray(window.__codemanCliCatalog) ? window.__codemanCliCatalog : [];
|
||||
container.replaceChildren();
|
||||
for (const cli of catalog) {
|
||||
if (!cli.enabled || !this.isCliAvailable(cli.id)) continue;
|
||||
const btn = document.createElement('button');
|
||||
btn.type = 'button';
|
||||
btn.className = `welcome-btn welcome-btn-cli welcome-btn-${cli.id}`;
|
||||
btn.dataset.mode = cli.id;
|
||||
const icon = document.createElementNS('http://www.w3.org/2000/svg', 'svg');
|
||||
icon.setAttribute('width', '20');
|
||||
icon.setAttribute('height', '20');
|
||||
icon.setAttribute('viewBox', '0 0 24 24');
|
||||
icon.setAttribute('fill', 'none');
|
||||
icon.setAttribute('stroke', 'currentColor');
|
||||
icon.setAttribute('stroke-width', '2');
|
||||
icon.setAttribute('aria-hidden', 'true');
|
||||
const play = document.createElementNS('http://www.w3.org/2000/svg', 'polygon');
|
||||
play.setAttribute('points', '5 3 19 12 5 21 5 3');
|
||||
icon.appendChild(play);
|
||||
btn.appendChild(icon);
|
||||
// Same "Run <label>" text the static buttons had ("Run Claude Code", "Run Shell"),
|
||||
// left translatable on purpose: i18n.js carries these strings, and a custom CLI's
|
||||
// label simply has no dictionary entry, so it renders as typed.
|
||||
btn.append(`Run ${cli.kind === 'shell' ? 'Shell' : cli.label}`);
|
||||
btn.onclick = () => {
|
||||
this.setRunMode(cli.id);
|
||||
void this.run();
|
||||
};
|
||||
container.appendChild(btn);
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* #200: show a welcome-screen button only where the thing it launches exists.
|
||||
* The markup ships them hidden, so an old cached page can never flash a button
|
||||
* for a tool this server does not have.
|
||||
* #200: show a welcome-screen action only where the thing it launches exists.
|
||||
* The registry catalog is injected with the initial document and is updated in
|
||||
* place after a Settings toggle, so the page never offers a disabled CLI.
|
||||
*/
|
||||
applyWelcomeCliVisibility() {
|
||||
const buttons = [
|
||||
['welcomeClaudeBtn', 'claude'],
|
||||
['welcomeOpencodeBtn', 'opencode'],
|
||||
['welcomeAntigravityBtn', 'antigravity'],
|
||||
['welcomeOmpBtn', 'omp'],
|
||||
['welcomeGeminiBtn', 'gemini'],
|
||||
['welcomePiBtn', 'pi'],
|
||||
['welcomeGrokBtn', 'grok'],
|
||||
['welcomeDeepSeekBtn', 'deepseek'],
|
||||
// Not a run mode, same reasoning: offering a Cloudflare Tunnel on a box
|
||||
// without cloudflared can only ever produce "cloudflared not found".
|
||||
['welcomeTunnelBtn', 'cloudflared'],
|
||||
];
|
||||
for (const [id, tool] of buttons) {
|
||||
const btn = document.getElementById(id);
|
||||
if (btn) btn.style.display = this.isCliAvailable(tool) ? 'flex' : 'none';
|
||||
}
|
||||
this.renderWelcomeCliActions();
|
||||
// Not a run mode, same reasoning: offering a Cloudflare Tunnel on a box
|
||||
// without cloudflared can only ever produce "cloudflared not found".
|
||||
const tunnel = document.getElementById('welcomeTunnelBtn');
|
||||
if (tunnel) tunnel.style.display = this.isCliAvailable('cloudflared') ? 'flex' : 'none';
|
||||
},
|
||||
|
||||
async loadTunnelStatus() {
|
||||
@@ -2118,6 +2158,7 @@ Object.assign(CodemanApp.prototype, {
|
||||
showUltracodeAgents: document.getElementById('appSettingsShowUltracodeAgents').checked,
|
||||
approvalsInboxEnabled: document.getElementById('appSettingsApprovalsInbox').checked,
|
||||
customModelEndpointsEnabled: document.getElementById('appSettingsCustomModelEndpoints').checked,
|
||||
cliManagementEnabled: document.getElementById('appSettingsCliManagement').checked,
|
||||
readMyMindEnabled: document.getElementById('appSettingsReadMyMind').checked,
|
||||
ultracodeFloatingWindows: document.getElementById('appSettingsUltracodeFloatingWindows').checked,
|
||||
showMultiMonitorButton: document.getElementById('appSettingsShowMultiMonitorButton').checked,
|
||||
@@ -2137,6 +2178,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(
|
||||
@@ -2363,6 +2405,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,
|
||||
@@ -2708,6 +2754,282 @@ Object.assign(CodemanApp.prototype, {
|
||||
}
|
||||
},
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
// CLI management (docs/cli-enable-disable-plan.md)
|
||||
//
|
||||
// CRUD against /api/clis, rendered into the Agents & CLIs settings section.
|
||||
// Same load/save-pair-outside-openAppSettings reasoning as the Custom Model
|
||||
// Endpoints block above: these are server-side registry records, not a
|
||||
// settings-payload field — only the `cliManagementEnabled` toggle itself
|
||||
// goes through openAppSettings/saveAppSettings.
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
|
||||
/**
|
||||
* Same two-caller shape as applyCustomModelEndpointsVisibility (assigning
|
||||
* .checked fires no change event, so this needs both an explicit call on
|
||||
* open AND the checkbox's own onchange) and the same reasoning for hiding
|
||||
* the whole list rather than showing it disabled: with the flag off the
|
||||
* rows would be controls that only 403.
|
||||
*/
|
||||
applyCliManagementVisibility() {
|
||||
const enabled = document.getElementById('appSettingsCliManagement').checked;
|
||||
const group = document.getElementById('cliListGroup');
|
||||
if (group) group.style.display = enabled ? '' : 'none';
|
||||
if (enabled) this.loadCliListForSettings();
|
||||
else this.closeCliCustomForm();
|
||||
this._applyCliManagementAdminGate();
|
||||
},
|
||||
|
||||
/**
|
||||
* Decision 5 (docs/cli-enable-disable-plan.md): hidden entirely for a
|
||||
* non-admin in multi-user mode, not shown-empty. GET /api/clis already
|
||||
* answers a non-admin with [], which empties the row list on its own; the
|
||||
* "Add a custom CLI" row has no list row to hide behind, so it needs its
|
||||
* own gate the same way the Custom Model Endpoints "+ Add" button does.
|
||||
*/
|
||||
_applyCliManagementAdminGate() {
|
||||
const group = document.getElementById('cliListGroup');
|
||||
if (!group) return;
|
||||
const me = window.__codemanUser || {};
|
||||
const blocked = me.multiUser && me.role !== 'admin';
|
||||
const featureOn = document.getElementById('appSettingsCliManagement')?.checked ?? false;
|
||||
group.style.display = blocked || !featureOn ? 'none' : '';
|
||||
const addRow = document.getElementById('cliCustomAddToggle');
|
||||
if (addRow) addRow.style.display = blocked ? 'none' : '';
|
||||
},
|
||||
|
||||
async loadCliListForSettings() {
|
||||
// GET /api/clis wraps its body in the { success, data } envelope like every
|
||||
// other /api route — _apiJson() unwraps it, same reasoning as the Custom
|
||||
// Model Endpoints list load above.
|
||||
const clis = await this._apiJson('/api/clis');
|
||||
this._cliList = Array.isArray(clis) ? clis : [];
|
||||
this._syncCliLaunchCatalog();
|
||||
this.renderCliList();
|
||||
},
|
||||
|
||||
/** Keep the launch surfaces in sync with Settings mutations without a reload. */
|
||||
_syncCliLaunchCatalog() {
|
||||
if (!Array.isArray(this._cliList) || this._cliList.length === 0) return;
|
||||
window.__codemanCliCatalog = this._cliList.map((cli) => ({
|
||||
id: cli.id,
|
||||
label: cli.label,
|
||||
shortBadge: cli.shortBadge,
|
||||
order: cli.order,
|
||||
kind: cli.kind,
|
||||
enabled: cli.enabled,
|
||||
available: cli.kind === 'shell' || (cli.enabled && cli.installed),
|
||||
}));
|
||||
window.__codemanCliAvailable = {
|
||||
...(window.__codemanCliAvailable || {}),
|
||||
...Object.fromEntries(this._cliList.map((cli) => [cli.id, cli.kind === 'shell' || (cli.enabled && cli.installed)])),
|
||||
};
|
||||
if (!window.__codemanCliCatalog.some((cli) => cli.id === this.runMode && cli.enabled)) {
|
||||
this.setRunMode?.('claude');
|
||||
}
|
||||
this.applyWelcomeCliVisibility?.();
|
||||
this.renderRegistryRunOptions?.();
|
||||
this.renderMobileOverview?.();
|
||||
const menu = document.getElementById('runModeMenu');
|
||||
if (menu) this._refreshRunModeAvailability?.(menu);
|
||||
},
|
||||
|
||||
renderCliList() {
|
||||
const list = document.getElementById('cliListRows');
|
||||
if (!list) return;
|
||||
const clis = [...(this._cliList || [])].sort((a, b) => {
|
||||
// Installed CLIs first, alphabetically; then not-installed, alphabetically.
|
||||
if (a.installed !== b.installed) return a.installed ? -1 : 1;
|
||||
return a.label.localeCompare(b.label);
|
||||
});
|
||||
if (clis.length === 0) {
|
||||
list.innerHTML = '<p class="set-group-hint">No CLIs found.</p>';
|
||||
return;
|
||||
}
|
||||
// Mirrors cli-registry-routes.ts's own isUndisableable(): a kind 'shell' entry
|
||||
// is the one the backend refuses to ever disable (keyed on kind, never an id).
|
||||
// Revised 2026-09-23: rather
|
||||
// than render a permanently-greyed switch for it (which read as "broken"
|
||||
// next to every other row's working toggle), shell gets NO switch at all —
|
||||
// a plain "Always available" label, so there is nothing to click that
|
||||
// could look like it should work but doesn't.
|
||||
list.innerHTML = clis
|
||||
.map((c) => {
|
||||
const idArg = escapeHtml(JSON.stringify(c.id));
|
||||
const untoggleable = c.kind === 'shell';
|
||||
const installBtn =
|
||||
c.stock && !c.installed
|
||||
? `<button type="button" class="btn-toolbar btn-sm" onclick="app.installCliEntry(${idArg})" id="cliInstallBtn-${escapeHtml(c.id)}">Install</button>`
|
||||
: '';
|
||||
const customActions = c.stock
|
||||
? ''
|
||||
: `<button type="button" class="btn-toolbar btn-sm" onclick="app.openCliCustomForm(${idArg})">Edit</button>
|
||||
<button type="button" class="btn-toolbar btn-danger btn-sm" onclick="app.deleteCliCustom(${idArg})">Delete</button>`;
|
||||
const toggle = untoggleable
|
||||
? '<span class="set-row-desc">Always available</span>'
|
||||
: `<label class="switch switch-sm">
|
||||
<input type="checkbox" ${c.enabled ? 'checked' : ''} onchange="app.toggleCliEnabled(${idArg}, this)">
|
||||
<span class="slider"></span>
|
||||
</label>`;
|
||||
return `
|
||||
<div class="set-row" data-cli-id="${escapeHtml(c.id)}">
|
||||
<div class="set-row-text">
|
||||
<span class="set-row-label">${escapeHtml(c.label)} <span class="set-scope">${escapeHtml(c.shortBadge)}</span></span>
|
||||
<span class="set-row-desc">${c.installed ? 'Installed' : 'Not installed'}${c.stock ? '' : ' · custom'}</span>
|
||||
</div>
|
||||
<div class="set-row-actions">
|
||||
${installBtn}
|
||||
${customActions}
|
||||
${toggle}
|
||||
</div>
|
||||
</div>`;
|
||||
})
|
||||
.join('');
|
||||
},
|
||||
|
||||
/**
|
||||
* ⚠️ A successful toggle must patch `window.__codemanCliAvailable` and refresh
|
||||
* every surface that reads it, or the change is invisible everywhere except
|
||||
* this settings row until the next full page reload — `window.__codemanCliAvailable`
|
||||
* is injected ONCE at initial page render (server.ts) and nothing else refetches
|
||||
* it. Same pattern `installDeepSeekProfile()` already uses for the same reason.
|
||||
*/
|
||||
async toggleCliEnabled(id, checkbox) {
|
||||
const next = checkbox.checked;
|
||||
const res = await this._api(`/api/clis/${encodeURIComponent(id)}`, { method: 'PUT', body: { enabled: next } });
|
||||
if (!res || !res.ok) {
|
||||
checkbox.checked = !next; // revert on failure — the row must not lie about server state
|
||||
let detail = '';
|
||||
try {
|
||||
detail = (await res?.json())?.error || '';
|
||||
} catch {
|
||||
/* no body to read */
|
||||
}
|
||||
this.showToast(`Failed to ${next ? 'enable' : 'disable'} "${id}"${detail ? `: ${detail}` : ''}`, 'error');
|
||||
return;
|
||||
}
|
||||
await this.loadCliListForSettings();
|
||||
},
|
||||
|
||||
async installCliEntry(id) {
|
||||
// Installing runs a command on the server, so it never happens on a single click:
|
||||
// the confirm names the exact command POST /api/clis/:id/install would run (the
|
||||
// #343 review's "auto-install may end up behind an explicit confirm").
|
||||
const entry = (this._cliList || []).find((c) => c.id === id);
|
||||
const label = entry?.label || id;
|
||||
const command = entry?.installCommand;
|
||||
const prompt = command
|
||||
? `Install ${label}? This runs the following on the Codeman server:\n\n${command}`
|
||||
: `Install ${label}? This runs its official install command on the Codeman server.`;
|
||||
if (!confirm(prompt)) return;
|
||||
const btn = document.getElementById(`cliInstallBtn-${id}`);
|
||||
if (btn) {
|
||||
btn.disabled = true;
|
||||
btn.textContent = 'Installing…';
|
||||
}
|
||||
try {
|
||||
const res = await this._api(`/api/clis/${encodeURIComponent(id)}/install`, { method: 'POST' });
|
||||
if (!res || !res.ok) {
|
||||
let detail = '';
|
||||
try {
|
||||
detail = (await res?.json())?.error || '';
|
||||
} catch {
|
||||
/* no body to read */
|
||||
}
|
||||
this.showToast(`Installing "${id}" failed${detail ? `: ${detail}` : ''}`, 'error');
|
||||
return;
|
||||
}
|
||||
this.showToast(`Installed "${id}"`, 'success');
|
||||
} finally {
|
||||
await this.loadCliListForSettings();
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Pass no id to create a new entry; pass an existing CUSTOM id to edit one.
|
||||
* ⚠️ GET /api/clis deliberately excludes discovery/launch (Phase 2's own
|
||||
* scope), so an edit cannot be pre-filled with the entry's existing binary
|
||||
* or argv — those two fields start blank and must be re-entered, since the
|
||||
* update endpoint (PUT /api/clis/custom/:id) replaces the whole launch
|
||||
* spec rather than patching it. id/label/badge DO come from the list row.
|
||||
*/
|
||||
openCliCustomForm(editId) {
|
||||
const form = document.getElementById('cliCustomForm');
|
||||
const errorEl = document.getElementById('cliCustomFormError');
|
||||
if (!form) return;
|
||||
const existing = editId ? (this._cliList || []).find((c) => c.id === editId) : null;
|
||||
this._editingCliCustomId = existing ? existing.id : null;
|
||||
document.getElementById('cliCustomId').value = existing ? existing.id : '';
|
||||
document.getElementById('cliCustomId').disabled = !!existing; // id is immutable once created
|
||||
document.getElementById('cliCustomLabel').value = existing ? existing.label : '';
|
||||
document.getElementById('cliCustomBadge').value = existing ? existing.shortBadge : '';
|
||||
document.getElementById('cliCustomBinary').value = '';
|
||||
document.getElementById('cliCustomArgv').value = '';
|
||||
document.getElementById('cliCustomSubmit').textContent = existing ? 'Save' : 'Create';
|
||||
if (errorEl) errorEl.style.display = 'none';
|
||||
form.style.display = '';
|
||||
},
|
||||
|
||||
closeCliCustomForm() {
|
||||
const form = document.getElementById('cliCustomForm');
|
||||
if (form) form.style.display = 'none';
|
||||
this._editingCliCustomId = null;
|
||||
},
|
||||
|
||||
/** Wired to #cliCustomForm's onsubmit; `event` is the submit event. */
|
||||
async submitCliCustomForm(event) {
|
||||
event.preventDefault();
|
||||
const errorEl = document.getElementById('cliCustomFormError');
|
||||
const showError = (msg) => {
|
||||
if (errorEl) {
|
||||
errorEl.textContent = msg;
|
||||
errorEl.style.display = '';
|
||||
}
|
||||
};
|
||||
const id = document.getElementById('cliCustomId').value.trim();
|
||||
const label = document.getElementById('cliCustomLabel').value.trim();
|
||||
const shortBadge = document.getElementById('cliCustomBadge').value.trim();
|
||||
const binaries = document.getElementById('cliCustomBinary').value.trim().split(/\s+/).filter(Boolean);
|
||||
const argv = document.getElementById('cliCustomArgv').value.trim().split(/\s+/).filter(Boolean);
|
||||
if (!id || !label || !shortBadge || binaries.length === 0 || argv.length === 0) {
|
||||
showError('All fields are required.');
|
||||
return;
|
||||
}
|
||||
const editing = this._editingCliCustomId;
|
||||
const path = editing ? `/api/clis/custom/${encodeURIComponent(editing)}` : '/api/clis';
|
||||
const method = editing ? 'PUT' : 'POST';
|
||||
const res = await this._api(path, { method, body: { id, label, shortBadge, binaries, argv } });
|
||||
if (!res || !res.ok) {
|
||||
let detail = 'Request failed';
|
||||
try {
|
||||
detail = (await res?.json())?.error || detail;
|
||||
} catch {
|
||||
/* no body to read */
|
||||
}
|
||||
showError(detail);
|
||||
return;
|
||||
}
|
||||
this.closeCliCustomForm();
|
||||
await this.loadCliListForSettings();
|
||||
},
|
||||
|
||||
async deleteCliCustom(id) {
|
||||
const entry = (this._cliList || []).find((c) => c.id === id);
|
||||
if (!confirm(`Delete custom CLI "${entry?.label || id}"? This cannot be undone.`)) return;
|
||||
const res = await this._api(`/api/clis/${encodeURIComponent(id)}`, { method: 'DELETE' });
|
||||
if (!res || !res.ok) {
|
||||
let detail = '';
|
||||
try {
|
||||
detail = (await res?.json())?.error || '';
|
||||
} catch {
|
||||
/* no body to read */
|
||||
}
|
||||
this.showToast(`Failed to delete "${id}"${detail ? `: ${detail}` : ''}`, 'error');
|
||||
return;
|
||||
}
|
||||
await this.loadCliListForSettings();
|
||||
},
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
// Visibility Settings & Device-Specific Defaults
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
@@ -3097,7 +3419,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
|
||||
@@ -3372,7 +3694,7 @@ Object.assign(CodemanApp.prototype, {
|
||||
'terminalFontFamily', 'terminalFontWeight', 'terminalFontWeightBold',
|
||||
'language',
|
||||
'terminalWheelLocalScrollback',
|
||||
'autoCopySelection',
|
||||
'autoCopySelection', 'copyStripMargin',
|
||||
'showSessionButton', 'showAwayDigestButton', 'showCronButton',
|
||||
'showTabDetachButton',
|
||||
'mobileOverviewEnabled',
|
||||
@@ -3783,4 +4105,5 @@ Object.assign(CodemanApp.prototype, {
|
||||
// evaluation, not just this feature.
|
||||
document.addEventListener?.('codeman:me', () => {
|
||||
window.app?._applyCustomModelAdminGate?.();
|
||||
window.app?._applyCliManagementAdminGate?.();
|
||||
});
|
||||
|
||||
+257
-6
@@ -2433,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).
|
||||
@@ -3817,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
|
||||
@@ -3969,6 +4045,27 @@ body.touch-device .terminal-container .xterm .xterm-helper-textarea {
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
.welcome-cli-actions {
|
||||
display: contents;
|
||||
}
|
||||
|
||||
.welcome-btn-cli {
|
||||
background: linear-gradient(135deg, #1f2937 0%, #374151 100%);
|
||||
border-color: rgba(148, 163, 184, 0.35);
|
||||
color: #e2e8f0;
|
||||
}
|
||||
|
||||
.welcome-btn-cli:hover {
|
||||
background: linear-gradient(135deg, #374151 0%, #4b5563 100%);
|
||||
border-color: rgba(203, 213, 225, 0.5);
|
||||
color: #f8fafc;
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.run-mode-cli-options {
|
||||
display: contents;
|
||||
}
|
||||
|
||||
.welcome-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -4013,6 +4110,21 @@ body.touch-device .terminal-container .xterm .xterm-helper-textarea {
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.welcome-btn-codex {
|
||||
background: linear-gradient(135deg, #2a0a3e 0%, #350b4d 50%, #400d5e 100%);
|
||||
border-color: rgba(168, 85, 247, 0.4);
|
||||
color: #d8b4fe;
|
||||
box-shadow: 0 2px 8px rgba(168, 85, 247, 0.16), inset 0 1px 0 rgba(255, 255, 255, 0.06);
|
||||
}
|
||||
|
||||
.welcome-btn-codex:hover {
|
||||
background: linear-gradient(135deg, #400d5e 0%, #581c87 50%, #6b21a8 100%);
|
||||
box-shadow: 0 4px 20px rgba(168, 85, 247, 0.3), 0 0 40px rgba(88, 28, 135, 0.12), inset 0 1px 0 rgba(255, 255, 255, 0.08);
|
||||
border-color: rgba(192, 132, 252, 0.5);
|
||||
color: #e9d5ff;
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
/* Antigravity: cyan identity, matching .btn-toolbar.btn-run.mode-antigravity and
|
||||
.run-mode-dot.antigravity so the welcome action reads as the same backend. */
|
||||
.welcome-btn-antigravity {
|
||||
@@ -6960,12 +7072,61 @@ body.touch-device .terminal-container .xterm .xterm-helper-textarea {
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.mobile-case-picker-search {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin: 12px 20px 4px;
|
||||
padding: 0 12px;
|
||||
background: var(--bg-input);
|
||||
border: 1px solid var(--border-light);
|
||||
border-radius: 8px;
|
||||
color: var(--text-dim);
|
||||
}
|
||||
|
||||
.mobile-case-picker-search:focus-within {
|
||||
border-color: var(--accent, #22c55e);
|
||||
}
|
||||
|
||||
.mobile-case-picker-search input {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
padding: 10px 0;
|
||||
background: transparent;
|
||||
border: none;
|
||||
outline: none;
|
||||
color: var(--text);
|
||||
/* 16px keeps iOS Safari from zooming the page on focus */
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
/* The wrapper draws the focus state; the global input focus ring doubled it */
|
||||
#mobileCaseSearch:focus {
|
||||
outline: none;
|
||||
border: none;
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
.mobile-case-empty {
|
||||
padding: 20px;
|
||||
text-align: center;
|
||||
color: var(--text-dim);
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.mobile-case-empty[hidden],
|
||||
.mobile-case-item[hidden] {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.mobile-case-picker-body {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
padding: 8px 0;
|
||||
max-height: 50vh;
|
||||
/* Fill the sheet (its max-height is the cap); a separate cap here left the
|
||||
list shorter than the sheet could show. min-height lets flex shrink it. */
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.mobile-case-list {
|
||||
@@ -12382,6 +12543,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;
|
||||
@@ -15142,11 +15314,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;
|
||||
@@ -16249,6 +16441,26 @@ html[data-tab-orientation='vertical'] .home-sessions {
|
||||
color: color-mix(in srgb, var(--green) 45%, var(--text-muted));
|
||||
}
|
||||
|
||||
/* An exited agent (Ark0N/Codeman#446): neutral, like the rich rail's exited pill.
|
||||
No green at all, since nothing is running behind this row. The dot and the row
|
||||
take the `--exited` class too and fall back to their neutral base rules. */
|
||||
.home-sessions-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 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. */
|
||||
@@ -18311,6 +18523,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.
|
||||
|
||||
@@ -18457,6 +18688,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
|
||||
|
||||
+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);
|
||||
|
||||
@@ -188,7 +188,18 @@
|
||||
if (ev.type === 'keydown' && global.app?.shouldCopyTerminalSelectionFromShortcut?.(ev)) {
|
||||
const raw = this.terminal?.getSelection?.() || '';
|
||||
const isColumnSelection = this.terminal?._core?._selectionService?._activeSelectionMode === 3;
|
||||
const selection = isColumnSelection ? raw : (global.CodemanCopySelection?.clean?.(raw) ?? raw);
|
||||
// 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) => {
|
||||
|
||||
+584
-67
@@ -368,10 +368,18 @@ Object.assign(CodemanApp.prototype, {
|
||||
// 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.
|
||||
const selection = this.cleanedTerminalSelection();
|
||||
//
|
||||
// ⚠️ 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
|
||||
@@ -543,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') {
|
||||
@@ -579,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
|
||||
@@ -756,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
|
||||
@@ -775,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;
|
||||
@@ -843,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
|
||||
@@ -854,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;
|
||||
@@ -991,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
|
||||
@@ -1014,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) {
|
||||
@@ -1026,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()
|
||||
) {
|
||||
@@ -1076,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 */
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1934,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 {
|
||||
@@ -3343,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.
|
||||
@@ -4170,11 +4348,16 @@ Object.assign(CodemanApp.prototype, {
|
||||
*
|
||||
* `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. The transform is idempotent on xterm output, so an
|
||||
* already-cleaned string is an acceptable argument: 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, and that is what makes the
|
||||
* trailing scan a fixed point. Fuzzed over 300 000 realistic selections.
|
||||
* 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
|
||||
@@ -4191,7 +4374,73 @@ Object.assign(CodemanApp.prototype, {
|
||||
if (this.terminal?._core?._selectionService?._activeSelectionMode === 3) return raw;
|
||||
const clean = window.CodemanCopySelection?.clean;
|
||||
if (!clean) return raw;
|
||||
return clean(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,
|
||||
@@ -4226,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).
|
||||
@@ -5126,7 +5395,7 @@ 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();
|
||||
@@ -5155,9 +5424,9 @@ 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) {
|
||||
@@ -5200,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 || []) {
|
||||
@@ -5293,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;
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
@@ -5315,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
|
||||
@@ -5362,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)
|
||||
|
||||
@@ -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 } : {}),
|
||||
});
|
||||
|
||||
@@ -0,0 +1,553 @@
|
||||
/**
|
||||
* @fileoverview CLI management (docs/cli-enable-disable-plan.md) — "PR C" from the
|
||||
* original #343 review, done in phases with the trust-model scope decided up front
|
||||
* (see that doc's "Decisions" section) rather than folded into a large diff.
|
||||
*
|
||||
* Phase 2: `GET /api/clis` — read-only list, ungated (reading is cheap, not the risky part).
|
||||
* Phase 3: `PUT /api/clis/:id` — enable/disable an EXISTING entry, stock or custom; 404 for an
|
||||
* id that does not exist, so this endpoint can never become a backdoor for creating an entry
|
||||
* (that's Phase 5's job).
|
||||
* Phase 4: `POST /api/clis/:id/install` — runs a STOCK entry's already-vetted install command
|
||||
* (never a custom entry's — Decision 3). Never auto-enables; Phase 3's endpoint is still
|
||||
* the only thing that flips `enabled`.
|
||||
* Phase 5: `POST /api/clis` (create) / `PUT /api/clis/custom/:id` (update) / `DELETE
|
||||
* /api/clis/:id` (custom only) — a deliberately separate write surface from Phase 3's, so
|
||||
* "stock entries can only have `enabled` toggled, custom entries can be fully edited"
|
||||
* stays structurally true rather than depending on every caller remembering the rule.
|
||||
*
|
||||
* Every registry mutation runs through `mutateRegistryFile()` (registry-writer.ts): one at a
|
||||
* time, the existence/duplicate checks inside the same serialized step as the write, and a
|
||||
* `clis.json` that is corrupt or has unsafe permissions refused with 409 rather than
|
||||
* overwritten.
|
||||
*
|
||||
* Every write endpoint answers the SAME way when `cliManagementEnabled` is off: 403
|
||||
* FORBIDDEN with a message naming the setting, via `requireCliManagementGate()`.
|
||||
*
|
||||
* Mirrors `custom-model-routes.ts`'s shape for the closest existing precedent: same
|
||||
* admin-gating pattern, same `readXEnabled()` helper shape reading `settings.json`
|
||||
* directly rather than threading the setting through every caller, same tmp+rename+0600
|
||||
* write path (`registry-writer.ts` mirrors `custom-model-hosts.ts`).
|
||||
*/
|
||||
|
||||
import { spawn } from 'node:child_process';
|
||||
import type { FastifyInstance, FastifyRequest } from 'fastify';
|
||||
import { ApiErrorCode, createErrorResponse, getErrorMessage, type ApiResponse } from '../../types.js';
|
||||
import { getAuthUser, isAdmin, parseBody, readJsonConfig, SETTINGS_PATH } from '../route-helpers.js';
|
||||
import { isMultiUserMode } from '../../config/multiuser.js';
|
||||
import { listClis, resolveInstallCommandForPlatform } from '../../config/cli-registry/registry.js';
|
||||
import { mutateRegistryFile, RegistryWriteRefusedError } from '../../config/cli-registry/registry-writer.js';
|
||||
import { CliEntrySchema } from '../../config/cli-registry/schema.js';
|
||||
import { STOCK_CLIS } from '../../config/cli-registry/stock.js';
|
||||
import type { CliEntry } from '../../config/cli-registry/types.js';
|
||||
import { CliCustomEntrySchema, CliEnableSchema } from '../schemas.js';
|
||||
import { appendAdminAudit } from '../admin-audit.js';
|
||||
import { invalidateCliExecutableResolvers } from '../../utils/cli-executable-resolver.js';
|
||||
import { invalidateCliResolverCache } from '../../utils/cli-resolver.js';
|
||||
import { isCliEntryInstalled, probeStockCliAvailability } from '../../utils/cli-installed-probes.js';
|
||||
|
||||
/**
|
||||
* `cliManagementEnabled` defaults OFF, same reasoning as
|
||||
* `readCustomModelEndpointsEnabled` in custom-model-routes.ts: this gate gets
|
||||
* checked by every WRITE endpoint (Phases 3-5), so it needs its own reader
|
||||
* rather than threading the setting value through every route handler.
|
||||
*/
|
||||
export async function readCliManagementEnabled(): Promise<boolean> {
|
||||
const settings = await readJsonConfig<Record<string, unknown>>(SETTINGS_PATH, 'settings.json', {});
|
||||
return settings.cliManagementEnabled === true;
|
||||
}
|
||||
|
||||
export interface CliListItem {
|
||||
id: string;
|
||||
label: string;
|
||||
shortBadge: string;
|
||||
order: number;
|
||||
kind: CliEntry['kind'];
|
||||
enabled: boolean;
|
||||
stock: boolean;
|
||||
installed: boolean;
|
||||
/**
|
||||
* The command `POST /api/clis/:id/install` would run, for a STOCK entry only, so the
|
||||
* Settings UI can name it in the confirm dialog before anything executes. The same
|
||||
* display text `missingCliMessage()` already prints in "CLI not found. Install with: …";
|
||||
* absent for a custom entry, whose install command is never executed (Decision 3).
|
||||
*/
|
||||
installCommand?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Forget every cached binary lookup for this CLI — the generic per-id resolver (which
|
||||
* captures the entry's binaries when first built) and every underlying per-binary cache,
|
||||
* success and negative-cache backoff alike. Called after anything that changes what is on
|
||||
* disk or what the CLI's binary IS; see `invalidateCliExecutableResolvers`.
|
||||
*/
|
||||
function forgetResolvedCli(id: string, binaries: readonly string[]): void {
|
||||
invalidateCliExecutableResolvers(binaries);
|
||||
invalidateCliResolverCache(id);
|
||||
}
|
||||
|
||||
const STOCK_IDS = new Set(STOCK_CLIS.map((e) => e.id as string));
|
||||
|
||||
/**
|
||||
* A `kind: 'shell'` entry can never be disabled — enforced here, not just in the UI (a
|
||||
* frontend-only guard is bypassable with curl). Keyed on KIND, never on an id, per the
|
||||
* registry's no-id-branching rule. Revised from Decision 4's original "shell/claude" scope
|
||||
* (2026-09-23): `claude` is now a normal toggleable entry like any other CLI. Internal
|
||||
* session creation (tmux-manager.ts, session.ts, Ralph, plan-orchestrator) resolves a CLI
|
||||
* via `getCli()`, which does NOT check `enabled` at all, so disabling `claude` only affects
|
||||
* the Run menu and the HTTP-facing `sessionModeSchema()` (new session requests via the
|
||||
* normal API) — identical in kind to disabling any other CLI, never a break to an internal
|
||||
* fallback path. The shell keeps the harder guarantee because it is the one non-agent mode
|
||||
* several code paths assume always exists as a raw-terminal fallback.
|
||||
*/
|
||||
function isUndisableable(entry: CliEntry): boolean {
|
||||
return entry.kind === 'shell';
|
||||
}
|
||||
|
||||
/** A write `mutateRegistryFile()` refused (corrupt or unsafe `clis.json`) becomes a 409 naming the fix. */
|
||||
function refusedWriteResponse(err: unknown): ApiResponse<never> {
|
||||
if (err instanceof RegistryWriteRefusedError) {
|
||||
return createErrorResponse(ApiErrorCode.CONFLICT, err.message);
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
|
||||
/**
|
||||
* Every write endpoint (Phases 3-5) answers the SAME way when the feature is off or the
|
||||
* caller is a non-admin in multi-user mode: 403 FORBIDDEN. Decided once here rather than
|
||||
* per-route, per docs/cli-enable-disable-plan.md Phase 1's own checklist item ("decide
|
||||
* exact behavior... before Phase 3 starts, so all three write endpoints answer the same way").
|
||||
*/
|
||||
async function requireCliManagementGate(req: FastifyRequest): Promise<ApiResponse<never> | null> {
|
||||
if (isMultiUserMode() && !isAdmin(req)) {
|
||||
return createErrorResponse(ApiErrorCode.FORBIDDEN, 'Admin only in multi-user mode');
|
||||
}
|
||||
if (!(await readCliManagementEnabled())) {
|
||||
return createErrorResponse(ApiErrorCode.FORBIDDEN, 'CLI management is disabled. Enable it in Settings first.');
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Assembles a full, schema-valid `CliEntry` from Phase 5's deliberately minimal request
|
||||
* shape (id/label/shortBadge/binaries/a simple launch variant — nothing else exposed in
|
||||
* v1), filling every other required field with conservative, safe defaults: no hooks, no
|
||||
* mux-optional fallback, no privileged params, no install command (Decision 3: a custom
|
||||
* entry's install text stays display-only, and there IS none here to display), no custom
|
||||
* model injection. `CliEntrySchema` re-validates the WHOLE thing below — this function
|
||||
* only shapes the object, it is not itself the safety layer.
|
||||
*/
|
||||
function buildCustomCliEntry(
|
||||
input: { id: string; label: string; shortBadge: string; binaries: string[]; argv: string[]; enabled: boolean },
|
||||
order: number
|
||||
): unknown {
|
||||
return {
|
||||
id: input.id,
|
||||
label: input.label,
|
||||
shortBadge: input.shortBadge,
|
||||
accent: '#6b7280',
|
||||
enabled: input.enabled,
|
||||
stock: false,
|
||||
order,
|
||||
kind: 'agent',
|
||||
discovery: {
|
||||
binaries: input.binaries,
|
||||
searchDirs: [],
|
||||
install: { command: {} },
|
||||
},
|
||||
launch: {
|
||||
params: {},
|
||||
variants: [{ id: 'default', args: input.argv.map((tok) => ({ lit: tok })) }],
|
||||
},
|
||||
env: {
|
||||
exports: [],
|
||||
unset: [],
|
||||
tmuxSetenvKeys: [],
|
||||
dockerExecEnvNames: [],
|
||||
allowedPrefixes: [],
|
||||
allowedKeys: [],
|
||||
},
|
||||
capabilities: {
|
||||
external: true,
|
||||
requiresMux: true,
|
||||
hooks: 'none',
|
||||
transcript: 'none',
|
||||
altScreen: 'strip-mux-only',
|
||||
echo: { policy: 'buffer', anchor: { kind: 'none' } },
|
||||
wheelForward: { mode: 'never' },
|
||||
keyboardAccessory: 'agent',
|
||||
privilegedCommandGate: false,
|
||||
startMode: 'interactive',
|
||||
stripInkBloat: false,
|
||||
ralph: false,
|
||||
respawn: false,
|
||||
effort: false,
|
||||
agentSkillInjection: false,
|
||||
statusLineTelemetry: false,
|
||||
model: { source: 'none' },
|
||||
privilegedParams: [],
|
||||
privilegedEnvKeys: [],
|
||||
gates: {},
|
||||
customModelInjection: { kind: 'unsupported' },
|
||||
},
|
||||
overlays: {},
|
||||
};
|
||||
}
|
||||
|
||||
function nextOrder(): number {
|
||||
const orders = listClis().map((e) => e.order);
|
||||
return (orders.length ? Math.max(...orders) : 0) + 10;
|
||||
}
|
||||
|
||||
/** Bounded execution: `PATH_INSTALL_TIMEOUT_MS`, output capped, process GROUP killed on timeout. */
|
||||
const CLI_INSTALL_TIMEOUT_MS = 300_000;
|
||||
|
||||
/**
|
||||
* Ids with an install running right now. A second request for the same id gets 409 rather
|
||||
* than a second `curl | bash` or `npm install -g` racing the first over the same prefix.
|
||||
*/
|
||||
const installsInFlight = new Set<string>();
|
||||
|
||||
/**
|
||||
* The server's environment minus every `CODEMAN_*` variable. An install script is third-party
|
||||
* code, and those variables carry Codeman's own secrets and wiring (`CODEMAN_PASSWORD`, the
|
||||
* data dir, the tmux socket), none of which an installer needs.
|
||||
*/
|
||||
export function installEnv(source: NodeJS.ProcessEnv = process.env): NodeJS.ProcessEnv {
|
||||
const env: NodeJS.ProcessEnv = {};
|
||||
for (const [key, value] of Object.entries(source)) {
|
||||
if (!key.startsWith('CODEMAN_')) env[key] = value;
|
||||
}
|
||||
return env;
|
||||
}
|
||||
|
||||
interface InstallResult {
|
||||
code: number | null;
|
||||
output: string;
|
||||
timedOut: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Runs a STOCK entry's already-vetted install command. `shell: true` is unavoidable here —
|
||||
* the shipped commands are genuinely `curl | bash` / `npm install -g` one-liners — but this
|
||||
* is NOT a reopening of the config-shell-text concern the registry's `shellToken` pattern
|
||||
* exists to prevent: the string executed here is NEVER user input, only ever what is
|
||||
* already hardcoded and reviewed in `stock.ts` (`resolveInstallCommandForPlatform`), and a
|
||||
* CUSTOM entry can never reach this function at all — see the route's own guard below.
|
||||
*/
|
||||
async function runInstallCommand(command: string): Promise<InstallResult> {
|
||||
return new Promise((resolve) => {
|
||||
let child: ReturnType<typeof spawn>;
|
||||
try {
|
||||
child = spawn(command, {
|
||||
shell: true,
|
||||
stdio: ['ignore', 'pipe', 'pipe'],
|
||||
// Own process group; the timeout below kills the whole tree by hand, mirroring
|
||||
// the DeepSeek profile-install endpoint's own reasoning: an install command fans
|
||||
// out into package-manager children, and spawn's own `timeout` option signals
|
||||
// only the direct child, leaving survivors holding the pipes open forever.
|
||||
detached: true,
|
||||
env: installEnv(),
|
||||
});
|
||||
} catch (err) {
|
||||
resolve({ code: null, output: `spawn failed: ${getErrorMessage(err)}`, timedOut: false });
|
||||
return;
|
||||
}
|
||||
|
||||
let output = '';
|
||||
let timedOut = false;
|
||||
let settled = false;
|
||||
let killTimer: NodeJS.Timeout | undefined;
|
||||
let reapTimer: NodeJS.Timeout | undefined;
|
||||
|
||||
const capture = (chunk: Buffer) => {
|
||||
if (output.length < 16_384) output += chunk.toString('utf-8');
|
||||
};
|
||||
child.stdout?.on('data', capture);
|
||||
child.stderr?.on('data', capture);
|
||||
|
||||
const killTree = (signal: NodeJS.Signals) => {
|
||||
try {
|
||||
if (child.pid) process.kill(-child.pid, signal);
|
||||
} catch {
|
||||
/* already gone */
|
||||
}
|
||||
};
|
||||
|
||||
const finish = (code: number | null) => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
clearTimeout(timer);
|
||||
if (killTimer) clearTimeout(killTimer);
|
||||
if (reapTimer) clearTimeout(reapTimer);
|
||||
resolve({ code, output, timedOut });
|
||||
};
|
||||
|
||||
const timer = setTimeout(() => {
|
||||
timedOut = true;
|
||||
killTree('SIGTERM');
|
||||
killTimer = setTimeout(() => killTree('SIGKILL'), 3_000);
|
||||
reapTimer = setTimeout(() => finish(null), 8_000);
|
||||
}, CLI_INSTALL_TIMEOUT_MS);
|
||||
|
||||
child.on('error', (err) => {
|
||||
output = `${output}\n${err.message}`;
|
||||
finish(null);
|
||||
});
|
||||
child.on('close', (code) => finish(code));
|
||||
});
|
||||
}
|
||||
|
||||
export function registerCliRegistryRoutes(app: FastifyInstance): void {
|
||||
// ---- Phase 2: read ----------------------------------------------------
|
||||
// GET /api/clis — every registry entry, disabled ones included (this is an
|
||||
// admin/settings surface; every SPAWN-time caller elsewhere uses
|
||||
// enabledClis() instead). Deliberately excludes launch/env/capabilities/
|
||||
// overlays/discovery — the same rule every other catalogue-export surface in
|
||||
// this codebase follows.
|
||||
//
|
||||
// NOT gated on cliManagementEnabled: reading the list is cheap and is not
|
||||
// the risky part. The Settings UI section simply never fetches this while
|
||||
// the flag is off (Phase 6).
|
||||
app.get('/api/clis', async (req: FastifyRequest): Promise<{ success: true; data: CliListItem[] }> => {
|
||||
if (isMultiUserMode() && !isAdmin(req)) {
|
||||
return { success: true, data: [] };
|
||||
}
|
||||
const stockAvailability = await probeStockCliAvailability();
|
||||
const data = listClis().map((entry) => ({
|
||||
id: entry.id as string,
|
||||
label: entry.label,
|
||||
shortBadge: entry.shortBadge,
|
||||
order: entry.order,
|
||||
kind: entry.kind,
|
||||
enabled: entry.enabled,
|
||||
stock: entry.stock,
|
||||
installed: isCliEntryInstalled(entry, stockAvailability),
|
||||
...(entry.stock ? { installCommand: resolveInstallCommandForPlatform(entry) } : {}),
|
||||
}));
|
||||
return { success: true, data };
|
||||
});
|
||||
|
||||
// ---- Phase 3: enable/disable (stock OR custom) -------------------------
|
||||
// PUT /api/clis/:id — body { enabled }. Toggles an EXISTING entry's
|
||||
// `enabled` flag, stock or custom alike; a not-yet-existing id is 404,
|
||||
// never a backdoor into CREATING one (Phase 5 owns creation via its own
|
||||
// endpoint, POST /api/clis). This is deliberately the one simple toggle
|
||||
// both kinds of entry share — full custom-entry editing is a SEPARATE path
|
||||
// (PUT /api/clis/custom/:id) precisely so a caller can flip `enabled`
|
||||
// without first knowing the rest of a custom entry's shape (its binaries,
|
||||
// its argv), which the Settings UI list row never carries.
|
||||
app.put('/api/clis/:id', async (req, reply): Promise<ApiResponse<{ id: string; enabled: boolean }>> => {
|
||||
const denied = await requireCliManagementGate(req);
|
||||
if (denied) {
|
||||
reply.code(403);
|
||||
return denied;
|
||||
}
|
||||
const { id } = req.params as { id: string };
|
||||
const body = parseBody(CliEnableSchema, req.body);
|
||||
|
||||
try {
|
||||
return await mutateRegistryFile<ApiResponse<{ id: string; enabled: boolean }>>((file) => {
|
||||
const entry = listClis().find((e) => (e.id as string) === id);
|
||||
if (!entry) {
|
||||
return { result: createErrorResponse(ApiErrorCode.NOT_FOUND, `"${id}" does not exist`) };
|
||||
}
|
||||
if (isUndisableable(entry) && !body.enabled) {
|
||||
return { result: createErrorResponse(ApiErrorCode.INVALID_INPUT, `"${id}" cannot be disabled`) };
|
||||
}
|
||||
const existingOverride = (file.clis[id] as Record<string, unknown> | undefined) ?? {};
|
||||
file.clis = { ...file.clis, [id]: { ...existingOverride, enabled: body.enabled } };
|
||||
return { file, result: { success: true as const, data: { id, enabled: body.enabled } } };
|
||||
});
|
||||
} catch (err) {
|
||||
return refusedWriteResponse(err);
|
||||
}
|
||||
});
|
||||
|
||||
// ---- Phase 4: auto-install (stock only) --------------------------------
|
||||
// One install per id at a time (409 otherwise), and the script never sees CODEMAN_* env.
|
||||
// POST /api/clis/:id/install — runs the entry's already-vetted install
|
||||
// command. Separate endpoint from Phase 3's toggle: installing is a bigger
|
||||
// action than a boolean flip and gets its own audit entry. Never auto-
|
||||
// enables — Phase 3's endpoint is still the only thing that flips `enabled`.
|
||||
app.post(
|
||||
'/api/clis/:id/install',
|
||||
async (req, reply): Promise<ApiResponse<{ id: string; code: number | null; output: string }>> => {
|
||||
const denied = await requireCliManagementGate(req);
|
||||
if (denied) {
|
||||
reply.code(403);
|
||||
return denied;
|
||||
}
|
||||
const { id } = req.params as { id: string };
|
||||
if (!STOCK_IDS.has(id)) {
|
||||
// Decision 3: a custom entry's install command is NEVER executed, full
|
||||
// stop — this guard is what makes that true independent of anything
|
||||
// Phase 5 does, even if a caller invents an id that happens to match
|
||||
// a custom entry's.
|
||||
return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Auto-install is only available for stock CLIs');
|
||||
}
|
||||
const entry = listClis().find((e) => (e.id as string) === id);
|
||||
if (!entry) return createErrorResponse(ApiErrorCode.NOT_FOUND, `"${id}" is not a stock CLI`);
|
||||
const command = resolveInstallCommandForPlatform(entry);
|
||||
if (!command) {
|
||||
return createErrorResponse(ApiErrorCode.OPERATION_FAILED, `"${id}" has no install command for this platform`);
|
||||
}
|
||||
|
||||
if (installsInFlight.has(id)) {
|
||||
return createErrorResponse(ApiErrorCode.CONFLICT, `"${id}" is already being installed`);
|
||||
}
|
||||
installsInFlight.add(id);
|
||||
let result: InstallResult;
|
||||
try {
|
||||
result = await runInstallCommand(command);
|
||||
} finally {
|
||||
installsInFlight.delete(id);
|
||||
}
|
||||
// Even a failed or timed-out install may have left a binary behind, so forget the
|
||||
// cached lookups either way: the next Run click or badge read probes afresh
|
||||
// instead of replaying a pre-install miss for up to the 5-minute backoff.
|
||||
forgetResolvedCli(id, entry.discovery.binaries);
|
||||
const admin = getAuthUser(req).username;
|
||||
void appendAdminAudit({
|
||||
admin,
|
||||
action: 'cli_install',
|
||||
target: id,
|
||||
ip: req.ip,
|
||||
detail: { command, exitCode: result.code, timedOut: result.timedOut },
|
||||
});
|
||||
|
||||
if (result.code !== 0) {
|
||||
const detail = result.timedOut
|
||||
? `timed out after ${Math.round(CLI_INSTALL_TIMEOUT_MS / 1000)}s`
|
||||
: result.output.slice(-1000).trim() || 'no output';
|
||||
return createErrorResponse(ApiErrorCode.OPERATION_FAILED, `Installing "${id}" failed: ${detail}`);
|
||||
}
|
||||
return { success: true, data: { id, code: result.code, output: result.output.slice(-4000) } };
|
||||
}
|
||||
);
|
||||
|
||||
// ---- Phase 5: custom CLI entries ----------------------------------------
|
||||
// POST /api/clis — create a custom entry. Deliberately separate from Phase
|
||||
// 3's PUT: that endpoint can only ever toggle an EXISTING stock entry, this
|
||||
// one can only ever create a NEW custom one, so the two write surfaces
|
||||
// cannot be confused for each other by a caller.
|
||||
app.post('/api/clis', async (req, reply): Promise<ApiResponse<{ id: string }>> => {
|
||||
const denied = await requireCliManagementGate(req);
|
||||
if (denied) {
|
||||
reply.code(403);
|
||||
return denied;
|
||||
}
|
||||
const body = parseBody(CliCustomEntrySchema, req.body);
|
||||
if (STOCK_IDS.has(body.id)) {
|
||||
return createErrorResponse(
|
||||
ApiErrorCode.ALREADY_EXISTS,
|
||||
`"${body.id}" is a stock CLI id and cannot be used for a custom entry`
|
||||
);
|
||||
}
|
||||
let outcome: ApiResponse<{ id: string }>;
|
||||
try {
|
||||
outcome = await mutateRegistryFile<ApiResponse<{ id: string }>>((file) => {
|
||||
if (Object.prototype.hasOwnProperty.call(file.clis, body.id)) {
|
||||
return {
|
||||
result: createErrorResponse(ApiErrorCode.ALREADY_EXISTS, `A custom CLI "${body.id}" already exists`),
|
||||
};
|
||||
}
|
||||
const candidate = buildCustomCliEntry({ ...body, enabled: body.enabled ?? true }, nextOrder());
|
||||
const parsed = CliEntrySchema.safeParse(candidate);
|
||||
if (!parsed.success) {
|
||||
return { result: createErrorResponse(ApiErrorCode.INVALID_INPUT, parsed.error.message) };
|
||||
}
|
||||
// Stored WITHOUT id/stock — those are forced back in by resolveRegistry() on every
|
||||
// read, so the override file never duplicates what the key and provenance already say.
|
||||
const { id: _id, stock: _stock, ...toStore } = parsed.data;
|
||||
file.clis = { ...file.clis, [body.id]: toStore };
|
||||
return { file, result: { success: true as const, data: { id: body.id } } };
|
||||
});
|
||||
} catch (err) {
|
||||
return refusedWriteResponse(err);
|
||||
}
|
||||
if (!outcome.success) return outcome;
|
||||
// A resolver may already exist for this id (a same-named entry deleted earlier in
|
||||
// this process) and would keep probing that entry's binaries.
|
||||
forgetResolvedCli(body.id, body.binaries);
|
||||
return outcome;
|
||||
});
|
||||
|
||||
// PUT /api/clis/custom/:id — full update of an EXISTING custom entry. A
|
||||
// separate path from Phase 3's PUT /api/clis/:id on purpose: that one is
|
||||
// structurally stock-only (404s any id it doesn't recognise as stock), so
|
||||
// there is no shared route where "which fields this id may change" depends
|
||||
// on a runtime check a caller could get wrong.
|
||||
app.put('/api/clis/custom/:id', async (req, reply): Promise<ApiResponse<{ id: string }>> => {
|
||||
const denied = await requireCliManagementGate(req);
|
||||
if (denied) {
|
||||
reply.code(403);
|
||||
return denied;
|
||||
}
|
||||
const { id } = req.params as { id: string };
|
||||
if (STOCK_IDS.has(id)) {
|
||||
return createErrorResponse(ApiErrorCode.INVALID_INPUT, `"${id}" is a stock CLI; use PUT /api/clis/${id} instead`);
|
||||
}
|
||||
const body = parseBody(CliCustomEntrySchema, { ...(req.body as object), id });
|
||||
let previousBinaries: readonly string[] = [];
|
||||
let outcome: ApiResponse<{ id: string }>;
|
||||
try {
|
||||
outcome = await mutateRegistryFile<ApiResponse<{ id: string }>>((file) => {
|
||||
if (!Object.prototype.hasOwnProperty.call(file.clis, id)) {
|
||||
return { result: createErrorResponse(ApiErrorCode.NOT_FOUND, `No custom CLI "${id}"`) };
|
||||
}
|
||||
const existing = listClis().find((e) => (e.id as string) === id);
|
||||
previousBinaries = existing?.discovery.binaries ?? [];
|
||||
// The edit form never sends `enabled`, so an absent value keeps the entry's current
|
||||
// state: editing a disabled CLI must not quietly re-enable it.
|
||||
const enabled = body.enabled ?? existing?.enabled ?? true;
|
||||
const candidate = buildCustomCliEntry({ ...body, enabled }, existing?.order ?? nextOrder());
|
||||
const parsed = CliEntrySchema.safeParse(candidate);
|
||||
if (!parsed.success) {
|
||||
return { result: createErrorResponse(ApiErrorCode.INVALID_INPUT, parsed.error.message) };
|
||||
}
|
||||
const { id: _id, stock: _stock, ...toStore } = parsed.data;
|
||||
file.clis = { ...file.clis, [id]: toStore };
|
||||
return { file, result: { success: true as const, data: { id } } };
|
||||
});
|
||||
} catch (err) {
|
||||
return refusedWriteResponse(err);
|
||||
}
|
||||
if (!outcome.success) return outcome;
|
||||
// The generic resolver captured the OLD binaries when first built; without this a
|
||||
// session spawn kept launching the previous binary until a restart.
|
||||
forgetResolvedCli(id, [...previousBinaries, ...body.binaries]);
|
||||
return outcome;
|
||||
});
|
||||
|
||||
// DELETE /api/clis/:id — refuses any STOCK id outright; deleting only ever
|
||||
// removes a CUSTOM entry's override.
|
||||
app.delete('/api/clis/:id', async (req, reply): Promise<ApiResponse<{ id: string }>> => {
|
||||
const denied = await requireCliManagementGate(req);
|
||||
if (denied) {
|
||||
reply.code(403);
|
||||
return denied;
|
||||
}
|
||||
const { id } = req.params as { id: string };
|
||||
if (STOCK_IDS.has(id)) {
|
||||
return createErrorResponse(ApiErrorCode.INVALID_INPUT, `"${id}" is a stock CLI and cannot be deleted`);
|
||||
}
|
||||
let previousBinaries: readonly string[] = [];
|
||||
let outcome: ApiResponse<{ id: string }>;
|
||||
try {
|
||||
outcome = await mutateRegistryFile<ApiResponse<{ id: string }>>((file) => {
|
||||
if (!Object.prototype.hasOwnProperty.call(file.clis, id)) {
|
||||
return { result: createErrorResponse(ApiErrorCode.NOT_FOUND, `No custom CLI "${id}"`) };
|
||||
}
|
||||
previousBinaries = listClis().find((e) => (e.id as string) === id)?.discovery.binaries ?? [];
|
||||
const { [id]: _removed, ...rest } = file.clis;
|
||||
file.clis = rest;
|
||||
return { file, result: { success: true as const, data: { id } } };
|
||||
});
|
||||
} catch (err) {
|
||||
return refusedWriteResponse(err);
|
||||
}
|
||||
if (!outcome.success) return outcome;
|
||||
forgetResolvedCli(id, previousBinaries);
|
||||
return outcome;
|
||||
});
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -38,3 +38,4 @@ export {
|
||||
type CustomModelSessionLike,
|
||||
type CustomModelSwapDisplacement,
|
||||
} from './custom-model-routes.js';
|
||||
export { registerCliRegistryRoutes, readCliManagementEnabled, type CliListItem } from './cli-registry-routes.js';
|
||||
|
||||
@@ -176,6 +176,7 @@ import {
|
||||
selectLastAnsweredTurn,
|
||||
} from '../response-viewer-transcript.js';
|
||||
import { readDeepSeekLastResponse } from '../../deepseek-transcript.js';
|
||||
import { appendClaudeCustomTitle } from '../../claude-session-title.js';
|
||||
|
||||
// Path to linked-cases registry (same file used by case-routes resolveCasePath)
|
||||
const LINKED_CASES_FILE = dataPath('linked-cases.json');
|
||||
@@ -1162,10 +1163,14 @@ export function registerSessionRoutes(
|
||||
const session = findSessionOrFail(ctx, id, req);
|
||||
|
||||
const name = String(body.name || '').slice(0, MAX_SESSION_NAME_LENGTH);
|
||||
// A no-op rename (the Session Options name field saves on blur and recomposes the same
|
||||
// string) must not flip nameSource to 'manual' or append a custom-title row to the transcript.
|
||||
if (name === session.name) return { name: session.name };
|
||||
session.name = name;
|
||||
// Also update the mux session name if applicable
|
||||
ctx.mux.updateSessionName(id, session.name);
|
||||
persistAndBroadcastSession(ctx, session);
|
||||
await syncClaudeTitle(session);
|
||||
return { name: session.name };
|
||||
});
|
||||
|
||||
@@ -1583,6 +1588,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) });
|
||||
|
||||
@@ -1612,6 +1623,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 {};
|
||||
@@ -2115,7 +2129,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) ==========
|
||||
@@ -2377,6 +2397,26 @@ export function registerSessionRoutes(
|
||||
return full ? { text: lastText, timestamp: lastTimestamp, messages } : { text: lastText, timestamp: lastTimestamp };
|
||||
}
|
||||
|
||||
/**
|
||||
* Mirror a rename into the conversation's `/resume` title (claude-session-title.ts).
|
||||
* Local Claude-format transcripts only: a remote pane's transcript lives on the remote host and a
|
||||
* docker pane's inside the container (HOME=/home/agent), never under the host's projects dir. Best
|
||||
* effort: the tab rename has already happened and must not fail on this.
|
||||
*/
|
||||
async function syncClaudeTitle(session: Session): Promise<void> {
|
||||
if (getCli(session.mode)?.capabilities.transcript !== 'claude-jsonl' || session.remote || session.docker) return;
|
||||
try {
|
||||
const projectsDir = join(process.env.HOME || '/tmp', '.claude', 'projects');
|
||||
const hookPath = ctx.getTranscriptPath(session.id);
|
||||
const transcript = hookPath
|
||||
? { sessionId: basename(hookPath, '.jsonl'), path: hookPath }
|
||||
: await findClaudeTranscript(projectsDir, session.claudeSessionId || session.id, session.id);
|
||||
if (transcript) await appendClaudeCustomTitle(transcript.path, transcript.sessionId, session.name);
|
||||
} catch (err) {
|
||||
console.warn(`[Session] Could not carry rename into the Claude transcript for ${session.id}:`, err);
|
||||
}
|
||||
}
|
||||
|
||||
/** Locate a top-level Claude transcript, including recovered tmux sessions. */
|
||||
async function findClaudeTranscript(
|
||||
projectsDir: string,
|
||||
|
||||
@@ -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
|
||||
@@ -172,7 +175,16 @@ export function registerWsRoutes(app: FastifyInstance, ctx: SessionPort, getHost
|
||||
try {
|
||||
const msg = JSON.parse(String(raw));
|
||||
if (msg.t === 'i' && typeof msg.d === 'string') {
|
||||
if (msg.d.length > MAX_INPUT_LENGTH) return;
|
||||
if (msg.d.length > MAX_INPUT_LENGTH) {
|
||||
// Refused for good, so say so: a silent return left the frame
|
||||
// unACKed and the client redelivered it every few seconds forever
|
||||
// (issue #484). A client that predates `err` reads this as a plain
|
||||
// ACK and drops the frame, which is also the right outcome.
|
||||
if (Number.isInteger(msg.seq) && socket.readyState === 1) {
|
||||
socket.send(`{"t":"ia","seq":${msg.seq as number},"err":"too_large","max":${MAX_INPUT_LENGTH}}`);
|
||||
}
|
||||
return;
|
||||
}
|
||||
// Reliable delivery: when the frame carries a clientId + seq, apply it
|
||||
// exactly once (skip a duplicate redelivery) but ACK it regardless so
|
||||
// the client can drop it from its durable queue. Frames without seq
|
||||
@@ -240,6 +252,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
|
||||
|
||||
+47
-1
@@ -20,6 +20,7 @@ import {
|
||||
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 { MAX_INPUT_LENGTH } from '../config/terminal-limits.js';
|
||||
import { enabledCliIds, enabledClis } from '../config/cli-registry/registry.js';
|
||||
import type { SessionMode } from '../types.js';
|
||||
|
||||
@@ -1317,6 +1318,15 @@ export const SettingsUpdateSchema = z
|
||||
* discovery, and the extra toolbar surface are all opt-in.
|
||||
*/
|
||||
customModelEndpointsEnabled: z.boolean().optional(),
|
||||
/**
|
||||
* CLI management (docs/cli-enable-disable-plan.md): the Settings UI section that
|
||||
* lets an admin enable/disable a stock CLI, trigger its install, and add/edit/
|
||||
* remove custom CLI entries — all previously hand-edit-only via ~/.codeman/clis.json.
|
||||
* SYNCED, default OFF: this is a machine-configuration surface (like Custom Model
|
||||
* Endpoints), not a display preference, and enabling it is what makes the write
|
||||
* endpoints (PUT/POST/DELETE /api/clis...) answer instead of refusing outright.
|
||||
*/
|
||||
cliManagementEnabled: z.boolean().optional(),
|
||||
/**
|
||||
* Read My Mind predictor model override. Empty/absent = the AI-checker
|
||||
* default (opus: prediction quality is the product and it runs only on an
|
||||
@@ -1491,7 +1501,10 @@ export const SettingsUpdateSchema = z
|
||||
* Schema for POST /api/sessions/:id/input with length limit
|
||||
*/
|
||||
export const SessionInputWithLimitSchema = z.object({
|
||||
input: z.string().max(100000), // 100KB max input
|
||||
// One limit for both transports (issue #484): the route's own length check and
|
||||
// ws-routes.ts read the same constant, so a schema cap above it only hid which
|
||||
// check refused the input.
|
||||
input: z.string().max(MAX_INPUT_LENGTH),
|
||||
useMux: z.boolean().optional(),
|
||||
// Reliable-delivery dedup (optional; absent for curl/legacy clients). The web
|
||||
// client tags each input with a stable clientId + a monotonic per-session seq
|
||||
@@ -1997,6 +2010,39 @@ export const CustomModelHostSchema = z.object({
|
||||
modelSizesGB: z.record(z.string().max(200), z.number().positive().max(100_000)).optional(),
|
||||
});
|
||||
|
||||
/**
|
||||
* A shell-safe bare word, mirroring `config/cli-registry/schema.ts`'s own `shellToken` —
|
||||
* duplicated rather than imported, since the REAL safety boundary for anything built from
|
||||
* this is `CliEntrySchema` itself, re-applied server-side once the full entry is assembled
|
||||
* (`cli-registry-routes.ts`). This is a request-shape sanity check, not the security gate.
|
||||
*/
|
||||
const cliShellToken = z
|
||||
.string()
|
||||
.min(1)
|
||||
.max(256)
|
||||
.regex(/^[A-Za-z0-9._:@=+/,-]+$/, 'must be a plain word with no shell metacharacters');
|
||||
|
||||
/** PUT /api/clis/:id (Phase 3) — enable/disable an existing entry, stock or custom; `enabled` is the ONLY thing this endpoint can flip. */
|
||||
export const CliEnableSchema = z.object({ enabled: z.boolean() });
|
||||
|
||||
/**
|
||||
* POST /api/clis + PUT /api/clis/custom/:id (Phase 5) — a deliberately MINIMAL custom-CLI
|
||||
* shape (docs/cli-enable-disable-plan.md, Phase 6 checklist: "scope the FIRST version to the
|
||||
* fields most stock entries actually use"), not the full `CliEntry`. `cli-registry-routes.ts`
|
||||
* assembles the rest with safe, conservative capability defaults and re-validates the whole
|
||||
* thing through `CliEntrySchema` before ever writing it — this schema exists to bound the
|
||||
* REQUEST shape, not to BE the safety layer (Decision 3: typed-argv only, no raw shell text).
|
||||
*/
|
||||
export const CliCustomEntrySchema = z.object({
|
||||
id: z.string().regex(/^[a-z][a-z0-9-]{0,23}$/, 'id must be lowercase, start with a letter, at most 24 chars'),
|
||||
label: z.string().min(1).max(60),
|
||||
shortBadge: z.string().min(1).max(6),
|
||||
enabled: z.boolean().optional(),
|
||||
binaries: z.array(cliShellToken).min(1).max(4),
|
||||
/** Bare argv tokens for the single launch variant — no flags-with-values, no params. */
|
||||
argv: z.array(cliShellToken).min(1).max(16),
|
||||
});
|
||||
|
||||
/** POST /api/sessions/:id/custom-model — apply or clear a session's custom-model selection. */
|
||||
export const CustomModelSelectionSchema = z.union([
|
||||
z.object({
|
||||
|
||||
+38
-2
@@ -226,6 +226,29 @@ export function reconcileStatusDecision(
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* PURE runtime staleness check, applied whenever the status is READ (not only on
|
||||
* boot). The live updater heartbeats `updatedAt` every few seconds, so an
|
||||
* in-flight status whose last write is older than the window has no updater
|
||||
* behind it. Without this, a status that never advanced (e.g. the updater could
|
||||
* not run its `--node` binary because Homebrew upgraded node under a long-running
|
||||
* server, so every status write failed) blocked every later update with "An
|
||||
* update is already in progress." until the server happened to restart.
|
||||
* Returns the failed status to persist, or null to leave the status untouched.
|
||||
*/
|
||||
export function expireStalledStatus(status: UpdateStatus | null, now: number): UpdateStatus | null {
|
||||
if (!status || !IN_FLIGHT_PHASES.has(status.phase)) return null;
|
||||
const lastWrite = status.updatedAt || status.startedAt;
|
||||
if (now - lastWrite <= RECONCILE_STALE_MS) return null;
|
||||
return {
|
||||
...status,
|
||||
phase: 'failed',
|
||||
message: 'Update stopped reporting progress',
|
||||
error: `no status update for ${Math.round((now - lastWrite) / 60_000)} min during "${status.phase}"`,
|
||||
updatedAt: now,
|
||||
};
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// PURE helpers — the container environment gate
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
@@ -380,6 +403,19 @@ export function writeUpdateStatusAtomic(status: UpdateStatus): void {
|
||||
renameSync(tmp, STATUS_FILE);
|
||||
}
|
||||
|
||||
/** Read the status, first failing (and persisting) an in-flight one that stopped heartbeating. */
|
||||
function readCurrentUpdateStatus(now = Date.now()): UpdateStatus | null {
|
||||
const status = readUpdateStatus();
|
||||
const expired = expireStalledStatus(status, now);
|
||||
if (!expired) return status;
|
||||
try {
|
||||
writeUpdateStatusAtomic(expired);
|
||||
} catch {
|
||||
// Still report the expired view; the next read retries the write.
|
||||
}
|
||||
return expired;
|
||||
}
|
||||
|
||||
/** Reconcile the status file on server boot (call once, early in start()). */
|
||||
export function reconcileUpdateOnBoot(now = Date.now()): void {
|
||||
const status = readUpdateStatus();
|
||||
@@ -796,7 +832,7 @@ export async function startUpdate(): Promise<StartUpdateResult> {
|
||||
: 'This is not a git install. Update with: npm i -g aicodeman@latest',
|
||||
};
|
||||
}
|
||||
const existing = readUpdateStatus();
|
||||
const existing = readCurrentUpdateStatus();
|
||||
if (isInFlight(existing)) {
|
||||
return { ok: false, code: 'in-flight', message: 'An update is already in progress.' };
|
||||
}
|
||||
@@ -885,7 +921,7 @@ export async function startUpdate(): Promise<StartUpdateResult> {
|
||||
|
||||
/** Current status for the polling endpoint; null collapses to an explicit idle. */
|
||||
export function getUpdateStatusForApi(): UpdateStatus {
|
||||
const status = readUpdateStatus();
|
||||
const status = readCurrentUpdateStatus();
|
||||
if (status) return status;
|
||||
return {
|
||||
updateId: '',
|
||||
|
||||
+218
-44
@@ -33,7 +33,8 @@ import fastifyCookie from '@fastify/cookie';
|
||||
import fastifyStatic from '@fastify/static';
|
||||
import fastifyWebsocket from '@fastify/websocket';
|
||||
import fastifyMultipart from '@fastify/multipart';
|
||||
import { startPasteImageGc } from './paste-image-gc.js';
|
||||
import { pasteImageDirInUseByOtherSession, startPasteImageGc } from './paste-image-gc.js';
|
||||
import { CLEAN_EXIT_CLOSE_REASON, shouldCloseCleanlyExitedSession } from '../pane-exit-sweep.js';
|
||||
import { join, dirname } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { existsSync, mkdirSync, readFileSync, chmodSync, rmSync, statSync } from 'node:fs';
|
||||
@@ -71,7 +72,8 @@ import {
|
||||
import { imageWatcher } from '../image-watcher.js';
|
||||
import { workflowRunWatcher, summarizeRun } from '../workflow-run-watcher.js';
|
||||
import { attachmentRegistry, buildFileThumbnailRoute, registerExternalAttachment } from '../attachment-registry.js';
|
||||
import { getCli, enabledClis } from '../config/cli-registry/registry.js';
|
||||
import { getCli, enabledClis, listClis } from '../config/cli-registry/registry.js';
|
||||
import { isCliEntryInstalled, probeStockCliAvailability } from '../utils/cli-installed-probes.js';
|
||||
import { readCustomModelHosts } from '../custom-model-hosts.js';
|
||||
import { applyCustomModelInjection, customModelConfigDir, removeConfigDir } from '../custom-model-injection-apply.js';
|
||||
import type { CustomModelBookkeeping } from '../types/session.js';
|
||||
@@ -201,6 +203,7 @@ import {
|
||||
detectCustomModelSwapDisplacements,
|
||||
pruneIdleLlamaSwapLogTails,
|
||||
tryWebviewRefererFallback,
|
||||
registerCliRegistryRoutes,
|
||||
} from './routes/index.js';
|
||||
import { isLostWebviewFrameNavigation } from './webview-proxy.js';
|
||||
import { CronService } from '../cron/cron-service.js';
|
||||
@@ -463,6 +466,11 @@ export class WebServer extends EventEmitter {
|
||||
this.mux.on('statsUpdated', (sessions) => {
|
||||
this.broadcast(SseEvent.MuxStatsUpdated, sessions);
|
||||
});
|
||||
// Ark0N/Codeman#446 — a pane read finished. Internal only: the field reaches
|
||||
// the browser on `session:updated`, and no SSE event was added for it.
|
||||
this.mux.on('paneExitsUpdated', () => {
|
||||
this.applyPaneExits();
|
||||
});
|
||||
|
||||
// COD-108 — remote-session auto-reconnect. The TmuxManager watcher detects a
|
||||
// dead remote pane and emits `remoteSessionDropped`; the session owner (here)
|
||||
@@ -1122,6 +1130,7 @@ export class WebServer extends EventEmitter {
|
||||
registerWebviewRoutes(this.app, ctx, this.basePath);
|
||||
registerTabLayoutRoutes(this.app, ctx);
|
||||
registerCustomModelRoutes(this.app);
|
||||
registerCliRegistryRoutes(this.app);
|
||||
|
||||
// Cron: build the service from the same context, recompute
|
||||
// due times for any persisted jobs, then expose it to its routes.
|
||||
@@ -1313,16 +1322,32 @@ export class WebServer extends EventEmitter {
|
||||
// Clean up all resources associated with a session
|
||||
// Track sessions currently being cleaned up to prevent concurrent cleanup races
|
||||
private cleaningUp: Set<string> = new Set();
|
||||
/**
|
||||
* The subset of {@link cleaningUp} whose tmux session is being KILLED rather
|
||||
* than detached. The paste-image guard needs the difference: a detaching
|
||||
* session keeps running in tmux and still uses its working directory.
|
||||
*/
|
||||
private killingSessions: Set<string> = new Set();
|
||||
|
||||
private async cleanupSession(sessionId: string, killMux: boolean = true, reason?: string): Promise<void> {
|
||||
// Guard against concurrent cleanup of the same session
|
||||
if (this.cleaningUp.has(sessionId)) return;
|
||||
this.cleaningUp.add(sessionId);
|
||||
if (killMux) this.killingSessions.add(sessionId);
|
||||
// Refuse a start or attach from here on (Ark0N/Codeman#446): a start that
|
||||
// raced this cleanup would launch a CLI in a tmux session whose record is
|
||||
// about to be deleted, leaving an orphan the next boot rediscovers.
|
||||
const session = this.sessions.get(sessionId);
|
||||
session?.markClosing(true);
|
||||
|
||||
try {
|
||||
await this._doCleanupSession(sessionId, killMux, reason);
|
||||
} finally {
|
||||
this.cleaningUp.delete(sessionId);
|
||||
this.killingSessions.delete(sessionId);
|
||||
// A cleanup that failed leaves the session on the board, so it must be
|
||||
// startable again.
|
||||
if (this.sessions.get(sessionId) === session) session?.markClosing(false);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1474,8 +1499,24 @@ export class WebServer extends EventEmitter {
|
||||
attachmentRegistry.clearSession(sessionId);
|
||||
// Stop watching for images in this session's directory
|
||||
imageWatcher.unwatchSession(sessionId);
|
||||
// Clean up pasted images directory for this session
|
||||
if (killMux && session.workingDir) {
|
||||
// Clean up pasted images directory for this session. The dir belongs to the
|
||||
// working directory rather than the session, so it stays while another live
|
||||
// session in the same case still uses it (Ark0N/Codeman#446).
|
||||
if (
|
||||
killMux &&
|
||||
session.workingDir &&
|
||||
!pasteImageDirInUseByOtherSession({
|
||||
live: this.sessions.values(),
|
||||
persisted: Object.entries(this.store.getSessions()).map(([id, record]) => ({
|
||||
id,
|
||||
workingDir: record.workingDir,
|
||||
status: record.status,
|
||||
})),
|
||||
closingId: sessionId,
|
||||
workingDir: session.workingDir,
|
||||
killing: this.killingSessions,
|
||||
})
|
||||
) {
|
||||
const pasteImageDir = join(session.workingDir, '.claude-images');
|
||||
try {
|
||||
rmSync(pasteImageDir, { recursive: true, force: true });
|
||||
@@ -1615,56 +1656,59 @@ export class WebServer extends EventEmitter {
|
||||
//
|
||||
// Solo popups skip it: no settings modal, no welcome screen, no run menu.
|
||||
if (!soloSessionId) {
|
||||
const [
|
||||
{ isClaudeAvailable },
|
||||
{ isOpenCodeAvailable },
|
||||
{ isCodexAvailable },
|
||||
{ isGeminiAvailable },
|
||||
{ isAntigravityAvailable },
|
||||
{ isPiAvailable },
|
||||
{ isGrokAvailable },
|
||||
{ isDeepSeekRunnable, isDeepSeekAvailable },
|
||||
{ isOmpAvailable },
|
||||
{ isCloudflaredAvailable },
|
||||
{ isGitAvailable },
|
||||
] = await Promise.all([
|
||||
import('../utils/claude-cli-resolver.js'),
|
||||
import('../utils/opencode-cli-resolver.js'),
|
||||
import('../utils/codex-cli-resolver.js'),
|
||||
import('../utils/gemini-cli-resolver.js'),
|
||||
import('../utils/antigravity-cli-resolver.js'),
|
||||
import('../utils/pi-cli-resolver.js'),
|
||||
import('../utils/grok-cli-resolver.js'),
|
||||
import('../utils/deepseek-cli-resolver.js'),
|
||||
import('../utils/omp-cli-resolver.js'),
|
||||
import('../utils/cloudflared-resolver.js'),
|
||||
import('../git-clone.js'),
|
||||
]);
|
||||
const available = {
|
||||
claude: isClaudeAvailable(),
|
||||
opencode: isOpenCodeAvailable(),
|
||||
codex: isCodexAvailable(),
|
||||
gemini: isGeminiAvailable(),
|
||||
antigravity: isAntigravityAvailable(),
|
||||
pi: isPiAvailable(),
|
||||
grok: isGrokAvailable(),
|
||||
// RUNNABLE, not merely installed: `dsh` is a profile launcher, and a dsh
|
||||
// with no pane-capable profile would offer a Run button that spawns a
|
||||
// pane which dies on arrival. The Add-Profile affordance in the run menu
|
||||
// keys off `deepseekBinary` instead, so a user who has the binary but no
|
||||
// profile is offered the fix rather than a greyed-out entry.
|
||||
deepseek: isDeepSeekRunnable(),
|
||||
const [{ isDeepSeekAvailable }, { isCloudflaredAvailable }, { isGitAvailable }, stockAvailability] =
|
||||
await Promise.all([
|
||||
import('../utils/deepseek-cli-resolver.js'),
|
||||
import('../utils/cloudflared-resolver.js'),
|
||||
import('../git-clone.js'),
|
||||
// Shared with GET /api/clis so the Settings badge and the Run menu cannot disagree.
|
||||
probeStockCliAvailability(),
|
||||
]);
|
||||
const available: Record<string, boolean> = {
|
||||
...stockAvailability,
|
||||
// `deepseek` above is RUNNABLE (binary + a pane-capable profile). The Add-Profile
|
||||
// affordance in the run menu keys off `deepseekBinary` instead, so a user who has
|
||||
// the binary but no profile is offered the fix rather than a greyed-out entry.
|
||||
deepseekBinary: isDeepSeekAvailable(),
|
||||
omp: isOmpAvailable(),
|
||||
cloudflared: isCloudflaredAvailable(),
|
||||
// Not a run mode: the Add Case → Clone tab is an offer this box cannot
|
||||
// keep without git (issue #236), same reasoning as cloudflared above.
|
||||
git: isGitAvailable(),
|
||||
};
|
||||
// A CLI disabled via the registry (docs/cli-enable-disable-plan.md's Settings UI,
|
||||
// or a hand-edited clis.json) must read as unavailable here too — `isCliAvailable()`
|
||||
// on the frontend is what the welcome screen, the Run-menu dropdown and the mobile
|
||||
// overview all gate on, and none of them otherwise know the registry's `enabled`
|
||||
// flag exists; without this, disabling a CLI in Settings toggled the row there but
|
||||
// left every launch surface still offering it. `git`/`cloudflared` are utility
|
||||
// binaries, not CLI registry entries, and `deepseekBinary` is a secondary
|
||||
// installed-only flag for the "add a profile" affordance — none of the three are
|
||||
// registry ids, so only the nine real SessionMode entries are gated.
|
||||
const cliCatalog = listClis().map((entry) => {
|
||||
const id = entry.id as string;
|
||||
const installed = isCliEntryInstalled(entry, stockAvailability);
|
||||
const enabled = entry.enabled;
|
||||
available[id] = enabled && installed;
|
||||
return {
|
||||
id,
|
||||
label: entry.label,
|
||||
shortBadge: entry.shortBadge,
|
||||
order: entry.order,
|
||||
kind: entry.kind,
|
||||
enabled,
|
||||
available: enabled && installed,
|
||||
};
|
||||
});
|
||||
html = html.replace(
|
||||
'</head>',
|
||||
() => `<script>window.__codemanCliAvailable=${JSON.stringify(available)};</script>\n</head>`
|
||||
);
|
||||
// The launch surfaces consume this deliberately small projection rather than
|
||||
// carrying a second hand-maintained list of CLI ids. It includes disabled
|
||||
// entries so Settings can redraw immediately after a toggle, while each
|
||||
// renderer filters on `enabled`/`available` before offering a launch action.
|
||||
const cliCatalogJson = escapeScriptJson(JSON.stringify(cliCatalog));
|
||||
html = html.replace('</head>', () => `<script>window.__codemanCliCatalog=${cliCatalogJson};</script>\n</head>`);
|
||||
// Which run modes the Run-menu picker (docs/custom-model-endpoints-plan.md) may
|
||||
// generate an entry for: read generically off the registry's `capabilities`
|
||||
// (never an id list here) so a CLI whose customModelInjection lands later shows
|
||||
@@ -1683,6 +1727,27 @@ export class WebServer extends EventEmitter {
|
||||
() => `<script>window.__codemanCustomModelClis=${customModelClisJson};</script>\n</head>`
|
||||
);
|
||||
}
|
||||
// How many columns each run mode indents its transcript by, so a copy can drop
|
||||
// that much. Read off `capabilities` like the payload above and never as an id
|
||||
// list here, so a CLI that declares a gutter later needs no frontend change.
|
||||
// Ids and small integers only, no user-settable strings, so JSON.stringify
|
||||
// alone is enough (same reasoning as __codemanCliAvailable's booleans).
|
||||
//
|
||||
// ⚠️ Outside the `if (!soloSessionId)` block above, unlike every other payload
|
||||
// here: a detached session window (`/session/:id`) runs a terminal, so Ctrl+C
|
||||
// copies there, and an absent map reads as "no session gets a strip". The
|
||||
// toggle used to work in the main window and do nothing in the popup on the
|
||||
// same device. This needs no availability probe, so it costs a solo window
|
||||
// nothing that the run menu's own payloads would have cost it.
|
||||
const gutterClis: Record<string, number> = {};
|
||||
for (const entry of enabledClis()) {
|
||||
const columns = entry.capabilities.transcriptGutter;
|
||||
if (typeof columns === 'number') gutterClis[entry.id] = columns;
|
||||
}
|
||||
html = html.replace(
|
||||
'</head>',
|
||||
() => `<script>window.__codemanTranscriptGutter=${JSON.stringify(gutterClis)};</script>\n</head>`
|
||||
);
|
||||
if (!soloSessionId && process.env.CODEMAN_GESTURE === '1') {
|
||||
html = html.replace('</head>', () => `<script>window.__codemanGestureAvailable=true;</script>\n</head>`);
|
||||
if (settings.gestureControlEnabled === true) {
|
||||
@@ -2455,6 +2520,96 @@ export class WebServer extends EventEmitter {
|
||||
this.sse.broadcastSessionStateDebounced(sessionId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Fold the latest pane readings into the sessions they belong to
|
||||
* (Ark0N/Codeman#446). A reading that changes a session's answer persists the
|
||||
* record and pushes a `session:updated`, which is how the tab learns; a read
|
||||
* that repeats what the last one said costs nothing.
|
||||
*
|
||||
* The answer is pulled per session from the mux rather than taken off a
|
||||
* broadcast payload. The mux reports the RAW pane reading, which for a remote
|
||||
* or docker session is the death of an ssh client or a `docker exec` rather
|
||||
* than of the agent, so it must not travel to a browser at all;
|
||||
* `Session.setPaneExit()` is where that scoping is applied.
|
||||
*
|
||||
* Nothing here touches `status` or `pid`. `status: 'error'` belongs to the
|
||||
* PTY-exit breaker and makes the browser offer a restart, and a null `pid` is
|
||||
* what makes the browser re-attach and launch a fresh CLI.
|
||||
*
|
||||
* Once the records are current, {@link closeCleanlyExitedSessions} closes the
|
||||
* sessions whose agent the user ended.
|
||||
*/
|
||||
private applyPaneExits(): void {
|
||||
const getPaneExit = this.mux.getPaneExit?.bind(this.mux);
|
||||
if (!getPaneExit) return;
|
||||
for (const session of this.sessions.values()) {
|
||||
const muxName = session.muxName;
|
||||
// No pane, so nothing to report — and `setPaneExit()` would force UNKNOWN
|
||||
// for such a session anyway.
|
||||
if (!muxName) continue;
|
||||
if (!session.setPaneExit(getPaneExit(muxName))) continue;
|
||||
this.persistSessionState(session);
|
||||
this.broadcastSessionStateDebounced(session.id);
|
||||
}
|
||||
this.closeCleanlyExitedSessions();
|
||||
}
|
||||
|
||||
/**
|
||||
* Close every session whose agent exited cleanly, through the same
|
||||
* `cleanupSession()` the X button uses (Ark0N/Codeman#446). A pinned session
|
||||
* is demoted to `status: 'stopped'` there rather than removed, and either way
|
||||
* the reboot restore stops offering it back. The conversation stays
|
||||
* resumable, since the Resume list reads the lifecycle log and the transcript
|
||||
* files, and the pane owns neither.
|
||||
*
|
||||
* `shouldCloseCleanlyExitedSession()` (`pane-exit-sweep.ts`) holds the rule:
|
||||
* an explicit status of 0, confirmed by more than one pane read, with no
|
||||
* start or attach in flight and not within seconds of one (a startup error). A crashed agent keeps its row with the exit
|
||||
* code on it. `session.paneExit` is already scoped to local mux-backed
|
||||
* sessions by `setPaneExit()`, so a remote, docker or direct-PTY session is
|
||||
* never closed here.
|
||||
*
|
||||
* The close runs in the background. `cleanupSession()` ignores a second call
|
||||
* for a session it is already closing, and the `closing` check below keeps
|
||||
* the next tick from queueing one.
|
||||
*
|
||||
* Each exit is attempted ONCE, keyed by session id and the exit's `at`
|
||||
* stamp. A close that fails leaves the session on the board with its exit
|
||||
* badge, which is where a crashed agent's row would be too, rather than
|
||||
* retrying and logging every two seconds. A new exit in the same pane has a
|
||||
* new `at` and gets its own attempt.
|
||||
*/
|
||||
/** Exits the clean-exit sweep has already tried to close, as `<sessionId>:<exit.at>`. */
|
||||
private cleanExitCloseAttempts: Set<string> = new Set();
|
||||
|
||||
private closeCleanlyExitedSessions(): void {
|
||||
const readCount = this.mux.getPaneExitReadCount?.bind(this.mux);
|
||||
if (!readCount) return;
|
||||
// Forget attempts for sessions that are gone, so the set stays bounded.
|
||||
for (const key of this.cleanExitCloseAttempts) {
|
||||
if (!this.sessions.has(key.slice(0, key.lastIndexOf(':')))) this.cleanExitCloseAttempts.delete(key);
|
||||
}
|
||||
for (const session of [...this.sessions.values()]) {
|
||||
const muxName = session.muxName;
|
||||
if (!muxName) continue;
|
||||
const close = shouldCloseCleanlyExitedSession({
|
||||
paneExit: session.paneExit,
|
||||
confirmingReads: readCount(muxName),
|
||||
paneLifecycleInFlight: session.paneLifecycleInFlight,
|
||||
closing: this.cleaningUp.has(session.id),
|
||||
paneStartedAt: session.paneStartedAt,
|
||||
});
|
||||
if (!close) continue;
|
||||
const attempt = `${session.id}:${session.paneExit?.at ?? 0}`;
|
||||
if (this.cleanExitCloseAttempts.has(attempt)) continue;
|
||||
this.cleanExitCloseAttempts.add(attempt);
|
||||
console.log(`[Server] Closing session ${session.id} (${session.name}): ${CLEAN_EXIT_CLOSE_REASON}`);
|
||||
void this.cleanupSession(session.id, true, CLEAN_EXIT_CLOSE_REASON).catch((err) => {
|
||||
console.error(`[Server] Failed to close cleanly exited session ${session.id}:`, err);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// ========== Web Push ==========
|
||||
|
||||
/** Map SSE event names to push notification payloads */
|
||||
@@ -3336,6 +3491,15 @@ export class WebServer extends EventEmitter {
|
||||
// the conversation the CLI was on when the server stopped, which is
|
||||
// what a re-attach must point the viewer at instead of the launch id.
|
||||
claudeSessionChain: savedState?.claudeSessionChain,
|
||||
// What the previous run last observed of this pane's agent. Carried
|
||||
// over so the first persist after boot does not blank a record that
|
||||
// says the agent exited; the attach below drops it, and the
|
||||
// pane-exit watcher's own tick replaces it with a first-hand
|
||||
// reading (not the stats collector — see `startPaneExitWatcher`).
|
||||
paneExit: savedState?.paneExit,
|
||||
// A record rebuilt from the socket has no provenance, so its
|
||||
// apparent locality is a guess (see `MuxSession.discovered`).
|
||||
discoveredMuxSession: muxSession.discovered,
|
||||
// The pane's last output, previous run's value. Without it every
|
||||
// restart restamped all sessions "now" (constructor + the attach
|
||||
// repaint within the same second), flattening the home screens'
|
||||
@@ -3545,6 +3709,15 @@ export class WebServer extends EventEmitter {
|
||||
(this.mux as { startMouseModeSync: (ms?: number) => void }).startMouseModeSync();
|
||||
}
|
||||
|
||||
// Ark0N/Codeman#446 — poll every pane for an exited agent. Always start,
|
||||
// even with no sessions, for the same reason as the two watchers around
|
||||
// it: sessions arrive later. Deliberately NOT folded into the stats
|
||||
// collector above, which the browser arms and disarms with the Monitor
|
||||
// panel and which boot skips entirely when nothing was recovered.
|
||||
if ('startPaneExitWatcher' in this.mux) {
|
||||
(this.mux as { startPaneExitWatcher: (ms?: number) => void }).startPaneExitWatcher();
|
||||
}
|
||||
|
||||
// COD-108 — start the remote-session auto-reconnect watcher (tmux only).
|
||||
// Always-on (D3) with a `remoteAutoReconnect` kill-switch the watcher reads
|
||||
// each tick. Start even with no sessions — remote sessions may arrive later.
|
||||
@@ -3883,6 +4056,7 @@ export class WebServer extends EventEmitter {
|
||||
}
|
||||
this.activePlanOrchestrators.clear();
|
||||
this.cleaningUp.clear();
|
||||
this.killingSessions.clear();
|
||||
|
||||
// Dispose push store (flush pending saves)
|
||||
this.pushStore.dispose();
|
||||
|
||||
@@ -43,6 +43,7 @@ export interface SessionListenerRefs {
|
||||
exit: (code: number | null) => void;
|
||||
working: () => void;
|
||||
idle: () => void;
|
||||
watchingChanged: () => void;
|
||||
taskCreated: (task: BackgroundTask) => void;
|
||||
taskUpdated: (task: BackgroundTask) => void;
|
||||
taskCompleted: (task: BackgroundTask) => void;
|
||||
@@ -263,6 +264,17 @@ export function createSessionListeners(session: Session, deps: SessionListenerDe
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Pushes the session state when `Session.watching` changes without the status
|
||||
* changing with it. That is the badge appearing or, more often, going away: a CLI can
|
||||
* finish its background work without taking a turn, so the row is idle before and
|
||||
* after and no other broadcast fires. There is no SSE event of its own, because the
|
||||
* badge reads off the session payload every surface already has.
|
||||
*/
|
||||
watchingChanged: () => {
|
||||
deps.broadcastSessionStateDebounced(session.id);
|
||||
},
|
||||
|
||||
// ─── Background Task Events ──────────────────────────────
|
||||
|
||||
/** Broadcasts `task:created` — new background task discovered */
|
||||
@@ -495,6 +507,7 @@ export function attachSessionListeners(session: Session, refs: SessionListenerRe
|
||||
session.on('exit', refs.exit);
|
||||
session.on('working', refs.working);
|
||||
session.on('idle', refs.idle);
|
||||
session.on('watchingChanged', refs.watchingChanged);
|
||||
session.on('taskCreated', refs.taskCreated);
|
||||
session.on('taskUpdated', refs.taskUpdated);
|
||||
session.on('taskCompleted', refs.taskCompleted);
|
||||
@@ -531,6 +544,7 @@ export function detachSessionListeners(session: Session, refs: SessionListenerRe
|
||||
session.off('exit', refs.exit);
|
||||
session.off('working', refs.working);
|
||||
session.off('idle', refs.idle);
|
||||
session.off('watchingChanged', refs.watchingChanged);
|
||||
session.off('taskCreated', refs.taskCreated);
|
||||
session.off('taskUpdated', refs.taskUpdated);
|
||||
session.off('taskCompleted', refs.taskCompleted);
|
||||
|
||||
@@ -20,11 +20,15 @@ import { fileURLToPath } from 'node:url';
|
||||
import {
|
||||
agentImageBuildArgPairs as mjsPairs,
|
||||
agentImageNpmPackages as mjsPackages,
|
||||
GIT_HOST_CLI_BUILD_ARGS as mjsGitHostArgs,
|
||||
gitHostCliBuildArgPairs as mjsGitHostPairs,
|
||||
} from '../scripts/lib/cli-catalog.mjs';
|
||||
import {
|
||||
agentImageBuildArgPairs as tsPairs,
|
||||
agentImageBuildArgs,
|
||||
agentImageNpmPackages as tsPackages,
|
||||
GIT_HOST_CLI_BUILD_ARGS as tsGitHostArgs,
|
||||
gitHostCliBuildArgPairs as tsGitHostPairs,
|
||||
} from '../src/docker-hosts.js';
|
||||
|
||||
const CATALOG = JSON.parse(readFileSync(fileURLToPath(new URL('../config/clis.stock.json', import.meta.url)), 'utf-8'));
|
||||
@@ -96,3 +100,48 @@ describe('agent-image build args: the .mjs and the TS mirror agree', () => {
|
||||
expect(extract(tsSource, 'docker-hosts.ts')).toBe(extract(mjsSource, 'cli-catalog.mjs'));
|
||||
});
|
||||
});
|
||||
|
||||
describe('optional gh / az in the agent image: both producers pass the same switches', () => {
|
||||
const ENV_GH = 'CODEMAN_AGENT_IMAGE_INSTALL_GH';
|
||||
const ENV_AZ = 'CODEMAN_AGENT_IMAGE_INSTALL_AZ';
|
||||
|
||||
it('map the same environment variables to the same Dockerfile ARGs', () => {
|
||||
expect(tsGitHostArgs).toEqual(mjsGitHostArgs);
|
||||
expect(tsGitHostArgs.map(([, arg]) => arg)).toEqual(['CODEMAN_INSTALL_GH', 'CODEMAN_INSTALL_AZ']);
|
||||
});
|
||||
|
||||
it('agree for every combination, and an unset or empty variable adds nothing', () => {
|
||||
for (const gh of [undefined, '', '0', '1']) {
|
||||
for (const az of [undefined, '', '0', '1']) {
|
||||
const env: NodeJS.ProcessEnv = {};
|
||||
if (gh !== undefined) env[ENV_GH] = gh;
|
||||
if (az !== undefined) env[ENV_AZ] = az;
|
||||
const expected: Array<[string, string]> = [];
|
||||
if (gh) expected.push(['CODEMAN_INSTALL_GH', gh]);
|
||||
if (az) expected.push(['CODEMAN_INSTALL_AZ', az]);
|
||||
expect(tsGitHostPairs(env)).toEqual(expected);
|
||||
expect(mjsGitHostPairs(env)).toEqual(expected);
|
||||
expect(tsPairs(env)).toEqual(mjsPairs(CATALOG, env));
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it('keeps the default argv unchanged when neither variable is set', () => {
|
||||
expect(tsPairs({})).toEqual([['CLI_NPM_PACKAGES', tsPackages().join(' ')]]);
|
||||
});
|
||||
|
||||
it('refuses anything but 0 or 1 on both sides, naming the variable', () => {
|
||||
for (const bad of ['yes', 'true', '2', ' 1', '0 && echo']) {
|
||||
expect(() => tsGitHostPairs({ [ENV_AZ]: bad })).toThrow(new RegExp(ENV_AZ));
|
||||
expect(() => mjsGitHostPairs({ [ENV_AZ]: bad })).toThrow(new RegExp(ENV_AZ));
|
||||
}
|
||||
});
|
||||
|
||||
it('both Dockerfiles declare the switches, defaulting to OFF (opt-in)', () => {
|
||||
for (const file of ['../docker/agent.Dockerfile', '../docker/server.Dockerfile']) {
|
||||
const dockerfile = readFileSync(fileURLToPath(new URL(file, import.meta.url)), 'utf-8');
|
||||
expect(dockerfile, file).toMatch(/^ARG CODEMAN_INSTALL_GH=0$/m);
|
||||
expect(dockerfile, file).toMatch(/^ARG CODEMAN_INSTALL_AZ=0$/m);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -123,7 +123,13 @@ describe('App Settings modal structure', () => {
|
||||
const select = modal.match(/id="appSettingsClaudeModel"([\s\S]*?)<\/select>/)?.[1] ?? '';
|
||||
// The cards render the base models; the [1m] rows exist so that base + the
|
||||
// context switch can compose back into a real claudeModel value.
|
||||
for (const value of ['opus[1m]', 'claude-fable-5[1m]', 'claude-fable-5-1[1m]', 'claude-opus-4-6[1m]']) {
|
||||
for (const value of [
|
||||
'opus[1m]',
|
||||
'claude-fable-5[1m]',
|
||||
'claude-fable-5-1[1m]',
|
||||
'claude-opus-5-5[1m]',
|
||||
'claude-opus-4-6[1m]',
|
||||
]) {
|
||||
expect(select).toContain(`value="${value}"`);
|
||||
}
|
||||
expect(select).toContain('data-ctx="1"');
|
||||
@@ -148,6 +154,22 @@ describe('App Settings modal structure', () => {
|
||||
}
|
||||
});
|
||||
|
||||
it('models: offers Opus 5.5 as a card and to task routing', () => {
|
||||
const modal = settingsModal();
|
||||
const select = modal.match(/id="appSettingsClaudeModel"([\s\S]*?)<\/select>/)?.[1] ?? '';
|
||||
expect(select).toMatch(/value="claude-opus-5-5"[^>]*data-ctx="1"/);
|
||||
for (const id of [
|
||||
'appSettingsDefaultModel',
|
||||
'appSettingsModelExplore',
|
||||
'appSettingsModelImplement',
|
||||
'appSettingsModelTest',
|
||||
'appSettingsModelReview',
|
||||
]) {
|
||||
const routing = modal.match(new RegExp(`id="${id}"([\\s\\S]*?)</select>`))?.[1] ?? '';
|
||||
expect(routing, `${id} does not offer Opus 5.5`).toContain('value="claude-opus-5-5"');
|
||||
}
|
||||
});
|
||||
|
||||
it('has retired the modal-tab chrome everywhere, not just here', () => {
|
||||
// Session Options and Add Case moved onto this same `set-*` surface, so the
|
||||
// old tab classes have no users left. A reappearance means a modal drifted
|
||||
|
||||
@@ -330,6 +330,58 @@ describe('ApprovalInbox', () => {
|
||||
expect(inbox.getById(second.id)).toBeDefined();
|
||||
});
|
||||
|
||||
describe('a session watching its own background work', () => {
|
||||
it('opens its idle prompt already acknowledged, and says why', () => {
|
||||
const item = inbox.notePrompt({ sessionId: 's1', sessionName: 'w1', kind: 'idle', watching: '1 monitor' });
|
||||
expect(item.acknowledgedAt).toBe(item.createdAt);
|
||||
expect(item.acknowledgedReason).toBe('watching 1 monitor');
|
||||
});
|
||||
|
||||
it('keeps the prompt pending and answerable: only its alert is spent', () => {
|
||||
// Acknowledging rather than skipping creation is what makes a wrong label cheap.
|
||||
// The prompt is real either way, and this way it is still in the drawer, still
|
||||
// answerable and still Read My Mind context.
|
||||
const item = inbox.notePrompt({ sessionId: 's1', sessionName: 'w1', kind: 'idle', watching: '2 shells' });
|
||||
expect(inbox.listPending().map((i) => i.id)).toEqual([item.id]);
|
||||
expect(inbox.getForSession('s1')?.id).toBe(item.id);
|
||||
expect(inbox.verifyStillAnswerable(item.id)).toBe(true);
|
||||
});
|
||||
|
||||
it('never pre-acknowledges a dialog that blocks the agent', () => {
|
||||
// A permission or question dialog blocks the turn whatever else the agent started,
|
||||
// so watching says nothing about whether a human is needed.
|
||||
for (const kind of ['permission', 'question'] as const) {
|
||||
const item = inbox.notePrompt({ sessionId: `s-${kind}`, sessionName: 'w1', kind, watching: '1 monitor' });
|
||||
expect(item.acknowledgedAt).toBeUndefined();
|
||||
expect(item.acknowledgedReason).toBeUndefined();
|
||||
}
|
||||
});
|
||||
|
||||
it('leaves an ordinary idle prompt alone', () => {
|
||||
for (const watching of [undefined, null, '']) {
|
||||
const item = inbox.notePrompt({ sessionId: 's1', sessionName: 'w1', kind: 'idle', watching });
|
||||
expect(item.acknowledgedAt).toBeUndefined();
|
||||
}
|
||||
});
|
||||
|
||||
it('re-arms by itself once the background work is over', () => {
|
||||
// The next prompt supersedes this one and is built fresh, so nothing has to
|
||||
// remember to clear the flag when the monitor ends.
|
||||
const watched = inbox.notePrompt({ sessionId: 's1', sessionName: 'w1', kind: 'idle', watching: '1 monitor' });
|
||||
expect(watched.acknowledgedAt).toBeDefined();
|
||||
const after = inbox.notePrompt({ sessionId: 's1', sessionName: 'w1', kind: 'idle' });
|
||||
expect(after.id).not.toBe(watched.id);
|
||||
expect(after.acknowledgedAt).toBeUndefined();
|
||||
expect(after.acknowledgedReason).toBeUndefined();
|
||||
});
|
||||
|
||||
it('cannot be acknowledged a second time by a human opening the session', () => {
|
||||
const item = inbox.notePrompt({ sessionId: 's1', sessionName: 'w1', kind: 'idle', watching: '1 monitor' });
|
||||
expect(inbox.acknowledge('s1')).toBeUndefined();
|
||||
expect(inbox.getById(item.id)?.acknowledgedReason).toBe('watching 1 monitor');
|
||||
});
|
||||
});
|
||||
|
||||
describe('verifyStillAnswerable', () => {
|
||||
it('resolves the item and refuses when a parsed dialog left the screen', () => {
|
||||
const { resolved } = collect(inbox);
|
||||
|
||||
@@ -136,9 +136,10 @@ async function stubTerminalDynamic(
|
||||
/**
|
||||
* Answer every fetch with the geometry the client itself is asking for, read
|
||||
* live from the page. That is the clamp signature: `getTerminalDimensions()`
|
||||
* floors at 40x10 while `fitAddon.fit()` does not, so a small enough viewport
|
||||
* makes the pane permanently bigger than the terminal at a size the client
|
||||
* requested itself.
|
||||
* floors at 40x10, and since #464 `syncTerminalGeometry()` applies that floor
|
||||
* to xterm too — so at a small enough viewport the pane, the report and the
|
||||
* terminal all agree on the floored size, which is the case the equality guard
|
||||
* is left covering.
|
||||
*/
|
||||
async function stubTerminalAtRequestedSize(page: Page, counter: { n: number; urls: string[] }) {
|
||||
await page.route('**/api/sessions/*/terminal*', async (route) => {
|
||||
@@ -498,13 +499,22 @@ describe('a capture bigger than the terminal', () => {
|
||||
}, 60_000);
|
||||
|
||||
it('does not replay a pane already at the size the client asked for', async () => {
|
||||
// `getTerminalDimensions()` floors at 40x10 while `fitAddon.fit()` does
|
||||
// not, so a viewport this small leaves the terminal shorter than the size
|
||||
// the client itself requests, and the pane obligingly draws at the floored
|
||||
// size. The captured height then exceeds the terminal's forever. A replay
|
||||
// cannot converge, because it re-requests the same floored size and
|
||||
// captures the same frame, so without the equality guard this retries on
|
||||
// every tab switch for the life of the page.
|
||||
// ⚠️ The premise of this case CHANGED with issue #464, and the old one can
|
||||
// never hold again. It used to be the clamp: `getTerminalDimensions()`
|
||||
// floors at 40x10 while `fitAddon.fit()` did not, so a viewport this small
|
||||
// left the terminal shorter than the size the client itself requested, the
|
||||
// pane drew at the floored size, and the captured height exceeded the
|
||||
// terminal's forever — a replay that re-requested the same floored size and
|
||||
// captured the same frame, on every tab switch, for the life of the page.
|
||||
//
|
||||
// `syncTerminalGeometry()` now applies the floor to xterm as well, so the
|
||||
// browser terminal IS the size it reports and that divergence is gone at
|
||||
// the source. The case survives on its own terms — a pane already drawing
|
||||
// at the requested size must not be replayed, because the retry would
|
||||
// capture the identical frame — and its premise is now the #464 invariant
|
||||
// itself, asserted below: the floored report and the terminal agree. That
|
||||
// is a stronger guard than the old one, since the clamp coming back would
|
||||
// fail it here rather than silently restoring the replay loop.
|
||||
context = await browser.newContext({ viewport: { width: 320, height: 200 } });
|
||||
page = await context.newPage();
|
||||
const sessionId = await openSession(page);
|
||||
@@ -514,8 +524,9 @@ describe('a capture bigger than the terminal', () => {
|
||||
await consumeFullHistory(page, sessionId, fetches);
|
||||
await select(page, sessionId, { forceReload: true });
|
||||
|
||||
// The premise: the floor really does bind here. Without this the case
|
||||
// would pass on any viewport, proving nothing.
|
||||
// The premise: the floor really does bind at this viewport — otherwise the
|
||||
// case would pass on any viewport, proving nothing — AND the terminal holds
|
||||
// exactly what it reports, which is what stops the old replay loop.
|
||||
const requested = await page.evaluate(
|
||||
() =>
|
||||
(
|
||||
@@ -523,7 +534,20 @@ describe('a capture bigger than the terminal', () => {
|
||||
).app.getTerminalDimensions?.() ?? null
|
||||
);
|
||||
expect(requested).not.toBeNull();
|
||||
expect(requested!.rows).toBeGreaterThan(await terminalRows(page));
|
||||
const proposed = await page.evaluate(
|
||||
() =>
|
||||
(
|
||||
window as unknown as { app: { fitAddon?: { proposeDimensions?: () => { cols: number; rows: number } } } }
|
||||
).app.fitAddon?.proposeDimensions?.() ?? null
|
||||
);
|
||||
expect(proposed, 'the terminal could not be measured').not.toBeNull();
|
||||
expect(
|
||||
proposed!.rows < requested!.rows || proposed!.cols < requested!.cols,
|
||||
`the floor must bind at this viewport, or the case proves nothing (proposed ${proposed!.cols}x${proposed!.rows}, reported ${requested!.cols}x${requested!.rows})`
|
||||
).toBe(true);
|
||||
// The #464 invariant: what the client reports is what the terminal holds.
|
||||
expect(requested!.rows).toBe(await terminalRows(page));
|
||||
expect(requested!.cols).toBe(await terminalCols(page));
|
||||
|
||||
expect(fetches.n).toBe(1);
|
||||
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
/**
|
||||
* @fileoverview Claude's `/resume` title belongs to Claude unless the user chose one.
|
||||
*
|
||||
* `--name` sets the prompt-box label, the `/resume` picker entry and the terminal
|
||||
* title, and a pinned title stops Claude generating its own. Pinning the
|
||||
* `w1-myapp` placeholder therefore listed every conversation of a case under the
|
||||
* same name in `/resume`. Only a manual name is pinned now (`cliPinnedName`), and
|
||||
* a rename reaches the transcript as a `custom-title` row.
|
||||
*/
|
||||
|
||||
import { mkdtempSync, readFileSync, rmSync, writeFileSync, existsSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { afterEach, describe, expect, it } from 'vitest';
|
||||
import { Session } from '../src/session.js';
|
||||
import { appendClaudeCustomTitle } from '../src/claude-session-title.js';
|
||||
|
||||
type RespawnOptionsProbe = { _buildRespawnPaneOptions(): { name?: string; cliName?: string } };
|
||||
|
||||
describe('Session.cliPinnedName', () => {
|
||||
it('pins nothing for a placeholder, so Claude titles the conversation itself', () => {
|
||||
const session = new Session({ workingDir: '/tmp', name: 'w1-demo' });
|
||||
expect(session.cliPinnedName).toBeUndefined();
|
||||
const options = (session as unknown as RespawnOptionsProbe)._buildRespawnPaneOptions();
|
||||
// The tab keeps its name; only the CLI flag is withheld.
|
||||
expect(options.name).toBe('w1-demo');
|
||||
expect(options.cliName).toBeUndefined();
|
||||
});
|
||||
|
||||
it('pins nothing for an auto name, whose cut of the prompt Claude beats', () => {
|
||||
const session = new Session({ workingDir: '/tmp', name: 'w1-demo' });
|
||||
expect(session.applyAutoName('w1-demo: fix the login redirect')).toBe(true);
|
||||
expect(session.cliPinnedName).toBeUndefined();
|
||||
});
|
||||
|
||||
it('pins a name the user chose, at creation or by a rename', () => {
|
||||
expect(new Session({ workingDir: '/tmp', name: 'msgtest-worker' }).cliPinnedName).toBe('msgtest-worker');
|
||||
|
||||
const renamed = new Session({ workingDir: '/tmp', name: 'w1-demo' });
|
||||
renamed.name = '登录修复';
|
||||
expect(renamed.cliPinnedName).toBe('登录修复');
|
||||
expect((renamed as unknown as RespawnOptionsProbe)._buildRespawnPaneOptions().cliName).toBe('登录修复');
|
||||
});
|
||||
});
|
||||
|
||||
describe('appendClaudeCustomTitle', () => {
|
||||
const dirs: string[] = [];
|
||||
afterEach(() => {
|
||||
for (const dir of dirs.splice(0)) rmSync(dir, { recursive: true, force: true });
|
||||
});
|
||||
const tempTranscript = (content: string) => {
|
||||
const dir = mkdtempSync(join(tmpdir(), 'codeman-claude-title-'));
|
||||
dirs.push(dir);
|
||||
const path = join(dir, 'conv.jsonl');
|
||||
if (content) writeFileSync(path, content);
|
||||
return path;
|
||||
};
|
||||
|
||||
it('appends one custom-title row after the existing rows', async () => {
|
||||
const path = tempTranscript('{"type":"user"}\n');
|
||||
expect(await appendClaudeCustomTitle(path, 'conv', ' release notes "v2" ')).toBe(true);
|
||||
const lines = readFileSync(path, 'utf8').split('\n');
|
||||
expect(lines).toHaveLength(3);
|
||||
expect(lines[0]).toBe('{"type":"user"}');
|
||||
expect(JSON.parse(lines[1])).toEqual({
|
||||
type: 'custom-title',
|
||||
customTitle: 'release notes "v2"',
|
||||
sessionId: 'conv',
|
||||
});
|
||||
expect(lines[2]).toBe('');
|
||||
});
|
||||
|
||||
it('never creates a transcript that does not exist yet', async () => {
|
||||
const path = tempTranscript('');
|
||||
expect(await appendClaudeCustomTitle(path, 'conv', 'title')).toBe(false);
|
||||
expect(existsSync(path)).toBe(false);
|
||||
});
|
||||
|
||||
it('writes nothing for a blank title', async () => {
|
||||
const path = tempTranscript('{"type":"user"}\n');
|
||||
expect(await appendClaudeCustomTitle(path, 'conv', ' ')).toBe(false);
|
||||
expect(readFileSync(path, 'utf8')).toBe('{"type":"user"}\n');
|
||||
});
|
||||
});
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
createCliExecutableResolver,
|
||||
createProductionCliResolverHost,
|
||||
formatCliNotFoundMessage,
|
||||
invalidateCliExecutableResolvers,
|
||||
type CliResolverHost,
|
||||
} from '../src/utils/cli-executable-resolver.js';
|
||||
|
||||
@@ -129,6 +130,49 @@ describe('createCliExecutableResolver', () => {
|
||||
expect(findInLoginShell).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('invalidation drops a negative-cache backoff immediately (a CLI installed from Settings)', () => {
|
||||
let now = 0;
|
||||
const findInLoginShell = vi.fn<() => string | null>().mockReturnValueOnce(null).mockReturnValue('/new/bin/grokx');
|
||||
const h = host({ findInLoginShell, exists: vi.fn((path) => path === '/new/bin/grokx') });
|
||||
const resolver = createCliExecutableResolver({ binary: 'grokx', searchDirs: [], now: () => now }, h);
|
||||
|
||||
expect(resolver.resolve()).toBeNull();
|
||||
// Still inside the backoff window: without invalidation this answers from the
|
||||
// negative cache for up to five minutes after a successful install.
|
||||
now = 1;
|
||||
invalidateCliExecutableResolvers(['grokx']);
|
||||
expect(resolver.resolve()?.binaryPath).toBe('/new/bin/grokx');
|
||||
expect(findInLoginShell).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('invalidation drops a cached success too, so an edited binary is re-resolved', () => {
|
||||
const findOnProcessPath = vi
|
||||
.fn<() => string | null>()
|
||||
.mockReturnValueOnce('/old/bin/editedx')
|
||||
.mockReturnValue('/new/bin/editedx');
|
||||
const h = host({ findOnProcessPath, exists: vi.fn(() => true) });
|
||||
const resolver = createCliExecutableResolver({ binary: 'editedx', searchDirs: [] }, h);
|
||||
|
||||
expect(resolver.resolve()?.binaryPath).toBe('/old/bin/editedx');
|
||||
expect(resolver.resolve()?.binaryPath).toBe('/old/bin/editedx');
|
||||
invalidateCliExecutableResolvers(['editedx']);
|
||||
expect(resolver.resolve()?.binaryPath).toBe('/new/bin/editedx');
|
||||
// And the fresh result is cached again rather than re-probed every call.
|
||||
expect(resolver.resolve()?.binaryPath).toBe('/new/bin/editedx');
|
||||
expect(findOnProcessPath).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('invalidation is scoped to the named binaries and leaves every other cache alone', () => {
|
||||
const findOnProcessPath = vi.fn(() => '/bin/untouchedx');
|
||||
const h = host({ findOnProcessPath, exists: vi.fn(() => true) });
|
||||
const resolver = createCliExecutableResolver({ binary: 'untouchedx', searchDirs: [] }, h);
|
||||
|
||||
resolver.resolve();
|
||||
invalidateCliExecutableResolvers(['some-other-binary']);
|
||||
resolver.resolve();
|
||||
expect(findOnProcessPath).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('doubles the retry delay per consecutive miss and caps it at five minutes', () => {
|
||||
let now = 0;
|
||||
const findInLoginShell = vi.fn(() => null);
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user