Compare commits

...
Author SHA1 Message Date
Codeman maintainer 71ffbf18e4 chore: version packages 2026-09-01 21:55:00 +02:00
Codeman maintainer 826ddaa9aa build(docker): ship the Docker CLI in the Compose image, not the whole engine
docker/server.Dockerfile installed Debian's `docker.io` to get a client for the
socket mounted by Compose. That package is the full ENGINE: even with
--no-install-recommends it pulls 15 packages including containerd, runc, dmsetup
and iptables, none of which a container that only talks to a mounted socket can
use, and it ships Docker 20.10.24 (2023).

Copy the CLI and the buildx plugin from the official docker:29-cli image
instead. Measured on the same node:22-bookworm-slim base: 266 MB -> 108 MB, so
158 MB smaller with a current CLI (29.7.2) in place of a two-year-old one.

Three things verified rather than assumed, by building the real image and
running it:

- docker:cli is an ALPINE image, so copying a binary into this Debian one is
  only safe because the binaries are static Go builds (ldd: "Not a valid dynamic
  program"). In the built image, `docker --version`, `docker ps` and
  `docker build` all work against a mounted host socket as the unprivileged
  runtime user.
- buildx is copied on purpose. scripts/build-agent-image.mjs shells out to
  `docker build` and Codeman auto-builds the agent image on the first Docker
  case. Without the plugin that still works today — CLI 29 falls back to the
  classic builder, tested — but that builder is deprecated and will be dropped,
  so the plugin keeps the path supported.
- docker-compose is NOT copied: Codeman never shells out to it.

Pinned to the 29 major, matching how the base images here are pinned.
2026-09-01 21:54:56 +02:00
Codeman maintainer e2b72aafd7 chore: version packages 2026-09-01 11:32:50 +02:00
Codeman maintainer b15cc0eb1a fix(ui): keep the plan-usage chip's 5h slot when no session window is open
The header chip silently shrank from "5h 4% · 7d 52%" to a lone "7d 52%", which
reads as half the feature breaking rather than as an idle window.

Nothing was broken. Claude Code documents `rate_limits.five_hour` as "present
only while the API reports it and its resets_at has not passed", so between
5-hour session windows the key simply leaves the statusline payload. Codeman's
snapshot replaces the Claude half wholesale on every sample, so the segment
disappeared until usage opened a new window. Confirmed against a live 2.1.252
session by capturing real statusline payloads on an isolated tmux socket: the
boot render carries no `rate_limits` at all, and the first post-response render
carries both windows.

The slot now stays, with a dimmed em dash. Claude only: a missing CODEX bucket
means that plan has no such limit rather than an idle window, so those stay
omitted (pinned by the existing test). The placeholder can never stand alone
either — hasWindows() still gates the row, so a provider reporting nothing
renders nothing rather than a row of dashes. The tooltip says "5-hour limit: no
active session window" instead of dropping the line.

Verified in a real browser against a dev instance: the idle chip renders
"5h — · 7d 52%" with the dash at opacity 0.55 in --text-dim while the live
value keeps its green, and the chip holds its shape (100px idle vs 107px with
both windows).
2026-09-01 11:32:18 +02:00
Codeman maintainer 2a32b5064a Merge pull request #349 from opticon454/feature/docker-compose
Docker Compose deployment: Codeman runs in a container and spawns Docker cases
as SIBLING containers through the mounted host socket (Docker-outside-of-Docker).

Resolved the README conflict (master had grown to eight CLIs since the branch
was cut) and moved the Compose blurb out of the feature bullets into Quick
Start, next to the other ways of starting Codeman.

Three review findings from the PR discussion are fixed here rather than left
for a follow-up, because two of them are shipped-image problems:

- `.dockerignore` excluded `.env` only at the ROOT. A pattern is matched against
  the whole context-relative path, so `docker/.env` — which the deployment's own
  README tells the user to fill with CODEMAN_PASSWORD and provider API keys —
  was picked up by `COPY . .` and baked into the image at
  /opt/codeman/docker/.env. Verified in both directions against a real build
  context: with a canary secret in docker/.env, the unfixed ignore file lets
  /ctx/docker/.env through, and `**/.env` (plus `**/.env.*` and a negation for
  the checked-in .env.example) leaves only the example behind.
- `CODEMAN_CASES_PATH` moved the server's CASES_DIR but not the CLI's, which
  still hardcoded ~/codeman-cases, so `codeman skill install --case <name>`
  reported "Case not found" on exactly the deployment the override exists for.
  Both now resolve through config/cases-dir.ts. state-store.ts keeps its own
  literal on purpose: that one migrates the historical ~/claudeman-cases
  directory by name and is about the old default, not the active location.
- CLAUDE.md gained the Compose paragraph (the sibling-container inversion, the
  three env vars, the .dockerignore and root-owned-bind traps) and .dockerignore
  joins the documented list of files that genuinely belong in the repo root.

The PR's `mode === 'claude'` guard on dockerResumeId is an unrelated master bug
fix riding along: appendResumeFlag() maps a resume id onto codex/gemini/pi/grok/
deepseek/omp/antigravity and RESUME_ID_SAFE accepts a UUID, so a Docker case's
lastClaudeSessionId was handed to every non-claude CLI.

Full gate green in a merge worktree: 6360 tests, lint, format, frontend syntax,
public assets, lockfile.
2026-09-01 11:32:03 +02:00
Codeman maintainer 0da0c8219d chore: version packages
1.24.2. Also corrects two numbers in the CLAUDE.md trust-dialog paragraph that
was written while the fix was still uncommitted: the keystroke cap is 6, not 3,
and the scan now schedules its own follow-up read rather than waiting on PTY
output that a static dialog never produces.
2026-09-01 02:31:31 +02:00
Codeman maintainer aaa93d4252 fix(session): answer Claude Code 2.1.252's reversed folder-trust dialog
Every claude session in a directory claude had not seen before died about six
seconds after it started (`Pane is dead (status 1)`), before the agent drew a
composer. Reproduced on a fresh case and measured.

Claude Code 2.1.252 rewrote the dialog. It used to be

  ❯ 1. Yes, I trust this folder
    2. No, exit

and is now unnumbered, reversed, and highlights the option that quits:

  ❯ No, exit
    Yes, I trust this folder

Detection still worked (the confirm affordance carries the match once the
numbered option text is gone), so the failure was entirely in the answer: the
auto-accept pressed Enter on the highlighted default, which is now exit.

trustDialogNextKey() reads the ❯ marker off the rendered pane and returns ONE
keystroke at a time: an arrow while the cursor is on the wrong option, Enter
only once the screen shows it on the trust option, and null for a frame that
does not say. Both layouts are handled, and which way the trust option lies is
read from the frame rather than assumed, so a further reordering costs a
repaint instead of a session. The last marked option wins, because the
direct-PTY fallback reads an append-only buffer where an older frame must not
out-vote the freshest one.

Two things only a live pane showed:

- The scan ran solely from the PTY onData handler. The arrow that moves the
  cursor is the last output the pane produces, so the first fix parked every
  session with the cursor sitting on the right option and no Enter ever sent.
  It now schedules its own follow-up read (_trustDialogTimer, cleared in
  _clearAllTimers()), offset past the scan throttle so the chain cannot break
  on a boundary.
- The keystroke cap goes 3 -> 6, since answering is no longer one press.

The bundled codeman skill had the same blind \r as its bounded fallback, so
preamble 1.21.0 replaces it with _trust_key/_accept_trust: read
terminal?full=1, steer onto the trust option, re-read, then confirm. Those
keystrokes go out under their own clientId, because input sequence numbers are
monotonic per client and spending prompt numbers on dialog keys would make the
next send-and-wait look like a stale duplicate and vanish while reporting
success. The readiness recipes in docs/extending-codeman.md,
docs/api-reference.md and the skill's own reference carry the corrected answer,
plus a symptom-table entry for a worker whose pane is dead seconds after spawn.

Verified live on an isolated instance (own data dir and tmux socket): fresh
case -> arrow at 5 s -> Enter at 7 s -> composer, with hasTrustDialogAccepted
recorded. With the server-side auto-accept disabled in a throwaway copy, the
skill's fallback cleared a genuinely parked dialog in 1.1 s and spawn_worker
took a brand-new case to a live composer in 7.2 s; spawn_workers + sendwait +
last_text then ran end to end.
2026-09-01 02:31:26 +02:00
Codeman maintainer 3518af3a9f docs: correct CLAUDE.md drift and document four undocumented subsystems
Audit of CLAUDE.md against the tree. Verified still accurate: the 31-module
frontend load order (matches index.html exactly), SSE registry parity at
157 = 157 (confirmed by running the parity test), config/ 21 files, types/ 22
domain files, 136 mobile device profiles, the version line, and every Quick
Reference command.

Drift corrected: 24 route modules to 25, ~220 handlers to ~227, system-routes
51 to 56, app.js ~5K lines to ~6.7K and 30 modules to 31, install.sh 92KB to
104KB. Completed the CLI resolver inventory, which was missing
deepseek-cli-resolver and omp-cli-resolver even though both modes are
documented, and named the shared cli-executable-resolver lookup chain.

Filled the gaps found by sweeping every src module against the file:

- Owner tab layouts (COD-359) had 6 source modules, 7 test files, 2 routes, an
  SSE event and a state.json key, with zero mentions anywhere in CLAUDE.md or
  docs/. The paragraph records the four things a reader would otherwise get
  wrong: it is backend-only as of 1.24.1 with no frontend consumer, the service
  is the sole mutation boundary, it projects onto PUT /api/session-order rather
  than replacing it, and reconciliation is gated on a successful restore.
- codeman doctor and codeman users were undocumented top-level CLI commands.
- Four subsystems whose invariants lived only in their @fileoverview:
  the workspace-trust dialog recognizer, proc-tree's bounded walk (the
  2026-07-30 incident that took a machine down), deepseek-web-server (one
  child process, deliberately not a shell session), and the Files panel
  search matcher (globs are never compiled to a RegExp).

Also fixes a stale "156 event types" comment in constants.js (actual: 157) and
a contradiction in AGENTS.md, which still carried the retired "never run the
full suite inside a managed tmux session" rule against CLAUDE.md's current
"npm test is the gate and is safe to run bare".

Note: the trust-dialog paragraph documents trustDialogNextKey(), which is part
of a sibling session's in-flight fix for the Claude Code 2.1.252 layout change
(unnumbered, reversed options with "No, exit" highlighted, so a blind carriage
return picks exit and kills the pane). That fix was uncommitted in the shared
tree when this landed, so the doc leads the code until it is committed.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NMN8UuvdBim3iM87reuQ9Z
2026-09-01 02:10:09 +02:00
Codeman maintainer e5c5d890aa chore: version packages 2026-08-31 22:34:57 +02:00
Codeman maintainer d5b5f8f618 fix(docker): make the dsh profile install survive pnpm's build-script gate
Follow-up to #350, which fixed the actual blocker (issue #352): `dsh plugin` is
a thin forwarder that `spawnSync`s a literal `pnpm` with no npm fallback, so an
image without pnpm dies at exit 127 and takes the whole build with it.

That PR also pinned an allowlist of the two packages whose lifecycle scripts
pnpm blocked at the time. Replace it with a policy that cannot go stale: pnpm,
unlike npm, refuses dependency build scripts by default and FAILS the install
over it (`ERR_PNPM_IGNORED_BUILDS`, exit 1, measured on pnpm 11.24), and the
names to allow move between rebuilds because `@deepseek-harness-tui/dsh-tui` is
resolved by dist-tag, not pinned: 0.9.3 pulled `@google/genai` (whose script is
a literal `preinstall: no-op`), 0.10.0-beta.x does not. An allowlist of two
names would have let the next tree break the build the same way. Allowing them
wholesale is also the exposure this image already accepts three layers up,
where `npm install -g` runs the install scripts of every transitive dep of the
five CLIs above with no gate at all.

Also correct a comment in the `/api/deepseek/install-profile` route that
asserted the opposite of what #352 proved ("dsh bundles its own package
manager, so no system pnpm is required"). The route's behavior is already
right: dsh's own "pnpm not found on PATH" stderr reaches the caller as the
OPERATION_FAILED detail, so the UI's "add a terminal profile" button names the
fix. Documented the prerequisite in docs/deepseek-integration.md, and taught
the docker-cases image smoke test about `dsh`/`omp` plus the profile check that
`dsh --version` does NOT cover.
2026-08-31 22:26:09 +02:00
Codeman maintainer 7762809202 Merge pull request #350 from opticon454/bugfix/dsh-pnpm
fix(docker): install pnpm for DeepSeek profile
2026-08-31 22:22:30 +02:00
Devvyn 26b4ffbb0f fix(docker): install pnpm for DeepSeek profile 2026-08-27 20:47:57 +08:00
Devvyn e2179bd530 chore(docker): remove local handover references 2026-08-27 19:42:37 +08:00
Devvyn b85f7659b7 feat(docker): add Compose deployment support 2026-08-27 19:38:38 +08:00
41 changed files with 1417 additions and 139 deletions
+18
View File
@@ -0,0 +1,18 @@
.git
.agents
.claude
.codex
# `**/` matters: a .dockerignore pattern is matched against the WHOLE
# context-relative path, so a bare `.env` excludes ONLY the root file and
# `COPY . .` would bake docker/.env -- CODEMAN_PASSWORD and any provider API
# keys -- into the published image at /opt/codeman/docker/.env (verified).
**/.env
**/.env.*
!**/.env.example
node_modules
dist
coverage
out
test-results
tmp
*.log
+2 -1
View File
@@ -2,7 +2,8 @@
Canonical agent/contributor guidance for this repository lives in [CLAUDE.md](CLAUDE.md) —
project structure, build/test/lint commands, code style, testing safety rules
(never run the full suite inside a managed tmux session), security notes, and
(`npm test` is the CI gate and is safe to run bare; the three excluded suites
have their own runners), security notes, and
the deployment workflow are all maintained there. Please read it before making
changes, and keep it the single source of truth rather than duplicating
sections here.
+119
View File
@@ -1,5 +1,124 @@
# aicodeman
## 1.24.4
### Patch Changes
- The Compose deployment image ships the Docker CLI instead of the whole Docker engine.
`docker/server.Dockerfile` installed Debian's `docker.io` to get a client for the mounted
host socket. That package is the full **engine**: even with `--no-install-recommends` it
pulls 15 packages including containerd, runc, dmsetup and iptables, none of which a
container that only talks to a socket can use. It also ships Docker 20.10.24, from 2023.
The CLI and the buildx plugin are now copied from the official `docker:29-cli` image
instead. Measured on the same `node:22-bookworm-slim` base: **266 MB → 108 MB**, a 158 MB
saving, with the current CLI (29.7.2) in place of a two-year-old one.
Verified by building the real image and running it: the binaries are static Go builds, so
they work on this glibc image even though they come from an Alpine one, and `docker
--version`, `docker ps` and `docker build` all succeed against a mounted host socket as
the unprivileged runtime user. buildx is copied deliberately — `scripts/build-agent-image.mjs`
shells out to `docker build` and Codeman auto-builds the agent image on the first Docker
case, which without the plugin falls back to the classic builder Docker has deprecated.
`docker-compose` is not copied; Codeman never shells out to it.
## 1.24.3
### Patch Changes
- Docker Compose deployment, and the plan-usage chip stops losing its 5-hour window.
**Run Codeman itself in a container** (#349, @opticon454). `docker/` now carries a
local-image Compose deployment: copy `docker/.env.example` to `docker/.env`, set
`CODEMAN_PASSWORD`, run `bash docker/Start-Codeman.sh`. Docker cases then start as
**sibling** containers through the mounted host socket rather than nested ones, which
inverts an assumption the bare-host path takes for granted: the daemon no longer shares
Codeman's filesystem, so a bind source that is valid inside Codeman means nothing to it.
`CODEMAN_DOCKER_HOST_HOME` translates sources under HOME into the daemon's namespace and
`CODEMAN_CASES_PATH` points the cases dir at a host-absolute bind mount, so a workspace
resolves to the same absolute path on both sides. `CODEMAN_DOCKER_DISABLE_SWAP_LIMIT=1`
drops `--memory-swap` for hosts without swap accounting (`--memory` still applies) and
filters only that one kernel warning. Guides: `docs/docker-compose.md`, `docker/README.md`.
Three things were fixed while landing it:
- **`docker/.env` was being baked into the image.** A `.dockerignore` pattern matches the
whole context-relative path, so the bare `.env` line excluded only the root file while
`COPY . .` picked up `docker/.env` — the file the deployment's own README tells you to
fill with `CODEMAN_PASSWORD` and provider API keys — and left it at
`/opt/codeman/docker/.env`. Now excluded via `**/.env`, verified in both directions
against a real build context with a canary secret.
- **`codeman skill install --case <name>` could not find a case under Compose.**
`CODEMAN_CASES_PATH` moved the server's cases dir but not the CLI's, which still
hardcoded `~/codeman-cases`. Both now resolve through one place.
- **A Docker case handed its Claude conversation id to every other CLI.** `resumeOnStart`
seeded `dockerResumeId` from `lastClaudeSessionId` regardless of mode, and
`appendResumeFlag()` maps a resume id onto codex/gemini/pi/grok/deepseek/omp/antigravity.
This one is a plain master bug, unrelated to Compose.
**The plan-usage chip keeps its 5-hour slot.** It silently shrank from `5h 4% · 7d 52%`
to a lone `7d 52%`, which reads as half the feature breaking. Nothing was broken: Claude
Code ships `rate_limits.five_hour` "only while the API reports it and its resets_at has
not passed", so between 5-hour session windows the key simply leaves the statusline
payload. The slot now stays with a dimmed em dash and the tooltip says "no active session
window". Claude only — a missing Codex bucket means that plan has no such limit, so those
stay omitted.
### Thanks
- @opticon454 for #349, and for a write-up that made an infrastructure PR quick to review
## 1.24.2
### Patch Changes
- Fix every new claude session dying on Claude Code 2.1.252's rewritten folder-trust dialog.
That dialog used to offer `❯ 1. Yes, I trust this folder` / `2. No, exit`, so Codeman
answered it by pressing Enter on the highlighted default. 2.1.252 dropped the numbers,
reversed the options and highlights `No, exit`, so the same Enter now answers _exit_: a
session in any directory claude had not seen before died (`Pane is dead (status 1)`)
about six seconds after it started, before the agent ever drew a composer.
- `trustDialogNextKey()` (`src/session-trust-dialog.ts`) now reads the `❯` marker off
the rendered pane and returns ONE keystroke at a time: an arrow while the cursor is on
the wrong option, Enter only once the screen shows it on the trust option. A frame it
cannot read presses nothing. Both the 2.1.252 and the older numbered layout are
handled, and the direction is derived from the frame rather than assumed, so a further
reordering costs a repaint instead of a session.
- The scan schedules its own follow-up read. It had only ever run from the PTY data
handler, which was enough while one Enter answered the dialog; the arrow that moves the
cursor is the last output the pane produces, so a two-keystroke answer would otherwise
stall with the cursor sitting on the right option forever. The keystroke cap goes from
3 to 6 for the same reason.
- The bundled `codeman` agent skill gets the same treatment (preamble 1.21.0): its
`_accept_trust` fallback reads `terminal?full=1`, steers onto the trust option and
confirms only after re-reading, instead of posting a blind `\r`. It sends those
keystrokes under its own `clientId`, because input sequence numbers are monotonic per
client and spending prompt numbers on dialog keys would make the next send-and-wait
look like a stale duplicate and vanish silently.
- Readiness recipes in `docs/extending-codeman.md`, `docs/api-reference.md` and the
skill's own reference carry the corrected answer and a new symptom-table entry for a
worker whose pane is dead seconds after the spawn.
Also included: a CLAUDE.md audit against the tree, correcting counted drift (route
modules, handler counts, frontend module count and app.js size, install.sh size) and
documenting several subsystems that had no entry.
## 1.24.1
### Patch Changes
- The Docker agent base image builds again.
**`docker/agent.Dockerfile` could not be built from a fresh checkout** (#352, fix in #350): the DeepSeek Harness step died with `dsh: pnpm not found on PATH` and exit 127, which took the whole image with it and, because Codeman auto-builds this image on the first Docker case, left Docker mode unusable on a clean host. `dsh plugin` does not bundle a package manager; it spawns a literal `pnpm` with no npm fallback, so pnpm is now installed alongside `dsh` and the layer proves it with `pnpm --version`.
The profile install also passes `--config.dangerouslyAllowAllBuilds=true`, because pnpm, unlike npm, refuses dependency lifecycle scripts by default and fails the install over it (`ERR_PNPM_IGNORED_BUILDS`, exit 1). Which packages that hits moves between rebuilds, since the terminal profile is resolved by dist-tag rather than pinned: the tree that broke the build in August pulled `@google/genai`, today's does not. An allowlist of those names would have gone stale rather than prevented the next break, and running those scripts is the same exposure the image already accepts three layers up, where `npm install -g` runs the install scripts of every transitive dependency of the five CLIs above it with no gate at all.
Documentation caught up with two things it had wrong: the image smoke test in `docs/docker-cases.md` now covers `dsh` and `omp`, and checks the dsh **profile** rather than only the binary (`dsh` is a launcher, so `dsh --version` says nothing about whether a session can start), and `docs/deepseek-integration.md` names pnpm as a prerequisite for installing a terminal profile at all, by hand or through the UI button. A comment in the `/api/deepseek/install-profile` route claimed the opposite of what this bug proved, and is corrected; the route's behaviour was already right, surfacing dsh's own "pnpm not found on PATH" line as the install error.
### Thanks
- @opticon454 for #350, with a reproduction that made this a confirmation rather than a hunt
- @timkjr for reporting #352, and for finding it while verifying Docker support for someone else's PR
## 1.24.0
### Minor Changes
+26 -12
View File
File diff suppressed because one or more lines are too long
+2
View File
@@ -82,6 +82,8 @@ codeman users add alice --admin # create the first admin account
codeman web --multiuser # named logins + per-user case spaces
```
**Prefer Docker Compose?** A local-image Compose deployment ships in `docker/`: copy `docker/.env.example` to `docker/.env`, set `CODEMAN_PASSWORD`, then run `bash docker/Start-Codeman.sh` on Linux. Codeman runs in a container and spawns Docker cases as sibling containers through the host socket. See the [Docker deployment guide](docker/README.md) for direct Compose commands, storage and networking options.
Details in [Multi-User Mode](#multi-user-mode-opt-in) below.
<details>
+69
View File
@@ -0,0 +1,69 @@
# =============================================================================
# Codeman Docker Compose environment template
# Copy this file to .env and set the values for the Docker host.
# =============================================================================
TZ=Australia/Perth
# Optional overrides for direct `docker compose` use. The Bash start script
# detects these values from CODEMAN_APPDATA_PATH automatically. Compose uses
# 1000:1000 when the variables are omitted.
# PUID=1000
# PGID=1000
# Name of the account that runs Codeman and all local CLI sessions. Changing
# this value rebuilds the image with a matching account.
CODEMAN_RUNTIME_USER=opencode
# Required. Persistent Codeman application data, CLI credentials, and session
# state are stored here on the host and mounted at the runtime account's home
# directory in the container.
CODEMAN_APPDATA_PATH=/mnt/user/appdata/Coding/codeman
# Required for Docker cases. This must be an absolute path on the Docker host.
# Codeman and each isolated case use this same path, so it cannot be a
# container-only path such as /home/opencode/codeman-cases.
CODEMAN_CASES_PATH=/mnt/user/appdata/Coding/codeman/codeman-cases
# Required. Network bind address, host port, and local image tag.
CODEMAN_HOST=0.0.0.0
CODEMAN_PORT=3000
CODEMAN_IMAGE=codeman:local
# Required for any network-accessible Codeman instance. Use a unique, strong
# password. This file is safe to commit; copy it to .env and set the value.
CODEMAN_PASSWORD=changeme
# Required. Username for Codeman HTTP Basic authentication.
CODEMAN_USERNAME=admin
# Optional: authenticate Gemini CLI without an interactive login.
GEMINI_API_KEY=
# Linux default. On Docker Desktop, use the socket path supported by your
# Docker installation when it differs from /var/run/docker.sock.
DOCKER_SOCKET=/var/run/docker.sock
# Optional override for direct `docker compose` use. The Bash start script
# detects this from DOCKER_SOCKET automatically. The direct Compose default is
# 999, but the correct value depends on the Docker host.
# DOCKER_SOCKET_GID=999
# Set to 1 only when Docker-case hook callbacks are required.
CODEMAN_DOCKER_BRIDGE_HOOKS=0
# Set to 1 when `docker info` reports `SwapLimit=false`. The case memory limit
# remains active; Codeman omits --memory-swap and filters the daemon's exact
# unsupported-swap warning while preserving all other Docker create errors.
CODEMAN_DOCKER_DISABLE_SWAP_LIMIT=0
# Required only when applying the macvlan example in README.md.
CODEMAN_MACVLAN_NETWORK=br0.11
CODEMAN_IPV4_ADDRESS=10.10.11.236
CODEMAN_MAC_ADDRESS=02:10:11:00:00:EC
# Required only when creating a new managed macvlan network, rather than using
# the external-network macvlan example.
CODEMAN_MACVLAN_PARENT=br0.11
CODEMAN_MACVLAN_SUBNET=10.10.11.0/24
CODEMAN_MACVLAN_GATEWAY=10.10.11.1
+96
View File
@@ -0,0 +1,96 @@
# Codeman Docker deployment
This folder contains the Compose configuration, server image Dockerfile, and environment template for a locally built Codeman server.
## Start
From the repository root, create the runtime environment file and set the required values, especially `CODEMAN_PASSWORD`.
```sh
cp docker/.env.example docker/.env
bash docker/Start-Codeman.sh
```
On PowerShell, use the following command instead.
```powershell
Copy-Item docker/.env.example docker/.env
docker compose --env-file docker/.env -f docker/docker-compose.yaml up --build -d
```
Every required value is defined and explained in `.env.example`. `GEMINI_API_KEY` is intentionally optional and may remain blank.
On Linux, `Start-Codeman.sh` stops with an error when required paths are missing. It creates the application-data directory when safe, detects its numeric owner as `PUID:PGID`, and detects `DOCKER_SOCKET_GID` from the configured Docker socket. It rejects a root-owned application-data directory because Codeman and its local CLI sessions must remain unprivileged.
Codeman, Claude, OpenCode, and other local sessions run as the unprivileged account named by `CODEMAN_RUNTIME_USER`, which defaults to `opencode`. When Compose is run directly, `PUID` and `PGID` default to `1000:1000`; set them in `.env` when the application-data directory has a different owner. The Bash start script determines them automatically instead.
To retain Docker-case support without root when running Compose directly, set `DOCKER_SOCKET_GID` to the numeric group ID of the host socket. On a standard Linux Docker host, obtain it with `stat -c '%g' /var/run/docker.sock`. The Bash start script detects it automatically.
## Application data storage
The default configuration uses a host-folder bind mount:
```yaml
volumes:
- type: bind
source: ${CODEMAN_APPDATA_PATH}
target: /home/${CODEMAN_RUNTIME_USER}
```
Set `CODEMAN_APPDATA_PATH` in `.env` to a directory that the Docker daemon can access. The example value is `/mnt/user/appdata/Coding/codeman`.
`CODEMAN_CASES_PATH` is the separate host directory for managed case workspaces. It is mounted into Codeman at the same absolute path, allowing the host Docker daemon to bind it into an isolated case container. Set it to a child directory of `CODEMAN_APPDATA_PATH` unless you deliberately store workspaces elsewhere.
Compose also exposes `CODEMAN_APPDATA_PATH` to Codeman as `CODEMAN_DOCKER_HOST_HOME`. This lets Docker case seed files, CLI credentials and the hook secret be mounted using paths that exist in the host daemon's filesystem. Direct host installations do not set this variable and retain their existing behaviour.
Set `CODEMAN_DOCKER_DISABLE_SWAP_LIMIT=1` when `docker info` reports `SwapLimit=false`. Codeman continues to apply the configured case memory limit, omits Docker's unsupported `--memory-swap` option, and filters only the daemon's exact swap-capability warning. Every other Docker create error and its exit status remain visible.
For an existing installation created by a root-running image, change ownership of the application-data directory before upgrading so the configured `PUID` and `PGID` can read the saved credentials and state:
```sh
chown -R 99:100 /mnt/user/appdata/Coding/codeman
```
Replace `99:100` and the path with the values from your `.env` file.
Do not replace this bind mount with a Docker-managed named volume when Docker cases are enabled. Codeman passes seed, credential, transcript and hook-secret bind sources to the host Docker daemon, so their source files must have stable paths in the daemon's filesystem. A named volume does not provide the required host path mapping.
## Static macvlan networking
The default configuration publishes a host port. It does not use `network_mode: host`. To attach Codeman directly to an existing external macvlan network with a static IP address and MAC address, remove the `ports:` section and add the following to the `codeman` service:
```yaml
mac_address: ${CODEMAN_MAC_ADDRESS}
networks:
codeman_lan:
ipv4_address: ${CODEMAN_IPV4_ADDRESS}
```
Then add this top-level network declaration:
```yaml
networks:
codeman_lan:
external: true
name: ${CODEMAN_MACVLAN_NETWORK}
```
Set `CODEMAN_MACVLAN_NETWORK`, `CODEMAN_IPV4_ADDRESS`, and `CODEMAN_MAC_ADDRESS` in `.env`. The values in `.env.example` match the supplied Unraid example network and should be changed for other hosts.
### Create a managed macvlan network
If an external macvlan network does not already exist, use this top-level declaration instead. Do not use it together with the external-network declaration.
```yaml
networks:
codeman_lan:
driver: macvlan
driver_opts:
parent: ${CODEMAN_MACVLAN_PARENT}
ipam:
config:
- subnet: ${CODEMAN_MACVLAN_SUBNET}
gateway: ${CODEMAN_MACVLAN_GATEWAY}
```
Macvlan containers are ordinarily not reachable from their Docker host without additional host-network routing. Confirm the selected address, MAC address, parent interface, and subnet are reserved and valid for the target network before starting the stack.
+73
View File
@@ -0,0 +1,73 @@
#!/usr/bin/env bash
set -euo pipefail
script_dir=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)
env_file="$script_dir/.env"
compose_file="$script_dir/docker-compose.yaml"
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 starting Codeman.\n' "$script_dir" >&2
exit 1
fi
compose_command=(docker compose --env-file "$env_file" -f "$compose_file")
appdata_path=$(
"${compose_command[@]}" config --environment |
awk -F= '$1 == "CODEMAN_APPDATA_PATH" { sub(/^[^=]*=/, ""); print; exit }'
)
docker_socket=$(
"${compose_command[@]}" config --environment |
awk -F= '$1 == "DOCKER_SOCKET" { sub(/^[^=]*=/, ""); print; exit }'
)
if [[ -z "$appdata_path" ]]; then
printf 'Error: CODEMAN_APPDATA_PATH is not set in %s\n' "$env_file" >&2
exit 1
fi
if [[ ! -d "$appdata_path" ]]; then
if [[ "$EUID" == '0' ]]; then
printf 'Error: Refusing to create CODEMAN_APPDATA_PATH as root: %s\n' "$appdata_path" >&2
printf 'Create it as the unprivileged account that should run Codeman, then retry.\n' >&2
exit 1
fi
mkdir -p -- "$appdata_path"
fi
if owner_ids=$(stat -c '%u:%g' -- "$appdata_path" 2>/dev/null); then
:
elif owner_ids=$(stat -f '%u:%g' "$appdata_path" 2>/dev/null); then
:
else
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
if [[ -z "$docker_socket" || ! -S "$docker_socket" ]]; then
printf 'Error: DOCKER_SOCKET is not a Unix socket: %s\n' "${docker_socket:-<unset>}" >&2
exit 1
fi
if socket_ids=$(stat -c '%u:%g' -- "$docker_socket" 2>/dev/null); then
:
elif socket_ids=$(stat -f '%u:%g' "$docker_socket" 2>/dev/null); then
:
else
printf 'Error: Cannot determine the owner of DOCKER_SOCKET: %s\n' "$docker_socket" >&2
exit 1
fi
export DOCKER_SOCKET_GID=${socket_ids##*:}
exec docker compose --env-file "$env_file" -f "$compose_file" up --build -d
+21 -3
View File
@@ -76,9 +76,16 @@ RUN curl -fsSL https://x.ai/cli/install.sh | bash \
# Codeman deliberately does NOT seed `profiles/` from the host: it is a
# per-profile node_modules tree, host-arch-specific and far too large to copy on
# every container start.
RUN npm install -g @deepseek-ai/dsh \
# ⚠️ `pnpm` is a HARD dependency of `dsh plugin`, not optional tooling: the
# subcommand is a thin forwarder that `spawnSync`s a literal `pnpm` with no
# fallback to npm, so on an image without it the profile install below dies
# with `dsh: pnpm not found on PATH` / exit 127 and takes the whole build with
# it (issue #352). It stays on PATH at runtime too, so a container user can run
# `dsh plugin add` themselves.
RUN npm install -g @deepseek-ai/dsh pnpm \
&& npm cache clean --force \
&& dsh --version
&& dsh --version \
&& pnpm --version
# OMP (Oh My Pi) is NOT on npm: a standalone binary via omp.sh's installer, which
# targets $HOME/.local/bin with no --dir override (verified 2026-08-27 — the
@@ -122,6 +129,16 @@ ENV HOME=/home/agent
# writable by the arbitrary uid the container actually runs as, and a profile
# installed after it would miss that fixup. DSH_HOME points the launcher at the
# agent's dir while this still runs as root.
# ⚠️ `dangerouslyAllowAllBuilds` is what keeps that profile install from becoming
# the next #352. pnpm (unlike npm) blocks dependency lifecycle scripts by default
# and FAILS the install over it — `ERR_PNPM_IGNORED_BUILDS`, exit 1, measured on
# pnpm 11.24 — so any package in the tui's tree that ships one stops the build
# dead. An allowlist of the offenders rots: `@deepseek-harness-tui/dsh-tui` is
# resolved by dist-tag, not pinned, and 0.9.3 pulled `@google/genai` (a
# `preinstall: no-op`) where 0.10.0-beta.x does not, so the names to allow move
# under us between rebuilds. Allowing them wholesale is also the SAME exposure
# this image already accepts three layers up: `npm install -g` runs the install
# scripts of every transitive dep of the five CLIs above it, with no gate at all.
# `.omp/agent` is pre-created for the same reason `.codex` is: it is a MIXED
# store (per-file config seeds PLUS a shared `sessions/` RW bind mount for
# Codeman's own host-side history/resume reads), and neither kind of artifact
@@ -131,7 +148,8 @@ RUN useradd -g 0 -m -d /home/agent -s /bin/bash agent \
/home/agent/.claude/projects /home/agent/.codex/sessions /home/agent/.pi/agent /home/agent/.grok \
/home/agent/.dsh /home/agent/.omp/agent \
&& DSH_HOME=/home/agent/.dsh HOME=/home/agent \
dsh plugin --profile dsh-tui add @deepseek-harness-tui/dsh-tui \
dsh plugin --profile dsh-tui add --config.dangerouslyAllowAllBuilds=true \
@deepseek-harness-tui/dsh-tui \
&& test -f /home/agent/.dsh/profiles/dsh-tui/package.json \
&& chgrp -R 0 /home/agent \
&& chmod -R g=u /home/agent
+65
View File
@@ -0,0 +1,65 @@
name: codeman
services:
codeman:
build:
context: ..
dockerfile: docker/server.Dockerfile
args:
CODEMAN_RUNTIME_USER: ${CODEMAN_RUNTIME_USER}
PGID: ${PGID:-1000}
PUID: ${PUID:-1000}
image: ${CODEMAN_IMAGE}
init: true
restart: unless-stopped
ports:
- "${CODEMAN_PORT}:${CODEMAN_PORT}"
environment:
CODEMAN_DOCKER_BRIDGE_HOOKS: ${CODEMAN_DOCKER_BRIDGE_HOOKS}
# Host-side equivalent of the runtime user's HOME. Docker case seed,
# credential and hook mounts are translated into the daemon namespace.
CODEMAN_DOCKER_HOST_HOME: ${CODEMAN_APPDATA_PATH}
CODEMAN_DOCKER_DISABLE_SWAP_LIMIT: ${CODEMAN_DOCKER_DISABLE_SWAP_LIMIT}
CODEMAN_CASES_PATH: ${CODEMAN_CASES_PATH}
CODEMAN_HOST: ${CODEMAN_HOST}
CODEMAN_PASSWORD: ${CODEMAN_PASSWORD}
CODEMAN_PORT: ${CODEMAN_PORT}
CODEMAN_USERNAME: ${CODEMAN_USERNAME}
GEMINI_API_KEY: ${GEMINI_API_KEY}
PGID: ${PGID:-1000}
PUID: ${PUID:-1000}
TZ: ${TZ}
group_add:
# Retain access to the host Docker socket without running as root.
- ${DOCKER_SOCKET_GID:-999}
volumes:
# Application data and CLI credentials persist on the configured host
# path, rather than in a Docker-managed volume.
- type: bind
source: ${CODEMAN_APPDATA_PATH}
target: /home/${CODEMAN_RUNTIME_USER}
# Docker cases are sibling containers on the host daemon. Their workspace
# must be visible to Codeman at the same absolute path used by that daemon.
- type: bind
source: ${CODEMAN_CASES_PATH}
target: ${CODEMAN_CASES_PATH}
# Codeman uses the host daemon to create isolated Docker cases. This is
# Docker-outside-of-Docker, not Docker-in-Docker.
- type: bind
source: ${DOCKER_SOCKET}
target: /var/run/docker.sock
extra_hosts:
- "host.docker.internal:host-gateway"
security_opt:
- no-new-privileges:true
cap_drop:
- ALL
healthcheck:
test:
- CMD-SHELL
- >-
node -e "fetch('http://127.0.0.1:${CODEMAN_PORT}/api/status').then((response) => process.exit(response.status < 500 ? 0 : 1)).catch(() => process.exit(1))"
interval: 30s
timeout: 5s
retries: 3
start_period: 30s
+114
View File
@@ -0,0 +1,114 @@
# syntax=docker/dockerfile:1
# Build the application from the checkout supplied as the Docker build context.
# No published Codeman application image is required.
FROM node:22-bookworm-slim AS build
RUN apt-get update \
&& apt-get install -y --no-install-recommends python3 make g++ \
&& rm -rf /var/lib/apt/lists/*
WORKDIR /opt/codeman
COPY . .
RUN npm ci \
&& npm run build \
&& npm prune --omit=dev --ignore-scripts \
&& npm cache clean --force
# The Docker CLI talks to the host daemon through the socket mounted by
# docker/docker-compose.yaml. It does not run a Docker daemon in this container.
FROM node:22-bookworm-slim
ARG CODEMAN_RUNTIME_USER=opencode
ARG PUID=1000
ARG PGID=1000
RUN apt-get update \
&& apt-get install -y --no-install-recommends \
ca-certificates \
curl \
git \
openssh-client \
procps \
ripgrep \
tmux \
&& rm -rf /var/lib/apt/lists/*
# The Docker CLI, taken from the official image rather than Debian's `docker.io`.
# That package is the full ENGINE: with --no-install-recommends it still pulls 15
# packages including containerd, runc, dmsetup and iptables, none of which a
# client that only talks to a mounted socket can use. Measured on top of this
# base image: `docker.io` costs 266 MB and ships Docker 20.10.24 (2023), while
# these two files cost 108 MB and ship the current CLI (493 MB vs 335 MB total).
#
# The binaries are STATIC Go builds, so they run on this glibc image even though
# the image they come from is Alpine (verified: `docker --version`, `docker ps`
# and `docker build` all work here against a mounted host socket).
#
# buildx is copied on purpose. `scripts/build-agent-image.mjs` shells out to
# `docker build` — Codeman auto-builds the agent image on the first Docker case —
# and without the plugin that silently falls back to the CLASSIC builder, which
# Docker has deprecated and will eventually drop. `docker-compose` is NOT copied:
# Codeman never shells out to it.
COPY --from=docker:29-cli /usr/local/bin/docker /usr/local/bin/docker
COPY --from=docker:29-cli \
/usr/local/libexec/docker/cli-plugins/docker-buildx \
/usr/local/libexec/docker/cli-plugins/docker-buildx
# Keep credentials out of the image. Users authenticate these CLIs at runtime
# through Codeman sessions, and the configured host bind mount retains state.
RUN npm install --global \
@anthropic-ai/claude-code \
@google/gemini-cli \
@openai/codex \
opencode-ai \
&& npm cache clean --force
# Keep the web server and every local Codeman session unprivileged. PUID and
# PGID match the host-owned application-data directory mounted by Compose. The
# requested GID may not exist in the base image, and a host UID such as 1000 may
# already belong to the baked `node` account, so handle both cases explicitly.
RUN set -eux; \
case "${PUID}" in ''|*[!0-9]*) echo "PUID must be numeric" >&2; exit 1;; esac; \
case "${PGID}" in ''|*[!0-9]*) echo "PGID must be numeric" >&2; exit 1;; esac; \
if [ "${PUID}" -eq 0 ]; then \
echo "PUID must identify an unprivileged account, not root" >&2; \
exit 1; \
fi; \
if ! getent group "${PGID}" >/dev/null; then \
groupadd --gid "${PGID}" codeman-runtime; \
fi; \
existing_user="$(getent passwd "${PUID}" | cut -d: -f1 || true)"; \
if [ -n "${existing_user}" ]; then \
usermod \
--login "${CODEMAN_RUNTIME_USER}" \
--gid "${PGID}" \
--home "/home/${CODEMAN_RUNTIME_USER}" \
--move-home \
--shell /bin/bash \
"${existing_user}"; \
else \
useradd \
--uid "${PUID}" \
--gid "${PGID}" \
--create-home \
--home-dir "/home/${CODEMAN_RUNTIME_USER}" \
--shell /bin/bash \
"${CODEMAN_RUNTIME_USER}"; \
fi
WORKDIR /opt/codeman
COPY --from=build /opt/codeman /opt/codeman
ENV CODEMAN_PORT=3000 \
HOME=/home/${CODEMAN_RUNTIME_USER} \
NODE_ENV=production
EXPOSE 3000
USER ${CODEMAN_RUNTIME_USER}
CMD ["node", "dist/index.js", "web"]
+10 -5
View File
@@ -204,11 +204,16 @@ turn.
The reliable sequence is: poll `GET /api/v1/sessions/:id` until `.data.pid` is
non-null, then `wait-output` for the composer's own marker (`bypass`, the status
bar of a CLI spawned in bypass mode) with a short timeout, handling the trust
dialog only as the bounded fallback (`trust` matched → send `\r` → wait for
`bypass` again). Do not probe `trust` first and Enter blindly: the dialog text
stays in the terminal buffer for the life of the session, so a `trust` probe with
`from=buffer` keeps matching on every later run and the Enter lands in a ready
composer. A worked version is in
dialog only as the bounded fallback.
⚠️ **The fallback is not a bare `\r`.** Claude Code 2.1.252 unnumbered the dialog's
options, reversed them and highlights `No, exit`, so an Enter sent blind quits the
CLI and the pane dies seconds after the spawn. Read the `❯` marker off the current
frame (`GET /api/v1/sessions/:id/terminal?full=1`), send `ESC [ B` while it is on
`No, exit`, re-read, and confirm only once it is on `Yes, I trust this folder`.
Reading the current frame is also what keeps this correct on later runs: the dialog
text stays in the terminal buffer for the life of the session, so a `trust` probe
with `from=buffer` keeps matching long after the dialog is gone. A worked version is in
[`extending-codeman.md`](extending-codeman.md#seam-3-http-api-and-cli).
### `GET /api/v1/sessions/:id/wait`
+8
View File
@@ -47,6 +47,14 @@ By hand, or to pick a different front door:
dsh plugin --profile dsh-tui add @deepseek-harness-tui/dsh-tui
```
⚠️ **`pnpm` has to be on PATH for either route.** `dsh plugin` is a thin forwarder
that spawns a literal `pnpm` with no npm fallback, so without one it exits 127 with
`dsh: pnpm not found on PATH` — both by hand and behind the UI button, which
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`.
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
contract described in §3. It is a **default, not a requirement**: any profile
+14 -2
View File
@@ -2,7 +2,7 @@
Run a case inside an **isolated Docker container** instead of directly on the host. Any number of Codeman sessions can share one container (it is scoped to the case, not the session), so a whole project lives in a sandbox with its own network, resource caps, and filesystem, and you can **export the container to move it to another machine**.
Docker mode is a **location overlay on cases**, the direct analog of [remote SSH cases](./remote-hosts.md): where a remote case runs a local tmux pane doing `ssh host` into a durable remote tmux server, a docker case runs a local tmux pane doing `docker exec -it` into a durable **in-container** tmux server. It is not a separate `SessionMode`, so `claude` / `shell` / `opencode` / `codex` / `gemini` / `antigravity` / `pi` / `grok` all work inside the container.
Docker mode is a **location overlay on cases**, the direct analog of [remote SSH cases](./remote-hosts.md): where a remote case runs a local tmux pane doing `ssh host` into a durable remote tmux server, a docker case runs a local tmux pane doing `docker exec -it` into a durable **in-container** tmux server. It is not a separate `SessionMode`, so `claude` / `shell` / `opencode` / `codex` / `gemini` / `antigravity` / `pi` / `grok` / `deepseek` / `omp` all work inside the container.
## One-time setup: build the base image
@@ -25,9 +25,21 @@ A zero exit code only proves the layers ran, not that the toolchain works. Verif
```bash
docker run --rm codeman/agent:base bash -lc \
'for c in claude codex gemini opencode agy pi grok; do printf "%-9s " $c; $c --version 2>&1 | head -1; done'
'for c in claude codex gemini opencode agy pi grok dsh omp; do printf "%-9s " $c; $c --version 2>&1 | head -1; done'
```
⚠️ `dsh --version` is the one line above that answers a different question than the
others: `dsh` is a profile launcher, so a working binary says nothing about whether
the image can actually run a DeepSeek session. Check the profile the Dockerfile
installs into the agent's HOME as well, or a `mode: 'deepseek'` case starts a pane
that dies on arrival:
```bash
docker run --rm codeman/agent:base ls ~/.dsh/profiles/dsh-tui/package.json
```
Building that profile is also why `pnpm` is in the image: `dsh plugin` forwards straight to a literal `pnpm` and exits 127 without it (issue #352), and pnpm — unlike npm — blocks dependency lifecycle scripts by default and fails the install over it, so the profile step passes `--config.dangerouslyAllowAllBuilds=true`.
Antigravity (`agy`) and Grok (`grok`) are the two CLIs not installed from npm (Google and xAI ship standalone binaries), so each has its own Dockerfile step, adding roughly 190MB and 160MB respectively. Pi also gets its own step, because upstream documents installing it with `--ignore-scripts` and that flag must not silently change how the other npm CLIs install.
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).
+68
View File
@@ -0,0 +1,68 @@
# Docker Compose deployment
This configuration builds the Codeman application image locally from this checkout. It does not download or depend on a pre-built Codeman image.
For the Compose configuration, environment settings, storage migration, and macvlan networking examples, see the [Docker deployment guide](../docker/README.md).
The image includes Claude Code, Codex, Gemini CLI, and OpenCode. Authenticate a CLI from its Codeman session; credentials are never baked into the image.
## Prerequisites
- Docker Engine or Docker Desktop with Docker Compose v2
- A reachable Docker daemon
The application container mounts the Docker daemon socket so Codeman can create and manage its isolated Docker cases. Treat anyone who can administer this Compose project as having Docker-host-equivalent access.
## Start
Copy the environment template, set a strong password, and confirm `CODEMAN_APPDATA_PATH`. The example maps `/mnt/user/appdata/Coding/codeman` on the host to `/home/${CODEMAN_RUNTIME_USER}` in the container, preserving Codeman state and CLI credentials outside Docker-managed volumes.
```sh
cp docker/.env.example docker/.env
```
On PowerShell, use the following command instead.
```powershell
Copy-Item docker/.env.example docker/.env
```
On Linux, run the stack with the start script. It determines `PUID` and `PGID` from the owner of `CODEMAN_APPDATA_PATH`, and `DOCKER_SOCKET_GID` from the configured Docker socket, before invoking Compose. A root-owned application-data directory is rejected so the runtime account cannot become UID 0.
```sh
bash docker/Start-Codeman.sh
```
On other platforms, run Compose directly. `PUID` and `PGID` default to `1000:1000`; set them in `docker/.env` when the application-data directory has a different owner.
```sh
docker compose --env-file docker/.env -f docker/docker-compose.yaml up --build -d
```
Open `http://localhost:3000` and sign in with the username and password from `docker/.env`.
## Operations
The local image is tagged `codeman:local` by default. Change `CODEMAN_IMAGE` in `docker/.env` if a different local tag suits your environment.
```sh
docker compose --env-file docker/.env -f docker/docker-compose.yaml logs -f codeman
bash docker/Start-Codeman.sh
docker compose --env-file docker/.env -f docker/docker-compose.yaml down
```
`CODEMAN_APPDATA_PATH` holds Codeman state and survives container recreation. Remove that host directory only when deliberately resetting the installation.
`CODEMAN_CASES_PATH` must be an absolute path on the Docker host. Compose mounts it at the same path inside Codeman, so the host daemon can bind the managed workspace into isolated Docker cases. Do not set it to `/home/${CODEMAN_RUNTIME_USER}/codeman-cases`.
Compose passes `CODEMAN_APPDATA_PATH` into Codeman as `CODEMAN_DOCKER_HOST_HOME`. Codeman uses that value to translate generated Docker seed, credential and hook-secret bind sources from the container's home path into paths visible to the host Docker daemon.
If `docker info` reports `SwapLimit=false`, set `CODEMAN_DOCKER_DISABLE_SWAP_LIMIT=1`. Isolated cases retain their configured memory limit. Codeman omits the unsupported swap-limit option and filters only the daemon's exact swap-capability warning while retaining every other Docker create error.
If that directory was created by an earlier root-running image, change its ownership to the configured `PUID:PGID` before starting this version. This preserves existing CLI credentials and session state while allowing the unprivileged runtime account to use them.
## Docker cases
The default socket path is `/var/run/docker.sock`, which works with a standard Linux Docker Engine. The Bash start script detects its numeric group ID. When running Compose directly, set `DOCKER_SOCKET_GID`, for example using `stat -c '%g' /var/run/docker.sock`, so the unprivileged `CODEMAN_RUNTIME_USER` account can create Docker cases. Docker Desktop users should set `DOCKER_SOCKET` in `docker/.env` only when their Docker installation exposes a different compatible socket path.
Codeman Docker cases are sibling containers on the host daemon, not children of the application container. The Compose configuration handles their workspace bind mount through `CODEMAN_CASES_PATH`; the `/home/${CODEMAN_RUNTIME_USER}` application-data mapping is for Codeman state and ordinary in-container sessions, not sibling-case workspaces.
+31 -11
View File
@@ -228,6 +228,14 @@ window: the wait resolves on `idle` in a couple of seconds with `timedOut: false
indistinguishable from a finished turn. Wait for the pid, then wait for the
composer, answering the dialog only as the bounded fallback.
⚠️ **Answering it is not "press Enter".** Claude Code 2.1.252 dropped the options'
numbers, reversed them, and highlights `No, exit` by default, so a blind `\r` quits
the CLI and the pane is dead seconds after the spawn. Read the `❯` marker off the
rendered pane (`GET .../terminal?full=1`), send `ESC [ B` while it sits on `No, exit`,
re-read, and confirm only once the marker is on `Yes, I trust this folder`. Codeman's
own auto-accept (`trustDialogNextKey()` in `src/session-trust-dialog.ts`) does exactly
this, inside a 90 s startup window and a 6-keystroke cap.
A worked orchestration: start a worker, get it ready, prompt it, wait, clean up.
```bash
@@ -244,24 +252,36 @@ SID=$("${CURL[@]}" -X POST "$API/api/v1/quick-start" \
[ -n "$SID" ] && [ "$SID" != null ] || { echo "quick-start failed"; exit 1; }
# 2. READINESS: composer marker first, trust dialog only as the bounded fallback.
# Skip this and step 3 reports a turn that never ran. Do NOT probe trust first
# and Enter blindly: the dialog text stays in the buffer for the life of the
# session, so on every later run that probe matches stale text and the Enter
# lands in a ready composer. Match single tokens only: TUI text can arrive
# without its spaces. Stage 1 is short on purpose (an already-trusted case
# matches in <1 s; a first-run case can never pass it and pays it in full).
# Skip this and step 3 reports a turn that never ran. Match single tokens only:
# TUI text can arrive without its spaces. Stage 1 is short on purpose (an
# already-trusted case matches in <1 s; a first-run case can never pass it and
# pays it in full).
# ⚠️ NEVER answer the dialog with a bare \r. Its highlighted option is `No, exit`
# (claude-cli 2.1.252), so a blind Enter quits the CLI; and the dialog text stays
# in the buffer for the life of the session, so a `from=buffer` probe for `trust`
# keeps matching long after it is gone. Read the CURRENT pane instead and steer.
until [ "$("${CURL[@]}" "$API/api/v1/sessions/$SID" | jq '.data.pid')" != null ]
do sleep 1; done
R=$("${CURL[@]}" -G "$API/api/v1/sessions/$SID/wait-output" \
--data-urlencode 'match=bypass' --data-urlencode 'from=buffer' \
--data-urlencode 'timeout=5000') # composer's status bar = ready
if ! jq -e '.data.wait.matched' <<<"$R" >/dev/null; then
T=$("${CURL[@]}" -G "$API/api/v1/sessions/$SID/wait-output" \
--data-urlencode 'match=trust' --data-urlencode 'from=buffer' \
--data-urlencode 'timeout=2000')
jq -e '.data.wait.matched' <<<"$T" >/dev/null && \
ESC=$(printf '\033') # \x1b is GNU-sed only; this form also works on macOS
for _ in 1 2 3 4 5 6; do
# Which option the ❯ marker sits on, read off the CURRENT frame.
K=$("${CURL[@]}" -G "$API/api/v1/sessions/$SID/terminal" --data-urlencode 'full=1' \
| jq -r '.data.terminalBuffer // empty' \
| sed -e "s/$ESC\[[0-9;?]*[a-zA-Z]//g" -e "s/$ESC[()][AB0]//g" | tr -d ' \t' \
| grep -i '❯[0-9.]*\(yes,itrustthisfolder\|no,exit\)' | tail -1 \
| sed -e 's/.*[Yy]es,.*/confirm/' -e 's/.*[Nn]o,.*/move/')
[ -n "$K" ] || break # no dialog on screen: nothing to answer
[ "$K" = confirm ] && IN="\r" || IN="$ESC[B"
"${CURL[@]}" -X POST "$API/api/v1/sessions/$SID/input" \
-H 'Content-Type: application/json' -d '{"input":"\r","useMux":true}' >/dev/null
-H 'Content-Type: application/json' \
-d "$(jq -nc --arg i "$IN" '{input:$i,useMux:true}')" >/dev/null
[ "$K" = confirm ] && break
sleep 1 # re-read: confirm the arrow landed
done
"${CURL[@]}" -G "$API/api/v1/sessions/$SID/wait-output" \
--data-urlencode 'match=bypass' --data-urlencode 'from=buffer' \
--data-urlencode 'timeout=45000' >/dev/null
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "aicodeman",
"version": "1.24.0",
"version": "1.24.4",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "aicodeman",
"version": "1.24.0",
"version": "1.24.4",
"hasInstallScript": true,
"license": "MIT",
"workspaces": [
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "aicodeman",
"version": "1.24.0",
"version": "1.24.4",
"description": "Mission control for AI coding agents - run 20 autonomous agents with real-time monitoring and session persistence",
"type": "module",
"main": "dist/index.js",
+50 -16
View File
@@ -47,7 +47,7 @@ later call opens with, and your first REAL call performs them anyway:
```bash
. "${XDG_CACHE_HOME:-$HOME/.cache}/codeman-agent-$CODEMAN_SESSION_ID.sh" 2>/dev/null
[ "${CODEMAN_PREAMBLE:-}" = 1.20.0 ] || { echo "preamble missing or stale; run the full §0 block"; exit 1; }
[ "${CODEMAN_PREAMBLE:-}" = 1.21.0 ] || { echo "preamble missing or stale; run the full §0 block"; exit 1; }
```
⚠️ **Never spend a Bash call on this check alone.** §1's block opens with this same
@@ -75,8 +75,8 @@ PRE="${XDG_CACHE_HOME:-$HOME/.cache}/codeman-agent-$CODEMAN_SESSION_ID.sh"
mkdir -p "$(dirname "$PRE")"
# Rewrite unless the file already ends with THIS version's stamp, so a stale or a
# half-written file self-heals here instead of costing you a round trip to rm it.
grep -qs '^CODEMAN_PREAMBLE=1.20.0$' "$PRE" || (umask 077; cat > "$PRE" <<'PREAMBLE'
# ---- Codeman agent preamble 1.20.0 (seeded by Codeman at session spawn; the SKILL.md §0 bootstrap rewrites it when missing or stale) ----
grep -qs '^CODEMAN_PREAMBLE=1.21.0$' "$PRE" || (umask 077; cat > "$PRE" <<'PREAMBLE'
# ---- Codeman agent preamble 1.21.0 (seeded by Codeman at session spawn; the SKILL.md §0 bootstrap rewrites it when missing or stale) ----
API="${CODEMAN_API_URL:?CODEMAN_API_URL not set; refusing to guess}"
SELF="${CODEMAN_SESSION_ID:?CODEMAN_SESSION_ID not set}"
# Credentials, cheapest first. Your session has usually INHERITED the server's
@@ -127,6 +127,43 @@ _dsh_up() { # <sid> <timeoutMs> -> "true"/"false". The DeepSeek Harness T
--data-urlencode "match=${DSH_READY_MARK:-❯}" --data-urlencode 'from=buffer' \
--data-urlencode "timeout=$2" | jq -r '.data.wait.matched // false'
}
# ---- the workspace-trust dialog: READ the screen, never press Enter blind ----
# Claude Code 2.1.252 dropped the option numbers, REVERSED them, and highlights
# "No, exit" by default:
# Security guide
# ❯ No, exit
# Yes, I trust this folder
# Enter to confirm . Esc to cancel
# so the bare \r that answered the old layout now answers *exit* and the pane is
# dead (`status 1`) seconds after the spawn -- measured on a live 2.1.252 case.
# These two read the rendered pane and steer onto the trust option instead.
_trust_key() { # <sid> -> "confirm" | "move" | "" (nothing safe to press)
# full=1 returns the RENDERED pane; a claude pane keeps no tmux history, so that
# is the current frame rather than every repaint since launch. tail -1 anyway,
# because the freshest marked row is the only one still true.
"${CURL[@]}" -G "$API/api/v1/sessions/$1/terminal" --data-urlencode 'full=1' \
| jq -r '.data.terminalBuffer // empty' \
| sed -e "s/$(printf '\033')\[[0-9;?]*[a-zA-Z]//g" -e "s/$(printf '\033')[()][AB0]//g" \
| tr -d ' \t' | grep -i '❯[0-9.]*\(yes,itrustthisfolder\|no,exit\)' | tail -1 \
| sed -e 's/.*[Yy]es,.*/confirm/' -e 's/.*[Nn]o,.*/move/'
}
_accept_trust() { # <sid> -> 0 once it has answered the dialog, 1 if it could not
local sid="$1" k i=1
while [ "$i" -le 6 ]; do
k=$(_trust_key "$sid")
[ -n "$k" ] || return 1 # no dialog on screen, or a layout this cannot read
# A SEPARATE clientId for these keys. seq is monotonic per clientId, so
# spending prompt numbers here would make the next sendwait -- whose default
# seq is the epoch second -- look like a stale duplicate and vanish silently.
"${CURL[@]}" -X POST "$API/api/v1/sessions/$sid/input" -H 'Content-Type: application/json' \
-d "$(jq -nc --arg k "$([ "$k" = confirm ] && printf '\r' || printf '\033[B')" \
--arg c "$CID-trust-$sid" --argjson s "$i" \
'{input:$k,useMux:true,clientId:$c,seq:$s}')" >/dev/null
[ "$k" = confirm ] && return 0
sleep 1; i=$((i+1)) # re-read: the arrow is CONFIRMED before Enter goes out
done
return 1
}
# spawn_worker <caseName> [mode] -> session id on stdout, diagnostics on stderr.
# quick-start AND readiness in one call, with a strict contract: NON-EMPTY stdout means
# a READY worker whose end-of-turn signal can be trusted -- a claude worker in a
@@ -177,19 +214,16 @@ spawn_worker() {
grep -qs '/api/hook-event' "$cp/.claude/settings.local.json" || {
echo "case '$name' resolved to '$cp', which has no Codeman hooks (workspaceHooksEnabled off, remote, or an older server?): turn the setting on, or work §5.1+§5.5 by hand with markers" >&2
delete_session "$sid" >/dev/null; return 1; }
# Short composer wait FIRST, then the trust-dialog probe: a case still showing the
# dialog can never pass the composer wait, so probing early keeps a cold case from
# Short composer wait FIRST, then the trust dialog: a case still showing the
# dialog can never pass the composer wait, so acting early keeps a cold case from
# paying the whole long wait before the fallback even runs (§5.2). A warm case
# matches in under a second and never reaches the probe.
# matches in under a second and never reaches it, and _accept_trust returns in a
# blink when there is no dialog, so this costs nothing in the ordinary slow case.
r=$(_composer_up "$sid" 5000)
if [ "$r" != true ]; then
if "${CURL[@]}" -G "$API/api/v1/sessions/$sid/wait-output" \
--data-urlencode 'match=trust' --data-urlencode 'from=buffer' --data-urlencode 'timeout=2000' \
| jq -e '.data.wait.matched' >/dev/null; then
# Codeman's own auto-accept gives up after 90 s / 3 tries; this is that bounded fallback.
"${CURL[@]}" -X POST "$API/api/v1/sessions/$sid/input" -H 'Content-Type: application/json' \
-d "$(jq -nc --arg c "$CID-$sid" '{input:"\r",useMux:true,clientId:$c,seq:1}')" >/dev/null
fi
# Codeman answers this dialog itself and normally wins the race; this is the
# bounded fallback for when its 90 s window / 6-keystroke cap has run out.
_accept_trust "$sid"
r=$(_composer_up "$sid" 45000)
fi
[ "$r" = true ] || { echo "worker $sid never drew a composer; deleted it. Retry by hand via the §5.2 ladder (its billed stage-4 probe included)" >&2
@@ -288,10 +322,10 @@ last_text() {
# The stamp is the LAST line on purpose (a truncated write leaves it unset) and is kept
# bare on purpose: the write condition above anchors on it with $, so an inline comment
# here would fail that match and rewrite this file on every single bootstrap.
CODEMAN_PREAMBLE=1.20.0
CODEMAN_PREAMBLE=1.21.0
PREAMBLE
)
. "$PRE"; [ "${CODEMAN_PREAMBLE:-}" = 1.20.0 ] || { echo "preamble at $PRE is stale or truncated: rm it and re-run this block"; exit 1; }
. "$PRE"; [ "${CODEMAN_PREAMBLE:-}" = 1.21.0 ] || { echo "preamble at $PRE is stale or truncated: rm it and re-run this block"; exit 1; }
```
Every later Bash call that touches the API starts with the same two loader lines from
@@ -342,7 +376,7 @@ and no per-call body to hand-build.
```bash
. "${XDG_CACHE_HOME:-$HOME/.cache}/codeman-agent-$CODEMAN_SESSION_ID.sh" 2>/dev/null # §0 loader
[ "${CODEMAN_PREAMBLE:-}" = 1.20.0 ] || { echo "preamble missing or stale; run the full §0 block"; exit 1; }
[ "${CODEMAN_PREAMBLE:-}" = 1.21.0 ] || { echo "preamble missing or stale; run the full §0 block"; exit 1; }
N=(alpha beta) # INVENT one fresh case name per worker; never list cases first
# (a name may carry a mode: `beta:deepseek`, see below)
T=('reply with one line: the absolute path of your working directory'
+46 -12
View File
@@ -1,4 +1,4 @@
# ---- Codeman agent preamble 1.20.0 (seeded by Codeman at session spawn; the SKILL.md §0 bootstrap rewrites it when missing or stale) ----
# ---- Codeman agent preamble 1.21.0 (seeded by Codeman at session spawn; the SKILL.md §0 bootstrap rewrites it when missing or stale) ----
API="${CODEMAN_API_URL:?CODEMAN_API_URL not set; refusing to guess}"
SELF="${CODEMAN_SESSION_ID:?CODEMAN_SESSION_ID not set}"
# Credentials, cheapest first. Your session has usually INHERITED the server's
@@ -49,6 +49,43 @@ _dsh_up() { # <sid> <timeoutMs> -> "true"/"false". The DeepSeek Harness T
--data-urlencode "match=${DSH_READY_MARK:-❯}" --data-urlencode 'from=buffer' \
--data-urlencode "timeout=$2" | jq -r '.data.wait.matched // false'
}
# ---- the workspace-trust dialog: READ the screen, never press Enter blind ----
# Claude Code 2.1.252 dropped the option numbers, REVERSED them, and highlights
# "No, exit" by default:
# Security guide
# ❯ No, exit
# Yes, I trust this folder
# Enter to confirm . Esc to cancel
# so the bare \r that answered the old layout now answers *exit* and the pane is
# dead (`status 1`) seconds after the spawn -- measured on a live 2.1.252 case.
# These two read the rendered pane and steer onto the trust option instead.
_trust_key() { # <sid> -> "confirm" | "move" | "" (nothing safe to press)
# full=1 returns the RENDERED pane; a claude pane keeps no tmux history, so that
# is the current frame rather than every repaint since launch. tail -1 anyway,
# because the freshest marked row is the only one still true.
"${CURL[@]}" -G "$API/api/v1/sessions/$1/terminal" --data-urlencode 'full=1' \
| jq -r '.data.terminalBuffer // empty' \
| sed -e "s/$(printf '\033')\[[0-9;?]*[a-zA-Z]//g" -e "s/$(printf '\033')[()][AB0]//g" \
| tr -d ' \t' | grep -i '❯[0-9.]*\(yes,itrustthisfolder\|no,exit\)' | tail -1 \
| sed -e 's/.*[Yy]es,.*/confirm/' -e 's/.*[Nn]o,.*/move/'
}
_accept_trust() { # <sid> -> 0 once it has answered the dialog, 1 if it could not
local sid="$1" k i=1
while [ "$i" -le 6 ]; do
k=$(_trust_key "$sid")
[ -n "$k" ] || return 1 # no dialog on screen, or a layout this cannot read
# A SEPARATE clientId for these keys. seq is monotonic per clientId, so
# spending prompt numbers here would make the next sendwait -- whose default
# seq is the epoch second -- look like a stale duplicate and vanish silently.
"${CURL[@]}" -X POST "$API/api/v1/sessions/$sid/input" -H 'Content-Type: application/json' \
-d "$(jq -nc --arg k "$([ "$k" = confirm ] && printf '\r' || printf '\033[B')" \
--arg c "$CID-trust-$sid" --argjson s "$i" \
'{input:$k,useMux:true,clientId:$c,seq:$s}')" >/dev/null
[ "$k" = confirm ] && return 0
sleep 1; i=$((i+1)) # re-read: the arrow is CONFIRMED before Enter goes out
done
return 1
}
# spawn_worker <caseName> [mode] -> session id on stdout, diagnostics on stderr.
# quick-start AND readiness in one call, with a strict contract: NON-EMPTY stdout means
# a READY worker whose end-of-turn signal can be trusted -- a claude worker in a
@@ -99,19 +136,16 @@ spawn_worker() {
grep -qs '/api/hook-event' "$cp/.claude/settings.local.json" || {
echo "case '$name' resolved to '$cp', which has no Codeman hooks (workspaceHooksEnabled off, remote, or an older server?): turn the setting on, or work §5.1+§5.5 by hand with markers" >&2
delete_session "$sid" >/dev/null; return 1; }
# Short composer wait FIRST, then the trust-dialog probe: a case still showing the
# dialog can never pass the composer wait, so probing early keeps a cold case from
# Short composer wait FIRST, then the trust dialog: a case still showing the
# dialog can never pass the composer wait, so acting early keeps a cold case from
# paying the whole long wait before the fallback even runs (§5.2). A warm case
# matches in under a second and never reaches the probe.
# matches in under a second and never reaches it, and _accept_trust returns in a
# blink when there is no dialog, so this costs nothing in the ordinary slow case.
r=$(_composer_up "$sid" 5000)
if [ "$r" != true ]; then
if "${CURL[@]}" -G "$API/api/v1/sessions/$sid/wait-output" \
--data-urlencode 'match=trust' --data-urlencode 'from=buffer' --data-urlencode 'timeout=2000' \
| jq -e '.data.wait.matched' >/dev/null; then
# Codeman's own auto-accept gives up after 90 s / 3 tries; this is that bounded fallback.
"${CURL[@]}" -X POST "$API/api/v1/sessions/$sid/input" -H 'Content-Type: application/json' \
-d "$(jq -nc --arg c "$CID-$sid" '{input:"\r",useMux:true,clientId:$c,seq:1}')" >/dev/null
fi
# Codeman answers this dialog itself and normally wins the race; this is the
# bounded fallback for when its 90 s window / 6-keystroke cap has run out.
_accept_trust "$sid"
r=$(_composer_up "$sid" 45000)
fi
[ "$r" = true ] || { echo "worker $sid never drew a composer; deleted it. Retry by hand via the §5.2 ladder (its billed stage-4 probe included)" >&2
@@ -210,4 +244,4 @@ last_text() {
# The stamp is the LAST line on purpose (a truncated write leaves it unset) and is kept
# bare on purpose: the write condition above anchors on it with $, so an inline comment
# here would fail that match and rewrite this file on every single bootstrap.
CODEMAN_PREAMBLE=1.20.0
CODEMAN_PREAMBLE=1.21.0
+2 -1
View File
@@ -800,7 +800,8 @@ for environment and setup problems.
| wait routes 404 on a valid session id | read the `.error` text: a `Route ...` prefix means the server predates the wait endpoints (< 1.13.0; a dev build can serve them while reporting an older version, so probe, never version-compare), poll `terminal?tail=` and say so. `Session ... not found` means your id is wrong, not the server |
| wait on `stop` never resolves | a mode with no hook signals, or hooks not reaching the server (Docker/remote), or a case created by Codeman < 1.13.0 against an `--https` install (its hook curls lacked `-k` and TLS-failed silently; a 1.13.0+ server rewrites them the next time a session starts in that case). Use markers or `idle,exit` |
| wait on `stop` never resolves, on a **dsh** worker whose pane clearly finished | that profile does not implement the harness's supervisor contract, which Codeman cannot detect at request time (an unrecognized profile is treated as launchable on purpose). The wait is accepted and then times out. Drive that worker with markers, or switch to a profile that reports — `@deepseek-harness-tui/dsh-tui` does |
| new claude worker ignores its first prompt | it was showing the first-run trust dialog and Codeman's auto-accept did not fire (it is bounded by a 90 s window and an attempt cap); use the readiness recipe in SKILL.md, wait for `shift+tab` first, accept the dialog only as the bounded fallback |
| new claude worker ignores its first prompt | it was showing the first-run trust dialog and Codeman's auto-accept did not fire (it is bounded by a 90 s window and a keystroke cap); use the readiness recipe in SKILL.md, wait for `shift+tab` first, answer the dialog only as the bounded fallback |
| a brand-new claude worker's pane is DEAD (`status 1`) seconds after the spawn | something pressed Enter at the first-run trust dialog. Since claude-cli 2.1.252 its options are unnumbered, reversed, and the highlighted default is `No, exit`, so a blind `\r` — an up-front Enter, or a task prompt typed into the dialog — quits the CLI. Answer it by reading the `❯` marker off `terminal?full=1` and arrowing onto `Yes, I trust this folder` first: `_accept_trust` in the §0 preamble |
| readiness burns its whole budget, then the worker answers fine anyway | you matched `bypass`, which is the statusline of ONE permission mode. Codeman spawns `--dangerously-skip-permissions` by default, but the server's `claudeMode` setting also has `auto` (`auto mode on`), `allowedTools` and `normal` (both `don't ask on`), and the effective per-session value is not exposed on `GET /api/v1/sessions/:id`. Match **`shift+tab`** instead: every mode's status bar ends `(shift+tab to cycle)` (measured per mode against claude-cli 2.1.226). Expect `blocked` signals mid-turn on the non-default modes |
| ANSI escapes survive the strip pipeline | `sed -e 's/\x1b…'` on macOS: `\x1b` is GNU-only, BSD sed matches nothing and strips nothing. Use the `ESC=$(printf '\033')` form above |
| `wait-output` times out although the pane shows the text | multi-word match against a TUI screen; the stream has no spaces there, match one token |
+16 -15
View File
@@ -69,9 +69,14 @@ SEQ=1 # $CID is the fixed literal from the preamble; never rebuild
# plus a two-marker screen match in session-trust-dialog.ts), not the output stream.
# It still misses two ways, and both leave the dialog up until someone answers it:
# it only scans in the first 90 s after the pane started (TRUST_DIALOG_WINDOW_MS),
# and it gives up after 3 Enter presses (TRUST_DIALOG_MAX_ATTEMPTS). So: composer
# marker first, dialog only as the bounded fallback (a blind Enter up front would
# land in an already-ready composer).
# and it gives up after 6 keystrokes (TRUST_DIALOG_MAX_ATTEMPTS). So: composer
# marker first, dialog only as the bounded fallback.
# ⚠️ The dialog is NOT answered with Enter. Since claude-cli 2.1.252 the options
# lost their numbers, swapped places, and the highlighted one is `No, exit`, so a
# blind \r quits the CLI and the pane is dead seconds after the spawn (measured).
# _accept_trust (§0 preamble) reads the ❯ marker off the rendered pane, arrows onto
# `Yes, I trust this folder`, re-reads to confirm the move landed, and only then
# presses Enter.
# Stage 1 is SHORT on purpose: an already-trusted case matches in <1 s, while a
# virgin case can never pass it (the dialog is up) and always pays it in full,
# the long budget belongs to stage 3, after the dialog is answered.
@@ -92,13 +97,8 @@ done
R=$("${CURL[@]}" -G "$API/api/v1/sessions/$SID/wait-output" \
--data-urlencode 'match=shift+tab' --data-urlencode 'from=buffer' --data-urlencode 'timeout=5000')
if ! jq -e '.data.wait.matched' <<<"$R" >/dev/null; then
T=$("${CURL[@]}" -G "$API/api/v1/sessions/$SID/wait-output" \
--data-urlencode 'match=trust' --data-urlencode 'from=buffer' --data-urlencode 'timeout=2000')
if jq -e '.data.wait.matched' <<<"$T" >/dev/null; then
"${CURL[@]}" -X POST "$API/api/v1/sessions/$SID/input" -H 'Content-Type: application/json' \
-d '{"input":"\r","useMux":true,"clientId":"'"$CID"'","seq":'$SEQ'}' >/dev/null
SEQ=$((SEQ+1))
fi
_accept_trust "$SID" # reads the marker and steers; never a blind \r. Own clientId,
# so it spends none of $SEQ's numbers.
R=$("${CURL[@]}" -G "$API/api/v1/sessions/$SID/wait-output" \
--data-urlencode 'match=shift+tab' --data-urlencode 'from=buffer' --data-urlencode 'timeout=45000')
fi
@@ -106,8 +106,8 @@ if ! jq -e '.data.wait.matched' <<<"$R" >/dev/null; then
# stage 4, mode-agnostic and bounded: answering a trivial prompt IS readiness.
# COSTS THE WORKER ONE BILLED TURN, so it only runs when the fast marker missed.
# Split token (the typed line echoes into the stream) and unique per call. Must stay
# AFTER the dialog fallback: free text plus \r into a trust dialog still up answers
# it blind, the same footgun as an up-front Enter.
# AFTER the dialog fallback: the select widget swallows the text and the \r answers
# whatever is highlighted, which on a live dialog is `No, exit`.
TOK="${RANDOM}_$$"
"${CURL[@]}" -X POST "$API/api/v1/sessions/$SID/input" -H 'Content-Type: application/json' \
-d '{"input":"reply with the word READY immediately followed by _'"$TOK"' and nothing else\r","useMux":true,"clientId":"'"$CID"'","seq":'$SEQ'}' >/dev/null
@@ -527,9 +527,10 @@ done
circuit breaker, which exists to stop a worker that crashes on every start from being
restarted in a loop; clearing it unasked re-arms that loop.
- Then run **Flow 1's readiness stages 1-3** on each SID. A path claude has never been
run in shows the trust dialog, and typing your task into a dialog answers it blind and
loses the task. Stages 1-3 cost no turn; stage 4, if it fires, costs that worker one
billed turn.
run in shows the trust dialog, and typing your task into it does not just lose the
task: the select widget swallows the text and the trailing `\r` answers the
highlighted option, which since claude-cli 2.1.252 is `No, exit`. Stages 1-3 cost no
turn; stage 4, if it fires, costs that worker one billed turn.
### 4. Hand out the tasks: markers, not send-and-wait
+30 -14
View File
@@ -171,10 +171,27 @@ itself, reliably enough that stage 1 usually just works: `_maybeAcceptTrustDialo
reads the **rendered pane** via `capturePaneText()` rather than the arriving chunk
(the per-chunk `includes()` version could never match, because tmux repaints the row
with cursor-forward escapes in place of spaces, and it is documented in-source as the
historical bug). The remaining miss modes are structural: the auto-accept only runs
inside a 90 s window after interactive start and gives up after 3 attempts. So keep
the dialog handling as a bounded fallback, and never send a blind Enter up front (if
auto-accept already fired, it lands in the composer).
historical bug).
⚠️ **The answer is no longer "press Enter".** Claude Code 2.1.252 dropped the option
numbers, reversed the two options, and highlights the one that quits:
```
❯ No, exit
Yes, I trust this folder
Enter to confirm · Esc to cancel
```
so a blind `\r` answers *exit*: the pane is dead (`Pane is dead (status 1)`) about six
seconds after the spawn, measured on a fresh case. Read the marker off the rendered
pane (`GET .../terminal?full=1`), send `ESC [ B` while it sits on `No, exit`, re-read,
and press Enter only once the marker is on the trust option. `_accept_trust` in the
§0 preamble is exactly that, and `trustDialogNextKey()` is the server-side twin.
The remaining miss modes are structural: the auto-accept only runs inside a 90 s window
after interactive start and gives up after 6 keystrokes. So keep the dialog handling as
a bounded fallback, and never send a blind Enter up front — landing in an already-ready
composer only wastes a turn, landing in this dialog ends the worker.
Stage 1 is short on purpose: an already-trusted case matches `shift+tab` in under a
second, while a case still showing the dialog cannot pass stage 1 at all and always
@@ -232,14 +249,11 @@ SEQ=1 # $CID came from the §0 preamble; do NOT rebuild it from $$
R=$("${CURL[@]}" -G "$API/api/v1/sessions/$SID/wait-output" \
--data-urlencode 'match=shift+tab' --data-urlencode 'from=buffer' --data-urlencode 'timeout=5000')
if ! jq -e '.data.wait.matched' <<<"$R" >/dev/null; then
# composer never appeared, so the trust dialog is probably still up; accept it once
T=$("${CURL[@]}" -G "$API/api/v1/sessions/$SID/wait-output" \
--data-urlencode 'match=trust' --data-urlencode 'from=buffer' --data-urlencode 'timeout=2000')
if jq -e '.data.wait.matched' <<<"$T" >/dev/null; then
"${CURL[@]}" -X POST "$API/api/v1/sessions/$SID/input" -H 'Content-Type: application/json' \
-d '{"input":"\r","useMux":true,"clientId":"'"$CID"'","seq":'$SEQ'}' >/dev/null
SEQ=$((SEQ+1))
fi
# Composer never appeared, so the trust dialog is probably still up. NEVER a blind
# Enter here: the highlighted option is "No, exit". _accept_trust (§0 preamble) reads
# the marker off the pane, arrows onto the trust option, re-reads, then confirms. It
# carries its OWN clientId, so it spends none of $SEQ's numbers.
_accept_trust "$SID"
R=$("${CURL[@]}" -G "$API/api/v1/sessions/$SID/wait-output" \
--data-urlencode 'match=shift+tab' --data-urlencode 'from=buffer' --data-urlencode 'timeout=45000')
fi
@@ -248,8 +262,10 @@ if ! jq -e '.data.wait.matched' <<<"$R" >/dev/null; then
# of a broken worker, and answering is proof that it works. Split the token (your
# keystrokes echo into the stream) and keep it unique per call. This costs the worker
# one billed turn, so it runs only after the fast path missed. It must stay AFTER
# stage 2, which is the only thing that clears the trust dialog: free text plus \r
# into a dialog still up answers it blind, the same footgun as the up-front Enter.
# stage 2, which is the only thing that clears the trust dialog: the typed text is
# swallowed by the select widget and the \r then answers whatever is highlighted,
# which since 2.1.252 is "No, exit" -- the same footgun as the up-front Enter, except
# that it kills the worker rather than wasting a turn.
TOK="${RANDOM}_$$"
"${CURL[@]}" -X POST "$API/api/v1/sessions/$SID/input" -H 'Content-Type: application/json' \
-d '{"input":"reply with the word READY immediately followed by _'"$TOK"' and nothing else\r","useMux":true,"clientId":"'"$CID"'","seq":'$SEQ'}' >/dev/null
+4 -1
View File
@@ -15,6 +15,7 @@ import { existsSync, readFileSync } from 'node:fs';
import { isAbsolute, join } from 'node:path';
import { homedir } from 'node:os';
import { dataPath } from './config/instance.js';
import { casePath } from './config/cases-dir.js';
import { installAgentSkillInto, removeAgentSkillFrom, type AgentSkillApplyResult } from './hooks-config.js';
import { getSessionManager } from './session-manager.js';
import { getTaskQueue } from './task-queue.js';
@@ -146,7 +147,9 @@ export function resolveCliCasePath(name: string): string {
} catch {
// no registry yet, or unreadable/invalid JSON: fall through to the cases dir
}
return join(homedir(), 'codeman-cases', name);
// Same resolver the server uses, so CODEMAN_CASES_PATH (Docker Compose) moves
// the CLI's idea of a case with it instead of leaving it on the home default.
return casePath(name);
}
/**
+37
View File
@@ -0,0 +1,37 @@
/**
* @fileoverview Where case (project) folders live.
*
* Deliberately NOT instance-scoped, unlike `dataPath()`: `~/codeman-cases` is
* shared by every Codeman on the machine, the same way `~/codeman-users/<u>`
* user spaces are, so a beta instance sees the same projects as prod.
*
* `CODEMAN_CASES_PATH` overrides the location. Docker Compose deployments set
* it to a host-absolute bind mount so a Docker case's workspace resolves to the
* SAME absolute path inside Codeman and on the host daemon that mounts it.
*
* ⚠️ **One resolver, every caller.** This started life as three hardcoded
* `join(homedir(), 'codeman-cases')` copies. When only the web server's copy
* learned the override, `codeman skill install --case <name>` still looked in
* the home default and reported "Case not found" on exactly the deployment the
* override exists for. A new cases-dir consumer imports this; it does not
* rebuild the path.
*
* (`state-store.ts` keeps its own literal on purpose: that one migrates the
* historical `~/claudeman-cases` directory to `~/codeman-cases` by name, and is
* about the old default location rather than the active one.)
*
* @module config/cases-dir
*/
import { homedir } from 'node:os';
import { join } from 'node:path';
/** Absolute path to the shared cases directory. */
export function getCasesDir(): string {
return process.env.CODEMAN_CASES_PATH || join(homedir(), 'codeman-cases');
}
/** Absolute path to one case folder inside it. */
export function casePath(name: string): string {
return join(getCasesDir(), name);
}
+28 -5
View File
@@ -23,7 +23,7 @@
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
import fs from 'node:fs/promises';
import { join, dirname } from 'node:path';
import { dirname, isAbsolute, join, relative, resolve } from 'node:path';
import { fileURLToPath } from 'node:url';
import { homedir } from 'node:os';
import { createHash } from 'node:crypto';
@@ -277,6 +277,24 @@ export interface DockerMount {
readonly?: boolean;
}
/**
* Resolve a bind source into the Docker daemon's filesystem namespace.
*
* A bare-host Codeman process and its Docker daemon see the same HOME, so the
* source is returned unchanged. In Docker-outside-of-Docker deployments,
* `runtimeHome` is the path inside Codeman while `daemonHome` is the host path
* bind-mounted there. Sources beneath HOME must therefore be translated before
* they are sent through the Docker socket.
*/
export function resolveDockerDaemonMountSource(source: string, runtimeHome: string, daemonHome?: string): string {
const configuredDaemonHome = daemonHome?.trim();
if (!configuredDaemonHome) return source;
const relativeSource = relative(resolve(runtimeHome), resolve(source));
if (relativeSource.startsWith('..') || isAbsolute(relativeSource)) return source;
return resolve(configuredDaemonHome, relativeSource);
}
/**
* Resolved, IO-free context for buildDockerCreateArgs. The caller (tmux-manager)
* resolves the environment-dependent bits (host uid, existing cred mounts, the
@@ -300,6 +318,8 @@ export interface DockerCreateContext {
addHostGateway: boolean;
/** Engine host-gateway alias (host.docker.internal / host.containers.internal). */
gatewayAlias: string;
/** Omit --memory-swap when the host kernel cannot enforce swap limits. */
disableSwapLimit?: boolean;
}
/**
@@ -317,12 +337,15 @@ function mountSpec(m: DockerMount): string {
return `type=bind,src=${m.src},dst=${m.dst}${m.readonly ? ',readonly' : ''}`;
}
function resourceFlags(resources?: DockerResourceLimits): string[] {
function resourceFlags(resources?: DockerResourceLimits, disableSwapLimit = false): string[] {
if (!resources) return [];
const flags: string[] = [];
if (resources.memory) {
// memory-swap == memory disables swap, making --memory a REAL OOM cap.
flags.push('--memory', resources.memory, '--memory-swap', resources.memory);
flags.push('--memory', resources.memory);
// memory-swap == memory disables swap where the daemon supports swap
// accounting. Some kernels, including the deployed Unraid host, do not;
// requesting it there emits a warning and Docker ignores the value.
if (!disableSwapLimit) flags.push('--memory-swap', resources.memory);
}
if (resources.cpus) flags.push('--cpus', resources.cpus);
if (resources.pidsLimit) flags.push('--pids-limit', String(resources.pidsLimit));
@@ -387,7 +410,7 @@ export function buildDockerCreateArgs(ctx: DockerCreateContext): string[] {
if (addHostGateway) args.push('--add-host', `${gatewayAlias}:host-gateway`);
args.push(
...resourceFlags(docker.resources),
...resourceFlags(docker.resources, ctx.disableSwapLimit),
// GPU passthrough (needs the NVIDIA container toolkit on the host). No storage
// cap is set, so the container's writable layer + volumes grow elastically as
// data flows in (bounded only by host disk).
+93 -9
View File
@@ -1,16 +1,32 @@
/**
* @fileoverview Recognizing Claude Code's workspace-trust dialog on screen.
* @fileoverview Recognizing Claude Code's workspace-trust dialog on screen, and
* working out which keystroke answers it.
*
* Claude asks once per directory before it will read or edit anything:
* Claude asks once per directory before it will read or edit anything. The
* layout has changed under us at least twice; both of these are live shapes:
*
* Quick safety check: Is this a project you created or one you trust? ...
* Quick safety check: Is this a project you created or one you trust? ... (<= 2.1.220)
* ❯ 1. Yes, I trust this folder
* 2. No, exit
* Enter to confirm · Esc to cancel
*
* Quick safety check: Is this a project you created or one you trust? ... (2.1.252)
* Security guide
* ❯ No, exit
* Yes, I trust this folder
* Enter to confirm · Esc to cancel
*
* Codeman sessions run permission-skipping or classifier-guarded modes, so the
* answer is always yes, and a session parked on this dialog is simply stuck.
*
* ⚠️ **Never press Enter without reading the selection.** The options are now
* unnumbered, REVERSED, and the highlighted default is "No, exit" — so the blind
* `\r` that answered the old layout picks *exit* on the new one and the pane
* dies (`Pane is dead (status 1)`) seconds after the session starts, which is
* exactly what a fresh case did on Claude Code 2.1.252. `trustDialogNextKey()`
* reads the `❯` marker instead and moves the cursor onto the trust option before
* it confirms anything.
*
* **Why the text has to be compacted.** tmux repaints a row by writing each word
* and then a cursor-forward (`\x1b[C`) instead of a space, and Ink colours each
* word separately, so the wire carries `I\x1b[Ctrust\x1b[Cthis\x1b[Cfolder`.
@@ -39,6 +55,25 @@ const TRUST_PHRASES = [
/** The dialog's own affordances. Prose that quotes the question will not have these. */
const CONFIRM_PHRASES = ['entertoconfirm', 'esctocancel', '2.no,exit'];
/** The option that answers yes, compacted. Identical text in both layouts. */
const YES_OPTION = 'yes,itrustthisfolder';
/** The option that quits Claude. It is the highlighted DEFAULT since 2.1.252. */
const NO_OPTION = 'no,exit';
/** Ink's selection marker. The only marked row while the dialog is up. */
const SELECTION_MARK = '❯';
/** A numbered option's `1.` / `2.` prefix, which the 2.1.220 layout put after the marker. */
const OPTION_NUMBER_PREFIX = /^\d+\./;
/** Move the selection one row down / up. Literal, so `send-keys -l` carries them. */
export const TRUST_KEY_DOWN = '\x1b[B';
export const TRUST_KEY_UP = '\x1b[A';
/** Confirm the highlighted option. */
export const TRUST_KEY_CONFIRM = '\r';
/**
* Charset-select sequences (`ESC ( B`), which tmux emits around styled runs and
* `stripAnsi` does not cover. Left in, they would land inside a phrase as a
@@ -65,6 +100,50 @@ export function isTrustDialogScreen(text: string): boolean {
return TRUST_PHRASES.some((p) => compact.includes(p)) && CONFIRM_PHRASES.some((p) => compact.includes(p));
}
/**
* Which option the `❯` marker sits on, or null when this text does not say.
*
* The LAST marked option wins. A pane capture holds exactly one frame and so
* exactly one marker, but the direct-PTY fallback reads an append-only buffer
* where every repaint since launch is still present — there the freshest frame
* is the one at the end, and an older one must not out-vote it.
*/
function selectedTrustOption(compact: string): { at: number; option: 'yes' | 'no' } | null {
let selected: { at: number; option: 'yes' | 'no' } | null = null;
for (let at = compact.indexOf(SELECTION_MARK); at >= 0; at = compact.indexOf(SELECTION_MARK, at + 1)) {
const after = compact.slice(at + SELECTION_MARK.length).replace(OPTION_NUMBER_PREFIX, '');
if (after.startsWith(YES_OPTION)) selected = { at, option: 'yes' };
else if (after.startsWith(NO_OPTION)) selected = { at, option: 'no' };
}
return selected;
}
/**
* The single keystroke that moves this dialog one step closer to "yes", or null
* when the screen does not show clearly enough to touch.
*
* One step per call on purpose: the caller re-reads the screen between
* keystrokes, so a moved cursor is CONFIRMED before Enter is pressed rather than
* assumed. Firing arrow+Enter together would re-create the failure this exists
* to prevent whenever the arrow is dropped (Ink drops keystrokes while it is
* still mounting a widget) — the Enter would then land on "No, exit".
*
* Returning null is the safe answer, not a failure: an unreadable frame means
* wait for the next repaint, and a layout whose options this cannot name means
* leave the dialog to the human. The caller's startup window bounds the waiting.
*/
export function trustDialogNextKey(text: string): string | null {
const compact = compactScreenText(text);
if (!compact.includes(YES_OPTION)) return null; // no trust option to steer onto
const selected = selectedTrustOption(compact);
if (!selected) return null; // marker missing, or not on an option we recognize
if (selected.option === 'yes') return TRUST_KEY_CONFIRM;
// On "No, exit". Which way the trust option lies is read from THIS frame — it
// sits below in 2.1.252 and above in the numbered layout before it — so the
// order flipping again costs a repaint, not a killed session.
return compact.includes(YES_OPTION, selected.at) ? TRUST_KEY_DOWN : TRUST_KEY_UP;
}
/**
* How long after the pane starts the dialog is still plausible. It renders
* before the main UI, so this only has to cover a slow first launch; leaving it
@@ -72,16 +151,21 @@ export function isTrustDialogScreen(text: string): boolean {
*/
export const TRUST_DIALOG_WINDOW_MS = 90_000;
/** Minimum gap between two Enter presses, and between two screen reads. */
/** Minimum gap between two keystrokes, and between two screen reads. */
export const TRUST_DIALOG_RETRY_MS = 1500;
/**
* Attempts before giving up and leaving the dialog to the user. A keystroke can
* land while Ink is still mounting the widget and be dropped, which is the other
* half of why sessions got stuck here; retrying costs nothing, but retrying
* forever would hammer Enter into whatever came next.
* Keystrokes before giving up and leaving the dialog to the user. A keystroke
* can land while Ink is still mounting the widget and be dropped, which is the
* other half of why sessions got stuck here; retrying costs nothing, but
* retrying forever would hammer Enter into whatever came next.
*
* Six rather than three because answering is no longer one press: the 2.1.252
* layout needs an arrow onto the trust option and then Enter, each confirmed
* against a re-read of the screen, so a cap of three left only one dropped
* keystroke of slack.
*/
export const TRUST_DIALOG_MAX_ATTEMPTS = 3;
export const TRUST_DIALOG_MAX_ATTEMPTS = 6;
/**
* How much of the append-only terminal buffer to read on a direct-PTY session,
+48 -4
View File
@@ -66,6 +66,8 @@ import { RalphTracker } from './ralph-tracker.js';
import { BashToolParser } from './bash-tool-parser.js';
import {
isTrustDialogScreen,
trustDialogNextKey,
TRUST_KEY_CONFIRM,
TRUST_DIALOG_WINDOW_MS,
TRUST_DIALOG_RETRY_MS,
TRUST_DIALOG_MAX_ATTEMPTS,
@@ -455,8 +457,9 @@ export class Session extends EventEmitter {
private _lastPaneProbeAt = 0; // Throttle for the tmux screen probe
private _lastPaneProbeWorking: boolean | null = null; // Its last verdict (null = could not read)
private _trustDialogAccepted: boolean = false; // Stops the trust-dialog scan (answered, or given up)
private _trustDialogAttempts = 0; // Enter presses sent at the trust dialog
private _trustDialogAttempts = 0; // Keystrokes sent at the trust dialog
private _lastTrustDialogScanAt = 0; // Throttle for the trust-dialog screen read
private _trustDialogTimer: NodeJS.Timeout | null = null; // Re-read after a keystroke (see below)
private _interactiveStartedAt = 0; // When the interactive pane launched (bounds that scan)
private _taskTracker: TaskTracker;
@@ -1841,6 +1844,10 @@ export class Session extends EventEmitter {
this._interactiveStartedAt = Date.now();
this._trustDialogAttempts = 0;
this._lastTrustDialogScanAt = 0;
if (this._trustDialogTimer) {
clearTimeout(this._trustDialogTimer);
this._trustDialogTimer = null;
}
// COD-118: if the PTY exit breaker has tripped (repeated non-zero exits in a
// short window), refuse to respawn. This is the uniform choke point that stops
@@ -2229,6 +2236,15 @@ export class Session extends EventEmitter {
* makes a retry safe, since the terminal buffer is append-only and keeps the
* dialog in its tail long after it has been answered.
*
* ⚠️ **The keystroke is read off the screen, never assumed.** Claude Code
* 2.1.252 dropped the option numbers, put "No, exit" first, and highlights IT
* by default, so the bare `\r` this used to send now answers *exit*: a fresh
* case died (`Pane is dead (status 1)`) about six seconds after spawning.
* `trustDialogNextKey()` returns one step at a time — an arrow while the
* cursor is on the wrong option, Enter only once the screen shows it on the
* trust option — and this method re-reads the pane between the two, so a
* dropped arrow costs a repaint instead of the session.
*
* Three guards keep an Enter press off a live session: a startup-only window,
* a two-marker match (isTrustDialogScreen), and an attempt cap.
*/
@@ -2249,17 +2265,39 @@ export class Session extends EventEmitter {
this._terminalBuffer.value.slice(-TRUST_DIALOG_SCAN_BYTES);
if (!isTrustDialogScreen(screen)) return;
// Null means the frame does not say which option is highlighted. Waiting for
// the next repaint is the safe move; pressing Enter blind is the bug.
const key = trustDialogNextKey(screen);
if (key === null) return;
this._trustDialogAttempts++;
if (this._trustDialogAttempts > TRUST_DIALOG_MAX_ATTEMPTS) {
this._trustDialogAccepted = true; // leave it to the user rather than keep typing
console.warn(`[Session] Workspace trust dialog did not clear after retries: ${this.id}`);
return;
}
const step = key === TRUST_KEY_CONFIRM ? 'confirming' : 'moving to the trust option';
console.log(
`[Session] Auto-accepting workspace trust dialog for: ${this.id} (attempt ${this._trustDialogAttempts})`
`[Session] Auto-accepting workspace trust dialog for: ${this.id} (attempt ${this._trustDialogAttempts}, ${step})`
);
// Enter confirms the highlighted default, "1. Yes, I trust this folder".
this.writeViaMux('\r');
this.writeViaMux(key);
// ⚠️ Schedule the next read; do NOT wait for more PTY output. This scan only
// ever ran from `onData`, which was enough while one Enter answered the
// dialog. It is not enough now: the arrow that moves the cursor is the LAST
// output the pane produces, so a dialog left sitting on the trust option
// never gets its Enter and the worker stays parked on it forever (measured
// on a live 2.1.252 spawn: cursor moved at 6 s, then nothing). The timer is
// one-shot and self-rearming through this same path, and every exit route
// goes through _clearAllTimers().
// The +100ms puts the re-entry OUTSIDE the scan throttle above; firing at
// exactly the throttle boundary would let the scan return early and break
// the chain with the dialog still on screen.
if (this._trustDialogTimer) clearTimeout(this._trustDialogTimer);
this._trustDialogTimer = setTimeout(() => {
this._trustDialogTimer = null;
this._maybeAcceptTrustDialog();
}, TRUST_DIALOG_RETRY_MS + 100);
}
/**
@@ -2784,6 +2822,12 @@ export class Session extends EventEmitter {
}
private _clearAllTimers(): void {
// Clear the workspace-trust follow-up read
if (this._trustDialogTimer) {
clearTimeout(this._trustDialogTimer);
this._trustDialogTimer = null;
}
// Clear activity timeout to prevent memory leak
if (this.activityTimeout) {
clearTimeout(this.activityTimeout);
+27 -4
View File
@@ -75,6 +75,7 @@ import {
hostGatewayAlias,
resolveDockerClaudeArtifacts,
resolveDockerCredentialArtifacts,
resolveDockerDaemonMountSource,
type DockerCreateContext,
type DockerMount,
type DockerSeedCopy,
@@ -1354,8 +1355,23 @@ export function buildDockerLaunchCommand(opts: DockerLaunchOptions): string {
const startFailMsg = shellescape(`Codeman: container ${docker.containerName} failed to start (docker daemon down?)`);
const imageCheck = `${base} image inspect ${image} >/dev/null 2>&1 || { echo ${imageMissingMsg}; exit 1; }`;
// create-if-missing (idempotent): reconnect / boot recovery re-runs this exact chain.
const ensure = `${base} inspect ${name} >/dev/null 2>&1 || ${base} ${createArgs}`;
// create-if-missing (idempotent): reconnect / boot recovery re-runs this exact
// chain. A daemon without swap accounting warns whenever --memory is present,
// even when --memory-swap is omitted. In compatibility mode, retain the memory
// cap and filter ONLY that exact warning; all other stdout/stderr and the real
// create exit status are preserved so mount/config failures remain visible.
// A session-unique file avoids shell variables and command substitution, both
// of which would be expanded too early by the nested bash/tmux launch layers.
const createOutputPath = shellescape(`/tmp/codeman-create-${sessionId}.log`);
const filteredCreateOutput = `sed '/^WARNING: Your kernel does not support swap limit capabilities or the cgroup is not mounted\\. Memory limited without swap\\.$/d' ${createOutputPath}`;
const removeCreateOutput = `rm -f ${createOutputPath}`;
const createCommand = createContext.disableSwapLimit
? `{ if ${base} ${createArgs} >${createOutputPath} 2>&1; ` +
`then ${filteredCreateOutput}; ${removeCreateOutput}; ` +
`elif ${base} inspect ${name} >/dev/null 2>&1; then ${removeCreateOutput}; ` +
`else ${filteredCreateOutput} >&2; ${removeCreateOutput}; false; fi; }`
: `${base} ${createArgs}`;
const ensure = `${base} inspect ${name} >/dev/null 2>&1 || ${createCommand}`;
const start = `${base} start ${name} >/dev/null 2>&1 || { echo ${startFailMsg}; exit 1; }`;
// Seed writable credential config from read-only host mounts ONCE per container
// (guarded by [ -e ] so reconnects never clobber in-container config; `cp -a` for
@@ -1466,11 +1482,18 @@ export function resolveDockerLaunchOptions(
sessionId,
instance: CODEMAN_INSTANCE,
userArgs,
credentialMounts,
extraMounts,
credentialMounts: credentialMounts.map((mount) => ({
...mount,
src: resolveDockerDaemonMountSource(mount.src, home, process.env.CODEMAN_DOCKER_HOST_HOME),
})),
extraMounts: extraMounts.map((mount) => ({
...mount,
src: resolveDockerDaemonMountSource(mount.src, home, process.env.CODEMAN_DOCKER_HOST_HOME),
})),
envCreate,
addHostGateway: !isDesktop,
gatewayAlias,
disableSwapLimit: process.env.CODEMAN_DOCKER_DISABLE_SWAP_LIMIT === '1',
};
const execEnv: Record<string, string> = {
+25 -7
View File
@@ -2634,8 +2634,19 @@ class CodemanApp {
// string field (e.g. modelDisplayName, which the route also broadcasts) is
// ever shown in this chip, render it via textContent — never interpolate an
// untrusted string into this template.
const seg = (label, p) => {
if (p === null) return '';
// `idle: true` keeps a missing window's SLOT with a dimmed em dash instead of
// dropping it. Claude only: Claude Code documents `five_hour` as "present
// only while the API reports it and its resets_at has not passed", so that
// key leaves the statusline payload whenever no 5-hour session window is
// open, and a chip that silently shrank from two windows to one read as a
// broken feature rather than as an idle window (reported 2026-09-01). A
// missing CODEX bucket means the opposite — that plan has no such limit —
// so those stay omitted rather than showing a dash forever.
const seg = (label, p, idle) => {
if (p === null) {
if (!idle) return '';
return `<span class="pu-win pu-win-idle"><span class="pu-label">${label}</span><span class="pu-val">—</span></span>`;
}
const n = Math.round(Number(p));
if (!Number.isFinite(n)) return '';
return `<span class="pu-win"><span class="pu-label">${label}</span><span class="pu-val ${colorClass(n)}">${n}%</span></span>`;
@@ -2644,24 +2655,31 @@ class CodemanApp {
// provider to tell apart: a machine with Claude alone shows bare windows.
const hasWindows = (usage) => pct(usage?.fiveHour) !== null || pct(usage?.sevenDay) !== null;
const labelled = hasWindows(data) && hasWindows(data.codex);
const row = (provider, usage) => {
const windows = [seg('5h', pct(usage?.fiveHour)), seg('7d', pct(usage?.sevenDay))].filter(Boolean);
const row = (provider, usage, idle) => {
// hasWindows() gates the row, so a placeholder can only ever appear
// ALONGSIDE a real reading — a provider reporting nothing still renders
// nothing, never a row of em dashes.
if (!hasWindows(usage)) return '';
const windows = [seg('5h', pct(usage?.fiveHour), idle), seg('7d', pct(usage?.sevenDay), idle)].filter(Boolean);
if (!windows.length) return '';
const label = labelled ? `<span class="pu-provider">${provider}</span>` : '';
return `<span class="pu-row">${label}<span class="pu-windows">${windows.join('<span class="pu-sep">·</span>')}</span></span>`;
};
const rows = [row('Claude', data), row('Codex', data.codex)].filter(Boolean);
const rows = [row('Claude', data, true), row('Codex', data.codex, false)].filter(Boolean);
chip.innerHTML = rows.length ? rows.join('') : '—';
const resetStr = (w) => (w && w.resetAt ? new Date(w.resetAt).toLocaleString() : '—');
const details = (provider, usage) => {
const details = (provider, usage, idle) => {
const lines = [];
const five = pct(usage?.fiveHour);
const seven = pct(usage?.sevenDay);
if (five !== null) lines.push(`5-hour limit: ${five}% used (resets ${resetStr(usage.fiveHour)})`);
else if (idle && seven !== null) lines.push('5-hour limit: no active session window');
if (seven !== null) lines.push(`Weekly limit: ${seven}% used (resets ${resetStr(usage.sevenDay)})`);
return lines.length ? `${provider} plan usage\n${lines.join('\n')}` : '';
};
chip.title = [details('Claude', data), details('Codex', data.codex)].filter(Boolean).join('\n\n') || 'Plan usage limits';
chip.title =
[details('Claude', data, true), details('Codex', data.codex, false)].filter(Boolean).join('\n\n') ||
'Plan usage limits';
}
// Scheduled runs
+1 -1
View File
@@ -10,7 +10,7 @@
* @globals {function} scheduleBackground - scheduler.postTask wrapper (background priority)
* @globals {function} getEventCoords - Unified mouse/touch coordinate extractor
* @globals {function} escapeHtml - XSS-safe HTML escaping
* @globals {object} SSE_EVENTS - Centralized SSE event type constants (156 event types; must match backend src/web/sse-events.ts)
* @globals {object} SSE_EVENTS - Centralized SSE event type constants (157 event types; must match backend src/web/sse-events.ts)
* @globals {Array} BUILTIN_RESPAWN_PRESETS - Built-in respawn configuration presets
*
* @dependency None (first in load order)
+7
View File
@@ -12470,6 +12470,13 @@ kbd {
color: var(--text-dim);
opacity: 0.45;
}
/* A window Claude is not currently reporting: the slot stays, dimmed, so the
chip keeps its shape instead of looking like half of it broke. */
.header-plan-usage .pu-win-idle .pu-label,
.header-plan-usage .pu-win-idle .pu-val {
color: var(--text-dim);
opacity: 0.55;
}
/* Green/yellow/red by how much of the window is used up. */
.header-plan-usage .pu-green {
color: #3fb950;
+4 -2
View File
@@ -8,7 +8,6 @@
import { join, resolve, relative, isAbsolute } from 'node:path';
import { realpathSync, existsSync, mkdirSync } from 'node:fs';
import fs from 'node:fs/promises';
import { homedir } from 'node:os';
import type { z } from 'zod';
import type { FastifyReply, FastifyRequest } from 'fastify';
import { Session } from '../session.js';
@@ -21,12 +20,15 @@ import type { EventPort } from './ports/event-port.js';
import type { AuthSessionRecord } from './ports/auth-port.js';
import type { StaleExpirationMap } from '../utils/index.js';
import { dataPath } from '../config/instance.js';
import { getCasesDir } from '../config/cases-dir.js';
import { isMultiUserMode, maxSessionsPerUser, userCasesDir } from '../config/multiuser.js';
import { SYNTHETIC_ADMIN, findUser } from '../user-store.js';
// Shared path constants used across route modules. CASES_DIR (project folders)
// stays shared across instances; SETTINGS_PATH is per-instance runtime state.
export const CASES_DIR = join(homedir(), 'codeman-cases');
// The cases dir is resolved in ONE place (config/cases-dir.ts) because the CLI
// resolves it too, and CODEMAN_CASES_PATH must move both or neither.
export const CASES_DIR = getCasesDir();
export const SETTINGS_PATH = dataPath('settings.json');
/**
+3 -3
View File
@@ -3076,9 +3076,9 @@ export function registerSessionRoutes(
casePath = dockerCase.hostWorkspacePath; // a REAL host dir (bind-mounted into the container)
docker = sessionDocker;
// Seed resume so a relaunch resumes the case's last conversation from the
// bind-mounted transcript (decision: resume-on-start default ON).
if (sessionDocker.resumeOnStart && dockerCase.lastClaudeSessionId) {
// Seed only Claude's resume id. Codex, Gemini, and the other CLIs have
// separate conversation stores and must never receive a Claude UUID.
if (mode === 'claude' && sessionDocker.resumeOnStart && dockerCase.lastClaudeSessionId) {
dockerResumeId = dockerCase.lastClaudeSessionId;
}
} else {
+7 -2
View File
@@ -614,8 +614,13 @@ export function registerSystemRoutes(
// same negative-pid signal as runGit() in git-clone.ts, which is the
// synchronous-spawn precedent this endpoint is modelled on.
detached: true,
// dsh bundles its own package manager, so no system pnpm is required —
// but it still needs a HOME to resolve $DSH_HOME against.
// Inherit the environment: this needs a HOME to resolve $DSH_HOME
// against, and a PATH carrying `pnpm`. ⚠️ `dsh plugin` does NOT bundle a
// package manager — it `spawnSync`s a literal `pnpm` with no npm
// fallback, so on a host without one this exits 127 and dsh's own
// stderr ("pnpm not found on PATH") is what reaches the caller through
// the OPERATION_FAILED detail below. That is the same missing
// dependency that broke the docker agent image in issue #352.
env: process.env,
});
} catch (err) {
+34
View File
@@ -19,6 +19,7 @@ import { homedir } from 'node:os';
import { join } from 'node:path';
import { dataPath } from '../src/config/instance.js';
import { program, resolveCliCasePath, resolveSkillTargetPath } from '../src/cli.js';
import { getCasesDir } from '../src/config/cases-dir.js';
const LINKED_CASES_FILE = dataPath('linked-cases.json');
const CASES_DIR = join(homedir(), 'codeman-cases');
@@ -142,3 +143,36 @@ describe('skill command wiring', () => {
}
});
});
describe('cases dir override (CODEMAN_CASES_PATH)', () => {
// The Docker Compose deployment points Codeman at a host-absolute bind mount
// so a Docker case resolves to the same path inside the container and on the
// host daemon. The override shipped on the server's CASES_DIR only, which left
// the CLI looking in the home default: `codeman skill install --case <name>`
// then reported "Case not found" on exactly the deployment it exists for.
const saved = process.env.CODEMAN_CASES_PATH;
afterEach(() => {
if (saved === undefined) delete process.env.CODEMAN_CASES_PATH;
else process.env.CODEMAN_CASES_PATH = saved;
});
it('moves the CLI and the server together', () => {
process.env.CODEMAN_CASES_PATH = '/srv/codeman-cases';
expect(getCasesDir()).toBe('/srv/codeman-cases');
expect(resolveCliCasePath('demo')).toBe(join('/srv/codeman-cases', 'demo'));
});
it('falls back to the home default when unset', () => {
delete process.env.CODEMAN_CASES_PATH;
expect(getCasesDir()).toBe(CASES_DIR);
expect(resolveCliCasePath('demo')).toBe(join(CASES_DIR, 'demo'));
});
it('still lets a linked case win over the override', () => {
// The registry lookup runs first, so a case linked in from outside the cases
// dir keeps resolving to its real location under Compose too.
process.env.CODEMAN_CASES_PATH = '/srv/codeman-cases';
writeLinkedCases(JSON.stringify({ linked: join(LINKED_ROOT, 'linked') }));
expect(resolveCliCasePath('linked')).toBe(join(LINKED_ROOT, 'linked'));
});
});
+20
View File
@@ -80,6 +80,26 @@ describe('buildDockerLaunchCommand', () => {
expect(cmd).toContain("docker start 'codeman-case-myproj'");
});
it('avoids eager create expansion, tolerates a concurrent creator, and preserves real failures in compatibility mode', () => {
const opts = launchOpts();
opts.createContext.disableSwapLimit = true;
const cmd = buildDockerLaunchCommand(opts);
// No command substitution or shell variables: either could expand eagerly
// before the inspect side of || short-circuits in a nested launch shell.
expect(cmd).not.toContain('$(');
expect(cmd).not.toContain('codeman_create_output');
expect(cmd).toContain('if docker create');
expect(cmd).toContain("'/tmp/codeman-create-1a2b3c4d5e6f.log'");
// If another session created the case between inspect and create, re-inspect
// succeeds and the losing creator continues without printing the conflict.
expect(cmd).toContain("elif docker inspect 'codeman-case-myproj' >/dev/null 2>&1; then rm -f");
expect(cmd).toContain('Your kernel does not support swap limit capabilities');
expect(cmd).toContain('else sed');
expect(cmd).toContain('>&2; rm -f');
expect(cmd).toContain('; false; fi;');
expect(cmd).not.toContain('--memory-swap');
});
it('execs a TTY into the durable in-container tmux', () => {
const cmd = buildDockerLaunchCommand(launchOpts());
expect(cmd).toContain("exec docker exec -it --workdir '/home/arkon/cases/myproj'");
+27
View File
@@ -32,6 +32,7 @@ import {
resolveClaudeJsonSeedMount,
resolveDockerClaudeArtifacts,
resolveDockerCredentialArtifacts,
resolveDockerDaemonMountSource,
toSessionDocker,
writeDockerCases,
writeDockerHosts,
@@ -267,6 +268,32 @@ describe('buildDockerCreateArgs', () => {
expect(s).not.toContain('--storage-opt');
expect(buildDockerCreateArgs(ctx()).join(' ')).not.toContain('--gpus');
});
it('omits the unsupported swap limit while retaining the memory limit when disabled', () => {
const s = buildDockerCreateArgs(ctx({ disableSwapLimit: true })).join(' ');
expect(s).toContain('--memory 4g');
expect(s).not.toContain('--memory-swap');
});
});
describe('resolveDockerDaemonMountSource', () => {
const runtimeHome = join(tmpdir(), 'codeman-runtime-home');
const daemonHome = join(tmpdir(), 'codeman-daemon-home');
it('maps paths beneath the runtime HOME into the daemon-visible HOME', () => {
const source = join(runtimeHome, '.codeman', 'docker-seeds', 'codeman-case-test1.json');
expect(resolveDockerDaemonMountSource(source, runtimeHome, daemonHome)).toBe(
join(daemonHome, '.codeman', 'docker-seeds', 'codeman-case-test1.json')
);
});
it('preserves direct-host and non-HOME sources', () => {
const source = join(runtimeHome, '.claude', 'settings.json');
expect(resolveDockerDaemonMountSource(source, runtimeHome)).toBe(source);
const outsideHome = join(tmpdir(), 'codeman-cases', 'test1');
expect(resolveDockerDaemonMountSource(outsideHome, runtimeHome, daemonHome)).toBe(outsideHome);
});
});
describe('resolveDockerCredentialArtifacts (isolated codex/gemini/gcloud/opencode)', () => {
+28
View File
@@ -80,6 +80,34 @@ describe('header plan usage chip', () => {
expect(codexRow).toContain('7d');
});
it("keeps Claude's 5h slot as a dash when no session window is open", () => {
// Claude Code ships `five_hour` "only while the API reports it and its
// resets_at has not passed", so between session windows the key is simply
// absent. The chip used to shrink to a lone 7d segment, which reads as a
// broken feature rather than an idle window (reported 2026-09-01).
const { CodemanApp, chip } = loadCodemanAppClass();
const app = Object.create((CodemanApp as { prototype: object }).prototype) as UsageApp;
app.updatePlanUsageChip({ sevenDay: { usedPercentage: 52, resetAt: 2000 } });
expect(chip.innerHTML).toContain('pu-win-idle');
expect(chip.innerHTML).toContain('5h');
expect(chip.innerHTML).toContain('52%');
expect(chip.title).toContain('no active session window');
});
it('renders no row at all for a provider reporting nothing', () => {
// The placeholder must never stand alone: a row of em dashes would claim a
// provider is idle when it is really absent.
const { CodemanApp, chip } = loadCodemanAppClass();
const app = Object.create((CodemanApp as { prototype: object }).prototype) as UsageApp;
app.updatePlanUsageChip({ codex: { sevenDay: { usedPercentage: 40, resetAt: 3000 } } });
expect(chip.innerHTML).not.toContain('pu-win-idle');
expect(chip.innerHTML).toContain('40%');
});
it('drops the provider label when Claude is the only provider with limits', () => {
const { CodemanApp, chip } = loadCodemanAppClass();
const app = Object.create((CodemanApp as { prototype: object }).prototype) as UsageApp;
@@ -19,19 +19,20 @@
* including the sweep's deleted-workspace guard.
*/
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
import Fastify, { type FastifyInstance } from 'fastify';
import fastifyCookie from '@fastify/cookie';
import { mkdtemp, rm, readFile, mkdir, writeFile } from 'node:fs/promises';
import { existsSync } from 'node:fs';
import { join } from 'node:path';
import { tmpdir } from 'node:os';
import { createMockRouteContext } from '../mocks/index.js';
import { createMockRouteContext, type MockRouteContext } from '../mocks/index.js';
import { installRouteErrorHandler } from '../../src/web/route-error-handler.js';
import { registerSessionRoutes } from '../../src/web/routes/session-routes.js';
import { generateHooksConfig, applyWorkspaceHooks } from '../../src/hooks-config.js';
import { getDataDir } from '../../src/config/instance.js';
import { CASES_DIR } from '../../src/web/route-helpers.js';
import { Session } from '../../src/session.js';
interface HooksFile {
hooks?: Record<string, Array<{ matcher?: string; hooks?: Array<{ command?: string }> }>>;
@@ -223,6 +224,7 @@ describe('POST /api/sessions workspace hooks', () => {
describe('POST /api/quick-start workspace hooks', () => {
let app: FastifyInstance;
let ctx: MockRouteContext;
const quickStart = (payload: Record<string, unknown>) =>
app.inject({ method: 'POST', url: '/api/quick-start', payload });
@@ -230,15 +232,19 @@ describe('POST /api/quick-start workspace hooks', () => {
const hooksFileIn = (dir: string) => join(dir, '.claude', 'settings.local.json');
beforeEach(async () => {
vi.spyOn(Session.prototype, 'startInteractive').mockResolvedValue(undefined);
vi.spyOn(Session.prototype, 'startShell').mockResolvedValue(undefined);
app = Fastify({ logger: false });
await app.register(fastifyCookie);
registerSessionRoutes(app, createMockRouteContext());
ctx = createMockRouteContext();
registerSessionRoutes(app, ctx);
installRouteErrorHandler(app);
await app.ready();
});
afterEach(async () => {
await app.close();
vi.restoreAllMocks();
// Docker fixtures + case dirs must not leak into the next test.
await rm(join(getDataDir(), 'docker-hosts.json'), { force: true });
await rm(join(getDataDir(), 'docker-cases.json'), { force: true });
@@ -260,7 +266,7 @@ describe('POST /api/quick-start workspace hooks', () => {
});
/** Minimal docker host + case fixtures (docker IO is no-op'd under vitest). */
const writeDockerFixtures = async (caseName: string, hostWorkspacePath: string) => {
const writeDockerFixtures = async (caseName: string, hostWorkspacePath: string, lastClaudeSessionId?: string) => {
await mkdir(getDataDir(), { recursive: true });
await writeFile(
join(getDataDir(), 'docker-hosts.json'),
@@ -268,7 +274,7 @@ describe('POST /api/quick-start workspace hooks', () => {
);
await writeFile(
join(getDataDir(), 'docker-cases.json'),
JSON.stringify([{ name: caseName, type: 'docker', hostId: 'd1', hostWorkspacePath }])
JSON.stringify([{ name: caseName, type: 'docker', hostId: 'd1', hostWorkspacePath, lastClaudeSessionId }])
);
};
@@ -300,6 +306,35 @@ describe('POST /api/quick-start workspace hooks', () => {
await rm(ws, { recursive: true, force: true });
}
});
it.each(['codex', 'gemini'] as const)('does not pass a saved Claude conversation id to Docker %s', async (mode) => {
const ws = await mkdtemp(join(tmpdir(), `codeman-docker-${mode}-`));
try {
await writeDockerFixtures('dockexternal', ws, 'e83a9063-3cb4-44d2-a9a0-df153b81721f');
const res = await quickStart({ caseName: 'dockexternal', mode });
expect(res.statusCode).toBe(200);
const session = ctx.sessions.get(JSON.parse(res.body).sessionId);
expect(session?.toState().resumeSessionId).toBeUndefined();
} finally {
await rm(ws, { recursive: true, force: true });
}
});
it('passes a saved Claude conversation id only to Docker Claude', async () => {
const ws = await mkdtemp(join(tmpdir(), 'codeman-docker-resume-'));
const resumeId = 'e83a9063-3cb4-44d2-a9a0-df153b81721f';
try {
await writeDockerFixtures('dockresume', ws, resumeId);
const res = await quickStart({ caseName: 'dockresume', mode: 'claude' });
expect(res.statusCode).toBe(200);
const session = ctx.sessions.get(JSON.parse(res.body).sessionId);
expect(session?.toState().resumeSessionId).toBe(resumeId);
} finally {
await rm(ws, { recursive: true, force: true });
}
});
});
describe('applyWorkspaceHooks (the shared decision core in hooks-config)', () => {
+101 -1
View File
@@ -8,10 +8,23 @@
*
* RAW_DIALOG_CHUNK below is a verbatim slice of the PTY stream from a live
* session parked on that dialog (Claude Code 2.1.220).
*
* The second bug this pins: Claude Code 2.1.252 dropped the option numbers, put
* "No, exit" first and highlights IT, so the blind Enter that answered the old
* layout selects *exit* and the pane dies seconds after the session starts.
* RENDERED_DIALOG_2_1_252 is a verbatim `capture-pane -p` of that screen.
*/
import { describe, expect, it, vi, afterEach } from 'vitest';
import { Session } from '../src/session.js';
import { isTrustDialogScreen, compactScreenText, TRUST_DIALOG_MAX_ATTEMPTS } from '../src/session-trust-dialog.js';
import {
isTrustDialogScreen,
compactScreenText,
trustDialogNextKey,
TRUST_KEY_CONFIRM,
TRUST_KEY_DOWN,
TRUST_KEY_UP,
TRUST_DIALOG_MAX_ATTEMPTS,
} from '../src/session-trust-dialog.js';
/** Verbatim from the wire: note the `\x1b[C` where every space should be. */
const RAW_DIALOG_CHUNK =
@@ -29,6 +42,25 @@ const RENDERED_DIALOG = [
' Enter to confirm · Esc to cancel',
].join('\n');
/**
* Verbatim `capture-pane -p` from Claude Code 2.1.252 on a fresh case: no
* numbers, the options reversed, and the cursor parked on the one that quits.
*/
const RENDERED_DIALOG_2_1_252 = [
' Accessing workspace:',
' /home/arkon/codeman-cases/trustprobe1',
' Quick safety check: Is this a project you created or one you trust? (Like your own code, a well-known open source',
" project, or work from your team). If not, take a moment to review what's in this folder first.",
" Claude Code'll be able to read, edit, and execute files here.",
' Security guide',
' ❯ No, exit',
' Yes, I trust this folder',
' Enter to confirm · Esc to cancel',
].join('\n');
/** The same screen after one arrow press: the cursor has moved onto "yes". */
const RENDERED_DIALOG_2_1_252_ON_YES = RENDERED_DIALOG_2_1_252.replace(' ❯ No, exit\n Yes,', ' No, exit\n ❯ Yes,');
/** An ordinary working session: no dialog anywhere. */
const RENDERED_MAIN_UI = [
'✻ Actualizing… (13m 23s · ↓ 47.5k tokens)',
@@ -61,12 +93,54 @@ describe('isTrustDialogScreen', () => {
expect(isTrustDialogScreen('press Enter to confirm the release')).toBe(false);
});
it('sees the 2.1.252 dialog, whose options lost their numbers', () => {
// '2.no,exit' is gone from this layout, so the confirm affordance is now the
// only thing carrying the match.
expect(isTrustDialogScreen(RENDERED_DIALOG_2_1_252)).toBe(true);
});
it('compacts away both real spaces and the escapes tmux sends instead', () => {
expect(compactScreenText('I\x1b[Ctrust\x1b[Cthis\x1b[Cfolder')).toBe('itrustthisfolder');
expect(compactScreenText('I trust this folder')).toBe('itrustthisfolder');
});
});
describe('trustDialogNextKey', () => {
it('confirms straight away when the trust option is already highlighted', () => {
expect(trustDialogNextKey(RENDERED_DIALOG)).toBe(TRUST_KEY_CONFIRM);
expect(trustDialogNextKey(RENDERED_DIALOG_2_1_252_ON_YES)).toBe(TRUST_KEY_CONFIRM);
});
it('moves DOWN instead of confirming when 2.1.252 parks the cursor on "No, exit"', () => {
// The regression in one line: Enter here answers *exit* and kills the pane.
expect(trustDialogNextKey(RENDERED_DIALOG_2_1_252)).toBe(TRUST_KEY_DOWN);
});
it('moves UP when the trust option is the one above, as in the numbered layout', () => {
const numberedOnNo = RENDERED_DIALOG.replace(' ❯ 1. Yes,', ' 1. Yes,').replace(
' 2. No, exit',
' ❯ 2. No, exit'
);
expect(trustDialogNextKey(numberedOnNo)).toBe(TRUST_KEY_UP);
});
it('reads the LAST frame in an append-only buffer, not the first', () => {
// The direct-PTY fallback has no pane to capture, so it reads a buffer that
// still holds every repaint since launch. The freshest frame is the truth.
const buffer = `${RENDERED_DIALOG_2_1_252}\n${RENDERED_DIALOG_2_1_252_ON_YES}`;
expect(trustDialogNextKey(buffer)).toBe(TRUST_KEY_CONFIRM);
});
it('presses nothing when the screen does not say which option is selected', () => {
// A layout this cannot read is a dialog for the human, not a coin flip: the
// wrong guess exits Claude.
const noMarker = RENDERED_DIALOG_2_1_252.replace(' ❯ No, exit', ' No, exit');
expect(trustDialogNextKey(noMarker)).toBe(null);
expect(trustDialogNextKey(RENDERED_MAIN_UI)).toBe(null);
expect(trustDialogNextKey('')).toBe(null);
});
});
describe('Session trust-dialog auto-accept', () => {
afterEach(() => vi.useRealTimers());
@@ -102,6 +176,32 @@ describe('Session trust-dialog auto-accept', () => {
expect(writes).toEqual(['\r']);
});
it('walks the 2.1.252 dialog onto the trust option before it confirms', () => {
vi.useFakeTimers();
// The whole point: no Enter goes out while "No, exit" is highlighted.
let screen = RENDERED_DIALOG_2_1_252;
const { writes, tick } = sessionShowing(() => screen);
tick();
expect(writes).toEqual([TRUST_KEY_DOWN]);
screen = RENDERED_DIALOG_2_1_252_ON_YES;
vi.advanceTimersByTime(2000);
tick();
expect(writes).toEqual([TRUST_KEY_DOWN, TRUST_KEY_CONFIRM]);
});
it('never presses Enter while the cursor sits on "No, exit"', () => {
vi.useFakeTimers();
// A dialog that never moves (a dropped arrow, a wedged pane) must run out of
// attempts pressing arrows, not answer *exit* on the way.
const { writes, tick } = sessionShowing(() => RENDERED_DIALOG_2_1_252);
for (let i = 0; i < 20; i++) {
tick();
vi.advanceTimersByTime(2000);
}
expect(writes).toEqual(Array(TRUST_DIALOG_MAX_ATTEMPTS).fill(TRUST_KEY_DOWN));
});
it('retries a dropped keystroke, then gives up rather than typing forever', () => {
vi.useFakeTimers();
// Ink can drop a keystroke while it is still mounting the widget, so one